Răsfoiți Sursa

feat(deepseek): implement interleaved thinking mode for deepseek-reasoner (#9969)

Hannes Rudolph 2 săptămâni în urmă
părinte
comite
d274812332

+ 5 - 1
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<string, ModelInfo>
 
 // https://api-docs.deepseek.com/quick_start/parameter_settings
-export const DEEP_SEEK_DEFAULT_TEMPERATURE = 0
+export const DEEP_SEEK_DEFAULT_TEMPERATURE = 0.3

+ 186 - 9
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")
+		})
+	})
 })

+ 110 - 3
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,

+ 3 - 3
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")
 	}

+ 216 - 0
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",
+			})
+		})
+	})
 })

+ 181 - 61
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<Message[]>((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
 }