mcp-staleness-banner.test.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. /**
  2. * Per-file staleness banner on MCP tool responses (issue #403).
  3. *
  4. * The watcher tracks every file event since the last successful sync; the
  5. * tool dispatcher intersects "files referenced in this response" with that
  6. * pending set and prepends a banner ("⚠️ Some files referenced below were
  7. * edited since the last index sync…") plus an optional footer ("(Note: N
  8. * file(s) elsewhere in this project are pending index sync…)").
  9. *
  10. * No auto-flush, no static wait — the response is instant and the agent
  11. * decides whether to Read the specific stale file. These tests exercise
  12. * the full real path: real CodeGraph index + real ToolHandler.execute().
  13. *
  14. * **Event delivery uses a synthetic seam** (`__emitWatchEventForTests`): the
  15. * real native fs.watch (FSEvents/inotify) delivery is non-deterministic under
  16. * parallel vitest execution and produced a consistent ~30% failure rate on
  17. * these tests when run inside the full suite. The seam drives the watcher's
  18. * pending-set pipeline directly so the tests synthesize file events
  19. * deterministically. The watcher's actual debounce timer (real setTimeout) is
  20. * left untouched.
  21. */
  22. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  23. import * as fs from 'fs';
  24. import * as path from 'path';
  25. import * as os from 'os';
  26. import CodeGraph from '../src/index';
  27. import { ToolHandler } from '../src/mcp/tools';
  28. import { __emitWatchEventForTests, __setFsWatchForTests } from '../src/sync/watcher';
  29. function waitFor(condition: () => boolean, timeoutMs = 2000, intervalMs = 25): Promise<void> {
  30. return new Promise((resolve, reject) => {
  31. const start = Date.now();
  32. const tick = () => {
  33. if (condition()) return resolve();
  34. if (Date.now() - start > timeoutMs) return reject(new Error('waitFor timed out'));
  35. setTimeout(tick, intervalMs);
  36. };
  37. tick();
  38. });
  39. }
  40. describe('MCP staleness banner', () => {
  41. let testDir: string;
  42. let cg: CodeGraph;
  43. let handler: ToolHandler;
  44. beforeEach(async () => {
  45. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-stale-banner-'));
  46. fs.mkdirSync(path.join(testDir, 'src'));
  47. // Three isolated files with no cross-references — keeps each test's
  48. // "which path does the response mention?" assertion unambiguous. If the
  49. // files shared imports/calls, codegraph_search responses would surface
  50. // multiple file paths and the banner-vs-footer split would be racy.
  51. fs.writeFileSync(
  52. path.join(testDir, 'src', 'alpha-only.ts'),
  53. 'export function alphaOnly() { return 1; }\n',
  54. );
  55. fs.writeFileSync(
  56. path.join(testDir, 'src', 'bravo-only.ts'),
  57. 'export function bravoOnly() { return 2; }\n',
  58. );
  59. fs.writeFileSync(
  60. path.join(testDir, 'src', 'charlie-only.ts'),
  61. 'export function charlieOnly() { return 3; }\n',
  62. );
  63. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  64. await cg.indexAll();
  65. handler = new ToolHandler(cg);
  66. });
  67. afterEach(() => {
  68. __setFsWatchForTests(null); // reset the injected fs.watch seam
  69. try { cg.unwatch(); } catch { /* ignore */ }
  70. try { cg.close(); } catch { /* ignore */ }
  71. if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  72. });
  73. // Force watch-resource exhaustion at startup so the real watcher degrades
  74. // deterministically on any platform (recursive or per-directory strategy).
  75. const degradeWatcher = () => {
  76. __setFsWatchForTests(() => {
  77. const err = new Error('too many open files') as NodeJS.ErrnoException;
  78. err.code = 'EMFILE';
  79. throw err;
  80. });
  81. const started = cg.watch({ debounceMs: 1000 }); // real (non-inert) watcher
  82. expect(started).toBe(false);
  83. expect(cg.isWatcherDegraded()).toBe(true);
  84. };
  85. it('prepends a stale banner when the response references a pending file', async () => {
  86. // Long debounce so the edit lingers in pendingFiles while we query.
  87. cg.watch({ debounceMs: 4000, inertForTests: true });
  88. await cg.waitUntilWatcherReady();
  89. // Real disk write so a later sync (if it fires) sees the new content,
  90. // plus a synthesized chokidar event so the watcher's pendingFiles set
  91. // updates immediately without waiting on OS-level event delivery.
  92. fs.writeFileSync(
  93. path.join(testDir, 'src', 'alpha-only.ts'),
  94. 'export function alphaOnly() { return 99; }\n',
  95. );
  96. __emitWatchEventForTests(testDir, 'src/alpha-only.ts');
  97. // With mocked chokidar this is synchronous — keep the wait just to
  98. // exercise the realistic shape (the watcher's `chokidarReady` gate
  99. // and the small window before the pending-file Map is populated).
  100. await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/alpha-only.ts'));
  101. const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
  102. expect(res.isError).toBeFalsy();
  103. const text = res.content[0].text;
  104. // Banner shape: warning glyph + filename + actionable instruction.
  105. expect(text.startsWith('⚠️')).toBe(true);
  106. expect(text).toContain('src/alpha-only.ts');
  107. expect(text).toMatch(/edited \d+ms ago/);
  108. expect(text).toMatch(/Read them directly/);
  109. // The actual result must still follow the banner.
  110. expect(text).toMatch(/alphaOnly/);
  111. });
  112. it('uses the footer (not the banner) when pending files are not referenced', async () => {
  113. cg.watch({ debounceMs: 4000, inertForTests: true });
  114. await cg.waitUntilWatcherReady();
  115. // Edit bravo-only.ts but search for the alphaOnly symbol, whose hit is
  116. // only in alpha-only.ts. The two files share no imports/calls so the
  117. // response text won't mention bravo-only.ts.
  118. fs.writeFileSync(
  119. path.join(testDir, 'src', 'bravo-only.ts'),
  120. 'export function bravoOnly() { return 22; }\n',
  121. );
  122. __emitWatchEventForTests(testDir, 'src/bravo-only.ts');
  123. await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/bravo-only.ts'));
  124. const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
  125. const text = res.content[0].text;
  126. expect(text.startsWith('⚠️')).toBe(false);
  127. expect(text).toMatch(/elsewhere in this project are pending index sync/);
  128. expect(text).toContain('src/bravo-only.ts');
  129. });
  130. it('drops the banner once the sync completes and clears the pending entry', async () => {
  131. cg.watch({ debounceMs: 200, inertForTests: true });
  132. await cg.waitUntilWatcherReady();
  133. fs.writeFileSync(
  134. path.join(testDir, 'src', 'alpha-only.ts'),
  135. 'export function alphaOnly() { return 7; }\n',
  136. );
  137. __emitWatchEventForTests(testDir, 'src/alpha-only.ts');
  138. // Wait through debounce (200ms) + sync; pendingFiles drains back to empty.
  139. await waitFor(() => cg.getPendingFiles().length === 0, 3000);
  140. const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
  141. const text = res.content[0].text;
  142. expect(text.startsWith('⚠️')).toBe(false);
  143. expect(text).not.toMatch(/elsewhere in this project are pending index sync/);
  144. });
  145. it('lists pending files under "Pending sync" in codegraph_status', async () => {
  146. cg.watch({ debounceMs: 4000, inertForTests: true });
  147. await cg.waitUntilWatcherReady();
  148. fs.writeFileSync(
  149. path.join(testDir, 'src', 'charlie-only.ts'),
  150. 'export function charlieOnly() { return 33; }\n',
  151. );
  152. __emitWatchEventForTests(testDir, 'src/charlie-only.ts');
  153. await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/charlie-only.ts'));
  154. const res = await handler.execute('codegraph_status', {});
  155. const text = res.content[0].text;
  156. expect(text).toContain('### Pending sync:');
  157. expect(text).toContain('src/charlie-only.ts');
  158. // Status embeds the info first-class, so the auto-banner is suppressed.
  159. expect(text.startsWith('⚠️')).toBe(false);
  160. });
  161. it('returns zero pending files when no watcher is active', () => {
  162. expect(cg.getPendingFiles()).toEqual([]);
  163. });
  164. it('prepends a whole-index degraded banner once live watching has permanently stopped (#876)', async () => {
  165. degradeWatcher();
  166. const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
  167. expect(res.isError).toBeFalsy();
  168. const text = res.content[0].text;
  169. expect(text.startsWith('⚠️')).toBe(true);
  170. expect(text).toMatch(/auto-sync is DISABLED/i);
  171. expect(text).toMatch(/Read files directly/i);
  172. expect(text).toContain('OS watch/file limit exhausted'); // the degrade reason
  173. expect(text).toMatch(/alphaOnly/); // the real result still follows the banner
  174. });
  175. it('surfaces the degraded state as its own section in codegraph_status (#876)', async () => {
  176. degradeWatcher();
  177. const res = await handler.execute('codegraph_status', {});
  178. const text = res.content[0].text;
  179. expect(text).toContain('### Auto-sync disabled:');
  180. expect(text).toContain('OS watch/file limit exhausted');
  181. // status renders the notice inline, so the auto-banner is not also prepended.
  182. expect(text.startsWith('⚠️')).toBe(false);
  183. });
  184. });