From 7046005e15e199daf9242fa52f0ff5b38facc857 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 16 Dec 2025 23:17:59 -0700 Subject: [PATCH 1/2] fix(deepseek): preserve reasoning_content during tool call sequences Fixes DeepSeek interleaved thinking mode to correctly preserve reasoning_content during multi-turn tool interactions. The issue was that environment_details text after tool_results was creating user messages, which causes DeepSeek to drop all previous reasoning_content per the API docs. The fix adds a mergeToolResultText option to convertToR1Format() that merges text content into the last tool message instead of creating a separate user message. This is enabled for deepseek-reasoner models. Ref: https://api-docs.deepseek.com/guides/thinking_mode --- src/api/providers/deepseek.ts | 8 +- src/api/transform/__tests__/r1-format.spec.ts | 221 ++++++++++++++++++ src/api/transform/r1-format.ts | 78 ++++--- 3 files changed, 280 insertions(+), 27 deletions(-) diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 01e747e11b1..4e5aef23a53 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -54,7 +54,13 @@ export class DeepSeekHandler extends OpenAiHandler { // Convert messages to R1 format (merges consecutive same-role messages) // This is required for DeepSeek which does not support successive messages with the same role - const convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) + // For thinking models (deepseek-reasoner), enable mergeToolResultText to preserve reasoning_content + // during tool call sequences. Without this, environment_details text after tool_results would + // create user messages that cause DeepSeek to drop all previous reasoning_content. + // See: https://api-docs.deepseek.com/guides/thinking_mode + const convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages], { + mergeToolResultText: isThinkingModel, + }) const requestOptions: DeepSeekChatCompletionParams = { model: modelId, diff --git a/src/api/transform/__tests__/r1-format.spec.ts b/src/api/transform/__tests__/r1-format.spec.ts index edfe9dc5d14..fdfdaf00d7e 100644 --- a/src/api/transform/__tests__/r1-format.spec.ts +++ b/src/api/transform/__tests__/r1-format.spec.ts @@ -393,6 +393,227 @@ describe("convertToR1Format", () => { role: "assistant", content: "Follow up response", }) + + describe("mergeToolResultText option for DeepSeek interleaved thinking", () => { + it("should merge text content into last tool message when mergeToolResultText is true", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_123", + content: "Tool result content", + }, + { + type: "text", + text: "\nSome context\n", + }, + ], + }, + ] + + const result = convertToR1Format(input, { mergeToolResultText: true }) + + // Should produce only one tool message with merged content + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_123", + content: "Tool result content\n\n\nSome context\n", + }) + }) + + it("should NOT merge text when mergeToolResultText is false (default behavior)", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_123", + content: "Tool result content", + }, + { + type: "text", + text: "Please continue", + }, + ], + }, + ] + + // Without option (default behavior) + const result = convertToR1Format(input) + + // Should produce two messages: tool message + user message + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_123", + content: "Tool result content", + }) + expect(result[1]).toEqual({ + role: "user", + content: "Please continue", + }) + }) + + it("should merge text into last tool message when multiple tool results exist", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_1", + content: "First result", + }, + { + type: "tool_result", + tool_use_id: "call_2", + content: "Second result", + }, + { + type: "text", + text: "Context", + }, + ], + }, + ] + + const result = convertToR1Format(input, { mergeToolResultText: true }) + + // Should produce two tool messages, with text merged into the last one + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_1", + content: "First result", + }) + expect(result[1]).toEqual({ + role: "tool", + tool_call_id: "call_2", + content: "Second result\n\nContext", + }) + }) + + it("should NOT merge when there are images (images need user message)", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_123", + content: "Tool result", + }, + { + type: "text", + text: "Check this image", + }, + { + type: "image", + source: { + type: "base64", + media_type: "image/jpeg", + data: "imagedata", + }, + }, + ], + }, + ] + + const result = convertToR1Format(input, { mergeToolResultText: true }) + + // Should produce tool message + user message with image + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_123", + content: "Tool result", + }) + expect(result[1]).toMatchObject({ + role: "user", + content: expect.arrayContaining([ + { type: "text", text: "Check this image" }, + { type: "image_url", image_url: expect.any(Object) }, + ]), + }) + }) + + it("should NOT merge when there are no tool results (text-only should remain user message)", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text", + text: "Just a regular message", + }, + ], + }, + ] + + const result = convertToR1Format(input, { mergeToolResultText: true }) + + // Should produce user message as normal + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "user", + content: "Just a regular message", + }) + }) + + it("should preserve reasoning_content on assistant messages in same conversation", () => { + const input = [ + { role: "user" as const, content: "Start" }, + { + role: "assistant" as const, + content: [ + { + type: "tool_use" as const, + id: "call_123", + name: "test_tool", + input: {}, + }, + ], + reasoning_content: "Let me think about this...", + }, + { + role: "user" as const, + content: [ + { + type: "tool_result" as const, + tool_use_id: "call_123", + content: "Result", + }, + { + type: "text" as const, + text: "Context", + }, + ], + }, + ] + + const result = convertToR1Format(input as Anthropic.Messages.MessageParam[], { + mergeToolResultText: true, + }) + + // Should have: user, assistant (with reasoning + tool_calls), tool + expect(result).toHaveLength(3) + expect(result[0]).toEqual({ role: "user", content: "Start" }) + expect((result[1] as any).reasoning_content).toBe("Let me think about this...") + expect((result[1] as any).tool_calls).toBeDefined() + // Tool message should have merged content + expect(result[2]).toEqual({ + role: "tool", + tool_call_id: "call_123", + content: "Result\n\nContext", + }) + // Most importantly: NO user message after tool message + expect(result.filter((m) => m.role === "user")).toHaveLength(1) + }) + }) }) }) }) diff --git a/src/api/transform/r1-format.ts b/src/api/transform/r1-format.ts index d4a7bef1ae7..8231e24f76f 100644 --- a/src/api/transform/r1-format.ts +++ b/src/api/transform/r1-format.ts @@ -26,11 +26,20 @@ export type DeepSeekAssistantMessage = AssistantMessage & { * - Preserves reasoning_content on assistant messages for tool call continuations * - Tool result messages are converted to OpenAI tool messages * - reasoning_content from previous assistant messages is preserved until a new user turn + * - Text content after tool_results (like environment_details) is merged into the last tool message + * to avoid creating user messages that would cause reasoning_content to be dropped * * @param messages Array of Anthropic messages + * @param options Optional configuration for message conversion + * @param options.mergeToolResultText If true, merge text content after tool_results into the last + * tool message instead of creating a separate user message. + * This is critical for DeepSeek's interleaved thinking mode. * @returns Array of OpenAI messages where consecutive messages with the same role are combined */ -export function convertToR1Format(messages: AnthropicMessage[]): Message[] { +export function convertToR1Format( + messages: AnthropicMessage[], + options?: { mergeToolResultText?: boolean }, +): Message[] { const result: Message[] = [] for (const message of messages) { @@ -87,37 +96,54 @@ export function convertToR1Format(messages: AnthropicMessage[]): Message[] { result.push(toolMessage) } - // Then add user message with text/image content if any + // Handle text/image content after tool results if (textParts.length > 0 || imageParts.length > 0) { - let content: UserMessage["content"] - if (imageParts.length > 0) { - const parts: (ContentPartText | ContentPartImage)[] = [] - if (textParts.length > 0) { - parts.push({ type: "text", text: textParts.join("\n") }) + // For DeepSeek interleaved thinking: when mergeToolResultText is enabled and we have + // tool results followed by text, merge the text into the last tool message to avoid + // creating a user message that would cause reasoning_content to be dropped. + // This is critical because DeepSeek drops all reasoning_content when it sees a user message. + const shouldMergeIntoToolMessage = + options?.mergeToolResultText && toolResults.length > 0 && imageParts.length === 0 + + if (shouldMergeIntoToolMessage) { + // Merge text content into the last tool message + const lastToolMessage = result[result.length - 1] as ToolMessage + if (lastToolMessage?.role === "tool") { + const additionalText = textParts.join("\n") + lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}` } - parts.push(...imageParts) - content = parts } else { - content = textParts.join("\n") - } + // Standard behavior: add user message with text/image content + let content: UserMessage["content"] + if (imageParts.length > 0) { + const parts: (ContentPartText | ContentPartImage)[] = [] + if (textParts.length > 0) { + parts.push({ type: "text", text: textParts.join("\n") }) + } + parts.push(...imageParts) + content = parts + } else { + content = textParts.join("\n") + } - // Check if we can merge with the last message - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "user") { - // Merge with existing user message - if (typeof lastMessage.content === "string" && typeof content === "string") { - lastMessage.content += `\n${content}` + // Check if we can merge with the last message + const lastMessage = result[result.length - 1] + if (lastMessage?.role === "user") { + // Merge with existing user message + if (typeof lastMessage.content === "string" && typeof content === "string") { + lastMessage.content += `\n${content}` + } else { + const lastContent = Array.isArray(lastMessage.content) + ? lastMessage.content + : [{ type: "text" as const, text: lastMessage.content || "" }] + const newContent = Array.isArray(content) + ? content + : [{ type: "text" as const, text: content }] + lastMessage.content = [...lastContent, ...newContent] as UserMessage["content"] + } } else { - const lastContent = Array.isArray(lastMessage.content) - ? lastMessage.content - : [{ type: "text" as const, text: lastMessage.content || "" }] - const newContent = Array.isArray(content) - ? content - : [{ type: "text" as const, text: content }] - lastMessage.content = [...lastContent, ...newContent] as UserMessage["content"] + result.push({ role: "user", content }) } - } else { - result.push({ role: "user", content }) } } } else { From 70ab055492d7b4656f51b058028864a41ffb0adb Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 17 Dec 2025 15:46:30 +0000 Subject: [PATCH 2/2] fix: move describe block to be sibling of it block for proper Vitest collection --- src/api/transform/__tests__/r1-format.spec.ts | 402 +++++++++--------- 1 file changed, 201 insertions(+), 201 deletions(-) diff --git a/src/api/transform/__tests__/r1-format.spec.ts b/src/api/transform/__tests__/r1-format.spec.ts index fdfdaf00d7e..3d875e9392f 100644 --- a/src/api/transform/__tests__/r1-format.spec.ts +++ b/src/api/transform/__tests__/r1-format.spec.ts @@ -393,226 +393,226 @@ describe("convertToR1Format", () => { role: "assistant", content: "Follow up response", }) + }) - describe("mergeToolResultText option for DeepSeek interleaved thinking", () => { - it("should merge text content into last tool message when mergeToolResultText is true", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_123", - content: "Tool result content", - }, - { - type: "text", - text: "\nSome context\n", - }, - ], - }, - ] + describe("mergeToolResultText option for DeepSeek interleaved thinking", () => { + it("should merge text content into last tool message when mergeToolResultText is true", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_123", + content: "Tool result content", + }, + { + type: "text", + text: "\nSome context\n", + }, + ], + }, + ] - const result = convertToR1Format(input, { mergeToolResultText: true }) + const result = convertToR1Format(input, { mergeToolResultText: true }) - // Should produce only one tool message with merged content - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_123", - content: "Tool result content\n\n\nSome context\n", - }) + // Should produce only one tool message with merged content + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_123", + content: "Tool result content\n\n\nSome context\n", }) + }) - it("should NOT merge text when mergeToolResultText is false (default behavior)", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_123", - content: "Tool result content", - }, - { - type: "text", - text: "Please continue", - }, - ], - }, - ] - - // Without option (default behavior) - const result = convertToR1Format(input) - - // Should produce two messages: tool message + user message - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_123", - content: "Tool result content", - }) - expect(result[1]).toEqual({ + it("should NOT merge text when mergeToolResultText is false (default behavior)", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { role: "user", - content: "Please continue", - }) - }) + content: [ + { + type: "tool_result", + tool_use_id: "call_123", + content: "Tool result content", + }, + { + type: "text", + text: "Please continue", + }, + ], + }, + ] - it("should merge text into last tool message when multiple tool results exist", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_1", - content: "First result", - }, - { - type: "tool_result", - tool_use_id: "call_2", - content: "Second result", - }, - { - type: "text", - text: "Context", - }, - ], - }, - ] - - const result = convertToR1Format(input, { mergeToolResultText: true }) - - // Should produce two tool messages, with text merged into the last one - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_1", - content: "First result", - }) - expect(result[1]).toEqual({ - role: "tool", - tool_call_id: "call_2", - content: "Second result\n\nContext", - }) + // Without option (default behavior) + const result = convertToR1Format(input) + + // Should produce two messages: tool message + user message + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_123", + content: "Tool result content", + }) + expect(result[1]).toEqual({ + role: "user", + content: "Please continue", }) + }) - it("should NOT merge when there are images (images need user message)", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_123", - content: "Tool result", - }, - { - type: "text", - text: "Check this image", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "imagedata", - }, - }, - ], - }, - ] - - const result = convertToR1Format(input, { mergeToolResultText: true }) - - // Should produce tool message + user message with image - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_123", - content: "Tool result", - }) - expect(result[1]).toMatchObject({ + it("should merge text into last tool message when multiple tool results exist", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { role: "user", - content: expect.arrayContaining([ - { type: "text", text: "Check this image" }, - { type: "image_url", image_url: expect.any(Object) }, - ]), - }) + content: [ + { + type: "tool_result", + tool_use_id: "call_1", + content: "First result", + }, + { + type: "tool_result", + tool_use_id: "call_2", + content: "Second result", + }, + { + type: "text", + text: "Context", + }, + ], + }, + ] + + const result = convertToR1Format(input, { mergeToolResultText: true }) + + // Should produce two tool messages, with text merged into the last one + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_1", + content: "First result", + }) + expect(result[1]).toEqual({ + role: "tool", + tool_call_id: "call_2", + content: "Second result\n\nContext", }) + }) - it("should NOT merge when there are no tool results (text-only should remain user message)", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "Just a regular message", + it("should NOT merge when there are images (images need user message)", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_123", + content: "Tool result", + }, + { + type: "text", + text: "Check this image", + }, + { + type: "image", + source: { + type: "base64", + media_type: "image/jpeg", + data: "imagedata", }, - ], - }, - ] + }, + ], + }, + ] + + const result = convertToR1Format(input, { mergeToolResultText: true }) - const result = convertToR1Format(input, { mergeToolResultText: true }) + // Should produce tool message + user message with image + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_123", + content: "Tool result", + }) + expect(result[1]).toMatchObject({ + role: "user", + content: expect.arrayContaining([ + { type: "text", text: "Check this image" }, + { type: "image_url", image_url: expect.any(Object) }, + ]), + }) + }) - // Should produce user message as normal - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ + it("should NOT merge when there are no tool results (text-only should remain user message)", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { role: "user", - content: "Just a regular message", - }) + content: [ + { + type: "text", + text: "Just a regular message", + }, + ], + }, + ] + + const result = convertToR1Format(input, { mergeToolResultText: true }) + + // Should produce user message as normal + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "user", + content: "Just a regular message", }) + }) - it("should preserve reasoning_content on assistant messages in same conversation", () => { - const input = [ - { role: "user" as const, content: "Start" }, - { - role: "assistant" as const, - content: [ - { - type: "tool_use" as const, - id: "call_123", - name: "test_tool", - input: {}, - }, - ], - reasoning_content: "Let me think about this...", - }, - { - role: "user" as const, - content: [ - { - type: "tool_result" as const, - tool_use_id: "call_123", - content: "Result", - }, - { - type: "text" as const, - text: "Context", - }, - ], - }, - ] - - const result = convertToR1Format(input as Anthropic.Messages.MessageParam[], { - mergeToolResultText: true, - }) - - // Should have: user, assistant (with reasoning + tool_calls), tool - expect(result).toHaveLength(3) - expect(result[0]).toEqual({ role: "user", content: "Start" }) - expect((result[1] as any).reasoning_content).toBe("Let me think about this...") - expect((result[1] as any).tool_calls).toBeDefined() - // Tool message should have merged content - expect(result[2]).toEqual({ - role: "tool", - tool_call_id: "call_123", - content: "Result\n\nContext", - }) - // Most importantly: NO user message after tool message - expect(result.filter((m) => m.role === "user")).toHaveLength(1) + it("should preserve reasoning_content on assistant messages in same conversation", () => { + const input = [ + { role: "user" as const, content: "Start" }, + { + role: "assistant" as const, + content: [ + { + type: "tool_use" as const, + id: "call_123", + name: "test_tool", + input: {}, + }, + ], + reasoning_content: "Let me think about this...", + }, + { + role: "user" as const, + content: [ + { + type: "tool_result" as const, + tool_use_id: "call_123", + content: "Result", + }, + { + type: "text" as const, + text: "Context", + }, + ], + }, + ] + + const result = convertToR1Format(input as Anthropic.Messages.MessageParam[], { + mergeToolResultText: true, + }) + + // Should have: user, assistant (with reasoning + tool_calls), tool + expect(result).toHaveLength(3) + expect(result[0]).toEqual({ role: "user", content: "Start" }) + expect((result[1] as any).reasoning_content).toBe("Let me think about this...") + expect((result[1] as any).tool_calls).toBeDefined() + // Tool message should have merged content + expect(result[2]).toEqual({ + role: "tool", + tool_call_id: "call_123", + content: "Result\n\nContext", }) + // Most importantly: NO user message after tool message + expect(result.filter((m) => m.role === "user")).toHaveLength(1) }) }) })