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" } }, + ]) + }) })