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

* 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

* feat: add isAiSdkProvider() method for dynamic AI SDK provider detection

- Add isAiSdkProvider() method to ApiHandler interface
- Default implementation in BaseProvider returns false
- Override to return true in 11 AI SDK providers:
  deepseek, fireworks, mistral, groq, xai, cerebras,
  sambanova, huggingface, gemini, vertex, openai-compatible
- Update Task.ts to use dynamic detection instead of hardcoded Set
- Add method to FakeAIHandler and update test mocks

* fix: handle reasoning parts in flattenAiSdkMessagesToStringContent

- Strip reasoning parts when flattening messages for string-only models
- Allow flattening when message contains only text and reasoning parts
- Add tests for reasoning part handling in string-only model contexts

This addresses the review feedback about ensuring flattenAiSdkMessagesToStringContent
works correctly when reasoning parts are present (e.g., SambaNova DeepSeek).

---------

Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>
This commit is contained in:
Hannes Rudolph 2026-02-05 10:48:21 -07:00 committed by GitHub
parent 934f34ea87
commit 1b75d59a68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 319 additions and 101 deletions

View file

@ -117,6 +117,15 @@ export interface ApiHandler {
* @returns A promise resolving to the token count
*/
countTokens(content: Array<Anthropic.Messages.ContentBlockParam>): Promise<number>
/**
* Indicates whether this provider uses the Vercel AI SDK for streaming.
* AI SDK providers handle reasoning blocks differently and need to preserve
* them in conversation history for proper round-tripping.
*
* @returns true if the provider uses AI SDK, false otherwise
*/
isAiSdkProvider(): boolean
}
export function buildApiHandler(configuration: ProviderSettings): ApiHandler {

View file

@ -119,4 +119,12 @@ export abstract class BaseProvider implements ApiHandler {
return countTokens(content, { useWorker: true })
}
/**
* Default implementation returns false.
* AI SDK providers should override this to return true.
*/
isAiSdkProvider(): boolean {
return false
}
}

View file

@ -156,4 +156,8 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan
return text
}
override isAiSdkProvider(): boolean {
return true
}
}

View file

@ -166,4 +166,8 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan
return text
}
override isAiSdkProvider(): boolean {
return true
}
}

View file

@ -78,4 +78,8 @@ export class FakeAIHandler implements ApiHandler, SingleCompletionHandler {
completePrompt(prompt: string): Promise<string> {
return this.ai.completePrompt(prompt)
}
isAiSdkProvider(): boolean {
return false
}
}

View file

@ -172,4 +172,8 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa
return text
}
override isAiSdkProvider(): boolean {
return true
}
}

View file

@ -397,4 +397,8 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
return totalCost
}
override isAiSdkProvider(): boolean {
return true
}
}

View file

@ -174,4 +174,8 @@ export class GroqHandler extends BaseProvider implements SingleCompletionHandler
return text
}
override isAiSdkProvider(): boolean {
return true
}
}

View file

@ -208,4 +208,8 @@ export class HuggingFaceHandler extends BaseProvider implements SingleCompletion
return text
}
override isAiSdkProvider(): boolean {
return true
}
}

View file

@ -198,4 +198,8 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand
return text
}
override isAiSdkProvider(): boolean {
return true
}
}

View file

@ -186,4 +186,8 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si
return text
}
override isAiSdkProvider(): boolean {
return true
}
}

View file

@ -177,4 +177,8 @@ export class SambaNovaHandler extends BaseProvider implements SingleCompletionHa
return text
}
override isAiSdkProvider(): boolean {
return true
}
}

View file

@ -402,4 +402,8 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
return totalCost
}
override isAiSdkProvider(): boolean {
return true
}
}

View file

@ -187,4 +187,8 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler
throw handleAiSdkError(error, "xAI")
}
}
override isAiSdkProvider(): boolean {
return true
}
}

View file

