Kaynağa Gözat

fix(ai-sdk): preserve reasoning parts in message conversion (#11196)

* fix(ai-sdk): preserve reasoning parts in message conversion

* fix(ai-sdk): convert message-level reasoning_content to reasoning part

* fix(task): remove invalid openai-compatible from reasoning allowlist
Hannes Rudolph 1 hafta önce
ebeveyn
işleme
227b9796d3

+ 91 - 0
src/api/transform/__tests__/ai-sdk.spec.ts

@@ -308,6 +308,97 @@ 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" },
+				],
+			})
+		})
+
+		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", () => {

+ 42 - 1
src/api/transform/ai-sdk.ts

@@ -126,6 +126,11 @@ 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
@@ -136,21 +141,57 @@ 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") {
+						// 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)
+						}
+						continue
+					}
+
+					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)
+						}
+						continue
 					}
 				}
 
 				const content: Array<
+					| { type: "reasoning"; text: string }
 					| { type: "text"; text: string }
 					| { type: "tool-call"; toolCallId: string; toolName: string; input: unknown }
 				> = []
 
+				if (reasoningContent) {
+					content.push({ type: "reasoning", text: reasoningContent })
+				} else if (reasoningParts.length > 0) {
+					content.push({ type: "reasoning", text: reasoningParts.join("") })
+				}
+
 				if (textParts.length > 0) {
 					content.push({ type: "text", text: textParts.join("\n") })
 				}

+ 19 - 5
src/core/task/Task.ts

@@ -4564,14 +4564,28 @@ export class Task extends EventEmitter<TaskEvents> 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",
+					])
+
+					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

+ 57 - 90
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 = `<think>${reasoningMessage}</think>\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 <think> tags to the assistant message
-		expect(addToApiHistorySpy).toHaveBeenCalledWith({
-			role: "assistant",
-			content: [
-				{
-					type: "text",
-					text: "<think>Let me think about this step by step. First, I need to...</think>\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("<think>")
-		expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toContain("</think>")
-		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 = `<think>${reasoningMessage}</think>\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("<think>")
+		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 = `<think>${reasoningMessage}</think>\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("<think>")
+		// 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 = `<think>${reasoningMessage}</think>\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("<think>")
+		// 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 () => {