fix(mcp): make explore cross-context dedup opt-in

This commit is contained in:
danusha2345
2026-08-26 17:48:31 +03:00
parent 6a056ec5db
commit 63992facab
3 changed files with 55 additions and 5 deletions
+3
View File
@@ -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
@@ -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();
+8 -5
View File
@@ -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());
}
/**