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