From 12cef822dd5caff4f06cf40ef28a866ff20bfeb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:37:18 +0000 Subject: [PATCH 1/2] fix(claude-code): keep an already-trimmed file read trimmed on later requests The tool_payload_trim pass sends a file read in full when its path was already disclosed, taking it as the model asking for the whole file again. The Claude Code gateway persisted every disclosed path and passed them all back in on the next request. Claude Code resends the whole history, so the pass saw the same read as a repeat of itself and sent it untrimmed: a probe showed a Read result trimmed to 2,830 chars on one request going out at 36,580 chars on the next, which also breaks the prompt cache from that point on. The session snapshot now records which tool_use_id disclosed each path (disclosedReadOwners). A path is carried into the next request only when that read is no longer in the history, so a genuine re-read after compaction still comes back in full. Snapshots from before this change (paths without owners) are ignored rather than carried. Tests: tests/reduction-disclosed-reads.test.ts (O1-O3, D1-D4, C1) and a gateway test for the same read staying trimmed. D1, D3 and the gateway test fail against the previous behaviour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S6it2Si8k8XNGMF8RdyMQz --- .../src/context-cleaner/session-catalog.ts | 3 + .../claude-code/src/gateway-runtime.ts | 4 + .../adapters/claude-code/src/reduction.ts | 79 ++++++++- .../adapters/claude-code/src/session-state.ts | 3 + .../claude-code/tests/gateway-runtime.test.ts | 121 ++++++++++++++ .../tests/reduction-disclosed-reads.test.ts | 153 ++++++++++++++++++ 6 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 components/adapters/claude-code/tests/reduction-disclosed-reads.test.ts 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..1e3a151c 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..df7446d1 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,72 @@ 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; +} + +/** Add newly disclosed paths, owned by the first read in this request with that path; known paths keep their owner. */ +export function recordDisclosedReadOwners( + owners: DisclosedReadOwners | undefined, + reportedPaths: unknown, + segments: readonly ContextSegment[], + bindings: readonly ClaudeSegmentBinding[], +): DisclosedReadOwners | undefined { + const paths = normalizeDisclosedReadPaths(reportedPaths); + if (!paths) return owners; + const next: DisclosedReadOwners = { ...(owners ?? {}) }; + const toolUseIdBySegment = new Map(bindings.map((binding) => [binding.segmentId, binding.toolUseId])); + for (const path of paths) { + if (path in next) continue; + const owner = segments.find((segment) => { + const segmentPath = asRecord(segment.metadata).path; + return typeof segmentPath === "string" && segmentPath.trim().toLowerCase() === path; + }); + next[path] = (owner && toolUseIdBySegment.get(owner.id)) || 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 +383,7 @@ function buildTurnContext( blockIndex, field: typeof entry.text === "string" ? "text" : "content", toolName: typeof entry.name === "string" ? entry.name : hint?.toolName, + ...(toolUseId ? { toolUseId } : {}), }); }); }); @@ -552,8 +624,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); @@ -601,6 +676,7 @@ export async function applyBeforeCallReductionToClaudePayload(params: { passEffects, diagnostics: built.diagnostics, disclosedReadPaths: normalizeDisclosedReadPaths(reducedCtx.metadata?.disclosedReadPaths), + disclosedReadOwners: recordDisclosedReadOwners(disclosedReadOwners, reducedCtx.metadata?.disclosedReadPaths, built.turnCtx.segments, bindings), skippedReason: "pipeline_no_effect", }; } @@ -659,6 +735,7 @@ export async function applyBeforeCallReductionToClaudePayload(params: { diagnostics: built.diagnostics, visualSegments, disclosedReadPaths: normalizeDisclosedReadPaths(reducedCtx.metadata?.disclosedReadPaths), + disclosedReadOwners: recordDisclosedReadOwners(disclosedReadOwners, reducedCtx.metadata?.disclosedReadPaths, built.turnCtx.segments, bindings), }; } 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..f580c65e --- /dev/null +++ b/components/adapters/claude-code/tests/reduction-disclosed-reads.test.ts @@ -0,0 +1,153 @@ +/** + * 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 segment with that path; no matching segment → null; a known path keeps + * its owner + * 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 + * 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 [ + { role: "assistant", content: [{ type: "tool_use", id, name: "Read", input: { file_path: PATH } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: id, content: CODE }] }, + ]; +} + +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, toolUseId: "toolu_first" }, + { segmentId: "message-3-block-0", messageIndex: 3, blockIndex: 0, field: "content" as const, toolUseId: "toolu_second" }, + ]; + const known = { "/known": "toolu_old" }; + assert.equal(recordDisclosedReadOwners(known, undefined, segments, bindings), known); + assert.deepEqual( + recordDisclosedReadOwners(known, ["/repo/a.ts", "/elsewhere.ts", "/known"], segments, bindings), + { "/known": "toolu_old", "/repo/a.ts": "toolu_first", "/elsewhere.ts": null }, + ); +}); + +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("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"]); +}); From eb1eb2cbdb84a3dfbaa16cb5dd3cd002a7d7366a Mon Sep 17 00:00:00 2001 From: trancefornow <2483151895@qq.com> Date: Wed, 30 Sep 2026 20:45:23 +0800 Subject: [PATCH 2/2] fix(claude-code): attribute disclosed reads to trimmed owners --- .../claude-code/src/gateway-runtime.ts | 2 +- .../adapters/claude-code/src/reduction.ts | 43 +++++++-- .../tests/reduction-disclosed-reads.test.ts | 87 +++++++++++++++++-- 3 files changed, 116 insertions(+), 16 deletions(-) diff --git a/components/adapters/claude-code/src/gateway-runtime.ts b/components/adapters/claude-code/src/gateway-runtime.ts index 1e3a151c..a0814e11 100644 --- a/components/adapters/claude-code/src/gateway-runtime.ts +++ b/components/adapters/claude-code/src/gateway-runtime.ts @@ -1161,7 +1161,7 @@ export async function startClaudeCodeGatewayRuntime(params: { responseId, previousResponseId, disclosedReadPaths: reductionSummary?.disclosedReadPaths, - disclosedReadOwners: reductionSummary?.disclosedReadOwners, + 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 df7446d1..3a6ecda8 100644 --- a/components/adapters/claude-code/src/reduction.ts +++ b/components/adapters/claude-code/src/reduction.ts @@ -128,24 +128,35 @@ export function carriedDisclosedReadPaths( return carried.length > 0 ? carried : undefined; } -/** Add newly disclosed paths, owned by the first read in this request with that path; known paths keep their owner. */ +/** + * 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 = { ...(owners ?? {}) }; - const toolUseIdBySegment = new Map(bindings.map((binding) => [binding.segmentId, binding.toolUseId])); + const next: DisclosedReadOwners = {}; + const bindingBySegment = new Map(bindings.map((binding) => [binding.segmentId, binding])); for (const path of paths) { - if (path in next) continue; + 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 && toolUseIdBySegment.get(owner.id)) || null; + next[path] = (owner && bindingBySegment.get(owner.id)?.toolUseId) || null; } return Object.keys(next).length > 0 ? next : undefined; } @@ -660,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) { @@ -676,7 +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), + disclosedReadOwners: recordDisclosedReadOwners( + disclosedReadOwners, + reducedCtx.metadata?.disclosedReadPaths, + built.turnCtx.segments, + bindings, + trimmedSegmentIds, + ), skippedReason: "pipeline_no_effect", }; } @@ -735,7 +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), + disclosedReadOwners: recordDisclosedReadOwners( + disclosedReadOwners, + reducedCtx.metadata?.disclosedReadPaths, + built.turnCtx.segments, + bindings, + trimmedSegmentIds, + ), }; } diff --git a/components/adapters/claude-code/tests/reduction-disclosed-reads.test.ts b/components/adapters/claude-code/tests/reduction-disclosed-reads.test.ts index f580c65e..815c77ee 100644 --- a/components/adapters/claude-code/tests/reduction-disclosed-reads.test.ts +++ b/components/adapters/claude-code/tests/reduction-disclosed-reads.test.ts @@ -16,8 +16,10 @@ * null owner → carried * recordDisclosedReadOwners * O3 no reported paths → unchanged; a new path is owned by the tool_use_id of the - * first segment with that path; no matching segment → null; a known path keeps - * its owner + * 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) @@ -26,6 +28,8 @@ * 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 */ @@ -56,9 +60,13 @@ async function setup() { } 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: CODE }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: id, content }] }, ]; } @@ -101,17 +109,60 @@ test("O3 recordDisclosedReadOwners", () => { { 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, toolUseId: "toolu_first" }, - { segmentId: "message-3-block-0", messageIndex: 3, blockIndex: 0, field: "content" as const, toolUseId: "toolu_second" }, + { 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), known); + assert.equal(recordDisclosedReadOwners(known, undefined, segments, bindings, new Set()), known); assert.deepEqual( - recordDisclosedReadOwners(known, ["/repo/a.ts", "/elsewhere.ts", "/known"], segments, bindings), + 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")]); @@ -144,6 +195,28 @@ test("D4 owners round-trip through the session snapshot", async () => { 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 } });