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.
This commit is contained in:
can1357
2026-07-02 03:39:59 +02:00
parent 21f5728ef8
commit af748c3e90
3 changed files with 99 additions and 0 deletions
+1
View File
@@ -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.
@@ -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)) {
@@ -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");
});
});