diff --git a/__tests__/explore-session-state.test.ts b/__tests__/explore-session-state.test.ts new file mode 100644 index 0000000..c0b71d9 --- /dev/null +++ b/__tests__/explore-session-state.test.ts @@ -0,0 +1,469 @@ +/** + * Session-scoped explore call state (CG-17). + * + * The tracker is the foundation for cross-call dedup (CG-18) and budget decay + * (CG-19), so what it must get right is what those two will trust: the count of + * calls, the line ranges already served, and — above all — WHOSE they are. Two + * agents on one daemon share a ToolHandler and a worker pool; if their histories + * blend, a dedup built on this would withhold source from an agent that never + * saw it, and the agent Reads the file. That is the failure this suite guards. + * + * Three layers: + * 1. the state container itself — keying, monotonic call index, bounds; + * 2. the handler seam — a real explore against a real index records real + * ranges, and the emission side-channel NEVER reaches the response; + * 3. the session seam — separate sessions on one engine, separate state. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { ToolHandler } from '../src/mcp/tools'; +import { MCPSession } from '../src/mcp/session'; +import type { MCPEngine } from '../src/mcp/engine'; +import type { JsonRpcTransport, JsonRpcRequest, JsonRpcNotification } from '../src/mcp/transport'; +import { + EXPLORE_EMISSION_KEY, + EXPLORE_SESSION_LIMITS, + EXPLORE_SESSION_VIEW_ARG, + ExploreSessionState, + coalesceRanges, + exploreProjectKey, + rangesCover, + readExploreSessionView, + viewForProject, + type ExploreEmission, +} from '../src/mcp/explore-session-state'; + +const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'payroll-go'); +const QUERY = 'how does payroll cycle create and calculate payslips?'; + +/** An emission shaped like a real one, for the container-level tests. */ +function emission(root: string, over: Partial = {}): ExploreEmission { + return { + projectRoot: root, + query: 'q', + files: [{ path: 'a.ts', ranges: [{ start: 1, end: 10 }], bytes: 100 }], + sourceBytes: 100, + responseBytes: 400, + ...over, + }; +} + +describe('ExploreSessionState — the container', () => { + it('counts calls per project and hands back a 1-based session index', () => { + const state = new ExploreSessionState(); + expect(state.record(emission('/repo/a'))?.index).toBe(1); + expect(state.record(emission('/repo/a'))?.index).toBe(2); + expect(state.callCount('/repo/a')).toBe(2); + expect(state.forProject('/repo/a')?.responseBytes).toBe(800); + }); + + it('keys state per project — a second project starts its own count', () => { + const state = new ExploreSessionState(); + state.record(emission('/repo/a')); + state.record(emission('/repo/a')); + expect(state.record(emission('/repo/b'))?.index).toBe(1); + expect(state.callCount('/repo/a')).toBe(2); + expect(state.callCount('/repo/b')).toBe(1); + expect(state.forProject('/repo/b')?.calls).toHaveLength(1); + }); + + it('treats trailing slashes and `.` segments as the same project', () => { + const state = new ExploreSessionState(); + state.record(emission('/repo/a')); + state.record(emission('/repo/a/')); + state.record(emission('/repo/a/./')); + expect(state.callCount('/repo/a')).toBe(3); + expect(state.snapshot()).toHaveLength(1); + }); + + it('never reports a project it was never told about', () => { + const state = new ExploreSessionState(); + expect(state.forProject('/never/queried')).toBeNull(); + expect(state.callCount('/never/queried')).toBe(0); + }); + + it('keeps counting past the retained-call bound — decay must not reset itself', () => { + const state = new ExploreSessionState(); + const total = EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED + 5; + for (let i = 0; i < total; i++) state.record(emission('/repo/a')); + const project = state.forProject('/repo/a')!; + expect(project.callCount).toBe(total); + expect(project.calls).toHaveLength(EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED); + // Detail is dropped from the OLDEST end; the newest call is always retained. + expect(project.calls[project.calls.length - 1]!.index).toBe(total); + expect(project.calls[0]!.index).toBe(total - EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED + 1); + }); + + it('bounds the number of projects, evicting the least recently used', () => { + const state = new ExploreSessionState(); + const roots = Array.from({ length: EXPLORE_SESSION_LIMITS.MAX_PROJECTS + 2 }, (_, i) => `/repo/${i}`); + for (const root of roots) state.record(emission(root)); + expect(state.snapshot()).toHaveLength(EXPLORE_SESSION_LIMITS.MAX_PROJECTS); + expect(state.forProject(roots[0]!)).toBeNull(); + expect(state.forProject(roots[roots.length - 1]!)).not.toBeNull(); + }); + + it('keeps a re-queried project alive past newer ones', () => { + const state = new ExploreSessionState(); + const roots = Array.from({ length: EXPLORE_SESSION_LIMITS.MAX_PROJECTS }, (_, i) => `/repo/${i}`); + for (const root of roots) state.record(emission(root)); + state.record(emission(roots[0]!)); // touch the oldest + state.record(emission('/repo/newcomer')); // forces one eviction + expect(state.forProject(roots[0]!)?.callCount).toBe(2); + expect(state.forProject(roots[1]!)).toBeNull(); + }); + + it('bounds files per call, keeping the ones that got the most source', () => { + const state = new ExploreSessionState(); + const files = Array.from({ length: EXPLORE_SESSION_LIMITS.MAX_FILES_PER_CALL + 6 }, (_, i) => ({ + path: `f${i}.ts`, + ranges: [{ start: 1, end: 5 }], + bytes: i + 1, + })); + state.record(emission('/repo/a', { files })); + const kept = state.forProject('/repo/a')!.calls[0]!.files; + expect(kept).toHaveLength(EXPLORE_SESSION_LIMITS.MAX_FILES_PER_CALL); + expect(kept.map((f) => f.path)).toContain(`f${files.length - 1}.ts`); + expect(kept.map((f) => f.path)).not.toContain('f0.ts'); + }); + + it('ignores an emission with no project root rather than filing it under ""', () => { + const state = new ExploreSessionState(); + expect(state.record({ ...emission(''), projectRoot: '' })).toBeNull(); + expect(state.snapshot()).toHaveLength(0); + }); + + it('hands out copies — a caller cannot mutate the record it read', () => { + const state = new ExploreSessionState(); + state.record(emission('/repo/a')); + const snap = state.forProject('/repo/a')!; + snap.calls[0]!.files[0]!.ranges.push({ start: 999, end: 1000 }); + expect(state.forProject('/repo/a')!.calls[0]!.files[0]!.ranges).toHaveLength(1); + }); + + it('view() carries only the most recent calls per project', () => { + const state = new ExploreSessionState(); + for (let i = 0; i < EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED; i++) state.record(emission('/repo/a')); + const view = state.view(); + expect(view.projects[0]!.callCount).toBe(EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED); + expect(view.projects[0]!.calls).toHaveLength(EXPLORE_SESSION_LIMITS.MAX_VIEW_CALLS); + expect(viewForProject(view, '/repo/a')?.callCount).toBe(EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED); + // A tracked session that hasn't touched this project yet reads as EMPTY, + // not untracked — only a missing view (nobody tracking) is null. + expect(viewForProject(view, '/repo/other')?.callCount).toBe(0); + expect(viewForProject(null, '/repo/a')).toBeNull(); + }); +}); + +describe('range bookkeeping', () => { + it('merges overlapping and adjacent spans into one', () => { + const { ranges, truncated } = coalesceRanges([ + { start: 10, end: 20 }, + { start: 15, end: 25 }, // overlaps + { start: 26, end: 30 }, // adjacent — one contiguous block of source + { start: 60, end: 61 }, + ]); + expect(ranges).toEqual([{ start: 10, end: 30 }, { start: 60, end: 61 }]); + expect(truncated).toBe(false); + }); + + it('drops junk spans instead of recording a range that was never served', () => { + const { ranges } = coalesceRanges([ + { start: 5, end: 1 }, // inverted + { start: 0, end: 3 }, // before line 1 + { start: NaN, end: 4 }, + { start: 7, end: 9 }, + ]); + expect(ranges).toEqual([{ start: 7, end: 9 }]); + }); + + it('caps the range list by KEEPING the largest spans, and says it truncated', () => { + // Spaced far enough apart that none of them merge — this is about the cap. + const many = Array.from({ length: EXPLORE_SESSION_LIMITS.MAX_RANGES_PER_FILE + 5 }, (_, i) => ({ + start: i * 200 + 1, + end: i * 200 + 2 + i, // later spans are longer + })); + const { ranges, truncated } = coalesceRanges(many); + expect(truncated).toBe(true); + expect(ranges).toHaveLength(EXPLORE_SESSION_LIMITS.MAX_RANGES_PER_FILE); + // Still in line order, and the biggest span survived. + expect(ranges.map((r) => r.start)).toEqual([...ranges.map((r) => r.start)].sort((a, b) => a - b)); + expect(ranges.some((r) => r.start === many[many.length - 1]!.start)).toBe(true); + }); + + it('flags truncation on the stored record so a consumer knows it under-knows', () => { + const state = new ExploreSessionState(); + const ranges = Array.from({ length: EXPLORE_SESSION_LIMITS.MAX_RANGES_PER_FILE + 3 }, (_, i) => ({ + start: i * 10 + 1, end: i * 10 + 4, + })); + state.record(emission('/repo/a', { files: [{ path: 'big.ts', ranges, bytes: 900 }] })); + expect(state.forProject('/repo/a')!.calls[0]!.files[0]!.rangesTruncated).toBe(true); + }); + + it('answers whether a line was already served', () => { + const ranges = [{ start: 10, end: 20 }, { start: 40, end: 41 }]; + expect(rangesCover(ranges, 10)).toBe(true); + expect(rangesCover(ranges, 20)).toBe(true); + expect(rangesCover(ranges, 21)).toBe(false); + expect(rangesCover(ranges, 40)).toBe(true); + }); + + it('folds case only on the case-insensitive platforms', () => { + const insensitive = process.platform === 'darwin' || process.platform === 'win32'; + expect(exploreProjectKey('/Repo/A') === exploreProjectKey('/repo/a')).toBe(insensitive); + }); +}); + +describe('session view arriving on tool args', () => { + it('reads a well-formed view and ignores anything else', () => { + const state = new ExploreSessionState(); + state.record(emission('/repo/a')); + expect(readExploreSessionView({ [EXPLORE_SESSION_VIEW_ARG]: state.view() })?.projects).toHaveLength(1); + expect(readExploreSessionView({})).toBeNull(); + expect(readExploreSessionView({ [EXPLORE_SESSION_VIEW_ARG]: 'nope' })).toBeNull(); + expect(readExploreSessionView({ [EXPLORE_SESSION_VIEW_ARG]: { projects: 'nope' } })).toBeNull(); + }); + + it('drops malformed project entries rather than trusting them', () => { + const view = readExploreSessionView({ + [EXPLORE_SESSION_VIEW_ARG]: { projects: [{ projectRoot: '/repo/a', calls: [] }, { nope: 1 }, null] }, + }); + expect(view?.projects).toHaveLength(1); + }); +}); + +describe('explore records what it actually served', () => { + let testDir: string; + let cg: CodeGraph; + let handler: ToolHandler; + + beforeAll(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg17-')); + fs.cpSync(FIXTURE_SRC, testDir, { recursive: true }); + fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true }); + cg = CodeGraph.initSync(testDir); + await cg.indexAll(); + handler = new ToolHandler(cg); + }, 120_000); + + afterAll(() => { + if (cg) cg.destroy(); + if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('files one record per call, with the files and line ranges it emitted', async () => { + const session = new ExploreSessionState(); + await handler.execute('codegraph_explore', { query: QUERY }, session); + + const project = session.forProject(cg.getProjectRoot()); + expect(project).not.toBeNull(); + expect(project!.callCount).toBe(1); + + const call = project!.calls[0]!; + expect(call.files.length).toBeGreaterThan(0); + expect(call.sourceBytes).toBeGreaterThan(0); + expect(call.responseBytes).toBeGreaterThan(call.sourceBytes); + for (const file of call.files) { + expect(file.ranges.length).toBeGreaterThan(0); + for (const r of file.ranges) { + expect(r.start).toBeGreaterThanOrEqual(1); + expect(r.end).toBeGreaterThanOrEqual(r.start); + } + } + }, 60_000); + + it('records only files whose source is really in the response', async () => { + const session = new ExploreSessionState(); + const result = await handler.execute('codegraph_explore', { query: QUERY }, session); + const text = result.content[0]!.text; + for (const file of session.forProject(cg.getProjectRoot())!.calls[0]!.files) { + expect(text).toContain(file.path); + } + }, 60_000); + + it('the recorded ranges name lines that are really in the emitted source', async () => { + const session = new ExploreSessionState(); + await handler.execute('codegraph_explore', { query: QUERY }, session); + for (const file of session.forProject(cg.getProjectRoot())!.calls[0]!.files) { + const lineCount = fs.readFileSync(path.join(testDir, file.path), 'utf-8').split('\n').length; + for (const r of file.ranges) expect(r.end).toBeLessThanOrEqual(lineCount); + } + }, 60_000); + + it('leaves the agent-facing response untouched — no side-channel on the wire', async () => { + const session = new ExploreSessionState(); + const tracked = await handler.execute('codegraph_explore', { query: QUERY }, session); + const untracked = await handler.execute('codegraph_explore', { query: QUERY }); + + expect(tracked.content[0]!.text).toBe(untracked.content[0]!.text); + for (const result of [tracked, untracked]) { + expect(EXPLORE_EMISSION_KEY in result).toBe(false); + expect(JSON.stringify(result)).not.toContain(EXPLORE_EMISSION_KEY); + } + }, 60_000); + + it('ignores a session view a client spelled itself — the record is the server\'s', async () => { + const forged = { + projects: [{ projectRoot: cg.getProjectRoot(), callCount: 99, responseBytes: 1e6, calls: [] }], + }; + const result = await handler.execute('codegraph_explore', { + query: QUERY, + [EXPLORE_SESSION_VIEW_ARG]: forged, + }); + const clean = await handler.execute('codegraph_explore', { query: QUERY }); + expect(result.content[0]!.text).toBe(clean.content[0]!.text); + }, 60_000); + + it('counts an empty answer as a call, since it still spends the tier budget', async () => { + const session = new ExploreSessionState(); + await handler.execute('codegraph_explore', { query: 'zzqqxx_no_such_symbol_anywhere' }, session); + const project = session.forProject(cg.getProjectRoot()); + expect(project?.callCount).toBe(1); + expect(project?.calls[0]!.files).toHaveLength(0); + }, 60_000); + + it('two sessions on ONE handler never see each other\'s calls', async () => { + const a = new ExploreSessionState(); + const b = new ExploreSessionState(); + await handler.execute('codegraph_explore', { query: QUERY }, a); + await handler.execute('codegraph_explore', { query: QUERY }, a); + await handler.execute('codegraph_explore', { query: QUERY }, b); + + expect(a.callCount(cg.getProjectRoot())).toBe(2); + expect(b.callCount(cg.getProjectRoot())).toBe(1); + }, 90_000); + + it('a caller that tracks nothing still gets a clean result', async () => { + const result = await handler.execute('codegraph_explore', { query: QUERY }); + expect(result.isError).toBeFalsy(); + expect(result.content[0]!.text.length).toBeGreaterThan(0); + }, 60_000); + + it('reports the session state through the CG-4 diagnostic', async () => { + const sidecar = path.join(testDir, 'cg17-diagnostic.jsonl'); + const session = new ExploreSessionState(); + const previous = process.env.CODEGRAPH_EXPLORE_DEBUG; + process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar; + try { + await handler.execute('codegraph_explore', { query: QUERY }, session); + await handler.execute('codegraph_explore', { query: QUERY }, session); + } finally { + if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG; + else process.env.CODEGRAPH_EXPLORE_DEBUG = previous; + } + + const reports = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').map((l) => JSON.parse(l)); + expect(reports).toHaveLength(2); + // The first call is the session's first: nothing served before it. + expect(reports[0].session).toEqual({ + callIndex: 1, priorCalls: 0, priorResponseChars: 0, priorFiles: [], + }); + // The second sees the first call's files and their ranges. + expect(reports[1].session.callIndex).toBe(2); + expect(reports[1].session.priorCalls).toBe(1); + expect(reports[1].session.priorResponseChars).toBeGreaterThan(0); + expect(reports[1].session.priorFiles.length).toBeGreaterThan(0); + expect(reports[1].session.priorFiles[0].ranges[0]).toHaveLength(2); + }, 90_000); + + it('omits the session block entirely when the caller tracks no state', async () => { + const sidecar = path.join(testDir, 'cg17-untracked.jsonl'); + const previous = process.env.CODEGRAPH_EXPLORE_DEBUG; + process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar; + try { + await handler.execute('codegraph_explore', { query: QUERY }); + } finally { + if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG; + else process.env.CODEGRAPH_EXPLORE_DEBUG = previous; + } + const report = JSON.parse(fs.readFileSync(sidecar, 'utf-8').trim()); + expect(report.session).toBeUndefined(); + }, 60_000); + + it('keys on the RESOLVED root, not the path the agent typed', async () => { + // The same project reached two ways — bare, and via a `projectPath` pointing + // at a subdirectory. Both resolve to one index, so both must land in one + // bucket; keying on the typed path would split a session's history in two + // and hand a later call a half-empty record. + // + // (Two genuinely DIFFERENT projects can't be exercised here: opening a + // second index inside vitest fails on the lazy `require('../index')` — see + // the ToolHandler cache notes. The container-level tests above cover the + // multi-project keying itself.) + const session = new ExploreSessionState(); + await handler.execute('codegraph_explore', { query: QUERY }, session); + await handler.execute( + 'codegraph_explore', + { query: QUERY, projectPath: path.join(testDir, 'internal') }, + session, + ); + + expect(session.snapshot()).toHaveLength(1); + expect(session.callCount(cg.getProjectRoot())).toBe(2); + }, 90_000); +}); + +describe('sessions sharing a daemon', () => { + /** Minimal transport: captures the message handler so a test can drive it. */ + function fakeTransport(): JsonRpcTransport & { deliver: (m: JsonRpcRequest) => Promise; results: unknown[] } { + let handle: ((m: JsonRpcRequest | JsonRpcNotification) => Promise) | null = null; + const results: unknown[] = []; + return { + start(h) { handle = h as typeof handle; }, + stop() { /* nothing to tear down */ }, + send() { /* unused */ }, + notify() { /* unused */ }, + async request() { return {}; }, + sendResult(_id, result) { results.push(result); }, + sendError() { /* unused */ }, + results, + async deliver(m: JsonRpcRequest) { await handle?.(m); }, + }; + } + + it('give each session its own state, and one session\'s calls stay there', async () => { + const calls: Array = []; + // A ToolHandler stand-in: the point here is WHICH state object arrives, not + // what explore returns, so a real index would only slow the assertion down. + const handler = { + getTools: () => [], + execute: async (_tool: string, _args: Record, state?: ExploreSessionState) => { + calls.push(state); + state?.record(emission('/repo/shared')); + return { content: [{ type: 'text' as const, text: 'ok' }] }; + }, + }; + const engine = { + ensureInitialized: async () => { /* already open */ }, + hasDefaultCodeGraph: () => true, + getProjectPath: () => '/repo/shared', + retryInitializeSync: () => { /* nothing to retry */ }, + getToolHandler: () => handler, + } as unknown as MCPEngine; + + const transportA = fakeTransport(); + const transportB = fakeTransport(); + const sessionA = new MCPSession(transportA, engine); + const sessionB = new MCPSession(transportB, engine); + sessionA.start(); + sessionB.start(); + + expect(sessionA.getExploreSessionState()).not.toBe(sessionB.getExploreSessionState()); + + const call = (id: number): JsonRpcRequest => ({ + jsonrpc: '2.0', id, method: 'tools/call', + params: { name: 'codegraph_explore', arguments: { query: 'q' } }, + }); + await transportA.deliver(call(1)); + await transportA.deliver(call(2)); + await transportB.deliver(call(3)); + + expect(calls[0]).toBe(sessionA.getExploreSessionState()); + expect(calls[2]).toBe(sessionB.getExploreSessionState()); + expect(sessionA.getExploreSessionState().callCount('/repo/shared')).toBe(2); + expect(sessionB.getExploreSessionState().callCount('/repo/shared')).toBe(1); + }); +}); diff --git a/src/mcp/explore-diagnostics.ts b/src/mcp/explore-diagnostics.ts index bec8d27..c54273a 100644 --- a/src/mcp/explore-diagnostics.ts +++ b/src/mcp/explore-diagnostics.ts @@ -32,6 +32,7 @@ */ import { appendFileSync } from 'fs'; +import type { ExploreProjectState } from './explore-session-state'; /** How a file's source was rendered into the response. */ export type ExploreRenderMode = @@ -135,6 +136,22 @@ export interface ExploreDiagnosticFile extends ExploreCandidateMeta { allocatedShare: number; } +/** + * This session's explore history for this project, as of BEFORE the call being + * reported (CG-17). Present only when the caller tracks session state — the CLI + * and bare-handler callers don't, so it is absent there rather than zeroed. + */ +export interface ExploreDiagnosticSession { + /** 1-based index of THIS call within the session, for this project. */ + callIndex: number; + /** Calls already served this session for this project. */ + priorCalls: number; + /** Response chars already served this session for this project. */ + priorResponseChars: number; + /** Files already served source this session, most-recent call first. */ + priorFiles: Array<{ path: string; ranges: Array<[number, number]>; bytes: number }>; +} + /** The full report — one per explore call, JSON-serialized to the sink. */ export interface ExploreDiagnosticReport { tool: 'codegraph_explore'; @@ -142,6 +159,8 @@ export interface ExploreDiagnosticReport { projectRoot: string; indexedFileCount: number; note?: string; + /** Session-scoped call state (CG-17); absent when the caller tracks none. */ + session?: ExploreDiagnosticSession; budget: { maxOutputChars: number; maxCharsPerFile: number; @@ -221,6 +240,7 @@ export class ExploreDiagnostics { private graphGateThreshold = 0; private graphGateApplied = false; private note = ''; + private session: ExploreDiagnosticSession | undefined; private allocPool = 0; private allocCliffAt = 0; private allocCliffed: string[] = []; @@ -265,6 +285,40 @@ export class ExploreDiagnostics { this.stages.pastRelevanceGate = kept; } + /** + * Record what this session had already been served for this project (CG-17), + * so the report says which call in the session it is and what the earlier ones + * cost. Read-only for now: nothing in the render loop consults it, which is + * what keeps the response byte-identical at this stage. + * + * Files are listed most-recent call first and de-duplicated by path — the same + * file re-served across calls is the pattern this instrument exists to make + * visible, and its ranges are unioned so a glance shows what of it the agent + * already holds. + */ + noteSession(prior: ExploreProjectState | null): void { + if (!prior) return; + const byPath = new Map; bytes: number }>(); + for (const call of [...prior.calls].reverse()) { + for (const file of call.files) { + const existing = byPath.get(file.path); + const spans = file.ranges.map((r) => [r.start, r.end] as [number, number]); + if (existing) { + existing.ranges.push(...spans); + existing.bytes += file.bytes; + } else { + byPath.set(file.path, { path: file.path, ranges: spans, bytes: file.bytes }); + } + } + } + this.session = { + callIndex: prior.callCount + 1, + priorCalls: prior.callCount, + priorResponseChars: prior.responseBytes, + priorFiles: [...byPath.values()], + }; + } + /** Candidate count after the `group.score >= floor` filter. */ setScoreFloor(floor: number, kept: number): void { this.scoreFloor = floor; @@ -384,6 +438,7 @@ export class ExploreDiagnostics { projectRoot: this.projectRoot, indexedFileCount: this.indexedFileCount, note: this.note || undefined, + session: this.session, budget: { maxOutputChars: this.budget.maxOutputChars, maxCharsPerFile: this.budget.maxCharsPerFile, @@ -524,6 +579,20 @@ export function renderTable(report: ExploreDiagnosticReport): string { out.push(`codegraph explore diagnostic — "${report.query}"`); out.push(` project ${report.projectRoot} · ${num(report.indexedFileCount)} files indexed`); if (report.note) out.push(` note: ${report.note}`); + if (report.session) { + const s = report.session; + out.push( + ` session call #${s.callIndex} for this project` + + ` · ${num(s.priorCalls)} prior call${s.priorCalls === 1 ? '' : 's'}` + + ` · ${num(s.priorResponseChars)} chars already served`, + ); + for (const f of s.priorFiles.slice(0, 12)) { + const spans = f.ranges.slice(0, 6).map(([a, b]) => (a === b ? `${a}` : `${a}-${b}`)).join(','); + const more = f.ranges.length > 6 ? `,+${f.ranges.length - 6}` : ''; + out.push(` already served ${f.path} · ${num(f.bytes)} chars · L${spans}${more}`); + } + if (s.priorFiles.length > 12) out.push(` … +${s.priorFiles.length - 12} more already-served file(s)`); + } out.push( ` envelope ${num(env.chars)} chars delivered · ${num(env.allocatedChars)} allocated` + ` of ${num(budget.maxOutputChars)} budget (hard ceiling ${num(budget.hardCeiling)})` + diff --git a/src/mcp/explore-session-state.ts b/src/mcp/explore-session-state.ts new file mode 100644 index 0000000..7bcdf5a --- /dev/null +++ b/src/mcp/explore-session-state.ts @@ -0,0 +1,360 @@ +/** + * Session-scoped `codegraph_explore` call state (CG-17). + * + * What it holds: for ONE MCP session, per project it queried, what explore has + * already returned — the files, the line ranges of source inside them, the bytes + * they cost, and where in the session each call fell. Nothing else in the server + * knows this today: every explore call is answered as if it were the first one, + * which is why a 4th call happily re-serves the same spine it already sent + * (#1500) and why the tier's call budget can only be *asked* for rather than + * enforced. This module is the record those two behaviours are built on + * (CG-18 cross-call dedup, CG-19 budget decay). It changes no response itself. + * + * Four constraints shape the design, all of them from how the daemon actually + * runs: + * + * 1. **Per session, never persisted.** One instance is owned by an + * {@link ../mcp/session.MCPSession} and dies with the socket. A new agent + * session starts clean — dedup across sessions would suppress source the + * new agent has never seen. + * 2. **Per project inside the session.** A session can query several projects + * by `projectPath`, so state is keyed by the RESOLVED project root + * (`cg.getProjectRoot()`), not by whatever path the agent typed. + * 3. **Bounded.** A long-lived session must not grow without limit, so + * everything is capped — see {@link EXPLORE_SESSION_LIMITS}. Eviction drops + * DETAIL only: `callCount` and `responseBytes` keep counting past it, since + * decay (CG-19) reads the count and must not be reset by its own bound. + * 4. **Daemon-safe.** The daemon shares ONE {@link ../mcp/tools.ToolHandler} + * (and a pool of worker threads) across every connected session, so this + * state can live neither on the handler nor in a worker. It lives on the + * session; the handler is handed it per call, and the record of what a call + * emitted travels back on the {@link ToolResult} so it can be recorded on + * the main thread whether dispatch ran in-process or on a worker. + * + * Over- vs under-reporting: where a bound forces a choice, this module keeps + * FEWER ranges than were emitted, never more. A consumer that under-knows + * re-serves something the agent already has (wasteful); one that over-knows + * withholds source the agent never saw (a Read — the failure this whole area + * exists to prevent). + */ + +import * as path from 'path'; + +/** + * Property on a {@link ../mcp/tools.ToolResult} carrying what an explore call + * emitted. INTERNAL: `ToolHandler.execute` records it and deletes it before the + * result reaches the wire, so the agent-facing response is unchanged. It is a + * plain-object property (not a Symbol) on purpose — it has to survive the + * structured clone back from a query-pool worker. + */ +export const EXPLORE_EMISSION_KEY = '_cgExploreEmission'; + +/** + * Argument key carrying this session's prior-call view INTO a tool call. Same + * reasoning as {@link EXPLORE_EMISSION_KEY}: it crosses the worker boundary, so + * it must be a serializable property on the args object. + */ +export const EXPLORE_SESSION_VIEW_ARG = '_cgExploreSession'; + +/** An inclusive 1-based line span of a file that was emitted. */ +export interface ExploreLineRange { + start: number; + end: number; +} + +/** What one call emitted for one file. */ +export interface ExploreFileEmission { + /** Project-relative path, exactly as the response's file header spells it. */ + path: string; + /** Coalesced line spans whose source was in the response. */ + ranges: ExploreLineRange[]; + /** Source chars emitted for this file (excludes headers / fences). */ + bytes: number; + /** Set when ranges were dropped to stay under the per-file bound. */ + rangesTruncated?: boolean; +} + +/** What one explore call emitted, as reported by the handler. */ +export interface ExploreEmission { + /** Resolved project root — the key state is filed under. */ + projectRoot: string; + /** Normalized query text (post `normalizeQuerySpelling`). */ + query: string; + files: ExploreFileEmission[]; + /** Source chars across all files. */ + sourceBytes: number; + /** Total chars of the response the agent received. */ + responseBytes: number; +} + +/** A recorded call: an emission plus where it fell in the session. */ +export interface ExploreCallRecord extends ExploreEmission { + /** 1-based call index within this session FOR THIS PROJECT. Survives eviction. */ + index: number; +} + +/** Everything the session knows about one project. */ +export interface ExploreProjectState { + projectRoot: string; + /** Explore calls made this session against this project, including evicted ones. */ + callCount: number; + /** Response chars across every call, including evicted ones. */ + responseBytes: number; + /** Retained call records, oldest first. Bounded — may omit early calls. */ + calls: ExploreCallRecord[]; +} + +/** + * The bounded, serializable read-view handed to a tool call. Deliberately + * smaller than the full state: only the most recent calls carry their ranges, + * because that is what a dedup/decay decision reads and the whole thing is + * structured-cloned to a worker on every call. + */ +export interface ExploreSessionView { + projects: ExploreProjectState[]; +} + +/** + * Memory bounds. Every one of them caps DETAIL; none caps the counters that + * CG-19's decay reads. + * + * Sized against how sessions actually behave: an agent explores one project + * (occasionally a second in a monorepo) and the tier call budget is 1–5, so the + * retained window covers a whole realistic session and the caps only bite on + * pathological ones. + */ +export const EXPLORE_SESSION_LIMITS = { + /** Distinct projects kept per session; least-recently-used evicted first. */ + MAX_PROJECTS: 4, + /** Call records kept per project (oldest dropped; `callCount` keeps counting). */ + MAX_CALLS_RETAINED: 8, + /** Files kept per call — the ones that got the most source. */ + MAX_FILES_PER_CALL: 24, + /** Line ranges kept per file after coalescing — the largest spans. */ + MAX_RANGES_PER_FILE: 24, + /** Most-recent calls per project included in {@link ExploreSessionView}. */ + MAX_VIEW_CALLS: 4, +} as const; + +/** + * Key a project root is filed under. Resolved so `/repo` and `/repo/` agree; + * case-folded on the two platforms whose filesystems are case-insensitive, so a + * drive-letter or capitalization difference doesn't split one project in two. + */ +export function exploreProjectKey(projectRoot: string): string { + const resolved = path.resolve(projectRoot); + return process.platform === 'win32' || process.platform === 'darwin' + ? resolved.toLowerCase() + : resolved; +} + +/** + * Merge overlapping / adjacent spans into the smallest equivalent set, then cap + * it. Adjacency (`next.start <= cur.end + 1`) counts as overlap: two ranges that + * touch describe one contiguous block of emitted source. + * + * When the cap bites, the LARGEST spans are kept and the result is re-sorted by + * line so the set still reads top-to-bottom — dropping small fragments loses the + * least information, and under-reporting is the safe direction (see the module + * header). + */ +export function coalesceRanges( + ranges: ReadonlyArray, + max: number = EXPLORE_SESSION_LIMITS.MAX_RANGES_PER_FILE, +): { ranges: ExploreLineRange[]; truncated: boolean } { + const valid = ranges + .filter((r) => Number.isFinite(r.start) && Number.isFinite(r.end) && r.end >= r.start && r.start >= 1) + .map((r) => ({ start: Math.floor(r.start), end: Math.floor(r.end) })) + .sort((a, b) => a.start - b.start || a.end - b.end); + + const merged: ExploreLineRange[] = []; + for (const r of valid) { + const last = merged[merged.length - 1]; + if (last && r.start <= last.end + 1) last.end = Math.max(last.end, r.end); + else merged.push({ ...r }); + } + if (merged.length <= max) return { ranges: merged, truncated: false }; + + const kept = [...merged] + .sort((a, b) => (b.end - b.start) - (a.end - a.start) || a.start - b.start) + .slice(0, max) + .sort((a, b) => a.start - b.start); + return { ranges: kept, truncated: true }; +} + +/** Whether a line falls inside any of the (sorted, coalesced) ranges. */ +export function rangesCover(ranges: ReadonlyArray, line: number): boolean { + return ranges.some((r) => line >= r.start && line <= r.end); +} + +interface MutableProjectState { + projectRoot: string; + callCount: number; + responseBytes: number; + calls: ExploreCallRecord[]; +} + +/** + * One MCP session's explore history. Created per session, thrown away with it. + * + * Not thread-shared and not a singleton: two sessions on the same daemon own two + * instances and can never observe each other's calls. Every method is total — + * malformed input is normalized away rather than thrown, because this sits on + * the tool-call path and a bookkeeping bug must never fail an explore. + */ +export class ExploreSessionState { + /** Insertion-ordered; a touched project is re-inserted, so the head is the LRU. */ + private readonly projects = new Map(); + + /** + * File an emission. Returns the record as stored (with its session call + * index), or `null` if the emission was unusable. + */ + record(emission: ExploreEmission): ExploreCallRecord | null { + if (!emission || typeof emission.projectRoot !== 'string' || !emission.projectRoot) return null; + const key = exploreProjectKey(emission.projectRoot); + const state = this.touch(key, emission.projectRoot); + + state.callCount += 1; + state.responseBytes += Math.max(0, emission.responseBytes || 0); + + const record: ExploreCallRecord = { + index: state.callCount, + projectRoot: emission.projectRoot, + query: typeof emission.query === 'string' ? emission.query : '', + files: this.boundFiles(emission.files), + sourceBytes: Math.max(0, emission.sourceBytes || 0), + responseBytes: Math.max(0, emission.responseBytes || 0), + }; + state.calls.push(record); + if (state.calls.length > EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED) { + state.calls.splice(0, state.calls.length - EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED); + } + return record; + } + + /** Full state for one project, or `null` if it was never queried this session. */ + forProject(projectRoot: string): ExploreProjectState | null { + const state = this.projects.get(exploreProjectKey(projectRoot)); + return state ? cloneProject(state) : null; + } + + /** Explore calls made this session against a project (including evicted ones). */ + callCount(projectRoot: string): number { + return this.projects.get(exploreProjectKey(projectRoot))?.callCount ?? 0; + } + + /** Every project this session has queried, least-recently-used first. */ + snapshot(): ExploreProjectState[] { + return [...this.projects.values()].map(cloneProject); + } + + /** + * The bounded view passed INTO a tool call. Trimmed to the most recent + * {@link EXPLORE_SESSION_LIMITS.MAX_VIEW_CALLS} calls per project: it crosses a + * worker boundary on every explore, so it carries what a dedup/decay decision + * needs and not the whole history. + */ + view(): ExploreSessionView { + return { + projects: [...this.projects.values()].map((state) => ({ + projectRoot: state.projectRoot, + callCount: state.callCount, + responseBytes: state.responseBytes, + calls: state.calls + .slice(-EXPLORE_SESSION_LIMITS.MAX_VIEW_CALLS) + .map((c) => ({ ...c, files: c.files.map((f) => ({ ...f, ranges: [...f.ranges] })) })), + })), + }; + } + + /** Drop everything. Used by tests; a real session just goes away instead. */ + clear(): void { + this.projects.clear(); + } + + /** + * Fetch a project's state, creating it if new, and mark it most-recently-used. + * Evicts the LRU project past the bound — dropping a project entirely (rather + * than its detail) is right here: a session that has moved on to four other + * repos is not about to re-ask the first one. + */ + private touch(key: string, projectRoot: string): MutableProjectState { + const existing = this.projects.get(key); + if (existing) { + this.projects.delete(key); + this.projects.set(key, existing); + return existing; + } + const created: MutableProjectState = { projectRoot, callCount: 0, responseBytes: 0, calls: [] }; + this.projects.set(key, created); + while (this.projects.size > EXPLORE_SESSION_LIMITS.MAX_PROJECTS) { + const lru = this.projects.keys().next().value as string | undefined; + if (lru === undefined) break; + this.projects.delete(lru); + } + return created; + } + + /** + * Normalize + bound one call's files: coalesce each file's ranges, then keep + * the files that got the most source. A call that renders more files than the + * bound has already spread its envelope thin, so the tail files carry the + * least — and losing them costs the least. + */ + private boundFiles(files: ReadonlyArray | undefined): ExploreFileEmission[] { + if (!Array.isArray(files) || files.length === 0) return []; + const normalized = files + .filter((f) => f && typeof f.path === 'string' && f.path.length > 0) + .map((f) => { + const { ranges, truncated } = coalesceRanges(f.ranges ?? []); + const out: ExploreFileEmission = { path: f.path, ranges, bytes: Math.max(0, f.bytes || 0) }; + if (truncated) out.rangesTruncated = true; + return out; + }); + if (normalized.length <= EXPLORE_SESSION_LIMITS.MAX_FILES_PER_CALL) return normalized; + return [...normalized] + .sort((a, b) => b.bytes - a.bytes) + .slice(0, EXPLORE_SESSION_LIMITS.MAX_FILES_PER_CALL); + } +} + +function cloneProject(state: MutableProjectState): ExploreProjectState { + return { + projectRoot: state.projectRoot, + callCount: state.callCount, + responseBytes: state.responseBytes, + calls: state.calls.map((c) => ({ ...c, files: c.files.map((f) => ({ ...f, ranges: [...f.ranges] })) })), + }; +} + +/** + * Read the session view a caller injected into tool args, if any. Defensive: + * the key is internal, but the args object comes off the wire, so a client that + * spells it itself gets ignored rather than trusted into a crash. + */ +export function readExploreSessionView(args: Record): ExploreSessionView | null { + const raw = args?.[EXPLORE_SESSION_VIEW_ARG]; + if (!raw || typeof raw !== 'object') return null; + const projects = (raw as ExploreSessionView).projects; + if (!Array.isArray(projects)) return null; + return { projects: projects.filter((p) => p && typeof p.projectRoot === 'string') }; +} + +/** + * This session's prior state for one project, from an injected view. + * + * `null` means NOBODY IS TRACKING (no view was injected — the CLI, a bare + * handler). A view that simply hasn't seen this project yet returns an EMPTY + * state, not null: the distinction matters to consumers, since "first call of a + * tracked session" and "untracked" are different situations. + */ +export function viewForProject( + view: ExploreSessionView | null, + projectRoot: string, +): ExploreProjectState | null { + if (!view) return null; + const key = exploreProjectKey(projectRoot); + return view.projects.find((p) => exploreProjectKey(p.projectRoot) === key) + ?? { projectRoot, callCount: 0, responseBytes: 0, calls: [] }; +} diff --git a/src/mcp/proxy.ts b/src/mcp/proxy.ts index 0429577..27e684e 100644 --- a/src/mcp/proxy.ts +++ b/src/mcp/proxy.ts @@ -30,6 +30,7 @@ import { CodeGraphPackageVersion } from './version'; import { SERVER_INFO, PROTOCOL_VERSION, initializeInstructions } from './session'; import { SERVER_INSTRUCTIONS } from './server-instructions'; import { getStaticTools } from './tools'; +import { ExploreSessionState } from './explore-session-state'; import { getTelemetry, ClientInfo } from '../telemetry'; import type { MCPEngine } from './engine'; @@ -230,6 +231,10 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise< // new session starts), these would otherwise hang forever; we re-serve them // in-process so the host always gets a reply. const inflight = new Map(); + // Explore call history for the ONE host connection this proxy serves (CG-17). + // Only the daemon-unavailable fallback below uses it; when the daemon is up, + // the tracking happens on the daemon's own MCPSession. + const exploreSession = new ExploreSessionState(); const trackInflight = (line: string): void => { try { const m = JSON.parse(line) as JsonRpc; @@ -261,7 +266,7 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise< try { await ensureEngine(); const params = (msg.params || {}) as { name: string; arguments?: Record }; - const result = await engine!.getToolHandler().execute(params.name, params.arguments || {}); + const result = await engine!.getToolHandler().execute(params.name, params.arguments || {}, exploreSession); writeClient({ jsonrpc: '2.0', id, result }); getTelemetry().recordUsage('mcp_tool', params.name, !result.isError, telemetryClient); } catch (err) { diff --git a/src/mcp/session.ts b/src/mcp/session.ts index fbfd9f1..866e001 100644 --- a/src/mcp/session.ts +++ b/src/mcp/session.ts @@ -21,6 +21,7 @@ import { CodeGraphPackageVersion } from './version'; import { findNearestCodeGraphRoot } from '../directory'; import { getTelemetry, ClientInfo } from '../telemetry'; import { getUpdateNotice } from '../upgrade/update-check'; +import { ExploreSessionState } from './explore-session-state'; /** * MCP Server Info — kept on the session because some clients log it. The @@ -110,6 +111,15 @@ export class MCPSession { private rootsAttempted = false; private resolvePromise: Promise | null = null; private explicitProjectPath: string | null; + /** + * What `codegraph_explore` has already returned to THIS client, per project + * (CG-17). Owned by the session, not the engine: the daemon shares one engine + * (and one ToolHandler, and a pool of worker threads) across every connected + * client, so state kept over there would blend two agents' histories and let + * one session's calls suppress source the other has never seen. It dies with + * the session — a reconnecting client starts clean. + */ + private readonly exploreSession = new ExploreSessionState(); constructor( private transport: JsonRpcTransport, @@ -140,6 +150,15 @@ export class MCPSession { return this.transport; } + /** + * This session's explore call history (CG-17). Exposed so tests can assert + * that two sessions on one daemon keep separate state; nothing in the server + * reaches for another session's copy. + */ + getExploreSessionState(): ExploreSessionState { + return this.exploreSession; + } + private async handleMessage(message: JsonRpcRequest | JsonRpcNotification): Promise { const isRequest = 'id' in message; switch (message.method) { @@ -286,7 +305,7 @@ export class MCPSession { await this.retryInitIfNeeded(); if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] toolsCall ${toolName} id=${String(request.id)} dispatch\n`); - const result = await this.engine.getToolHandler().execute(toolName, toolArgs); + const result = await this.engine.getToolHandler().execute(toolName, toolArgs, this.exploreSession); if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] toolsCall ${toolName} id=${String(request.id)} done\n`); this.transport.sendResult(request.id, result); // After the reply is on the wire — telemetry must never delay a tool diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 51c81e1..1493516 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -42,6 +42,16 @@ import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, C import { scanDynamicDispatch } from './dynamic-boundaries'; import { getUpdateNotice } from '../upgrade/update-check'; import { ExploreDiagnostics } from './explore-diagnostics'; +import { + EXPLORE_EMISSION_KEY, + EXPLORE_SESSION_VIEW_ARG, + ExploreSessionState, + readExploreSessionView, + viewForProject, + type ExploreEmission, + type ExploreFileEmission, + type ExploreLineRange, +} from './explore-session-state'; /** * An expected, recoverable "codegraph can't serve this" condition — most @@ -891,6 +901,15 @@ export interface ToolResult { text: string; }>; isError?: boolean; + /** + * INTERNAL side-channel (CG-17): what a `codegraph_explore` call actually put + * on the wire — files, line ranges, bytes. It rides the result because the + * call may have run on a query-pool worker, while the session state it feeds + * lives on the main thread. {@link ToolHandler.execute} records it and DELETES + * it, so nothing here ever reaches the client. Keyed by + * {@link EXPLORE_EMISSION_KEY}; the two must stay in sync. + */ + _cgExploreEmission?: ExploreEmission; } /** @@ -1805,9 +1824,19 @@ export class ToolHandler { } /** - * Execute a tool by name + * Execute a tool by name. + * + * `sessionState` is the CALLER's per-session explore history (CG-17). The + * daemon shares one ToolHandler across every connected session, so this state + * cannot live on the handler — each session owns one and hands it in, which is + * what keeps two sessions on one daemon from ever seeing each other's calls. + * Omit it (the CLI does) and explore behaves exactly as before, untracked. */ - async execute(toolName: string, args: Record): Promise { + async execute( + toolName: string, + args: Record, + sessionState?: ExploreSessionState, + ): Promise { try { // Block the first tool call on the engine's post-open reconcile so we // never serve rows for files deleted/edited while no MCP server was @@ -1869,9 +1898,20 @@ export class ToolHandler { // cross-cutting notices — worktree-index mismatch (#155) and per-file // staleness (#403) — which need the watched MAIN instance and so are // always applied here, never in the worker. - const result = (this.queryPool && this.queryPool.healthy && this.queryPool.ready) - ? await this.queryPool.run(toolName, args) - : await this.executeReadTool(toolName, args); + // + // Explore also carries the session's own call history down (CG-17) and its + // emission record back up. Both travel as plain properties — on the args + // object down, on the ToolResult up — because either leg may cross a + // structured-clone boundary into a worker, where a closure or a handler + // field could not follow. + const dispatchArgs = this.withSessionView(toolName, args, sessionState); + const raw = (this.queryPool && this.queryPool.healthy && this.queryPool.ready) + ? await this.queryPool.run(toolName, dispatchArgs) + : await this.executeReadTool(toolName, dispatchArgs); + // Record + STRIP before anything else touches the result: the emission is + // internal bookkeeping and must never reach the client, whether or not a + // caller passed session state. + const result = this.takeExploreEmission(raw, sessionState); const withWorktree = this.withWorktreeNotice(result, args.projectPath as string | undefined); return this.withStalenessNotice(withWorktree, args.projectPath as string | undefined); } catch (err) { @@ -1893,6 +1933,57 @@ export class ToolHandler { } } + /** + * Attach the caller's session view to an explore call's args (CG-17), on a + * COPY so the caller's object is never mutated. Nothing else sees it: a + * non-explore tool, or a caller with no session state, gets the args + * unchanged and pays nothing. + * + * A client that spells the internal key itself is stripped rather than + * trusted — the view decides what source a later call may withhold, so it has + * to come from the server's own record, never from the wire. + */ + private withSessionView( + toolName: string, + args: Record, + sessionState: ExploreSessionState | undefined, + ): Record { + if (!(EXPLORE_SESSION_VIEW_ARG in args) && (!sessionState || toolName !== 'codegraph_explore')) { + return args; + } + const copy = { ...args }; + delete copy[EXPLORE_SESSION_VIEW_ARG]; + if (sessionState && toolName === 'codegraph_explore') { + copy[EXPLORE_SESSION_VIEW_ARG] = sessionState.view(); + } + return copy; + } + + /** + * Record an explore call's emission into the caller's session state and strip + * it from the result (CG-17). + * + * Unconditional strip: the property is internal, so it comes off even when + * there is no session state to record it into (the CLI path) — that is what + * keeps the agent-facing response byte-identical. Recording is wrapped + * because a bookkeeping bug must never fail a tool call that already + * succeeded. + */ + private takeExploreEmission( + result: ToolResult, + sessionState: ExploreSessionState | undefined, + ): ToolResult { + const emission = result?.[EXPLORE_EMISSION_KEY]; + if (emission === undefined) return result; + delete result[EXPLORE_EMISSION_KEY]; + if (sessionState) { + try { + sessionState.record(emission); + } catch { /* bookkeeping only — never fail a served call */ } + } + return result; + } + /** * Run a single read tool to completion and return its raw {@link ToolResult}, * classifying expected failures the same way {@link execute}'s catch does so @@ -3029,6 +3120,29 @@ export class ToolHandler { // byte-identical. It only OBSERVES: it must never feed back into rendering. const diag = ExploreDiagnostics.start(query, projectRoot, budget, maxFiles, indexedFileCount); + // What this session has already been served for THIS project (CG-17). + // Read-only at this stage — it is reported in the diagnostic and nothing + // else, so the response is unchanged. Cross-call dedup (CG-18) and budget + // decay (CG-19) are the consumers this exists for. + const priorCalls = viewForProject(readExploreSessionView(args), projectRoot); + diag?.noteSession(priorCalls); + + // What this call ends up emitting, per file — the record handed back to the + // session state on the main thread. Filled by every render path below, then + // filtered to the files that SURVIVE the final hard-ceiling cut, so the + // record is what the agent actually received rather than what the loop + // hoped to send. + const emittedByFile = new Map(); + const noteEmitted = (fp: string, ranges: ExploreLineRange[], bytes: number): void => { + const existing = emittedByFile.get(fp); + if (existing) { + existing.ranges.push(...ranges); + existing.bytes += bytes; + } else { + emittedByFile.set(fp, { ranges: [...ranges], bytes }); + } + }; + // Step 1: Find relevant context with generous parameters. // Use a large maxNodes budget — explore has its own 35k char output limit // that prevents context bloat, so more nodes just means better coverage @@ -3042,7 +3156,12 @@ export class ToolHandler { if (subgraph.nodes.size === 0) { diag?.finishEmpty('no relevant code found — empty subgraph'); - return this.textResult(`No relevant code found for "${query}"`); + const empty = `No relevant code found for "${query}"`; + // Still an explore call, so it is still recorded: an empty answer spends a + // call against the tier budget even though it emits no source. + return this.exploreResult(empty, { + projectRoot, query, files: [], sourceBytes: 0, responseBytes: empty.length, + }); } // Graph-aware glue: findRelevantContext builds the subgraph from name/text @@ -4014,6 +4133,7 @@ export class ToolHandler { // signature line (capped, with a "+N more" tail so the structure map of a // god-file doesn't itself bloat the budget). const skel: string[] = []; + const skelRanges: ExploreLineRange[] = []; let coveredUntil = 0; // skip symbols already inside an emitted body let sigCount = 0, sigDropped = 0; const SIG_MAX = Math.max(12, budget.maxSymbolsInFileHeader * 2); @@ -4023,6 +4143,7 @@ export class ToolHandler { const end = n.endLine; const body = fileLines.slice(n.startLine - 1, end).join('\n'); skel.push(exploreLineNumbersEnabled() ? numberSourceLines(body, n.startLine) : body); + skelRanges.push({ start: n.startLine, end }); coveredUntil = end; } else { // Elide the body, emit the signature. node.startLine can point at a @@ -4034,7 +4155,11 @@ export class ToolHandler { if (lineNo <= coveredUntil) continue; if (sigCount >= SIG_MAX) { sigDropped++; continue; } const sig = (fileLines[lineNo - 1] || '').trim(); - if (sig) { skel.push(exploreLineNumbersEnabled() ? `${lineNo}\t${sig}` : sig); sigCount++; } + if (sig) { + skel.push(exploreLineNumbersEnabled() ? `${lineNo}\t${sig}` : sig); + skelRanges.push({ start: lineNo, end: lineNo }); + sigCount++; + } } } if (sigDropped > 0) skel.push(`… +${sigDropped} more (signatures elided)`); @@ -4055,6 +4180,7 @@ export class ToolHandler { sourceSpent += skel.join('\n').length; // Always "clipped": the per-symbol view elides bodies by construction. diag?.recordRender(filePath, bodyIds.size > 0 ? 'focused' : 'skeleton', skel.join('\n').length, true); + noteEmitted(filePath, skelRanges, skel.join('\n').length); renderedFilePaths.push(filePath); filesIncluded++; continue; @@ -4159,6 +4285,8 @@ export class ToolHandler { totalChars += wholeSection.length + 200; sourceSpent += wholeSection.length; diag?.recordRender(filePath, 'whole', wholeSection.length, false); + // The whole file, minus any trailing blank lines the render trimmed. + noteEmitted(filePath, [{ start: 1, end: body.split('\n').length }], wholeSection.length); renderedFilePaths.push(filePath); filesIncluded++; if (fileStale) staleRendered.push(filePath); @@ -4325,28 +4453,42 @@ export class ToolHandler { // the spine's call still appears in context. const OVERSIZE_SPINE_LINES = 200; const SPINE_WINDOW = 28; // lines each side of the next-hop call site - const buildSection = (c: { start: number; end: number; hasSpine?: boolean; spineCallLine?: number }): string => { + // Returns the rendered text AND the line spans it covers. The spans are + // what the session record is built from (CG-17): reporting them from the + // same function that slices the source is what keeps the record honest — + // a second function mirroring these window/padding rules would drift, and + // a record that claims lines it never sent withholds them from a later + // call, which costs a Read. + const buildSection = ( + c: { start: number; end: number; hasSpine?: boolean; spineCallLine?: number }, + ): { text: string; ranges: ExploreLineRange[] } => { if (c.hasSpine && c.spineCallLine && (c.end - c.start + 1) > OVERSIZE_SPINE_LINES) { const call = c.spineCallLine; const winStart = Math.max(c.start, call - SPINE_WINDOW); const winEnd = Math.min(c.end, call + SPINE_WINDOW); const parts: string[] = []; + const spans: ExploreLineRange[] = []; // Signature head, only when it sits clearly above the window (else the // window already covers the method opening). const headEnd = Math.min(c.start + 4, winStart - 2); if (headEnd >= c.start) { const head = fileLines.slice(c.start - 1, headEnd).join('\n'); parts.push(withLineNumbers ? numberSourceLines(head, c.start) : head); + spans.push({ start: c.start, end: headEnd }); } const win = fileLines.slice(winStart - 1, winEnd).join('\n'); parts.push(withLineNumbers ? numberSourceLines(win, winStart) : win); - return parts.join(GAP_MARKER); + spans.push({ start: winStart, end: winEnd }); + return { text: parts.join(GAP_MARKER), ranges: spans }; } const startIdx = Math.max(0, c.start - 1 - contextPadding); const endIdx = Math.min(fileLines.length, c.end + contextPadding); const slice = fileLines.slice(startIdx, endIdx).join('\n'); // startIdx is 0-based, so the slice's first line is line startIdx + 1. - return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice; + return { + text: withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice, + ranges: [{ start: startIdx + 1, end: endIdx }], + }; }; /** @@ -4365,7 +4507,10 @@ export class ToolHandler { * body is never cut, and the members are chosen by the same importance the * cluster ranking uses. Returns null when nothing needed shrinking. */ - const shrinkCluster = (c: ExploreCluster, cap: number): string | null => { + const shrinkCluster = ( + c: ExploreCluster, + cap: number, + ): { text: string; ranges: ExploreLineRange[] } | null => { if (c.members.length < 2) return null; const byImportance = [...c.members].sort((a, b) => b.importance - a.importance || (a.end - a.start) - (b.end - b.start) || a.start - b.start); @@ -4390,7 +4535,11 @@ export class ToolHandler { if (last && r.start <= last.end + gapThreshold) last.end = Math.max(last.end, r.end); else merged.push({ start: r.start, end: r.end }); } - return merged.map((m) => buildSection(m)).join(GAP_MARKER); + const sections = merged.map((m) => buildSection(m)); + return { + text: sections.map((s) => s.text).join(GAP_MARKER), + ranges: sections.flatMap((s) => s.ranges), + }; }; // Rank clusters for inclusion under the per-file cap. Entry-point @@ -4439,10 +4588,10 @@ export class ToolHandler { const chosenIndices = new Set(); // Shrunk renders for oversize clusters, by cluster index (CG-12). Computed // during selection and reused at emission so the two never disagree. - const shrunkSections = new Map(); + const shrunkSections = new Map(); let projectedChars = 0; for (const rc of rankedClusters) { - const sectionLen = buildSection(rc.c).length + (chosenIndices.size > 0 ? GAP_MARKER.length : 0); + const sectionLen = buildSection(rc.c).text.length + (chosenIndices.size > 0 ? GAP_MARKER.length : 0); // The top-ranked cluster is always taken — an empty file section sends the // agent to Read, negating the savings. But "always taken" is not "taken at // any size": when it overruns the reservation it is SHRUNK to the @@ -4453,7 +4602,7 @@ export class ToolHandler { const shrunk = sectionLen > cap ? shrinkCluster(rc.c, cap) : null; if (shrunk !== null) shrunkSections.set(rc.idx, shrunk); chosenIndices.add(rc.idx); - projectedChars += shrunk !== null ? shrunk.length : sectionLen; + projectedChars += shrunk !== null ? shrunk.text.length : sectionLen; continue; } // A spine cluster (the rendered call path) is the flow answer — include it @@ -4469,12 +4618,14 @@ export class ToolHandler { // Emit chosen clusters in source order so the file reads top-to-bottom. let fileSection = ''; const allSymbols: string[] = []; + const sectionRanges: ExploreLineRange[] = []; for (let i = 0; i < clusters.length; i++) { if (!chosenIndices.has(i)) continue; const cluster = clusters[i]!; const section = shrunkSections.get(i) ?? buildSection(cluster); if (fileSection.length > 0) fileSection += GAP_MARKER; - fileSection += section; + fileSection += section.text; + sectionRanges.push(...section.ranges); allSymbols.push(...cluster.symbols); } @@ -4532,6 +4683,7 @@ export class ToolHandler { totalChars += fileSection.length + 200; sourceSpent += fileSection.length; diag?.recordRender(filePath, 'clusters', fileSection.length, chosenIndices.size < clusters.length); + noteEmitted(filePath, sectionRanges, fileSection.length); renderedFilePaths.push(filePath); filesIncluded++; } @@ -4678,7 +4830,35 @@ export class ToolHandler { // shares account for the hard-ceiling truncation above (CG-4). diag?.finish(finalText, output.length, hardCeiling, filesIncluded); - return this.textResult(finalText); + // Session record (CG-17): only the files that SURVIVED the hard ceiling — + // a section the truncation dropped was never delivered, and recording it + // would let a later call withhold source the agent has never seen. + const emittedFiles: ExploreFileEmission[] = []; + let sourceBytes = 0; + for (const fp of survivors) { + const emitted = emittedByFile.get(fp); + if (!emitted || emitted.bytes <= 0) continue; + emittedFiles.push({ path: fp, ranges: emitted.ranges, bytes: emitted.bytes }); + sourceBytes += emitted.bytes; + } + return this.exploreResult(finalText, { + projectRoot, + query, + files: emittedFiles, + sourceBytes, + responseBytes: finalText.length, + }); + } + + /** + * An explore response plus the record of what it emitted (CG-17). The record + * rides the result only as far as {@link execute}, which files it into the + * calling session's state and deletes it — see {@link EXPLORE_EMISSION_KEY}. + */ + private exploreResult(text: string, emission: ExploreEmission): ToolResult { + const result = this.textResult(text); + result[EXPLORE_EMISSION_KEY] = emission; + return result; } /**