@ -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", () => {
@ -817,5 +908,54 @@ describe("AI SDK conversion utilities", () => {
expect(result[0].content).toBe("\nHello")
})
it("should strip reasoning parts and flatten text for string-only models", () => {
const messages = [
{
role: "assistant" as const,
content: [
{ type: "reasoning" as const, text: "I am thinking about this..." },
{ type: "text" as const, text: "Here is my answer" },
],
},
]
const result = flattenAiSdkMessagesToStringContent(messages)
// Reasoning should be stripped, only text should remain
expect(result[0].content).toBe("Here is my answer")
})
it("should handle messages with only reasoning parts", () => {
const messages = [
{
role: "assistant" as const,
content: [{ type: "reasoning" as const, text: "Only reasoning, no text" }],
},
]
const result = flattenAiSdkMessagesToStringContent(messages)
// Should flatten to empty string when only reasoning is present
expect(result[0].content).toBe("")
})
it("should not flatten if tool calls are present with reasoning", () => {
const messages = [
{
role: "assistant" as const,
content: [
{ type: "reasoning" as const, text: "Thinking..." },
{ type: "text" as const, text: "Using tool" },
{ type: "tool-call" as const, toolCallId: "abc", toolName: "test", input: {} },
],
},
]
const result = flattenAiSdkMessagesToStringContent(messages)
// Should not flatten because there's a tool call
expect(result[0]).toEqual(messages[0])
})
})
})

View file

@ -18,6 +18,7 @@ describe("maybeRemoveImageBlocks", () => {
}),
createMessage: vitest.fn(),
countTokens: vitest.fn(),
isAiSdkProvider: vitest.fn().mockReturnValue(false),
}
}

View file

@ -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") })
}
@ -226,10 +267,13 @@ export function flattenAiSdkMessagesToStringContent(
// Handle assistant messages
if (message.role === "assistant" && flattenAssistantMessages && Array.isArray(message.content)) {
const parts = message.content as Array<{ type: string; text?: string }>
// Only flatten if all parts are text (no tool calls)
const allText = parts.every((part) => part.type === "text")
if (allText && parts.length > 0) {
const textContent = parts.map((part) => part.text || "").join("\n")
// Only flatten if all parts are text or reasoning (no tool calls)
// Reasoning parts are included in text to avoid sending multipart content to string-only models
const allTextOrReasoning = parts.every((part) => part.type === "text" || part.type === "reasoning")
if (allTextOrReasoning && parts.length > 0) {
// Extract only text parts for the flattened content (reasoning is stripped for string-only models)
const textParts = parts.filter((part) => part.type === "text")
const textContent = textParts.map((part) => part.text || "").join("\n")
return {
...message,
content: textContent,

View file

@ -4564,14 +4564,15 @@ 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 shouldPreserveForApi =
this.api.getModel().info.preserveReasoning === true || this.api.isAiSdkProvider()
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

View file

@ -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: assistantMessage }],
},
reasoningMessage,
)
await (task as any).addToApiConversationHistory({
role: "assistant",
content: [{ type: "text", text: finalAssistantMessage }],
})
// Verify that reasoning was stored as a separate reasoning block
expect(addToApiHistorySpy).toHaveBeenCalledWith(
{
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 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." }],
})
// Verify the API conversation history does NOT contain reasoning
expect(task.apiConversationHistory).toHaveLength(1)
expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toBe(
"Here is my response to your question.",
await (task as any).addToApiConversationHistory(
{
role: "assistant",
content: [{ type: "text", text: assistantMessage }],
},
reasoningMessage,
)
expect((task.apiConversationHistory[0].content[0] as { text: string }).text).not.toContain("<think>")
// Verify the API conversation history contains a reasoning block (storage is unconditional)
expect(task.apiConversationHistory).toHaveLength(1)
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")
await (task as any).addToApiConversationHistory(
{
role: "assistant",
content: [{ type: "text", text: assistantMessage }],
},
reasoningMessage || undefined,
)
// 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." }],
})
// 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: assistantMessage }],
},
reasoningMessage,
)
await (task as any).addToApiConversationHistory({
role: "assistant",
content: [{ type: "text", text: finalAssistantMessage }],
})
// 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 () => {