diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index e0eaf270f..217d924dd 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -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 // `` 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 }; } } diff --git a/packages/coding-agent/test/session-manager/build-context.test.ts b/packages/coding-agent/test/session-manager/build-context.test.ts index a101b4616..91a47fd73 100644 --- a/packages/coding-agent/test/session-manager/build-context.test.ts +++ b/packages/coding-agent/test/session-manager/build-context.test.ts @@ -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); });