From a3ac699bebefaffdf02a5fc34eb3587e912dfe93 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 4 Feb 2026 15:04:32 -0700 Subject: [PATCH 1/3] fix(ai-sdk): preserve reasoning parts in message conversion --- src/api/transform/__tests__/ai-sdk.spec.ts | 46 ++++++ src/api/transform/ai-sdk.ts | 31 +++- src/core/task/Task.ts | 25 ++- .../__tests__/reasoning-preservation.test.ts | 147 +++++++----------- 4 files changed, 153 insertions(+), 96 deletions(-) diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index fb4e3b9e2f2..1f7dbccd34e 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -308,6 +308,52 @@ describe("AI SDK conversion utilities", () => { content: [{ type: "text", text: "" }], }) }) + + it("converts assistant reasoning blocks", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "reasoning" as any, text: "Thinking..." }, + { type: "text", text: "Answer" }, + ], + }, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "assistant", + content: [ + { type: "reasoning", text: "Thinking..." }, + { type: "text", text: "Answer" }, + ], + }) + }) + + it("converts assistant thinking blocks to reasoning", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "thinking" as any, thinking: "Deep thought", signature: "sig" }, + { type: "text", text: "OK" }, + ], + }, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "assistant", + content: [ + { type: "reasoning", text: "Deep thought" }, + { type: "text", text: "OK" }, + ], + }) + }) }) describe("convertToolsForAiSdk", () => { diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index c6f37be694d..c1b939e66e5 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -126,6 +126,7 @@ export function convertToAiSdkMessages( } } else if (message.role === "assistant") { const textParts: string[] = [] + const reasoningParts: string[] = [] const toolCalls: Array<{ type: "tool-call" toolCallId: string @@ -136,21 +137,49 @@ export function convertToAiSdkMessages( for (const part of message.content) { if (part.type === "text") { textParts.push(part.text) - } else if (part.type === "tool_use") { + continue + } + + if (part.type === "tool_use") { toolCalls.push({ type: "tool-call", toolCallId: part.id, toolName: part.name, input: part.input, }) + continue + } + + // Some providers (DeepSeek, Gemini, etc.) require reasoning to be round-tripped. + // Task stores reasoning as a content block (type: "reasoning") and Anthropic extended + // thinking as (type: "thinking"). Convert both to AI SDK's reasoning part. + if ((part as unknown as { type?: string }).type === "reasoning") { + const text = (part as unknown as { text?: string }).text + if (typeof text === "string" && text.length > 0) { + reasoningParts.push(text) + } + continue + } + + if ((part as unknown as { type?: string }).type === "thinking") { + const thinking = (part as unknown as { thinking?: string }).thinking + if (typeof thinking === "string" && thinking.length > 0) { + reasoningParts.push(thinking) + } + continue } } const content: Array< + | { type: "reasoning"; text: string } | { type: "text"; text: string } | { type: "tool-call"; toolCallId: string; toolName: string; input: unknown } > = [] + if (reasoningParts.length > 0) { + content.push({ type: "reasoning", text: reasoningParts.join("") }) + } + if (textParts.length > 0) { content.push({ type: "text", text: textParts.join("\n") }) } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 107cfdf9e9e..d19d205a731 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4564,14 +4564,29 @@ export class Task extends EventEmitter implements TaskLike { continue } else if (hasPlainTextReasoning) { - // Check if the model's preserveReasoning flag is set - // If true, include the reasoning block in API requests - // If false/undefined, strip it out (stored for history only, not sent back to API) - const shouldPreserveForApi = this.api.getModel().info.preserveReasoning === true + // Preserve plain-text reasoning blocks for: + // - models explicitly opting in via preserveReasoning + // - AI SDK providers (provider packages decide what to include in the native request) + const aiSdkProviders = new Set([ + "deepseek", + "fireworks", + "moonshot", + "mistral", + "groq", + "xai", + "cerebras", + "sambanova", + "huggingface", + "openai-compatible", + ]) + + const shouldPreserveForApi = + this.api.getModel().info.preserveReasoning === true || + aiSdkProviders.has(this.apiConfiguration.apiProvider ?? "") + let assistantContent: Anthropic.Messages.MessageParam["content"] if (shouldPreserveForApi) { - // Include reasoning block in the content sent to API assistantContent = contentArray } else { // Strip reasoning out - stored for history only, not sent back to API diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts index 3bf2dec2986..2a3978e9111 100644 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ b/src/core/task/__tests__/reasoning-preservation.test.ts @@ -219,41 +219,33 @@ describe("Task reasoning preservation", () => { // Spy on addToApiConversationHistory const addToApiHistorySpy = vi.spyOn(task as any, "addToApiConversationHistory") - // Simulate what happens in the streaming loop when preserveReasoning is true - let finalAssistantMessage = assistantMessage - if (reasoningMessage && task.api.getModel().info.preserveReasoning) { - finalAssistantMessage = `${reasoningMessage}\n${assistantMessage}` - } - - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [{ type: "text", text: finalAssistantMessage }], - }) + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: assistantMessage }], + }, + reasoningMessage, + ) - // Verify that reasoning was prepended in tags to the assistant message - expect(addToApiHistorySpy).toHaveBeenCalledWith({ - role: "assistant", - content: [ - { - type: "text", - text: "Let me think about this step by step. First, I need to...\nHere is my response to your question.", - }, - ], - }) + // Verify that reasoning was stored as a separate reasoning block + expect(addToApiHistorySpy).toHaveBeenCalledWith( + { + role: "assistant", + content: [{ type: "text", text: assistantMessage }], + }, + reasoningMessage, + ) - // Verify the API conversation history contains the message with reasoning + // Verify the API conversation history contains the message with reasoning block expect(task.apiConversationHistory).toHaveLength(1) - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toContain("") - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toContain("") - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toContain( - "Here is my response to your question.", - ) - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toContain( - "Let me think about this step by step. First, I need to...", - ) + expect(task.apiConversationHistory[0].role).toBe("assistant") + expect(task.apiConversationHistory[0].content).toEqual([ + { type: "reasoning", text: reasoningMessage, summary: [] }, + { type: "text", text: assistantMessage }, + ]) }) - it("should NOT append reasoning to assistant message when preserveReasoning is false", async () => { + it("should store reasoning blocks even when preserveReasoning is false", async () => { // Create a task instance const task = new Task({ provider: mockProvider as ClineProvider, @@ -279,36 +271,25 @@ describe("Task reasoning preservation", () => { // Mock the API conversation history task.apiConversationHistory = [] - // Simulate adding an assistant message with reasoning + // Add an assistant message while passing reasoning separately (Task does this in normal streaming). const assistantMessage = "Here is my response to your question." const reasoningMessage = "Let me think about this step by step. First, I need to..." - // Spy on addToApiConversationHistory - const addToApiHistorySpy = vi.spyOn(task as any, "addToApiConversationHistory") - - // Simulate what happens in the streaming loop when preserveReasoning is false - let finalAssistantMessage = assistantMessage - if (reasoningMessage && task.api.getModel().info.preserveReasoning) { - finalAssistantMessage = `${reasoningMessage}\n${assistantMessage}` - } - - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [{ type: "text", text: finalAssistantMessage }], - }) - - // Verify that reasoning was NOT appended to the assistant message - expect(addToApiHistorySpy).toHaveBeenCalledWith({ - role: "assistant", - content: [{ type: "text", text: "Here is my response to your question." }], - }) + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: assistantMessage }], + }, + reasoningMessage, + ) - // Verify the API conversation history does NOT contain reasoning + // Verify the API conversation history contains a reasoning block (storage is unconditional) expect(task.apiConversationHistory).toHaveLength(1) - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toBe( - "Here is my response to your question.", - ) - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).not.toContain("") + expect(task.apiConversationHistory[0].role).toBe("assistant") + expect(task.apiConversationHistory[0].content).toEqual([ + { type: "reasoning", text: reasoningMessage, summary: [] }, + { type: "text", text: assistantMessage }, + ]) }) it("should handle empty reasoning message gracefully when preserveReasoning is true", async () => { @@ -340,29 +321,16 @@ describe("Task reasoning preservation", () => { const assistantMessage = "Here is my response." const reasoningMessage = "" // Empty reasoning - // Spy on addToApiConversationHistory - const addToApiHistorySpy = vi.spyOn(task as any, "addToApiConversationHistory") - - // Simulate what happens in the streaming loop - let finalAssistantMessage = assistantMessage - if (reasoningMessage && task.api.getModel().info.preserveReasoning) { - finalAssistantMessage = `${reasoningMessage}\n${assistantMessage}` - } - - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [{ type: "text", text: finalAssistantMessage }], - }) - - // Verify that no reasoning tags were added when reasoning is empty - expect(addToApiHistorySpy).toHaveBeenCalledWith({ - role: "assistant", - content: [{ type: "text", text: "Here is my response." }], - }) + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: assistantMessage }], + }, + reasoningMessage || undefined, + ) - // Verify the message doesn't contain reasoning tags - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toBe("Here is my response.") - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).not.toContain("") + // Verify no reasoning blocks were added when reasoning is empty + expect(task.apiConversationHistory[0].content).toEqual([{ type: "text", text: "Here is my response." }]) }) it("should handle undefined preserveReasoning (defaults to false)", async () => { @@ -394,20 +362,19 @@ describe("Task reasoning preservation", () => { const assistantMessage = "Here is my response." const reasoningMessage = "Some reasoning here." - // Simulate what happens in the streaming loop - let finalAssistantMessage = assistantMessage - if (reasoningMessage && task.api.getModel().info.preserveReasoning) { - finalAssistantMessage = `${reasoningMessage}\n${assistantMessage}` - } - - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [{ type: "text", text: finalAssistantMessage }], - }) + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: assistantMessage }], + }, + reasoningMessage, + ) - // Verify reasoning was NOT prepended (undefined defaults to false) - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toBe("Here is my response.") - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).not.toContain("") + // Verify reasoning is stored even when preserveReasoning is undefined + expect(task.apiConversationHistory[0].content).toEqual([ + { type: "reasoning", text: reasoningMessage, summary: [] }, + { type: "text", text: assistantMessage }, + ]) }) it("should embed encrypted reasoning as first assistant content block", async () => { From b0a3febeb7b7d7eba05757c21c4c13ccc1b7ec5c Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 4 Feb 2026 16:25:29 -0700 Subject: [PATCH 2/3] fix(ai-sdk): convert message-level reasoning_content to reasoning part --- src/api/transform/__tests__/ai-sdk.spec.ts | 45 ++++++++++++++++++++++ src/api/transform/ai-sdk.ts | 14 ++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index 1f7dbccd34e..af9f56380be 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -354,6 +354,51 @@ describe("AI SDK conversion utilities", () => { ], }) }) + + it("converts assistant message-level reasoning_content to reasoning part", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [{ type: "text", text: "Answer" }], + reasoning_content: "Thinking...", + } as any, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "assistant", + content: [ + { type: "reasoning", text: "Thinking..." }, + { type: "text", text: "Answer" }, + ], + }) + }) + + it("prefers message-level reasoning_content over reasoning blocks", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "reasoning" as any, text: "BLOCK" }, + { type: "text", text: "Answer" }, + ], + reasoning_content: "MSG", + } as any, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "assistant", + content: [ + { type: "reasoning", text: "MSG" }, + { type: "text", text: "Answer" }, + ], + }) + }) }) describe("convertToolsForAiSdk", () => { diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index c1b939e66e5..e8b861ed84f 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -127,6 +127,10 @@ export function convertToAiSdkMessages( } else if (message.role === "assistant") { const textParts: string[] = [] const reasoningParts: string[] = [] + const reasoningContent = (() => { + const maybe = (message as unknown as { reasoning_content?: unknown }).reasoning_content + return typeof maybe === "string" && maybe.length > 0 ? maybe : undefined + })() const toolCalls: Array<{ type: "tool-call" toolCallId: string @@ -154,6 +158,10 @@ export function convertToAiSdkMessages( // Task stores reasoning as a content block (type: "reasoning") and Anthropic extended // thinking as (type: "thinking"). Convert both to AI SDK's reasoning part. if ((part as unknown as { type?: string }).type === "reasoning") { + // If message-level reasoning_content is present, treat it as canonical and + // avoid mixing it with content-block reasoning (which can cause duplication). + if (reasoningContent) continue + const text = (part as unknown as { text?: string }).text if (typeof text === "string" && text.length > 0) { reasoningParts.push(text) @@ -162,6 +170,8 @@ export function convertToAiSdkMessages( } if ((part as unknown as { type?: string }).type === "thinking") { + if (reasoningContent) continue + const thinking = (part as unknown as { thinking?: string }).thinking if (typeof thinking === "string" && thinking.length > 0) { reasoningParts.push(thinking) @@ -176,7 +186,9 @@ export function convertToAiSdkMessages( | { type: "tool-call"; toolCallId: string; toolName: string; input: unknown } > = [] - if (reasoningParts.length > 0) { + if (reasoningContent) { + content.push({ type: "reasoning", text: reasoningContent }) + } else if (reasoningParts.length > 0) { content.push({ type: "reasoning", text: reasoningParts.join("") }) } From 9bf5a39616b912c8f6040e397b20913cefe9c296 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 4 Feb 2026 19:53:51 -0700 Subject: [PATCH 3/3] fix(task): remove invalid openai-compatible from reasoning allowlist --- src/core/task/Task.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d19d205a731..ccb68611bee 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4577,7 +4577,6 @@ export class Task extends EventEmitter implements TaskLike { "cerebras", "sambanova", "huggingface", - "openai-compatible", ]) const shouldPreserveForApi =