diff --git a/components/adapters/claude-code/src/context-cleaner/session-catalog.ts b/components/adapters/claude-code/src/context-cleaner/session-catalog.ts index 38596f5e..3a667f08 100644 --- a/components/adapters/claude-code/src/context-cleaner/session-catalog.ts +++ b/components/adapters/claude-code/src/context-cleaner/session-catalog.ts @@ -41,6 +41,9 @@ function validSessionState(value: unknown, sessionId: string): value is { && (value.disclosedReadPaths === undefined || (Array.isArray(value.disclosedReadPaths) && value.disclosedReadPaths.every((path) => typeof path === "string"))) + && (value.disclosedReadOwners === undefined + || (isRecord(value.disclosedReadOwners) + && Object.values(value.disclosedReadOwners).every((owner) => owner === null || typeof owner === "string"))) && optionalCount(value.lastToolInputChars) && optionalCount(value.lastToolOutputChars) && optionalCount(value.requestChars) diff --git a/components/adapters/claude-code/src/gateway-runtime.ts b/components/adapters/claude-code/src/gateway-runtime.ts index 1aef6cec..a0814e11 100644 --- a/components/adapters/claude-code/src/gateway-runtime.ts +++ b/components/adapters/claude-code/src/gateway-runtime.ts @@ -232,6 +232,7 @@ async function recordClaudeGatewayTurn(params: { responseId?: string; previousResponseId?: string; disclosedReadPaths?: string[]; + disclosedReadOwners?: Record; requestChars: number; responseChars: number; assistantChars: number; @@ -249,6 +250,7 @@ async function recordClaudeGatewayTurn(params: { latestModel: params.model, workspaceHint: params.workspaceHint, disclosedReadPaths: params.disclosedReadPaths, + disclosedReadOwners: params.disclosedReadOwners, requestChars: params.requestChars, responseChars: params.responseChars, assistantChars: params.assistantChars, @@ -1066,6 +1068,7 @@ export async function startClaudeCodeGatewayRuntime(params: { responseId, previousResponseId, disclosedReadPaths: reductionSummary?.disclosedReadPaths, + disclosedReadOwners: reductionSummary?.disclosedReadOwners, requestChars: body.length, responseChars: rawStreamText.length, assistantChars: snapshot.assistantText.length, @@ -1158,6 +1161,7 @@ export async function startClaudeCodeGatewayRuntime(params: { responseId, previousResponseId, disclosedReadPaths: reductionSummary?.disclosedReadPaths, + disclosedReadOwners: reductionSummary?.disclosedReadOwners, requestChars: body.length, responseChars: upstreamResp.text.length, assistantChars, diff --git a/components/adapters/claude-code/src/reduction.ts b/components/adapters/claude-code/src/reduction.ts index ad0e8d00..3a6ecda8 100644 --- a/components/adapters/claude-code/src/reduction.ts +++ b/components/adapters/claude-code/src/reduction.ts @@ -16,6 +16,7 @@ type ClaudeSegmentBinding = { blockIndex?: number; field: "content" | "text"; toolName?: string; + toolUseId?: string; }; type ClaudeReductionInstruction = { @@ -80,9 +81,13 @@ export type ClaudeReductionSummary = { diagnostics: ClaudeReductionDiagnostics; visualSegments?: ClaudeReductionVisualSegment[]; disclosedReadPaths?: string[]; + /** Normalized read path -> tool_use_id of the read that first disclosed it (null: not attributable). */ + disclosedReadOwners?: DisclosedReadOwners; skippedReason?: string; }; +export type DisclosedReadOwners = Record; + function normalizeDisclosedReadPaths(value: unknown): string[] | undefined { if (!Array.isArray(value)) return undefined; const next = new Set(); @@ -94,6 +99,83 @@ function normalizeDisclosedReadPaths(value: unknown): string[] | undefined { return next.size > 0 ? [...next] : undefined; } +export function normalizeDisclosedReadOwners(value: unknown): DisclosedReadOwners | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const next: DisclosedReadOwners = {}; + for (const [path, owner] of Object.entries(value as Record)) { + const normalized = path.trim().toLowerCase(); + if (!normalized) continue; + next[normalized] = typeof owner === "string" && owner ? owner : null; + } + return Object.keys(next).length > 0 ? next : undefined; +} + +/** + * Paths to hand the pass as "already disclosed". Claude Code resends the whole + * history every request, so a disclosing read that is still in it is re-detected by + * the pass itself. Carrying its path too would make the pass treat that same read as + * a repeat and send it untrimmed, which breaks the prompt cache. Only paths whose + * disclosing read has left the history (compaction, a new session branch) are carried. + */ +export function carriedDisclosedReadPaths( + owners: DisclosedReadOwners | undefined, + presentToolUseIds: ReadonlySet, +): string[] | undefined { + if (!owners) return undefined; + const carried = Object.entries(owners) + .filter(([, owner]) => owner === null || !presentToolUseIds.has(owner)) + .map(([path]) => path); + return carried.length > 0 ? carried : undefined; +} + +/** + * Rebuild owners for the reported path set; new paths belong to a matching read + * actually trimmed in this request. + */ +export function recordDisclosedReadOwners( + owners: DisclosedReadOwners | undefined, + reportedPaths: unknown, + segments: readonly ContextSegment[], + bindings: readonly ClaudeSegmentBinding[], + trimmedSegmentIds: ReadonlySet, +): DisclosedReadOwners | undefined { + const paths = normalizeDisclosedReadPaths(reportedPaths); + if (!paths) return owners; + const next: DisclosedReadOwners = {}; + const bindingBySegment = new Map(bindings.map((binding) => [binding.segmentId, binding])); + for (const path of paths) { + if (owners && Object.hasOwn(owners, path)) { + next[path] = owners[path] ?? null; + continue; + } + const owner = segments.find((segment) => { + if (!trimmedSegmentIds.has(segment.id)) return false; + const binding = bindingBySegment.get(segment.id); + const toolName = binding?.toolName?.trim().toLowerCase(); + if (toolName !== "read" && toolName !== "file_read") return false; + const segmentPath = asRecord(segment.metadata).path; + return typeof segmentPath === "string" && segmentPath.trim().toLowerCase() === path; + }); + next[path] = (owner && bindingBySegment.get(owner.id)?.toolUseId) || null; + } + return Object.keys(next).length > 0 ? next : undefined; +} + +function presentToolResultIds(payload: any): Set { + const ids = new Set(); + for (const message of Array.isArray(payload?.messages) ? payload.messages : []) { + const content = asRecord(message).content; + if (!Array.isArray(content)) continue; + for (const block of content) { + const entry = asRecord(block); + if (String(entry.type ?? "").toLowerCase() === "tool_result" && typeof entry.tool_use_id === "string" && entry.tool_use_id) { + ids.add(entry.tool_use_id); + } + } + } + return ids; +} + function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record @@ -312,6 +394,7 @@ function buildTurnContext( blockIndex, field: typeof entry.text === "string" ? "text" : "content", toolName: typeof entry.name === "string" ? entry.name : hint?.toolName, + ...(toolUseId ? { toolUseId } : {}), }); }); }); @@ -552,8 +635,11 @@ export async function applyBeforeCallReductionToClaudePayload(params: { } const snapshot = await loadClaudeCodeSessionSnapshot(config.stateDir, sessionId); + // Snapshots written before owners were tracked only have `disclosedReadPaths`; those + // are ignored rather than carried, because carrying them reproduces the cache miss. + const disclosedReadOwners = normalizeDisclosedReadOwners(snapshot?.disclosedReadOwners); const built = buildTurnContext(payload, sessionId, { - disclosedReadPaths: normalizeDisclosedReadPaths(snapshot?.disclosedReadPaths), + disclosedReadPaths: carriedDisclosedReadPaths(disclosedReadOwners, presentToolResultIds(payload)), }); const analyzerInstructions = buildAnalyzerReductionInstructions(built.turnCtx.segments, config); const fallbackInstructions = buildFallbackReductionInstructions(built.turnCtx.segments, config); @@ -585,9 +671,13 @@ export async function applyBeforeCallReductionToClaudePayload(params: { const { turnCtx: reducedCtx, report } = await runReductionBeforeCall({ turnCtx, passes }); const passEffects = summarizePassEffects(report); const changedSegmentIds = new Set(); + const trimmedSegmentIds = new Set(); for (const entry of report) { if (!entry.changed) continue; - for (const id of entry.touchedSegmentIds ?? []) changedSegmentIds.add(id); + for (const id of entry.touchedSegmentIds ?? []) { + changedSegmentIds.add(id); + if (entry.id === "tool_payload_trim") trimmedSegmentIds.add(id); + } } if (changedSegmentIds.size === 0) { @@ -601,6 +691,13 @@ export async function applyBeforeCallReductionToClaudePayload(params: { passEffects, diagnostics: built.diagnostics, disclosedReadPaths: normalizeDisclosedReadPaths(reducedCtx.metadata?.disclosedReadPaths), + disclosedReadOwners: recordDisclosedReadOwners( + disclosedReadOwners, + reducedCtx.metadata?.disclosedReadPaths, + built.turnCtx.segments, + bindings, + trimmedSegmentIds, + ), skippedReason: "pipeline_no_effect", }; } @@ -659,6 +756,13 @@ export async function applyBeforeCallReductionToClaudePayload(params: { diagnostics: built.diagnostics, visualSegments, disclosedReadPaths: normalizeDisclosedReadPaths(reducedCtx.metadata?.disclosedReadPaths), + disclosedReadOwners: recordDisclosedReadOwners( + disclosedReadOwners, + reducedCtx.metadata?.disclosedReadPaths, + built.turnCtx.segments, + bindings, + trimmedSegmentIds, + ), }; } diff --git a/components/adapters/claude-code/src/session-state.ts b/components/adapters/claude-code/src/session-state.ts index 171768d3..1a4d0ddd 100644 --- a/components/adapters/claude-code/src/session-state.ts +++ b/components/adapters/claude-code/src/session-state.ts @@ -14,6 +14,8 @@ export type ClaudeCodeSessionSnapshot = { latestModel?: string; workspaceHint?: string; disclosedReadPaths?: string[]; + /** Normalized read path -> tool_use_id of the read that first disclosed it (null: not attributable). */ + disclosedReadOwners?: Record; lastHookEvent?: string; /** pid of the `claude` process that owns this session, when it could be resolved. */ hostPid?: number; @@ -69,6 +71,7 @@ export async function upsertClaudeCodeSessionSnapshot( latestModel: patch.latestModel ?? current?.latestModel, workspaceHint: patch.workspaceHint ?? current?.workspaceHint, disclosedReadPaths: patch.disclosedReadPaths ?? current?.disclosedReadPaths, + disclosedReadOwners: patch.disclosedReadOwners ?? current?.disclosedReadOwners, lastHookEvent: patch.lastHookEvent ?? current?.lastHookEvent, hostPid: patch.hostPid ?? current?.hostPid, lastToolName: patch.lastToolName ?? current?.lastToolName, diff --git a/components/adapters/claude-code/tests/gateway-runtime.test.ts b/components/adapters/claude-code/tests/gateway-runtime.test.ts index 71f1e136..3d8c3563 100644 --- a/components/adapters/claude-code/tests/gateway-runtime.test.ts +++ b/components/adapters/claude-code/tests/gateway-runtime.test.ts @@ -963,6 +963,127 @@ export function saveConfig(file: string, text: string) { } }); +test("gateway runtime keeps a read trimmed while that same read is still in the history", async () => { + const dir = await mkdtemp(join(tmpdir(), "lightrsi-claude-gateway-same-read-")); + const proxyPort = await reserveUnusedPort(); + const codePayload = ` +export function loadConfig(file: string) { + return file.trim(); +} + +export function saveConfig(file: string, text: string) { + return text + file; +} +`.repeat(30); + const seenPayloads: Array> = []; + const forwarder: HostGatewayForwarder = { + requestRaw: async () => { throw new Error("requestRaw not used in test"); }, + async request(params) { + seenPayloads.push(params.payload as Record); + return { + status: 200, + headers: { + "content-type": "application/json", + }, + text: JSON.stringify({ + id: "msg_disclosed_1", + type: "message", + role: "assistant", + content: [{ type: "text", text: "done" }], + usage: { input_tokens: 20, output_tokens: 5 }, + stop_reason: "end_turn", + }), + }; + }, + async requestStream() { + throw new Error("stream path should not be used in this test"); + }, + }; + + const runtime = await startClaudeCodeGatewayRuntime({ + config: normalizeTokenPilotClaudeCodeConfig({ + stateDir: join(dir, "state"), + proxyPort, + reduction: { + triggerMinChars: 256, + maxToolChars: 300, + passes: { + readStateCompaction: false, + toolPayloadTrim: true, + htmlSlimming: false, + execOutputTruncation: false, + agentsStartupOptimization: false, + }, + }, + }), + logger: createConsoleLogger(false), + forwarder, + }); + + try { + for (let turn = 0; turn < 2; turn += 1) { + const response = await fetch(`${runtime.baseUrl}/v1/messages`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-session-id": "sess-same-read-1", + }, + body: JSON.stringify({ + model: "claude-sonnet-4-6", + stream: false, + system: "Your working directory is: /repo/demo", + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_read_0", + name: "Read", + input: { path: "/repo/src/config.ts" }, + }, + ], + }, + { + role: "user", + content: [ + { type: "text", text: "summarize this" }, + { type: "tool_result", tool_use_id: "toolu_read_0", content: codePayload }, + ], + }, + ...(turn === 0 ? [] : [ + { role: "assistant", content: [{ type: "tool_use", id: "toolu_write_1", name: "Write", input: { path: "/repo/out.txt", content: "x" } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_write_1", content: "wrote 1 byte" }] }, + ]), + ], + max_tokens: 256, + }), + }); + assert.equal(response.status, 200); + } + + assert.equal(seenPayloads.length, 2); + const firstMessages = seenPayloads[0]?.messages as Array>; + const secondMessages = seenPayloads[1]?.messages as Array>; + const firstToolResult = ((firstMessages?.[1]?.content as Array>)?.[1] ?? {}) as Record; + const secondToolResult = ((secondMessages?.[1]?.content as Array>)?.[1] ?? {}) as Record; + + const firstText = String(firstToolResult.content ?? firstToolResult.text ?? ""); + const secondText = String(secondToolResult.content ?? secondToolResult.text ?? ""); + assert.match(firstText, /\[code outlined lines=/); + assert.match(secondText, /\[code outlined lines=/, "the same read must not be treated as a repeat read"); + assert.equal(secondText, firstText); + + const snapshot = JSON.parse( + await readFile(join(dir, "state", "session-state", "sessions", "sess-same-read-1.json"), "utf8"), + ) as { disclosedReadOwners?: Record }; + assert.deepEqual(snapshot.disclosedReadOwners, { "/repo/src/config.ts": "toolu_read_0" }); + } finally { + await runtime.close(); + await rm(dir, { recursive: true, force: true }); + } +}); + test("gateway runtime does not record ux-effects when reduced request fails upstream", async () => { const dir = await mkdtemp(join(tmpdir(), "lightrsi-claude-gateway-failed-")); const proxyPort = await reserveUnusedPort(); diff --git a/components/adapters/claude-code/tests/reduction-disclosed-reads.test.ts b/components/adapters/claude-code/tests/reduction-disclosed-reads.test.ts new file mode 100644 index 00000000..815c77ee --- /dev/null +++ b/components/adapters/claude-code/tests/reduction-disclosed-reads.test.ts @@ -0,0 +1,226 @@ +/** + * Regression matrix: repeat-read disclosure carried across Claude Code requests + * (src/reduction.ts, src/session-state.ts, src/context-cleaner/session-catalog.ts) + * + * The tool_payload_trim pass leaves a file read untrimmed when its path was already + * disclosed (the model re-reading a file it saw summarised wants the full body). + * Claude Code resends the whole history on every request, so the adapter must not + * hand the pass a path whose disclosing read is still in the history: the pass would + * then treat that same read as a repeat and send it untrimmed (prompt-cache miss). + * + * normalizeDisclosedReadOwners + * O1 non-objects/arrays → undefined; keys trimmed + lowercased, blank keys dropped; + * non-string or empty owners → null + * carriedDisclosedReadPaths + * O2 no owners → undefined; owner present → not carried; owner absent → carried; + * null owner → carried + * recordDisclosedReadOwners + * O3 no reported paths → unchanged; a new path is owned by the tool_use_id of the + * first eligible trimmed read with that path; no matching segment → null; a + * known path keeps its owner + * O4 a non-read tool using the same path cannot become the disclosure owner + * O5 owners not present in the bounded reported path set are discarded + * applyBeforeCallReductionToClaudePayload + snapshot (as the gateway persists it) + * D1 the same read still in history on the next request stays trimmed and + * byte-identical (regression) + * D2 once the disclosing read has left history, a new read of the same path is sent + * in full (existing progressive-disclosure behaviour kept) + * D3 a legacy snapshot with only disclosedReadPaths (no owners) is ignored: the read + * is trimmed + * D4 owners round-trip through upsertClaudeCodeSessionSnapshot + * D5 when two reads share a path, ownership follows the read actually trimmed; + * removing an earlier untrimmed read does not make the retained read expand + * session catalog + * C1 a snapshot with valid owners is listed; one with a non-string owner is not + */ +import assert from "node:assert/strict"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import type { ContextSegment } from "@lightrsi/kernel"; + +import { normalizeTokenPilotClaudeCodeConfig } from "../src/config.js"; +import { listClaudeCleanerSessions } from "../src/context-cleaner/session-catalog.js"; +import { + applyBeforeCallReductionToClaudePayload, + carriedDisclosedReadPaths, + normalizeDisclosedReadOwners, + recordDisclosedReadOwners, +} from "../src/reduction.js"; +import { loadClaudeCodeSessionSnapshot, upsertClaudeCodeSessionSnapshot } from "../src/session-state.js"; + +const CODE = Array.from({ length: 400 }, (_, i) => `export function handler${i}(input: string): string {\n return input.trim() + "${i}";\n}\n`).join("\n"); +const PATH = "/repo/src/handlers.ts"; + +async function setup() { + const stateDir = await mkdtemp(join(tmpdir(), "cc-disclosed-")); + return { stateDir, config: normalizeTokenPilotClaudeCodeConfig({ stateDir }) }; +} + +function readTurn(id: string) { + return readTurnWithContent(id, CODE); +} + +function readTurnWithContent(id: string, content: string) { + return [ + { role: "assistant", content: [{ type: "tool_use", id, name: "Read", input: { file_path: PATH } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: id, content }] }, + ]; +} + +const writeTurn = [ + { role: "assistant", content: [{ type: "tool_use", id: "toolu_w", name: "Write", input: { file_path: "/repo/out.txt", content: "x" } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_w", content: "wrote 1 byte" }] }, +]; + +const resultText = (payload: any, messageIndex: number) => String(payload.messages[messageIndex].content[0].content); + +/** One gateway request: reduce, then persist the summary's disclosure fields the way recordClaudeGatewayTurn does. */ +async function request(env: Awaited>, sessionId: string, messages: unknown[]) { + const payload: any = { model: "m", messages: structuredClone(messages) }; + const summary = await applyBeforeCallReductionToClaudePayload({ payload, sessionId, config: env.config }); + await upsertClaudeCodeSessionSnapshot(env.stateDir, sessionId, { + disclosedReadPaths: summary.disclosedReadPaths, + disclosedReadOwners: summary.disclosedReadOwners, + }); + return { payload, summary }; +} + +test("O1 normalizeDisclosedReadOwners", () => { + for (const junk of [undefined, null, "x", 3, ["/a"]]) assert.equal(normalizeDisclosedReadOwners(junk), undefined); + assert.deepEqual( + normalizeDisclosedReadOwners({ " /Repo/A.ts ": "toolu_1", " ": "toolu_2", "/b": 5, "/c": "", "/d": null }), + { "/repo/a.ts": "toolu_1", "/b": null, "/c": null, "/d": null }, + ); +}); + +test("O2 carriedDisclosedReadPaths", () => { + assert.equal(carriedDisclosedReadPaths(undefined, new Set()), undefined); + const owners = { "/present": "toolu_1", "/gone": "toolu_2", "/unowned": null }; + assert.deepEqual(carriedDisclosedReadPaths(owners, new Set(["toolu_1"])), ["/gone", "/unowned"]); + assert.equal(carriedDisclosedReadPaths({ "/present": "toolu_1" }, new Set(["toolu_1"])), undefined); +}); + +test("O3 recordDisclosedReadOwners", () => { + const segments = [ + { id: "message-1-block-0", kind: "volatile", text: "x", priority: 0, metadata: { path: "/Repo/A.ts" } }, + { id: "message-3-block-0", kind: "volatile", text: "x", priority: 0, metadata: { path: "/repo/a.ts" } }, + ] as ContextSegment[]; + const bindings = [ + { segmentId: "message-1-block-0", messageIndex: 1, blockIndex: 0, field: "content" as const, toolName: "Read", toolUseId: "toolu_first" }, + { segmentId: "message-3-block-0", messageIndex: 3, blockIndex: 0, field: "content" as const, toolName: "Read", toolUseId: "toolu_second" }, + ]; + const known = { "/known": "toolu_old" }; + assert.equal(recordDisclosedReadOwners(known, undefined, segments, bindings, new Set()), known); + assert.deepEqual( + recordDisclosedReadOwners( + known, + ["/repo/a.ts", "/elsewhere.ts", "/known"], + segments, + bindings, + new Set(["message-1-block-0", "message-3-block-0"]), + ), + { "/known": "toolu_old", "/repo/a.ts": "toolu_first", "/elsewhere.ts": null }, + ); +}); + +test("O4 recordDisclosedReadOwners assigns a same-path disclosure only to a read", () => { + const segments = [ + { id: "write-result", kind: "volatile", text: "wrote 1 byte", priority: 0, metadata: { path: "/repo/a.ts" } }, + { id: "read-result", kind: "volatile", text: "file contents", priority: 0, metadata: { path: "/repo/a.ts" } }, + ] as ContextSegment[]; + const bindings = [ + { segmentId: "write-result", messageIndex: 1, blockIndex: 0, field: "content" as const, toolName: "Write", toolUseId: "toolu_write" }, + { segmentId: "read-result", messageIndex: 3, blockIndex: 0, field: "content" as const, toolName: "Read", toolUseId: "toolu_read" }, + ]; + + assert.deepEqual( + recordDisclosedReadOwners( + undefined, + ["/repo/a.ts"], + segments, + bindings, + new Set(["write-result", "read-result"]), + ), + { "/repo/a.ts": "toolu_read" }, + ); +}); + +test("O5 recordDisclosedReadOwners keeps only the bounded reported path set", () => { + const existing = Object.fromEntries( + Array.from({ length: 130 }, (_, index) => [`/repo/file-${index}.ts`, `toolu_${index}`]), + ); + const reported = Array.from({ length: 128 }, (_, index) => `/repo/file-${index + 2}.ts`); + + const owners = recordDisclosedReadOwners(existing, reported, [], [], new Set()); + + assert.equal(Object.keys(owners ?? {}).length, 128); + assert.equal(owners?.["/repo/file-0.ts"], undefined); + assert.equal(owners?.["/repo/file-1.ts"], undefined); + assert.equal(owners?.["/repo/file-2.ts"], "toolu_2"); + assert.equal(owners?.["/repo/file-129.ts"], "toolu_129"); +}); + +test("D1 the same read still in history stays trimmed on the next request", async () => { + const env = await setup(); + const first = await request(env, "d1", [{ role: "user", content: [{ type: "text", text: "read it" }] }, ...readTurn("toolu_r1")]); + assert.ok(resultText(first.payload, 2).length < CODE.length, "precondition: the first read is trimmed"); + const second = await request(env, "d1", [{ role: "user", content: [{ type: "text", text: "read it" }] }, ...readTurn("toolu_r1"), ...writeTurn]); + assert.equal(resultText(second.payload, 2), resultText(first.payload, 2)); +}); + +test("D2 a new read of the same path after the first left history is sent in full", async () => { + const env = await setup(); + const first = await request(env, "d2", [{ role: "user", content: [{ type: "text", text: "read it" }] }, ...readTurn("toolu_r1")]); + assert.ok(resultText(first.payload, 2).length < CODE.length); + const second = await request(env, "d2", [{ role: "user", content: [{ type: "text", text: "summary, then read it again in full" }] }, ...readTurn("toolu_r2")]); + assert.equal(resultText(second.payload, 2), CODE); +}); + +test("D3 a legacy snapshot without owners does not force reads untrimmed", async () => { + const env = await setup(); + await upsertClaudeCodeSessionSnapshot(env.stateDir, "d3", { disclosedReadPaths: [PATH] }); + const out = await request(env, "d3", [{ role: "user", content: [{ type: "text", text: "read it" }] }, ...readTurn("toolu_r1")]); + assert.ok(resultText(out.payload, 2).length < CODE.length); +}); + +test("D4 owners round-trip through the session snapshot", async () => { + const env = await setup(); + await request(env, "d4", [{ role: "user", content: [{ type: "text", text: "read it" }] }, ...readTurn("toolu_r1")]); + const snapshot = await loadClaudeCodeSessionSnapshot(env.stateDir, "d4"); + assert.deepEqual(snapshot?.disclosedReadOwners, { [PATH]: "toolu_r1" }); + await upsertClaudeCodeSessionSnapshot(env.stateDir, "d4", { requestChars: 10 }); + assert.deepEqual((await loadClaudeCodeSessionSnapshot(env.stateDir, "d4"))?.disclosedReadOwners, { [PATH]: "toolu_r1" }); +}); + +test("D5 ownership follows the same-path read actually trimmed", async () => { + const env = await setup(); + const first = await request(env, "d5", [ + { role: "user", content: [{ type: "text", text: "read it twice" }] }, + ...readTurnWithContent("toolu_short", "not found"), + ...readTurn("toolu_long"), + ]); + const firstLongResult = resultText(first.payload, 4); + + assert.ok(firstLongResult.length < CODE.length, "precondition: only the long read is trimmed"); + assert.deepEqual(first.summary.disclosedReadOwners, { [PATH]: "toolu_long" }); + + const second = await request(env, "d5", [ + { role: "user", content: [{ type: "text", text: "continue after history compaction" }] }, + ...readTurn("toolu_long"), + ]); + const secondLongResult = resultText(second.payload, 2); + assert.ok(secondLongResult.length < CODE.length, "the retained long read must not expand back to full content"); + const normalizeArchiveLocation = (text: string) => text.replace(/^Archive: .+$/m, "Archive: "); + assert.equal(normalizeArchiveLocation(secondLongResult), normalizeArchiveLocation(firstLongResult)); +}); + +test("C1 the cleaner session catalog validates owners", async () => { + const env = await setup(); + await upsertClaudeCodeSessionSnapshot(env.stateDir, "good", { disclosedReadOwners: { "/a": "toolu_1", "/b": null } }); + await upsertClaudeCodeSessionSnapshot(env.stateDir, "bad", { disclosedReadOwners: { "/a": 5 } as never }); + const listed = (await listClaudeCleanerSessions(env.stateDir)).map((session) => session.sessionId); + assert.deepEqual(listed, ["good"]); +});