mcp-staleness-banner.test.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  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 watcher + real CodeGraph index + real
  13. * ToolHandler.execute().
  14. */
  15. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  16. import * as fs from 'fs';
  17. import * as path from 'path';
  18. import * as os from 'os';
  19. import CodeGraph from '../src/index';
  20. import { ToolHandler } from '../src/mcp/tools';
  21. function waitFor(condition: () => boolean, timeoutMs = 5000, intervalMs = 50): Promise<void> {
  22. return new Promise((resolve, reject) => {
  23. const start = Date.now();
  24. const tick = () => {
  25. if (condition()) return resolve();
  26. if (Date.now() - start > timeoutMs) return reject(new Error('waitFor timed out'));
  27. setTimeout(tick, intervalMs);
  28. };
  29. tick();
  30. });
  31. }
  32. describe('MCP staleness banner', () => {
  33. let testDir: string;
  34. let cg: CodeGraph;
  35. let handler: ToolHandler;
  36. beforeEach(async () => {
  37. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-stale-banner-'));
  38. fs.mkdirSync(path.join(testDir, 'src'));
  39. // Three isolated files with no cross-references — keeps each test's
  40. // "which path does the response mention?" assertion unambiguous. If the
  41. // files shared imports/calls, codegraph_search responses would surface
  42. // multiple file paths and the banner-vs-footer split would be racy.
  43. fs.writeFileSync(
  44. path.join(testDir, 'src', 'alpha-only.ts'),
  45. 'export function alphaOnly() { return 1; }\n',
  46. );
  47. fs.writeFileSync(
  48. path.join(testDir, 'src', 'bravo-only.ts'),
  49. 'export function bravoOnly() { return 2; }\n',
  50. );
  51. fs.writeFileSync(
  52. path.join(testDir, 'src', 'charlie-only.ts'),
  53. 'export function charlieOnly() { return 3; }\n',
  54. );
  55. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  56. await cg.indexAll();
  57. handler = new ToolHandler(cg);
  58. });
  59. afterEach(() => {
  60. try { cg.unwatch(); } catch { /* ignore */ }
  61. try { cg.close(); } catch { /* ignore */ }
  62. if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  63. });
  64. it('prepends a stale banner when the response references a pending file', async () => {
  65. // Long debounce so the edit lingers in pendingFiles while we query.
  66. cg.watch({ debounceMs: 4000 });
  67. await cg.waitUntilWatcherReady();
  68. fs.writeFileSync(
  69. path.join(testDir, 'src', 'alpha-only.ts'),
  70. 'export function alphaOnly() { return 99; }\n',
  71. );
  72. await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/alpha-only.ts'), 8000);
  73. const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
  74. expect(res.isError).toBeFalsy();
  75. const text = res.content[0].text;
  76. // Banner shape: warning glyph + filename + actionable instruction.
  77. expect(text.startsWith('⚠️')).toBe(true);
  78. expect(text).toContain('src/alpha-only.ts');
  79. expect(text).toMatch(/edited \d+ms ago/);
  80. expect(text).toMatch(/Read them directly/);
  81. // The actual result must still follow the banner.
  82. expect(text).toMatch(/alphaOnly/);
  83. });
  84. it('uses the footer (not the banner) when pending files are not referenced', async () => {
  85. cg.watch({ debounceMs: 4000 });
  86. await cg.waitUntilWatcherReady();
  87. // Edit bravo-only.ts but search for the alphaOnly symbol, whose hit is
  88. // only in alpha-only.ts. The two files share no imports/calls so the
  89. // response text won't mention bravo-only.ts.
  90. fs.writeFileSync(
  91. path.join(testDir, 'src', 'bravo-only.ts'),
  92. 'export function bravoOnly() { return 22; }\n',
  93. );
  94. await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/bravo-only.ts'), 8000);
  95. const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
  96. const text = res.content[0].text;
  97. expect(text.startsWith('⚠️')).toBe(false);
  98. expect(text).toMatch(/elsewhere in this project are pending index sync/);
  99. expect(text).toContain('src/bravo-only.ts');
  100. });
  101. it('drops the banner once the sync completes and clears the pending entry', async () => {
  102. cg.watch({ debounceMs: 200 });
  103. await cg.waitUntilWatcherReady();
  104. fs.writeFileSync(
  105. path.join(testDir, 'src', 'alpha-only.ts'),
  106. 'export function alphaOnly() { return 7; }\n',
  107. );
  108. await waitFor(() => cg.getPendingFiles().length === 0, 5000);
  109. const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
  110. const text = res.content[0].text;
  111. expect(text.startsWith('⚠️')).toBe(false);
  112. expect(text).not.toMatch(/elsewhere in this project are pending index sync/);
  113. });
  114. it('lists pending files under "Pending sync" in codegraph_status', async () => {
  115. cg.watch({ debounceMs: 4000 });
  116. await cg.waitUntilWatcherReady();
  117. fs.writeFileSync(
  118. path.join(testDir, 'src', 'charlie-only.ts'),
  119. 'export function charlieOnly() { return 33; }\n',
  120. );
  121. await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/charlie-only.ts'), 8000);
  122. const res = await handler.execute('codegraph_status', {});
  123. const text = res.content[0].text;
  124. expect(text).toContain('### Pending sync:');
  125. expect(text).toContain('src/charlie-only.ts');
  126. // Status embeds the info first-class, so the auto-banner is suppressed.
  127. expect(text.startsWith('⚠️')).toBe(false);
  128. });
  129. it('returns zero pending files when no watcher is active', () => {
  130. expect(cg.getPendingFiles()).toEqual([]);
  131. });
  132. });