diff --git a/packages/types/src/providers/deepseek.ts b/packages/types/src/providers/deepseek.ts index a7380e57ea7..80c72ba7250 100644 --- a/packages/types/src/providers/deepseek.ts +++ b/packages/types/src/providers/deepseek.ts @@ -1,6 +1,9 @@ import type { ModelInfo } from "../model.js" // https://platform.deepseek.com/docs/api +// preserveReasoning enables interleaved thinking mode for tool calls: +// DeepSeek requires reasoning_content to be passed back during tool call +// continuation within the same turn. See: https://api-docs.deepseek.com/guides/thinking_mode export type DeepSeekModelId = keyof typeof deepSeekModels export const deepSeekDefaultModelId: DeepSeekModelId = "deepseek-chat" @@ -26,6 +29,7 @@ export const deepSeekModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + preserveReasoning: true, inputPrice: 0.28, // $0.28 per million tokens (cache miss) - Updated Dec 9, 2025 outputPrice: 0.42, // $0.42 per million tokens - Updated Dec 9, 2025 cacheWritesPrice: 0.28, // $0.28 per million tokens (cache miss) - Updated Dec 9, 2025 @@ -35,4 +39,4 @@ export const deepSeekModels = { } as const satisfies Record // https://api-docs.deepseek.com/quick_start/parameter_settings -export const DEEP_SEEK_DEFAULT_TEMPERATURE = 0 +export const DEEP_SEEK_DEFAULT_TEMPERATURE = 0.3 diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index 5e5a677590d..1aac662d9a8 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/src/api/providers/__tests__/deepseek.spec.ts @@ -29,23 +29,75 @@ vi.mock("openai", () => { } } + // Check if this is a reasoning_content test by looking at model + const isReasonerModel = options.model?.includes("deepseek-reasoner") + const isToolCallTest = options.tools?.length > 0 + // Return async iterator for streaming return { [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - usage: null, + // For reasoner models, emit reasoning_content first + if (isReasonerModel) { + yield { + choices: [ + { + delta: { reasoning_content: "Let me think about this..." }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: { reasoning_content: " I'll analyze step by step." }, + index: 0, + }, + ], + usage: null, + } } + + // For tool call tests with reasoner, emit tool call + if (isReasonerModel && isToolCallTest) { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_123", + function: { + name: "get_weather", + arguments: '{"location":"SF"}', + }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + } else { + yield { + choices: [ + { + delta: { content: "Test response" }, + index: 0, + }, + ], + usage: null, + } + } + yield { choices: [ { delta: {}, index: 0, + finish_reason: isToolCallTest ? "tool_calls" : "stop", }, ], usage: { @@ -70,7 +122,7 @@ vi.mock("openai", () => { import OpenAI from "openai" import type { Anthropic } from "@anthropic-ai/sdk" -import { deepSeekDefaultModelId } from "@roo-code/types" +import { deepSeekDefaultModelId, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" @@ -174,6 +226,27 @@ describe("DeepSeekHandler", () => { expect(model.info.supportsPromptCache).toBe(true) }) + it("should have preserveReasoning enabled for deepseek-reasoner to support interleaved thinking", () => { + // This is critical for DeepSeek's interleaved thinking mode with tool calls. + // See: https://api-docs.deepseek.com/guides/thinking_mode + // The reasoning_content needs to be passed back during tool call continuation + // within the same turn for the model to continue reasoning properly. + const handlerWithReasoner = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-reasoner", + }) + const model = handlerWithReasoner.getModel() + // Cast to ModelInfo to access preserveReasoning which is an optional property + expect((model.info as ModelInfo).preserveReasoning).toBe(true) + }) + + it("should NOT have preserveReasoning enabled for deepseek-chat", () => { + // deepseek-chat doesn't use thinking mode, so no need to preserve reasoning + const model = handler.getModel() + // Cast to ModelInfo to access preserveReasoning which is an optional property + expect((model.info as ModelInfo).preserveReasoning).toBeUndefined() + }) + it("should return provided model ID with default model info if model does not exist", () => { const handlerWithInvalidModel = new DeepSeekHandler({ ...mockOptions, @@ -317,4 +390,108 @@ describe("DeepSeekHandler", () => { expect(result.cacheReadTokens).toBeUndefined() }) }) + + describe("interleaved thinking mode", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], + }, + ] + + it("should handle reasoning_content in streaming responses for deepseek-reasoner", async () => { + const reasonerHandler = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-reasoner", + }) + + const stream = reasonerHandler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have reasoning chunks + const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") + expect(reasoningChunks.length).toBeGreaterThan(0) + expect(reasoningChunks[0].text).toBe("Let me think about this...") + expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.") + }) + + it("should pass thinking parameter for deepseek-reasoner model", async () => { + const reasonerHandler = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-reasoner", + }) + + const stream = reasonerHandler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // Consume the stream + } + + // Verify that the thinking parameter was passed to the API + // Note: mockCreate receives two arguments - request options and path options + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + thinking: { type: "enabled" }, + }), + {}, // Empty path options for non-Azure URLs + ) + }) + + it("should NOT pass thinking parameter for deepseek-chat model", async () => { + const chatHandler = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-chat", + }) + + const stream = chatHandler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // Consume the stream + } + + // Verify that the thinking parameter was NOT passed to the API + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs.thinking).toBeUndefined() + }) + + it("should handle tool calls with reasoning_content", async () => { + const reasonerHandler = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-reasoner", + }) + + const tools: any[] = [ + { + type: "function", + function: { + name: "get_weather", + description: "Get weather", + parameters: { type: "object", properties: {} }, + }, + }, + ] + + const stream = reasonerHandler.createMessage(systemPrompt, messages, { taskId: "test", tools }) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have reasoning chunks + const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") + expect(reasoningChunks.length).toBeGreaterThan(0) + + // Should have tool call chunks + const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + expect(toolCallChunks.length).toBeGreaterThan(0) + expect(toolCallChunks[0].name).toBe("get_weather") + }) + }) }) diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index de119de6dba..01e747e11b1 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -1,11 +1,26 @@ -import { deepSeekModels, deepSeekDefaultModelId } from "@roo-code/types" +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { + deepSeekModels, + deepSeekDefaultModelId, + DEEP_SEEK_DEFAULT_TEMPERATURE, + OPENAI_AZURE_AI_INFERENCE_PATH, +} from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import type { ApiStreamUsageChunk } from "../transform/stream" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" +import { convertToR1Format } from "../transform/r1-format" import { OpenAiHandler } from "./openai" +import type { ApiHandlerCreateMessageMetadata } from "../index" + +// Custom interface for DeepSeek params to support thinking mode +type DeepSeekChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParamsStreaming & { + thinking?: { type: "enabled" | "disabled" } +} export class DeepSeekHandler extends OpenAiHandler { constructor(options: ApiHandlerOptions) { @@ -26,8 +41,100 @@ export class DeepSeekHandler extends OpenAiHandler { return { id, info, ...params } } + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const modelId = this.options.apiModelId ?? deepSeekDefaultModelId + const { info: modelInfo } = this.getModel() + + // Check if this is a thinking-enabled model (deepseek-reasoner) + const isThinkingModel = modelId.includes("deepseek-reasoner") + + // 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]) + + const requestOptions: DeepSeekChatCompletionParams = { + model: modelId, + temperature: this.options.modelTemperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE, + messages: convertedMessages, + stream: true as const, + stream_options: { include_usage: true }, + // Enable thinking mode for deepseek-reasoner or when tools are used with thinking model + ...(isThinkingModel && { thinking: { type: "enabled" } }), + ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), + ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), + ...(metadata?.toolProtocol === "native" && { + parallel_tool_calls: metadata.parallelToolCalls ?? false, + }), + } + + // Add max_tokens if needed + this.addMaxTokensIfNeeded(requestOptions, modelInfo) + + // Check if base URL is Azure AI Inference (for DeepSeek via Azure) + const isAzureAiInference = this._isAzureAiInference(this.options.deepSeekBaseUrl) + + let stream + try { + stream = await this.client.chat.completions.create( + requestOptions, + isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, + ) + } catch (error) { + const { handleOpenAIError } = await import("./utils/openai-error-handler") + throw handleOpenAIError(error, "DeepSeek") + } + + let lastUsage + + for await (const chunk of stream) { + const delta = chunk.choices?.[0]?.delta ?? {} + + // Handle regular text content + if (delta.content) { + yield { + type: "text", + text: delta.content, + } + } + + // Handle reasoning_content from DeepSeek's interleaved thinking + // This is the proper way DeepSeek sends thinking content in streaming + if ("reasoning_content" in delta && delta.reasoning_content) { + yield { + type: "reasoning", + text: (delta.reasoning_content as string) || "", + } + } + + // Handle tool calls + if (delta.tool_calls) { + for (const toolCall of delta.tool_calls) { + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, + } + } + } + + if (chunk.usage) { + lastUsage = chunk.usage + } + } + + if (lastUsage) { + yield this.processUsageMetrics(lastUsage, modelInfo) + } + } + // Override to handle DeepSeek's usage metrics, including caching. - protected override processUsageMetrics(usage: any): ApiStreamUsageChunk { + protected override processUsageMetrics(usage: any, _modelInfo?: any): ApiStreamUsageChunk { return { type: "usage", inputTokens: usage?.prompt_tokens || 0, diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 2a2065edd6e..b198fe11d37 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -31,7 +31,7 @@ import { handleOpenAIError } from "./utils/openai-error-handler" // compatible with the OpenAI API. We can also rename it to `OpenAIHandler`. export class OpenAiHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private client: OpenAI + protected client: OpenAI private readonly providerName = "OpenAI" constructor(options: ApiHandlerOptions) { @@ -478,7 +478,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } - private _getUrlHost(baseUrl?: string): string { + protected _getUrlHost(baseUrl?: string): string { try { return new URL(baseUrl ?? "").host } catch (error) { @@ -491,7 +491,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl return urlHost.includes("x.ai") } - private _isAzureAiInference(baseUrl?: string): boolean { + protected _isAzureAiInference(baseUrl?: string): boolean { const urlHost = this._getUrlHost(baseUrl) return urlHost.endsWith(".services.ai.azure.com") } diff --git a/src/api/transform/__tests__/r1-format.spec.ts b/src/api/transform/__tests__/r1-format.spec.ts index 80e641d94d8..edfe9dc5d14 100644 --- a/src/api/transform/__tests__/r1-format.spec.ts +++ b/src/api/transform/__tests__/r1-format.spec.ts @@ -179,4 +179,220 @@ describe("convertToR1Format", () => { expect(convertToR1Format(input)).toEqual(expected) }) + + describe("tool calls support for DeepSeek interleaved thinking", () => { + it("should convert assistant messages with tool_use to OpenAI format", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "What's the weather?" }, + { + role: "assistant", + content: [ + { type: "text", text: "Let me check the weather for you." }, + { + type: "tool_use", + id: "call_123", + name: "get_weather", + input: { location: "San Francisco" }, + }, + ], + }, + ] + + const result = convertToR1Format(input) + + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ role: "user", content: "What's the weather?" }) + expect(result[1]).toMatchObject({ + role: "assistant", + content: "Let me check the weather for you.", + tool_calls: [ + { + id: "call_123", + type: "function", + function: { + name: "get_weather", + arguments: '{"location":"San Francisco"}', + }, + }, + ], + }) + }) + + it("should convert user messages with tool_result to OpenAI tool messages", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "What's the weather?" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call_123", + name: "get_weather", + input: { location: "San Francisco" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_123", + content: "72°F and sunny", + }, + ], + }, + ] + + const result = convertToR1Format(input) + + expect(result).toHaveLength(3) + expect(result[0]).toEqual({ role: "user", content: "What's the weather?" }) + expect(result[1]).toMatchObject({ + role: "assistant", + content: null, + tool_calls: expect.any(Array), + }) + expect(result[2]).toEqual({ + role: "tool", + tool_call_id: "call_123", + content: "72°F and sunny", + }) + }) + + it("should handle tool_result with array content", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_456", + content: [ + { type: "text", text: "Line 1" }, + { type: "text", text: "Line 2" }, + ], + }, + ], + }, + ] + + const result = convertToR1Format(input) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_456", + content: "Line 1\nLine 2", + }) + }) + + it("should preserve reasoning_content on assistant messages", () => { + const input = [ + { role: "user" as const, content: "Think about this" }, + { + role: "assistant" as const, + content: "Here's my answer", + reasoning_content: "Let me analyze step by step...", + }, + ] + + const result = convertToR1Format(input as Anthropic.Messages.MessageParam[]) + + expect(result).toHaveLength(2) + expect((result[1] as any).reasoning_content).toBe("Let me analyze step by step...") + }) + + it("should handle mixed tool_result and text in user message", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_789", + content: "Tool result", + }, + { + type: "text", + text: "Please continue", + }, + ], + }, + ] + + const result = convertToR1Format(input) + + // Should produce two messages: tool message first, then user message + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_789", + content: "Tool result", + }) + expect(result[1]).toEqual({ + role: "user", + content: "Please continue", + }) + }) + + it("should handle multiple tool calls in single assistant message", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call_1", + name: "tool_a", + input: { param: "a" }, + }, + { + type: "tool_use", + id: "call_2", + name: "tool_b", + input: { param: "b" }, + }, + ], + }, + ] + + const result = convertToR1Format(input) + + expect(result).toHaveLength(1) + expect((result[0] as any).tool_calls).toHaveLength(2) + expect((result[0] as any).tool_calls[0].id).toBe("call_1") + expect((result[0] as any).tool_calls[1].id).toBe("call_2") + }) + + it("should not merge assistant messages that have tool calls", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call_1", + name: "tool_a", + input: {}, + }, + ], + }, + { + role: "assistant", + content: "Follow up response", + }, + ] + + const result = convertToR1Format(input) + + // Should NOT merge because first message has tool calls + expect(result).toHaveLength(2) + expect((result[0] as any).tool_calls).toBeDefined() + expect(result[1]).toEqual({ + role: "assistant", + content: "Follow up response", + }) + }) + }) }) diff --git a/src/api/transform/r1-format.ts b/src/api/transform/r1-format.ts index 51a4b94dbc4..d4a7bef1ae7 100644 --- a/src/api/transform/r1-format.ts +++ b/src/api/transform/r1-format.ts @@ -5,94 +5,214 @@ type ContentPartText = OpenAI.Chat.ChatCompletionContentPartText type ContentPartImage = OpenAI.Chat.ChatCompletionContentPartImage type UserMessage = OpenAI.Chat.ChatCompletionUserMessageParam type AssistantMessage = OpenAI.Chat.ChatCompletionAssistantMessageParam +type ToolMessage = OpenAI.Chat.ChatCompletionToolMessageParam type Message = OpenAI.Chat.ChatCompletionMessageParam type AnthropicMessage = Anthropic.Messages.MessageParam +/** + * Extended assistant message type to support DeepSeek's interleaved thinking. + * DeepSeek's API returns reasoning_content alongside content and tool_calls, + * and requires it to be passed back in subsequent requests within the same turn. + */ +export type DeepSeekAssistantMessage = AssistantMessage & { + reasoning_content?: string +} + /** * Converts Anthropic messages to OpenAI format while merging consecutive messages with the same role. * This is required for DeepSeek Reasoner which does not support successive messages with the same role. * + * For DeepSeek's interleaved thinking mode: + * - 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 + * * @param messages Array of Anthropic messages * @returns Array of OpenAI messages where consecutive messages with the same role are combined */ export function convertToR1Format(messages: AnthropicMessage[]): Message[] { - return messages.reduce((merged, message) => { - const lastMessage = merged[merged.length - 1] - let messageContent: string | (ContentPartText | ContentPartImage)[] = "" - let hasImages = false + const result: Message[] = [] - // Convert content to appropriate format - if (Array.isArray(message.content)) { - const textParts: string[] = [] - const imageParts: ContentPartImage[] = [] + for (const message of messages) { + // Check if the message has reasoning_content (for DeepSeek interleaved thinking) + const messageWithReasoning = message as AnthropicMessage & { reasoning_content?: string } + const reasoningContent = messageWithReasoning.reasoning_content - message.content.forEach((part) => { - if (part.type === "text") { - textParts.push(part.text) + if (message.role === "user") { + // Handle user messages - may contain tool_result blocks + if (Array.isArray(message.content)) { + const textParts: string[] = [] + const imageParts: ContentPartImage[] = [] + const toolResults: { tool_use_id: string; content: string }[] = [] + + for (const part of message.content) { + if (part.type === "text") { + textParts.push(part.text) + } else if (part.type === "image") { + imageParts.push({ + type: "image_url", + image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, + }) + } else if (part.type === "tool_result") { + // Convert tool_result to OpenAI tool message format + let content: string + if (typeof part.content === "string") { + content = part.content + } else if (Array.isArray(part.content)) { + content = + part.content + ?.map((c) => { + if (c.type === "text") return c.text + if (c.type === "image") return "(image)" + return "" + }) + .join("\n") ?? "" + } else { + content = "" + } + toolResults.push({ + tool_use_id: part.tool_use_id, + content, + }) + } } - if (part.type === "image") { - hasImages = true - imageParts.push({ - type: "image_url", - image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, - }) + + // Add tool messages first (they must follow assistant tool_use) + for (const toolResult of toolResults) { + const toolMessage: ToolMessage = { + role: "tool", + tool_call_id: toolResult.tool_use_id, + content: toolResult.content, + } + result.push(toolMessage) } - }) - if (hasImages) { - const parts: (ContentPartText | ContentPartImage)[] = [] - if (textParts.length > 0) { - parts.push({ type: "text", text: textParts.join("\n") }) + // Then add user message with text/image content if any + 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") }) + } + 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}` + } 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 { + result.push({ role: "user", content }) + } } - parts.push(...imageParts) - messageContent = parts } else { - messageContent = textParts.join("\n") + // Simple string content + const lastMessage = result[result.length - 1] + if (lastMessage?.role === "user") { + if (typeof lastMessage.content === "string") { + lastMessage.content += `\n${message.content}` + } else { + ;(lastMessage.content as (ContentPartText | ContentPartImage)[]).push({ + type: "text", + text: message.content, + }) + } + } else { + result.push({ role: "user", content: message.content }) + } } - } else { - messageContent = message.content - } + } else if (message.role === "assistant") { + // Handle assistant messages - may contain tool_use blocks and reasoning blocks + if (Array.isArray(message.content)) { + const textParts: string[] = [] + const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = [] + let extractedReasoning: string | undefined - // If last message has same role, merge the content - if (lastMessage?.role === message.role) { - if (typeof lastMessage.content === "string" && typeof messageContent === "string") { - lastMessage.content += `\n${messageContent}` - } - // If either has image content, convert both to array format - else { - const lastContent = Array.isArray(lastMessage.content) - ? lastMessage.content - : [{ type: "text" as const, text: lastMessage.content || "" }] + for (const part of message.content) { + if (part.type === "text") { + textParts.push(part.text) + } else if (part.type === "tool_use") { + toolCalls.push({ + id: part.id, + type: "function", + function: { + name: part.name, + arguments: JSON.stringify(part.input), + }, + }) + } else if ((part as any).type === "reasoning" && (part as any).text) { + // Extract reasoning from content blocks (Task stores it this way) + extractedReasoning = (part as any).text + } + } - const newContent = Array.isArray(messageContent) - ? messageContent - : [{ type: "text" as const, text: messageContent }] + // Use reasoning from content blocks if not provided at top level + const finalReasoning = reasoningContent || extractedReasoning - if (message.role === "assistant") { - const mergedContent = [...lastContent, ...newContent] as AssistantMessage["content"] - lastMessage.content = mergedContent - } else { - const mergedContent = [...lastContent, ...newContent] as UserMessage["content"] - lastMessage.content = mergedContent - } - } - } else { - // Add as new message with the correct type based on role - if (message.role === "assistant") { - const newMessage: AssistantMessage = { + const assistantMessage: DeepSeekAssistantMessage = { role: "assistant", - content: messageContent as AssistantMessage["content"], + content: textParts.length > 0 ? textParts.join("\n") : null, + ...(toolCalls.length > 0 && { tool_calls: toolCalls }), + // Preserve reasoning_content for DeepSeek interleaved thinking + ...(finalReasoning && { reasoning_content: finalReasoning }), + } + + // Check if we can merge with the last message (only if no tool calls) + const lastMessage = result[result.length - 1] + if (lastMessage?.role === "assistant" && !toolCalls.length && !(lastMessage as any).tool_calls) { + // Merge text content + if (typeof lastMessage.content === "string" && typeof assistantMessage.content === "string") { + lastMessage.content += `\n${assistantMessage.content}` + } else if (assistantMessage.content) { + const lastContent = lastMessage.content || "" + lastMessage.content = `${lastContent}\n${assistantMessage.content}` + } + // Preserve reasoning_content from the new message if present + if (finalReasoning) { + ;(lastMessage as DeepSeekAssistantMessage).reasoning_content = finalReasoning + } + } else { + result.push(assistantMessage) } - merged.push(newMessage) } else { - const newMessage: UserMessage = { - role: "user", - content: messageContent as UserMessage["content"], + // Simple string content + const lastMessage = result[result.length - 1] + if (lastMessage?.role === "assistant" && !(lastMessage as any).tool_calls) { + if (typeof lastMessage.content === "string") { + lastMessage.content += `\n${message.content}` + } else { + lastMessage.content = message.content + } + // Preserve reasoning_content from the new message if present + if (reasoningContent) { + ;(lastMessage as DeepSeekAssistantMessage).reasoning_content = reasoningContent + } + } else { + const assistantMessage: DeepSeekAssistantMessage = { + role: "assistant", + content: message.content, + ...(reasoningContent && { reasoning_content: reasoningContent }), + } + result.push(assistantMessage) } - merged.push(newMessage) } } + } - return merged - }, []) + return result }