explore-cross-call-dedup.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. /**
  2. * Cross-call source dedup (CG-18).
  3. *
  4. * A later `codegraph_explore` call in a session must not re-send source an
  5. * earlier call already delivered — but every byte it withholds has to be
  6. * replaced by a POINTER, never a silence. That asymmetry is what this suite
  7. * guards, because the two failure directions cost wildly different amounts: a
  8. * duplicate range wastes a few thousand chars, while a response that reads as
  9. * "codegraph doesn't have it" costs a Read — and one or two of those early in a
  10. * session teach an agent to stop calling the tool at all.
  11. *
  12. * Three layers:
  13. * 1. the range algebra — what is withheld, and the thresholds that stop it
  14. * from shredding a block into slivers;
  15. * 2. the fingerprint gate — an edit between two calls must re-emit, since a
  16. * pointer to pre-edit source is worse than no dedup at all;
  17. * 3. the handler seam — a real second call against a real index: no duplicate
  18. * ranges, a pointer for everything withheld, the reclaimed budget spent on
  19. * source the agent has NOT seen, and never an all-pointer response.
  20. */
  21. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  22. import * as fs from 'fs';
  23. import * as path from 'path';
  24. import * as os from 'os';
  25. import CodeGraph from '../src/index';
  26. import { ToolHandler } from '../src/mcp/tools';
  27. import { ExploreSessionState, type ExploreProjectState } from '../src/mcp/explore-session-state';
  28. import {
  29. EXPLORE_DEDUP,
  30. dedupeRange,
  31. exploreDedupEnabled,
  32. fileFingerprint,
  33. formatBackReference,
  34. intersectRange,
  35. mergeRanges,
  36. servedRangesForFile,
  37. subtractRange,
  38. symbolsInSpans,
  39. } from '../src/mcp/explore-dedup';
  40. const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'payroll-go');
  41. const QUERY = 'how does payroll cycle create and calculate payslips?';
  42. const POINTER = 'Already sent earlier in this conversation';
  43. describe('dedup configuration', () => {
  44. it('defaults off and requires an explicit truthy opt-in', () => {
  45. const previous = process.env.CODEGRAPH_EXPLORE_DEDUP;
  46. try {
  47. delete process.env.CODEGRAPH_EXPLORE_DEDUP;
  48. expect(exploreDedupEnabled()).toBe(false);
  49. for (const enabled of ['1', 'true', 'on', 'yes', ' YES ']) {
  50. process.env.CODEGRAPH_EXPLORE_DEDUP = enabled;
  51. expect(exploreDedupEnabled()).toBe(true);
  52. }
  53. for (const disabled of ['0', 'false', 'off', 'no', 'unexpected']) {
  54. process.env.CODEGRAPH_EXPLORE_DEDUP = disabled;
  55. expect(exploreDedupEnabled()).toBe(false);
  56. }
  57. } finally {
  58. if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEDUP;
  59. else process.env.CODEGRAPH_EXPLORE_DEDUP = previous;
  60. }
  61. });
  62. });
  63. /** A prior-state shaped like the session tracker's, for the algebra tests. */
  64. function prior(files: Array<{ path: string; ranges: Array<[number, number]>; fingerprint?: string }>): ExploreProjectState {
  65. return {
  66. projectRoot: '/repo',
  67. callCount: 1,
  68. responseBytes: 1000,
  69. calls: [{
  70. index: 1,
  71. projectRoot: '/repo',
  72. query: 'q',
  73. sourceBytes: 500,
  74. responseBytes: 1000,
  75. files: files.map((f) => ({
  76. path: f.path,
  77. ranges: f.ranges.map(([start, end]) => ({ start, end })),
  78. bytes: 500,
  79. fingerprint: f.fingerprint,
  80. })),
  81. }],
  82. };
  83. }
  84. describe('range algebra', () => {
  85. it('subtracts a held span out of the middle of an intended one', () => {
  86. expect(subtractRange({ start: 1, end: 100 }, [{ start: 20, end: 40 }]))
  87. .toEqual([{ start: 1, end: 19 }, { start: 41, end: 100 }]);
  88. });
  89. it('subtracts held spans at either edge, and a full cover to nothing', () => {
  90. expect(subtractRange({ start: 10, end: 50 }, [{ start: 1, end: 20 }]))
  91. .toEqual([{ start: 21, end: 50 }]);
  92. expect(subtractRange({ start: 10, end: 50 }, [{ start: 30, end: 90 }]))
  93. .toEqual([{ start: 10, end: 29 }]);
  94. expect(subtractRange({ start: 10, end: 50 }, [{ start: 1, end: 90 }])).toEqual([]);
  95. });
  96. it('intersects to exactly what both sides hold', () => {
  97. expect(intersectRange({ start: 10, end: 50 }, [{ start: 1, end: 20 }, { start: 45, end: 80 }]))
  98. .toEqual([{ start: 10, end: 20 }, { start: 45, end: 50 }]);
  99. expect(intersectRange({ start: 10, end: 50 }, [{ start: 60, end: 80 }])).toEqual([]);
  100. });
  101. it('merges touching spans — two adjacent blocks are one block of source', () => {
  102. expect(mergeRanges([{ start: 5, end: 9 }, { start: 10, end: 12 }, { start: 40, end: 41 }]))
  103. .toEqual([{ start: 5, end: 12 }, { start: 40, end: 41 }]);
  104. });
  105. it('emits ONLY the delta when a later call wants a wider window', () => {
  106. // Call 1 sent the method; call 2 wants the class around it.
  107. const { emit, covered } = dedupeRange({ start: 80, end: 200 }, [{ start: 100, end: 140 }]);
  108. expect(covered).toEqual([{ start: 100, end: 140 }]);
  109. expect(emit).toEqual([{ start: 80, end: 99 }, { start: 141, end: 200 }]);
  110. });
  111. it('withholds nothing when the overlap is smaller than a chunk worth pointing at', () => {
  112. // Context padding and signature lines land here. Replacing them costs more
  113. // in pointer text than the source is worth, and shreds the block.
  114. const overlap = EXPLORE_DEDUP.MIN_COVERED_LINES - 1;
  115. const { emit, covered } = dedupeRange({ start: 1, end: 100 }, [{ start: 10, end: 10 + overlap - 1 }]);
  116. expect(covered).toEqual([]);
  117. expect(emit).toEqual([{ start: 1, end: 100 }]);
  118. });
  119. it('leaves an untouched span exactly as it was', () => {
  120. expect(dedupeRange({ start: 1, end: 50 }, [{ start: 200, end: 400 }]))
  121. .toEqual({ emit: [{ start: 1, end: 50 }], covered: [] });
  122. expect(dedupeRange({ start: 1, end: 50 }, []))
  123. .toEqual({ emit: [{ start: 1, end: 50 }], covered: [] });
  124. });
  125. });
  126. describe('the fingerprint gate', () => {
  127. const FP = fileFingerprint('package main\nfunc main() {}\n');
  128. it('returns the spans a call served for a file whose bytes are unchanged', () => {
  129. const state = prior([{ path: 'a.go', ranges: [[1, 40], [60, 80]], fingerprint: FP }]);
  130. expect(servedRangesForFile(state, 'a.go', FP)).toEqual([{ start: 1, end: 40 }, { start: 60, end: 80 }]);
  131. });
  132. it('returns NOTHING once the file has been edited — a pointer would be wrong', () => {
  133. const state = prior([{ path: 'a.go', ranges: [[1, 40]], fingerprint: FP }]);
  134. const edited = fileFingerprint('package main\nfunc main() { changed() }\n');
  135. expect(servedRangesForFile(state, 'a.go', edited)).toEqual([]);
  136. });
  137. it('ignores a record that cannot prove what it served', () => {
  138. const state = prior([{ path: 'a.go', ranges: [[1, 40]] }]);
  139. expect(servedRangesForFile(state, 'a.go', FP)).toEqual([]);
  140. });
  141. it('never crosses files, and is empty for an untracked session', () => {
  142. const state = prior([{ path: 'a.go', ranges: [[1, 40]], fingerprint: FP }]);
  143. expect(servedRangesForFile(state, 'b.go', FP)).toEqual([]);
  144. expect(servedRangesForFile(null, 'a.go', FP)).toEqual([]);
  145. });
  146. it('distinguishes two files that hash the same prefix but differ in length', () => {
  147. expect(fileFingerprint('abc')).not.toBe(fileFingerprint('abcd'));
  148. expect(fileFingerprint('abc')).toBe(fileFingerprint('abc'));
  149. });
  150. });
  151. describe('the back-reference itself', () => {
  152. const covered = [{ start: 100, end: 240 }];
  153. it('names the file, the span and the symbols, and says the copy is still good', () => {
  154. const text = formatBackReference('internal/x.go', covered, ['RunCycle', 'BuildPayslip'], { partial: false });
  155. expect(text).toContain('internal/x.go');
  156. expect(text).toContain('L100-240');
  157. expect(text).toContain('RunCycle, BuildPayslip');
  158. expect(text).toContain(POINTER);
  159. expect(text).toContain('unchanged on disk');
  160. });
  161. it('never tells the agent to Read, in either shape', () => {
  162. for (const partial of [true, false]) {
  163. const text = formatBackReference('x.go', covered, ['A'], { partial });
  164. expect(text).toMatch(/do NOT Read/i);
  165. expect(text).not.toMatch(/\bRead (this|the) file (for|to)\b/i);
  166. expect(text).not.toMatch(/omitted|unavailable|could not/i);
  167. }
  168. });
  169. it('says the block below is only the NEW lines when the call still sends some', () => {
  170. expect(formatBackReference('x.go', covered, [], { partial: true })).toContain('NEW lines');
  171. expect(formatBackReference('x.go', covered, [], { partial: false })).toContain('not repeated here');
  172. });
  173. it('names only symbols that actually fall in the withheld spans', () => {
  174. const nodes = [
  175. { name: 'InSpan', kind: 'function', startLine: 110, endLine: 130 },
  176. { name: 'Outside', kind: 'function', startLine: 300, endLine: 320 },
  177. { name: 'AnImport', kind: 'import', startLine: 105, endLine: 105 },
  178. ];
  179. expect(symbolsInSpans(nodes, covered)).toEqual(['InSpan']);
  180. });
  181. });
  182. describe('a second call against a real index', () => {
  183. let testDir: string;
  184. let cg: CodeGraph;
  185. let handler: ToolHandler;
  186. let previousDedup: string | undefined;
  187. beforeAll(async () => {
  188. previousDedup = process.env.CODEGRAPH_EXPLORE_DEDUP;
  189. process.env.CODEGRAPH_EXPLORE_DEDUP = '1';
  190. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg18-'));
  191. fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
  192. fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
  193. cg = CodeGraph.initSync(testDir);
  194. await cg.indexAll();
  195. handler = new ToolHandler(cg);
  196. }, 120_000);
  197. afterAll(() => {
  198. if (previousDedup === undefined) delete process.env.CODEGRAPH_EXPLORE_DEDUP;
  199. else process.env.CODEGRAPH_EXPLORE_DEDUP = previousDedup;
  200. if (cg) cg.destroy();
  201. if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  202. });
  203. const explore = (query: string, session?: ExploreSessionState, args: Record<string, unknown> = {}) =>
  204. handler.execute('codegraph_explore', { query, ...args }, session).then((r) => r.content[0]!.text);
  205. /**
  206. * The line numbers actually inside each file's fenced source. Read off the
  207. * RESPONSE, not the bookkeeping — "no duplicate ranges" is a claim about what
  208. * the agent received, and checking it against the record we also wrote would
  209. * prove only that the two agree.
  210. */
  211. function fencedLines(text: string): Map<string, Set<number>> {
  212. const out = new Map<string, Set<number>>();
  213. let current: string | null = null;
  214. let inFence = false;
  215. for (const line of text.split('\n')) {
  216. const header = /^\*\*`([^`]+)`\*\*/.exec(line);
  217. if (header && !inFence) { current = header[1]!; continue; }
  218. if (!inFence && current && line.startsWith('```')) { inFence = true; continue; }
  219. if (inFence && line === '```') { inFence = false; continue; }
  220. if (!inFence || !current) continue;
  221. const numbered = /^(\d+)\t/.exec(line);
  222. if (!numbered) continue;
  223. if (!out.has(current)) out.set(current, new Set());
  224. out.get(current)!.add(Number(numbered[1]));
  225. }
  226. return out;
  227. }
  228. it('never re-sends a line it already sent, and points at every line it withholds', async () => {
  229. const session = new ExploreSessionState();
  230. const first = await explore(QUERY, session);
  231. const second = await explore(QUERY, session);
  232. const before = fencedLines(first);
  233. const after = fencedLines(second);
  234. expect(after.size).toBeGreaterThan(0);
  235. // Every file whose source the second call withheld carries a pointer, and
  236. // the pointer names it.
  237. expect(second).toContain(POINTER);
  238. for (const [file, lines] of before) {
  239. const repeated = [...(after.get(file) ?? [])].filter((n) => lines.has(n));
  240. if (repeated.length === 0) continue;
  241. // The only sanctioned repeat is the anti-abandonment restore, which fires
  242. // ONLY when the call found nothing new to say — and this one did.
  243. throw new Error(`call 2 re-sent ${file} lines ${repeated.slice(0, 5).join(',')}`);
  244. }
  245. }, 120_000);
  246. it('spends the reclaimed bytes on source the agent has not seen', async () => {
  247. const session = new ExploreSessionState();
  248. const first = await explore(QUERY, session);
  249. const second = await explore(QUERY, session);
  250. const before = fencedLines(first);
  251. const after = fencedLines(second);
  252. const fresh = [...after.entries()].reduce(
  253. (sum, [file, lines]) => sum + [...lines].filter((n) => !(before.get(file)?.has(n))).length, 0);
  254. // Not merely "smaller": a shrunken response is what dedup must NOT produce.
  255. // The freed budget has to come back as lines the first call never sent.
  256. expect(fresh).toBeGreaterThan(20);
  257. expect(second.length).toBeLessThan(first.length);
  258. }, 120_000);
  259. it('re-emits in full when the file changed between the two calls', async () => {
  260. const session = new ExploreSessionState();
  261. const target = path.join(testDir, 'internal/usecase/payroll/payslip_builder.go');
  262. const original = fs.readFileSync(target, 'utf-8');
  263. try {
  264. const first = await explore(QUERY, session);
  265. expect(fencedLines(first).has('internal/usecase/payroll/payslip_builder.go')).toBe(true);
  266. fs.writeFileSync(target, original.replace('func sumKind(', 'func sumKindRenamed('), 'utf-8');
  267. const second = await explore(QUERY, session);
  268. // The edited file is served again, whole — a pointer here would send the
  269. // agent to a copy of the file that no longer exists.
  270. const pointerLines = second.split('\n').filter((l) => l.includes(POINTER));
  271. expect(pointerLines.some((l) => l.includes('payslip_builder.go'))).toBe(false);
  272. expect(fencedLines(second).get('internal/usecase/payroll/payslip_builder.go')?.size ?? 0)
  273. .toBeGreaterThan(20);
  274. } finally {
  275. fs.writeFileSync(target, original, 'utf-8');
  276. }
  277. }, 120_000);
  278. it('always returns real source, even when the session already holds everything', async () => {
  279. const session = new ExploreSessionState();
  280. await explore(QUERY, session);
  281. await explore(QUERY, session);
  282. const third = await explore(QUERY, session);
  283. const fourth = await explore(QUERY, session);
  284. // An all-pointer response is the shape that reads as failure. Every call
  285. // keeps at least one real fenced block, however much the session holds.
  286. for (const [n, text] of [[3, third], [4, fourth]] as const) {
  287. const lines = [...fencedLines(text).values()].reduce((s, set) => s + set.size, 0);
  288. expect(lines, `call ${n} returned no source at all`).toBeGreaterThan(10);
  289. }
  290. }, 180_000);
  291. it('leaves the first call of a session untouched', async () => {
  292. const tracked = await explore(QUERY, new ExploreSessionState());
  293. const untracked = await explore(QUERY);
  294. expect(tracked).toBe(untracked);
  295. }, 120_000);
  296. it('keeps two sessions on one handler independent', async () => {
  297. const a = new ExploreSessionState();
  298. const b = new ExploreSessionState();
  299. const firstForA = await explore(QUERY, a);
  300. await explore(QUERY, a);
  301. // B's first call has seen nothing, whatever A has been served.
  302. expect(await explore(QUERY, b)).toBe(firstForA);
  303. }, 180_000);
  304. it('is off entirely under CODEGRAPH_EXPLORE_DEDUP=0', async () => {
  305. const session = new ExploreSessionState();
  306. const previous = process.env.CODEGRAPH_EXPLORE_DEDUP;
  307. process.env.CODEGRAPH_EXPLORE_DEDUP = '0';
  308. try {
  309. const first = await explore(QUERY, session);
  310. const second = await explore(QUERY, session);
  311. expect(second).toBe(first);
  312. expect(second).not.toContain(POINTER);
  313. } finally {
  314. if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEDUP;
  315. else process.env.CODEGRAPH_EXPLORE_DEDUP = previous;
  316. }
  317. }, 120_000);
  318. it('re-serves source by default when a connection may outlive the current context', async () => {
  319. const session = new ExploreSessionState();
  320. const previous = process.env.CODEGRAPH_EXPLORE_DEDUP;
  321. delete process.env.CODEGRAPH_EXPLORE_DEDUP;
  322. try {
  323. const first = await explore(QUERY, session);
  324. const second = await explore(QUERY, session);
  325. expect(second).toBe(first);
  326. expect(second).not.toContain(POINTER);
  327. expect([...fencedLines(second).values()].reduce((sum, lines) => sum + lines.size, 0))
  328. .toBeGreaterThan(20);
  329. } finally {
  330. if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEDUP;
  331. else process.env.CODEGRAPH_EXPLORE_DEDUP = previous;
  332. }
  333. }, 120_000);
  334. it('reports the reclaimed bytes through the CG-4 diagnostic', async () => {
  335. const sidecar = path.join(testDir, 'cg18-diagnostic.jsonl');
  336. const session = new ExploreSessionState();
  337. const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
  338. process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
  339. try {
  340. await explore(QUERY, session);
  341. await explore(QUERY, session);
  342. } finally {
  343. if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
  344. else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
  345. }
  346. const [one, two] = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').map((l) => JSON.parse(l));
  347. expect(one.dedup.savedChars).toBe(0);
  348. expect(two.dedup.savedChars).toBeGreaterThan(1000);
  349. expect(two.dedup.backReferenced.length).toBeGreaterThan(0);
  350. // The reclamation is legible file by file: a back-referenced file spent
  351. // none of its reservation, and the response still filled its envelope.
  352. const backref = two.files.filter((f: { render: string }) => f.render === 'backref');
  353. for (const f of backref) {
  354. expect(f.emittedChars).toBe(0);
  355. expect(f.dedupSavedChars).toBeGreaterThan(0);
  356. expect(f.dedupCovered.length).toBeGreaterThan(0);
  357. }
  358. const spentOnFreshSource = two.files.reduce((s: number, f: { emittedChars: number }) => s + f.emittedChars, 0);
  359. expect(spentOnFreshSource).toBeGreaterThan(0);
  360. }, 120_000);
  361. });