Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/api/transform/__tests__/ai-sdk.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
43 changes: 42 additions & 1 deletion src/api/transform/ai-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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") })
}
Expand Down
24 changes: 19 additions & 5 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to figure this out dynamically? I’m worried about forgetting to update this when we add them

"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
Expand Down
147 changes: 57 additions & 90 deletions src/core/task/__tests__/reasoning-preservation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading