fix(session): neutralized signed thinking blocks on dangling tool-use cleanup

- Stripped `redactedThinking` blocks (encrypted, no downgradeable plaintext) from trailing assistant turns during context rebuild.
- Cleared `thinkingSignature` on `thinking` blocks so the encoder downgrades them to plain text, avoiding Anthropic's "modified latest assistant message" rejection.
- Extended existing test to cover signed/redacted thinking alongside dangling tool calls.
This commit is contained in:
can1357
2026-05-30 02:06:29 +02:00
parent de90713c76
commit f6bf17d8cc
2 changed files with 34 additions and 8 deletions
@@ -709,17 +709,31 @@ export function buildSessionContext(
// unpaired as the final message. Downstream that forces `transformMessages` to
// fabricate one synthetic "aborted"/"No result provided" result per call plus a
// `<turn-aborted>` developer note, which re-injects the whole failed batch and
// pushes the model to re-issue it — the rewind/restore loop. Strip the dangling
// tool_use so the turn resumes from its reasoning/text; drop it if nothing else
// remains. (A live turn never lands here: its results are persisted and the leaf
// advances past the assistant before any context rebuild.)
// pushes the model to re-issue it — the rewind/restore loop.
//
// Stripping the tool_use is necessary but not sufficient: a *modified* latest
// assistant turn that still carries signed `thinking`/`redacted_thinking` is
// rejected by Anthropic — "thinking blocks in the latest assistant message cannot
// be modified" (and signed thinking replayed out of its original turn shape can
// also fail signature validation). This bites the handoff/branch-summary request,
// which ends on exactly this turn. So when we rewrite the turn we also neutralize
// its protected reasoning: drop `redactedThinking` (encrypted, no plaintext to
// keep) and clear `thinking` signatures so the provider encoder downgrades them to
// plain text (verified accepted by the live API), preserving the visible reasoning
// while removing the immutability/invalid-signature hazard. Drop the turn entirely
// if nothing usable remains. (A live turn never lands here: its results are
// persisted and the leaf advances past the assistant before any context rebuild.)
const lastMessage = messages[messages.length - 1];
if (lastMessage?.role === "assistant" && lastMessage.content.some(block => block.type === "toolCall")) {
const withoutToolCalls = lastMessage.content.filter(block => block.type !== "toolCall");
if (withoutToolCalls.length === 0) {
const normalized = lastMessage.content
.filter(block => block.type !== "toolCall" && block.type !== "redactedThinking")
.map(block =>
block.type === "thinking" && block.thinkingSignature ? { ...block, thinkingSignature: undefined } : block,
);
if (normalized.length === 0) {
messages.pop();
} else {
messages[messages.length - 1] = { ...lastMessage, content: withoutToolCalls };
messages[messages.length - 1] = { ...lastMessage, content: normalized };
}
}
@@ -355,8 +355,11 @@ describe("buildSessionContext", () => {
message: {
role: "assistant",
content: [
{ type: "thinking", thinking: "deliberating step 1", thinkingSignature: "sig_1" },
{ type: "text", text: "Let me finish duel.py now" },
{ type: "toolCall", id: "call_1", name: "write", arguments: { path: "duel.py" } },
{ type: "thinking", thinking: "deliberating step 2", thinkingSignature: "sig_2" },
{ type: "redactedThinking", data: "encrypted" },
{ type: "toolCall", id: "call_2", name: "bash", arguments: { command: "pytest" } },
],
api: "anthropic-messages",
@@ -379,8 +382,17 @@ describe("buildSessionContext", () => {
expect(ctx.messages).toHaveLength(2);
const last = ctx.messages[1];
expect(last.role).toBe("assistant");
const content = (last as { content: Array<{ type: string }> }).content;
const content = (last as { content: Array<{ type: string; thinkingSignature?: string }> }).content;
// Dangling tool_use stripped.
expect(content.some(block => block.type === "toolCall")).toBe(false);
// redacted_thinking dropped (encrypted; cannot be downgraded, would trip immutability).
expect(content.some(block => block.type === "redactedThinking")).toBe(false);
// thinking preserved but de-signed so the encoder downgrades it to plain text on the wire
// (a *modified* latest turn carrying signed thinking is rejected by Anthropic).
const thinking = content.filter(block => block.type === "thinking");
expect(thinking.length).toBeGreaterThan(0);
expect(thinking.every(block => block.thinkingSignature === undefined)).toBe(true);
// Visible reasoning/text preserved.
expect(content.some(block => block.type === "text")).toBe(true);
});