From af748c3e90be3aed857d343f99566dd9fd92c2ce Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 2 Jul 2026 03:39:59 +0200 Subject: [PATCH] fix(coding-agent/session): prevented truncation of signed thinking and redacted reasoning blocks - Excluded signed `thinking` blocks and `redactedThinking` blobs from size-based persistence truncation. - Preserved signature-bound reasoning verbatim to prevent provider validation failures on session replay. - Maintained normal truncation behavior for unsigned thinking and standard text blocks. --- packages/coding-agent/CHANGELOG.md | 1 + .../src/session/session-persistence.ts | 15 ++++ ...ession-persistence-reasoning-dedup.test.ts | 83 +++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 62172f2ef..49db9fcc5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,6 +17,7 @@ ### Fixed - Fixed subagent HUD layout and tree rendering in the TUI to align correctly with other HUD panels +- Fixed session persistence corrupting Anthropic signed thinking blocks: the generic 500K-character truncation cap could shorten a large `thinking` string while leaving its cryptographic signature intact, so replaying or resuming the session sent a signature that no longer matched the text and the provider rejected it with an HTTP 400. Signed `thinking` blocks and encrypted `redactedThinking` blobs are now persisted verbatim (all-or-nothing); unsigned thinking and plain text remain truncatable for size control. - Fixed several issues with the `apply_patch` and edit tools, including preventing dirty buffers on early aborts, rejecting overwrites of pre-existing files, stopping at the first failing file in multi-file operations, and pruning extremely large file snapshots to prevent session inflation. - Fixed process termination (SIGTERM, SIGHUP, uncaught exceptions) to ensure editor drafts are saved, sessions shut down cleanly, and background jobs are cleaned up. diff --git a/packages/coding-agent/src/session/session-persistence.ts b/packages/coding-agent/src/session/session-persistence.ts index 03c6401e4..71b7ecfaf 100644 --- a/packages/coding-agent/src/session/session-persistence.ts +++ b/packages/coding-agent/src/session/session-persistence.ts @@ -76,6 +76,21 @@ function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string if (shouldExternalizeImagePayload(obj, key)) { return { ...obj, data: externalizeImageDataSync(blobStore, obj.data, obj.mimeType) }; } + // Signed/encrypted reasoning is bound to its exact bytes: a truncated `thinking` + // no longer matches its signature and a truncated `redacted_thinking` blob is + // undecryptable, so the provider 400s the replay. Persist these verbatim — never + // truncate, externalize, or descend. Unsigned thinking (e.g. an interrupted + // stream) has no such binding and stays truncatable for size control. + if (typeof obj === "object" && "type" in obj) { + const signedThinking = + obj.type === "thinking" && + "thinkingSignature" in obj && + typeof obj.thinkingSignature === "string" && + obj.thinkingSignature.length > 0; + const redacted = + obj.type === "redactedThinking" && "data" in obj && typeof obj.data === "string" && obj.data.length > 0; + if (signedThinking || redacted) return obj; + } if (typeof obj === "string") { if (key === "image_url" && isImageDataUrl(obj)) { diff --git a/packages/coding-agent/test/session-persistence-reasoning-dedup.test.ts b/packages/coding-agent/test/session-persistence-reasoning-dedup.test.ts index c706720fa..ea7773ff9 100644 --- a/packages/coding-agent/test/session-persistence-reasoning-dedup.test.ts +++ b/packages/coding-agent/test/session-persistence-reasoning-dedup.test.ts @@ -94,3 +94,86 @@ describe("session reasoning-signature dedup", () => { expect(thinking.thinkingSignature).toBe(signature); }); }); + +describe("session atomic reasoning persistence", () => { + const truncationNotice = "[Session persistence truncated large content]"; + + it("preserves an oversized signed thinking block and its signature verbatim", () => { + using tempDir = TempDir.createSync("@pi-session-atomic-thinking-"); + const blobStore = new BlobStore(tempDir.path()); + + const message = persistedAssistant( + assistantEntry([{ type: "thinking", thinking: "x".repeat(600_000), thinkingSignature: "sig-abc" }], undefined), + blobStore, + ); + + const thinking = message.content[0]; + if (thinking?.type !== "thinking") throw new Error("Expected thinking block"); + expect(thinking.thinking).toHaveLength(600_000); + expect(thinking.thinking.endsWith(truncationNotice)).toBe(false); + expect(thinking.thinkingSignature).toBe("sig-abc"); + }); + + it("preserves an oversized redactedThinking blob verbatim", () => { + using tempDir = TempDir.createSync("@pi-session-atomic-redacted-"); + const blobStore = new BlobStore(tempDir.path()); + + const message = persistedAssistant( + assistantEntry([{ type: "redactedThinking", data: "r".repeat(600_000) }], undefined), + blobStore, + ); + + const redactedThinking = message.content[0]; + if (redactedThinking?.type !== "redactedThinking") throw new Error("Expected redactedThinking block"); + expect(redactedThinking.data).toHaveLength(600_000); + expect(redactedThinking.data.endsWith(truncationNotice)).toBe(false); + }); + + it("still truncates oversized UNSIGNED thinking and text blocks", () => { + using tempDir = TempDir.createSync("@pi-session-atomic-unsigned-"); + const blobStore = new BlobStore(tempDir.path()); + + const message = persistedAssistant( + assistantEntry( + [ + { type: "thinking", thinking: "y".repeat(600_000) }, + { type: "text", text: "z".repeat(600_000) }, + ], + undefined, + ), + blobStore, + ); + + const thinking = message.content[0]; + if (thinking?.type !== "thinking") throw new Error("Expected thinking block"); + expect(thinking.thinking.length).toBeLessThan(600_000); + expect(thinking.thinking.endsWith(truncationNotice)).toBe(true); + + const text = message.content[1]; + if (text?.type !== "text") throw new Error("Expected text block"); + expect(text.text.length).toBeLessThan(600_000); + expect(text.text.endsWith(truncationNotice)).toBe(true); + }); + + it("survives a full JSONL string round-trip for signed thinking", () => { + using tempDir = TempDir.createSync("@pi-session-atomic-roundtrip-"); + const blobStore = new BlobStore(tempDir.path()); + const entry = assistantEntry( + [{ type: "thinking", thinking: "x".repeat(600_000), thinkingSignature: "sig-abc" }], + undefined, + ); + + const persistedEntry = prepareEntryForPersistence(entry, blobStore); + const line = JSON.stringify(persistedEntry); + const reparsed = JSON.parse(line); + if (reparsed.type !== "message" || reparsed.message.role !== "assistant") { + throw new Error("Expected reparsed assistant message"); + } + + const thinking = reparsed.message.content[0]; + if (thinking?.type !== "thinking") throw new Error("Expected thinking block"); + expect(thinking.thinking).toHaveLength(600_000); + expect(thinking.thinking.endsWith(truncationNotice)).toBe(false); + expect(thinking.thinkingSignature).toBe("sig-abc"); + }); +});