From 08af506916a9b41bac44d7a06f3c40fdfe4db764 Mon Sep 17 00:00:00 2001 From: mike2024-dev Date: Tue, 17 Mar 2026 02:19:06 -0700 Subject: [PATCH 1/2] fix(repair): strip tool_use blocks from error/aborted messages instead of skipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When repairToolUseResultPairing encounters an assistant message with stopReason === 'error' or 'aborted', it previously skipped the message entirely to avoid creating synthetic tool_results for incomplete tool calls (see #4597). However, skipping these messages preserves any orphaned tool_use blocks inside them. Anthropic then rejects every subsequent API request with: 'tool_use ids were found without tool_result blocks immediately after: ' This causes permanent session corruption — the same error fires on every retry until a human manually resets the session (see #48354). The fix: instead of skipping, strip the tool_use/toolCall blocks from error/aborted messages. Text content (if any) is preserved. If the message becomes empty after stripping, a placeholder text block is inserted so the message remains structurally valid. This avoids both failure modes: - Does NOT create synthetic tool_results (preserves #4597 fix) - Does NOT preserve orphaned tool_use blocks (fixes #48354) Fixes #48354 Related: #4597 #46866 #45393 --- src/agents/session-transcript-repair.test.ts | 53 +++++++++++++++----- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index eea82268d7d..6ceb1c57aa6 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -144,10 +144,11 @@ describe("sanitizeToolUseResultPairing", () => { expect(out.map((m) => m.role)).toEqual(["user", "assistant"]); }); - it("skips tool call extraction for assistant messages with stopReason 'error'", () => { - // When an assistant message has stopReason: "error", its tool_use blocks may be - // incomplete/malformed. We should NOT create synthetic tool_results for them, - // as this causes API 400 errors: "unexpected tool_use_id found in tool_result blocks" + it("strips tool_use blocks from assistant messages with stopReason 'error'", () => { + // When an assistant message has stopReason: "error", its tool_use blocks are + // orphaned (no tool_result will ever come). Instead of passing them through + // (which causes permanent session corruption — see #48354), strip the tool + // call blocks entirely. Do NOT create synthetic tool_results either (#4597). const input = castAgentMessages([ { role: "assistant", @@ -159,17 +160,19 @@ describe("sanitizeToolUseResultPairing", () => { const result = repairToolUseResultPairing(input); - // Should NOT add synthetic tool results for errored messages - expect(result.added).toHaveLength(0); - // The assistant message should be passed through unchanged + // Should strip the tool call block, not create synthetic results expect(result.messages[0]?.role).toBe("assistant"); + const assistantContent = (result.messages[0] as { content: { type: string }[] }).content; + // Tool call block should be gone, replaced with placeholder text + expect(assistantContent.every((b: { type: string }) => b.type !== "toolCall")).toBe(true); expect(result.messages[1]?.role).toBe("user"); expect(result.messages).toHaveLength(2); + expect(result.changed).toBe(true); }); - it("skips tool call extraction for assistant messages with stopReason 'aborted'", () => { - // When a request is aborted mid-stream, the assistant message may have incomplete - // tool_use blocks (with partialJson). We should NOT create synthetic tool_results. + it("strips tool_use blocks from assistant messages with stopReason 'aborted'", () => { + // When a request is aborted mid-stream, strip orphaned tool_use blocks + // to prevent permanent session corruption from dangling tool calls. const input = castAgentMessages([ { role: "assistant", @@ -181,12 +184,36 @@ describe("sanitizeToolUseResultPairing", () => { const result = repairToolUseResultPairing(input); - // Should NOT add synthetic tool results for aborted messages - expect(result.added).toHaveLength(0); - // Messages should be passed through without synthetic insertions + // Tool call blocks should be stripped expect(result.messages).toHaveLength(2); expect(result.messages[0]?.role).toBe("assistant"); + const assistantContent = (result.messages[0] as { content: { type: string }[] }).content; + expect(assistantContent.every((b: { type: string }) => b.type !== "toolCall")).toBe(true); expect(result.messages[1]?.role).toBe("user"); + expect(result.changed).toBe(true); + }); + + it("preserves text content alongside stripped tool_use in error messages", () => { + // If the errored assistant message has both text and tool_use blocks, + // only strip the tool calls and keep the text. + const input = castAgentMessages([ + { + role: "assistant", + content: [ + { type: "text", text: "Let me try running that..." }, + { type: "toolCall", id: "call_err2", name: "exec", arguments: {} }, + ], + stopReason: "error", + }, + { role: "user", content: "what happened?" }, + ]); + + const result = repairToolUseResultPairing(input); + + const assistantContent = (result.messages[0] as { content: { type: string; text?: string }[] }).content; + expect(assistantContent).toHaveLength(1); + expect(assistantContent[0]?.type).toBe("text"); + expect(assistantContent[0]?.text).toBe("Let me try running that..."); }); it("still repairs tool results for normal assistant messages with stopReason 'toolUse'", () => { From 8c25ba3a0f8c7300de6b3d3d419f751946db7deb Mon Sep 17 00:00:00 2001 From: lanclaw Date: Tue, 17 Mar 2026 22:11:32 -0700 Subject: [PATCH 2/2] fix: correct type errors and formatting in error/aborted tool_use stripping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove invalid `added.push(strings)` that pushed strings into `Array>` — stripped error/aborted tool calls should not appear in the `added` report - Fix test assertions: `result.changed` → `result.moved` since `ToolUseRepairReport` exposes `moved` (which includes `changed`), not `changed` directly - Fix formatting to pass `oxfmt --check` Co-Authored-By: Claude Opus 4.6 --- src/agents/session-transcript-repair.test.ts | 7 +-- src/agents/session-transcript-repair.ts | 56 ++++++++++++-------- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index 6ceb1c57aa6..a240ba2bbab 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -167,7 +167,7 @@ describe("sanitizeToolUseResultPairing", () => { expect(assistantContent.every((b: { type: string }) => b.type !== "toolCall")).toBe(true); expect(result.messages[1]?.role).toBe("user"); expect(result.messages).toHaveLength(2); - expect(result.changed).toBe(true); + expect(result.moved).toBe(true); }); it("strips tool_use blocks from assistant messages with stopReason 'aborted'", () => { @@ -190,7 +190,7 @@ describe("sanitizeToolUseResultPairing", () => { const assistantContent = (result.messages[0] as { content: { type: string }[] }).content; expect(assistantContent.every((b: { type: string }) => b.type !== "toolCall")).toBe(true); expect(result.messages[1]?.role).toBe("user"); - expect(result.changed).toBe(true); + expect(result.moved).toBe(true); }); it("preserves text content alongside stripped tool_use in error messages", () => { @@ -210,7 +210,8 @@ describe("sanitizeToolUseResultPairing", () => { const result = repairToolUseResultPairing(input); - const assistantContent = (result.messages[0] as { content: { type: string; text?: string }[] }).content; + const assistantContent = (result.messages[0] as { content: { type: string; text?: string }[] }) + .content; expect(assistantContent).toHaveLength(1); expect(assistantContent[0]?.type).toBe("text"); expect(assistantContent[0]?.text).toBe("Let me try running that..."); diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index 9455837d930..0c2a407aec6 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -348,7 +348,7 @@ export type ToolUseRepairReport = { export function repairToolUseResultPairing( messages: AgentMessage[], - options?: ToolUseResultPairingOptions, + _options?: ToolUseResultPairingOptions, ): ToolUseRepairReport { // Anthropic (and Cloud Code Assist) reject transcripts where assistant tool calls are not // immediately followed by matching tool results. Session files can end up with results @@ -400,6 +400,36 @@ export function repairToolUseResultPairing( const assistant = msg as Extract; + // For aborted or errored assistant messages, strip any tool_use/toolCall blocks + // rather than skipping the message entirely. These messages are the most likely + // to contain orphaned tool_use blocks (tool call started but result never generated). + // Previously we skipped them to avoid creating synthetic tool_results for incomplete + // tool calls (see #4597), but skipping preserves the orphaned blocks, which then + // cause permanent session corruption — Anthropic rejects every subsequent request + // with "tool_use ids were found without tool_result blocks" (see #48354). + // The safe fix: strip the tool call blocks entirely instead of trying to pair them. + const stopReason = (assistant as { stopReason?: string }).stopReason; + if (stopReason === "error" || stopReason === "aborted") { + const errorToolCalls = extractToolCallsFromAssistant(assistant); + if (errorToolCalls.length > 0) { + const content = Array.isArray(assistant.content) ? assistant.content : []; + const stripped = content.filter( + (b: { type?: string }) => b && b.type !== "toolCall" && b.type !== "toolUse", + ); + changed = true; + out.push({ + ...assistant, + content: + stripped.length > 0 + ? stripped + : [{ type: "text" as const, text: "[tool calls from errored turn stripped]" }], + } as typeof assistant); + } else { + out.push(msg); + } + continue; + } + const toolCalls = extractToolCallsFromAssistant(assistant); if (toolCalls.length === 0) { out.push(msg); @@ -457,27 +487,9 @@ export function repairToolUseResultPairing( } } - // Aborted/errored assistant turns should never synthesize missing tool results, but - // the replay sanitizer can still legitimately retain real tool results for surviving - // tool calls in the same turn after malformed siblings are dropped. - const stopReason = (assistant as { stopReason?: string }).stopReason; - if (stopReason === "error" || stopReason === "aborted") { - out.push(msg); - if (options?.preserveErroredAssistantResults) { - for (const toolCall of toolCalls) { - const result = spanResultsById.get(toolCall.id); - if (!result) { - continue; - } - pushToolResult(result); - } - } - for (const rem of remainder) { - out.push(rem); - } - i = j - 1; - continue; - } + // Note: aborted/errored assistant turns are already handled above (tool_use blocks + // are stripped and the message is emitted with `continue`). This point is only + // reached for normal assistant messages, so no stopReason guard is needed here. out.push(msg);