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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user