diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca5b9ff1..a9d525781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- `codegraph_explore` no longer tells a fresh subagent, or an agent after context compaction, to reuse source that only an earlier context received. Cross-call source suppression now requires explicit `CODEGRAPH_EXPLORE_DEDUP=1`; without a reliable host-provided context lifecycle, the default safely re-serves source on every call. (#1620) ## [1.6.0] - 2026-08-26 diff --git a/__tests__/explore-cross-call-dedup.test.ts b/__tests__/explore-cross-call-dedup.test.ts index 608d85ce8..ce208dc95 100644 --- a/__tests__/explore-cross-call-dedup.test.ts +++ b/__tests__/explore-cross-call-dedup.test.ts @@ -28,6 +28,7 @@ import { ExploreSessionState, type ExploreProjectState } from '../src/mcp/explor import { EXPLORE_DEDUP, dedupeRange, + exploreDedupEnabled, fileFingerprint, formatBackReference, intersectRange, @@ -41,6 +42,27 @@ const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'payroll-go'); const QUERY = 'how does payroll cycle create and calculate payslips?'; const POINTER = 'Already sent earlier in this conversation'; +describe('dedup configuration', () => { + it('defaults off and requires an explicit truthy opt-in', () => { + const previous = process.env.CODEGRAPH_EXPLORE_DEDUP; + try { + delete process.env.CODEGRAPH_EXPLORE_DEDUP; + expect(exploreDedupEnabled()).toBe(false); + for (const enabled of ['1', 'true', 'on', 'yes', ' YES ']) { + process.env.CODEGRAPH_EXPLORE_DEDUP = enabled; + expect(exploreDedupEnabled()).toBe(true); + } + for (const disabled of ['0', 'false', 'off', 'no', 'unexpected']) { + process.env.CODEGRAPH_EXPLORE_DEDUP = disabled; + expect(exploreDedupEnabled()).toBe(false); + } + } finally { + if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEDUP; + else process.env.CODEGRAPH_EXPLORE_DEDUP = previous; + } + }); +}); + /** A prior-state shaped like the session tracker's, for the algebra tests. */ function prior(files: Array<{ path: string; ranges: Array<[number, number]>; fingerprint?: string }>): ExploreProjectState { return { @@ -183,8 +205,11 @@ describe('a second call against a real index', () => { let testDir: string; let cg: CodeGraph; let handler: ToolHandler; + let previousDedup: string | undefined; beforeAll(async () => { + previousDedup = process.env.CODEGRAPH_EXPLORE_DEDUP; + process.env.CODEGRAPH_EXPLORE_DEDUP = '1'; testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg18-')); fs.cpSync(FIXTURE_SRC, testDir, { recursive: true }); fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true }); @@ -194,6 +219,8 @@ describe('a second call against a real index', () => { }, 120_000); afterAll(() => { + if (previousDedup === undefined) delete process.env.CODEGRAPH_EXPLORE_DEDUP; + else process.env.CODEGRAPH_EXPLORE_DEDUP = previousDedup; if (cg) cg.destroy(); if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); }); @@ -328,6 +355,23 @@ describe('a second call against a real index', () => { } }, 120_000); + it('re-serves source by default when a connection may outlive the current context', async () => { + const session = new ExploreSessionState(); + const previous = process.env.CODEGRAPH_EXPLORE_DEDUP; + delete process.env.CODEGRAPH_EXPLORE_DEDUP; + try { + const first = await explore(QUERY, session); + const second = await explore(QUERY, session); + expect(second).toBe(first); + expect(second).not.toContain(POINTER); + expect([...fencedLines(second).values()].reduce((sum, lines) => sum + lines.size, 0)) + .toBeGreaterThan(20); + } finally { + if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEDUP; + else process.env.CODEGRAPH_EXPLORE_DEDUP = previous; + } + }, 120_000); + it('reports the reclaimed bytes through the CG-4 diagnostic', async () => { const sidecar = path.join(testDir, 'cg18-diagnostic.jsonl'); const session = new ExploreSessionState(); diff --git a/src/mcp/explore-dedup.ts b/src/mcp/explore-dedup.ts index 541945c7d..f39f2211b 100644 --- a/src/mcp/explore-dedup.ts +++ b/src/mcp/explore-dedup.ts @@ -68,16 +68,19 @@ export const EXPLORE_DEDUP = { MAX_SYMBOLS_IN_POINTER: 5, } as const; -const OFF = new Set(['0', 'false', 'off', 'no']); +const ON = new Set(['1', 'true', 'on', 'yes']); /** - * Kill switch: `CODEGRAPH_EXPLORE_DEDUP=0` renders every call as if the session - * had no history. Read per call (not memoized) so a test can toggle it. + * Cross-call source suppression is opt-in. An MCP connection is not a reliable + * conversation boundary: some hosts reuse it for subagents, and compaction can + * discard source while keeping the connection alive (#1620). Without a host- + * supplied context lifecycle, re-serving source is the only always-correct + * default. Read per call (not memoized) so tests and launchers can toggle it. */ export function exploreDedupEnabled(): boolean { const raw = process.env.CODEGRAPH_EXPLORE_DEDUP; - if (raw === undefined) return true; - return !OFF.has(raw.trim().toLowerCase()); + if (raw === undefined) return false; + return ON.has(raw.trim().toLowerCase()); } /**