Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
44 changes: 44 additions & 0 deletions __tests__/explore-cross-call-dedup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { ExploreSessionState, type ExploreProjectState } from '../src/mcp/explor
import {
EXPLORE_DEDUP,
dedupeRange,
exploreDedupEnabled,
fileFingerprint,
formatBackReference,
intersectRange,
Expand All @@ -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 {
Expand Down Expand Up @@ -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 });
Expand All @@ -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 });
});
Expand Down Expand Up @@ -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();
Expand Down
13 changes: 8 additions & 5 deletions src/mcp/explore-dedup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

/**
Expand Down