diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index b2b1af5d30f1..216ff0bfb595 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -70,7 +70,12 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid const assistant = (message: SessionMessage.Assistant, model: Model) => { const sameModel = String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id) - const reuseProviderMetadata = sameModel && message.error === undefined + // Reasoning continuation metadata survives failed turns (#38620): replaying a + // tool_use part without its preceding signed/redacted thinking block makes + // Anthropic reject the request with a 400 once thinking is enabled, so the + // reasoning provider state must be reused whenever the turn stays on the same + // model. Tool execution metadata from a failed turn is still not trusted. + const reuseToolMetadata = sameModel && message.error === undefined const content = message.content.flatMap((item): ContentPart[] => { if (item.type === "text") return [{ type: "text", text: item.text }] if (item.type === "reasoning") @@ -79,17 +84,17 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => { { type: "reasoning", text: item.text, - providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined, + providerMetadata: item.providerMetadata, }, ] : item.text.length > 0 ? [{ type: "text", text: item.text }] : [] - const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined) + const call = toolCall(item, reuseToolMetadata ? item.provider?.metadata : undefined) if (item.provider?.executed !== true) return [call] const result = toolResult( item, - reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined, + reuseToolMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined, ) return result ? [call, result] : [call] }) @@ -101,7 +106,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => { 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), + toolResult(item, reuseToolMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined), ) .filter((message) => message !== undefined) .map(Message.tool) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 5798b665a86a..79d6b09fb6c4 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -327,7 +327,7 @@ Recent work ]) }) - test("drops provider-native continuation metadata from failed assistant turns", () => { + test("preserves reasoning continuation metadata on failed same-model assistant turns", () => { const messages = toLLMMessages( [ SessionMessage.Assistant.make({ @@ -370,7 +370,11 @@ Recent work ) expect(messages[0]?.content).toEqual([ - { type: "reasoning", text: "Partial thought", providerMetadata: undefined }, + { + type: "reasoning", + text: "Partial thought", + providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } }, + }, { type: "tool-call", id: "hosted-failed", @@ -399,6 +403,131 @@ Recent work ]) }) + test("preserves Anthropic thinking signature on failed assistant turns (#38620)", () => { + const messages = toLLMMessages( + [ + SessionMessage.Assistant.make({ + id: id("assistant-failed-anthropic"), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content: [ + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + id: "reasoning-failed-anthropic", + text: "Signed thought", + providerMetadata: { anthropic: { signature: "sig_failed" } }, + }), + SessionMessage.AssistantTool.make({ + type: "tool", + id: "hosted-failed-anthropic", + name: "web_search", + provider: { + executed: true, + metadata: { anthropic: { signature: "call_sig_failed" } }, + }, + state: SessionMessage.ToolStateError.make({ + status: "error", + input: { query: "Effect" }, + error: { type: "unknown", message: "Provider turn interrupted" }, + content: [], + structured: {}, + }), + time: { created, completed: created }, + }), + ], + finish: "error", + error: { type: "unknown", message: "Provider turn interrupted" }, + time: { created, completed: created }, + }), + ], + model, + ) + + // The replayed tool_use must be preceded by its signed thinking block or + // Anthropic rejects the request with a 400 once thinking is enabled. + expect(messages[0]?.content).toEqual([ + { + type: "reasoning", + text: "Signed thought", + providerMetadata: { anthropic: { signature: "sig_failed" } }, + }, + { + type: "tool-call", + id: "hosted-failed-anthropic", + name: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerMetadata: undefined, + }, + { + type: "tool-result", + id: "hosted-failed-anthropic", + name: "web_search", + result: { + type: "error", + value: { + error: { type: "unknown", message: "Provider turn interrupted" }, + content: [], + structured: {}, + }, + }, + providerExecuted: true, + cache: undefined, + metadata: undefined, + providerMetadata: undefined, + }, + ]) + }) + + test("preserves redacted thinking data on failed assistant turns (#38620)", () => { + const messages = toLLMMessages( + [ + SessionMessage.Assistant.make({ + id: id("assistant-failed-redacted"), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content: [ + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + id: "reasoning-failed-redacted", + text: "", + providerMetadata: { anthropic: { redactedData: "redacted-blob-failed" } }, + }), + SessionMessage.AssistantTool.make({ + type: "tool", + id: "hosted-failed-redacted", + name: "web_search", + provider: { + executed: true, + metadata: {}, + }, + state: SessionMessage.ToolStateError.make({ + status: "error", + input: { query: "Effect" }, + error: { type: "unknown", message: "Provider turn interrupted" }, + content: [], + structured: {}, + }), + time: { created, completed: created }, + }), + ], + finish: "error", + error: { type: "unknown", message: "Provider turn interrupted" }, + time: { created, completed: created }, + }), + ], + model, + ) + + expect(messages[0]?.content[0]).toEqual({ + type: "reasoning", + text: "", + providerMetadata: { anthropic: { redactedData: "redacted-blob-failed" } }, + }) + }) + test("drops provider-native continuation metadata after a model switch", () => { const messages = toLLMMessages( [