From b597e15b4d6a1e4258c91b5f3adfc294a3af959d Mon Sep 17 00:00:00 2001 From: Asjad Abbas <215788583+asjad3@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:32:30 +0500 Subject: [PATCH] fix(core): drop tool parts when a failed turn loses its reasoning block Replaying an errored assistant turn could send Anthropic a tool_use with no preceding thinking block, which the API rejects with a 400. reuseProviderMetadata is false once message.error is set, so the reasoning part is emitted without its signature. Reasoning text is empty whenever thinking display is "omitted" -- the default on current Anthropic models -- so the part then carries neither text nor provider state and is filtered out as empty, leaving the turn's tool calls orphaned. Drop the tool parts alongside the reasoning so the replayed turn stays self-consistent. Scoped to same-model replay: a model switch lowers reasoning to plain text on purpose, so its tool calls remain valid and are kept. Fixes #38620 --- .../core/src/session/runner/to-llm-message.ts | 39 ++++++++-- .../core/test/session-runner-message.test.ts | 76 +++++++++++++++++++ 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index b2b1af5d30f1..948dfc5513be 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -93,18 +93,41 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => { ) return result ? [call, result] : [call] }) - const meaningful = content.filter((part) => { + const retained = content.filter((part) => { if (part.type === "text") return part.text !== "" if (part.type !== "reasoning") return true return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0) }) - const results = message.content - .filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true) - .map((item) => - toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined), - ) - .filter((message) => message !== undefined) - .map(Message.tool) + // A turn that reasoned before calling a tool must keep the two together: Anthropic + // rejects a tool_use that is not preceded by its thinking block. The reasoning part is + // dropped above when it carries neither text nor provider state, which is what a failed + // step looks like once its provider metadata is stripped — and reasoning text is empty + // whenever thinking display is "omitted", the default on current Anthropic models. Drop + // the tool parts as well in that case so the replayed turn stays self-consistent rather + // than replaying orphaned calls. + // Only when replaying to the same model. A model switch lowers reasoning to plain text + // on purpose, so the tool calls stay valid and must be kept. + const droppedReasoning = + sameModel && + message.content.some((item) => item.type === "reasoning") && + !retained.some((part) => part.type === "reasoning") + const meaningful = droppedReasoning + ? retained.filter((part) => part.type !== "tool-call" && part.type !== "tool-result") + : retained + const results = droppedReasoning + ? [] + : message.content + .filter( + (item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true, + ) + .map((item) => + toolResult( + item, + reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined, + ), + ) + .filter((message) => message !== undefined) + .map(Message.tool) if (meaningful.length === 0) return results return [ Message.make({ id: message.id, role: "assistant", content: meaningful, metadata: message.metadata }), diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 5798b665a86a..674617fa87d6 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -498,4 +498,80 @@ Recent work }, ]) }) + + test("drops tool parts when a failed turn loses its reasoning block", () => { + const messages = toLLMMessages( + [ + SessionMessage.Assistant.make({ + id: id("assistant-thinking-failed"), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content: [ + // Reasoning text is empty whenever thinking display is "omitted", so once the + // failed turn's provider metadata is stripped there is nothing left to replay + // and the reasoning part is dropped as empty. + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + id: "reasoning-failed", + text: "", + providerMetadata: { anthropic: { signature: "sig_1" } }, + }), + SessionMessage.AssistantTool.make({ + type: "tool", + id: "orphan-call", + name: "read", + state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }), + time: { created }, + }), + ], + finish: "error", + error: { type: "unknown", message: "Provider turn interrupted" }, + time: { created, completed: created }, + }), + ], + model, + ) + + // The tool call must not survive on its own: Anthropic rejects a tool_use that is not + // preceded by its thinking block. + expect(messages).toEqual([]) + }) + + test("keeps tool parts when reasoning survives a failed turn as text", () => { + const messages = toLLMMessages( + [ + SessionMessage.Assistant.make({ + id: id("assistant-text-reasoning-failed"), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content: [ + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + id: "reasoning-partial", + text: "Partial thought", + providerMetadata: { openai: { itemId: "rs_failed" } }, + }), + SessionMessage.AssistantTool.make({ + type: "tool", + id: "kept-call", + name: "read", + state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }), + time: { created }, + }), + ], + finish: "error", + error: { type: "unknown", message: "Provider turn interrupted" }, + time: { created, completed: created }, + }), + ], + model, + ) + + expect(messages[0]?.content).toEqual([ + { type: "reasoning", text: "Partial thought", providerMetadata: undefined }, + { type: "tool-call", id: "kept-call", name: "read", input: { path: "README.md" } }, + ]) + }) })