fix(repair): strip tool_use blocks from error/aborted messages instead of skipping

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: <ID>'

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
This commit is contained in:
mike2024-dev 2026-03-17 02:19:06 -07:00 committed by lanclaw
parent e635cedb85
commit 08af506916

View File

@ -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'", () => {