diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 6c7d0a4b4b6..a40a9478e93 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -83,6 +83,19 @@ export const modelInfoSchema = z.object({ supportsReasoningBudget: z.boolean().optional(), // Capability flag to indicate whether the model supports simple on/off binary reasoning supportsReasoningBinary: z.boolean().optional(), + /** + * Capability flag to indicate whether the model supports interleaved thinking. + * When true, the model emits `reasoning_content` alongside `content` in responses. + * Examples: DeepSeek reasoner, Kimi K2 Thinking, Minimax M2. + */ + supportsInterleavedThinking: z.boolean().optional(), + /** + * Provider-specific parameters needed to enable interleaved thinking. + * Different providers may use different parameter formats. + * Example: DeepSeek uses `{ thinking: { type: "enabled" } }`. + * This parameter is passed via `extra_body` or similar mechanism. + */ + interleavedThinkingParam: z.record(z.any()).optional(), // Capability flag to indicate whether the model supports temperature parameter supportsTemperature: z.boolean().optional(), defaultTemperature: z.number().optional(), diff --git a/packages/types/src/providers/deepseek.ts b/packages/types/src/providers/deepseek.ts index c5c297cdb94..0f59944e96c 100644 --- a/packages/types/src/providers/deepseek.ts +++ b/packages/types/src/providers/deepseek.ts @@ -24,6 +24,10 @@ export const deepSeekModels = { supportsImages: false, supportsPromptCache: true, supportsNativeTools: true, + // Enables interleaved thinking mode (reasoning_content field) + supportsInterleavedThinking: true, + // Parameter passed via extra_body to enable thinking mode + interleavedThinkingParam: { thinking: { type: "enabled" } }, inputPrice: 0.56, // $0.56 per million tokens (cache miss) - Updated Sept 5, 2025 outputPrice: 1.68, // $1.68 per million tokens - Updated Sept 5, 2025 cacheWritesPrice: 0.56, // $0.56 per million tokens (cache miss) - Updated Sept 5, 2025 diff --git a/packages/types/src/providers/openai.ts b/packages/types/src/providers/openai.ts index 722b57677cc..88172f4d69f 100644 --- a/packages/types/src/providers/openai.ts +++ b/packages/types/src/providers/openai.ts @@ -484,6 +484,7 @@ export const openAiModelInfoSaneDefaults: ModelInfo = { inputPrice: 0, outputPrice: 0, supportsNativeTools: true, + supportsInterleavedThinking: false, } // https://learn.microsoft.com/en-us/azure/ai-services/openai/api-version-deprecation diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index 50cabfa9226..50aebc341d0 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/src/api/providers/__tests__/deepseek.spec.ts @@ -70,7 +70,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" @@ -172,6 +172,15 @@ describe("DeepSeekHandler", () => { expect(model.info.contextWindow).toBe(128_000) expect(model.info.supportsImages).toBe(false) expect(model.info.supportsPromptCache).toBe(true) + // Verify interleaved thinking capability flags + expect((model.info as ModelInfo).supportsInterleavedThinking).toBe(true) + expect((model.info as ModelInfo).interleavedThinkingParam).toEqual({ thinking: { type: "enabled" } }) + }) + + it("should not have interleaved thinking flags for deepseek-chat", () => { + const model = handler.getModel() + expect((model.info as ModelInfo).supportsInterleavedThinking).toBeUndefined() + expect((model.info as ModelInfo).interleavedThinkingParam).toBeUndefined() }) it("should return provided model ID with default model info if model does not exist", () => { diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 2a2065edd6e..a7f084c1ba9 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -17,7 +17,8 @@ import { XmlMatcher } from "../../utils/xml-matcher" import { convertToOpenAiMessages } from "../transform/openai-format" import { convertToR1Format } from "../transform/r1-format" import { convertToSimpleMessages } from "../transform/simple-format" -import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { isNewUserTurn } from "../transform/detect-turn-boundary" +import { ApiStream, ApiStreamUsageChunk, type ApiStreamToolCallPartialChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { DEFAULT_HEADERS } from "./constants" @@ -85,13 +86,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const { info: modelInfo, reasoning } = this.getModel() + const { info: modelInfo, reasoning, temperature } = this.getModel() const modelUrl = this.options.openAiBaseUrl ?? "" const modelId = this.options.openAiModelId ?? "" const enabledR1Format = this.options.openAiR1FormatEnabled ?? false const enabledLegacyFormat = this.options.openAiLegacyFormat ?? false const isAzureAiInference = this._isAzureAiInference(modelUrl) - const deepseekReasoner = modelId.includes("deepseek-reasoner") || enabledR1Format + const supportsInterleavedThinking = modelInfo?.supportsInterleavedThinking === true const ark = modelUrl.includes(".volces.com") if (modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4")) { @@ -107,8 +108,16 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl if (this.options.openAiStreamingEnabled ?? true) { let convertedMessages - if (deepseekReasoner) { - convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) + if (supportsInterleavedThinking) { + // For interleaved thinking models, conditionally clear reasoning_content: + // - Clear for new user turns (preserve only final answers) + // - Preserve during tool call sequences (required by API) + const allMessages: Anthropic.Messages.MessageParam[] = [ + { role: "user" as const, content: systemPrompt }, + ...messages, + ] + const shouldClearReasoning = isNewUserTurn(allMessages) + convertedMessages = convertToR1Format(allMessages, shouldClearReasoning) } else if (ark || enabledLegacyFormat) { convertedMessages = [systemMessage, ...convertToSimpleMessages(messages)] } else { @@ -159,7 +168,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model: modelId, - temperature: this.options.modelTemperature ?? (deepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0), + temperature, messages: convertedMessages, stream: true as const, ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), @@ -171,6 +180,12 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl }), } + // Add interleaved thinking parameter if supported + if (supportsInterleavedThinking && modelInfo?.interleavedThinkingParam) { + // @ts-ignore-next-line - extra_body is not in the type definition but is supported by OpenAI API + requestOptions.extra_body = modelInfo.interleavedThinkingParam + } + // Add max_tokens if needed this.addMaxTokensIfNeeded(requestOptions, modelInfo) @@ -193,33 +208,84 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl }) as const, ) + // Accumulation state for interleaved thinking mode + // According to API documentation for interleaved thinking, chunks contain either reasoning_content OR content, not both + // However, tool_calls may appear alongside either reasoning_content or content + let reasoningAccumulator = "" + let isReasoningPhase = true + let hasEmittedReasoning = false + let lastUsage + let finalToolCalls: any[] = [] + let toolCallBuffer: ApiStreamToolCallPartialChunk[] = [] for await (const chunk of stream) { const delta = chunk.choices?.[0]?.delta ?? {} + // Handle reasoning_content accumulation (interleaved thinking mode) + if ("reasoning_content" in delta && delta.reasoning_content) { + reasoningAccumulator += (delta.reasoning_content as string | undefined) || "" + isReasoningPhase = true + // Note: Continue to process tool_calls and usage in same chunk if present + } + + // Handle content - if we were in reasoning phase, emit accumulated reasoning first if (delta.content) { - for (const chunk of matcher.update(delta.content)) { - yield chunk + // Transition from reasoning to content phase + if (isReasoningPhase && reasoningAccumulator && !hasEmittedReasoning) { + yield { + type: "reasoning", + text: reasoningAccumulator, + } + hasEmittedReasoning = true + reasoningAccumulator = "" } - } - if ("reasoning_content" in delta && delta.reasoning_content) { - yield { - type: "reasoning", - text: (delta.reasoning_content as string | undefined) || "", + // Emit buffered tool calls before processing content + for (const toolCall of toolCallBuffer) { + yield toolCall + } + toolCallBuffer = [] + + isReasoningPhase = false + + // Process content as usual + for (const chunk of matcher.update(delta.content)) { + yield chunk } } + // Handle tool calls (can occur during reasoning or content phase) + // Note: Reasoning may continue after tool calls, so we don't emit reasoning here + // Reasoning will be emitted when transitioning to content phase or at stream end + // Buffer tool calls instead of yielding immediately to ensure reasoning appears first if (delta.tool_calls) { for (const toolCall of delta.tool_calls) { - yield { + // Track tool calls for debug logging + if (toolCall.index !== undefined) { + if (!finalToolCalls[toolCall.index]) { + finalToolCalls[toolCall.index] = { + id: toolCall.id, + type: toolCall.type, + function: { name: toolCall.function?.name, arguments: "" }, + } + } + if (toolCall.function?.name) { + finalToolCalls[toolCall.index].function.name = toolCall.function.name + } + if (toolCall.function?.arguments) { + finalToolCalls[toolCall.index].function.arguments += toolCall.function.arguments + } + } + // Buffer tool calls instead of yielding immediately + // Default index to 0 if undefined (required by type) + toolCallBuffer.push({ type: "tool_call_partial", - index: toolCall.index, + index: toolCall.index ?? 0, id: toolCall.id, name: toolCall.function?.name, arguments: toolCall.function?.arguments, - } + }) } } @@ -228,6 +294,22 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } + // Emit any remaining accumulated reasoning content at stream end + // This handles cases where stream ends during reasoning phase + if (reasoningAccumulator && !hasEmittedReasoning) { + yield { + type: "reasoning", + text: reasoningAccumulator, + } + } + + // Emit any buffered tool calls after reasoning is emitted + // This ensures reasoning appears before tool calls in the UI + for (const toolCall of toolCallBuffer) { + yield toolCall + } + toolCallBuffer = [] + for (const chunk of matcher.final()) { yield chunk } @@ -238,8 +320,18 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } else { const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { model: modelId, - messages: deepseekReasoner - ? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) + messages: supportsInterleavedThinking + ? (() => { + // For interleaved thinking models, conditionally clear reasoning_content: + // - Clear for new user turns (preserve only final answers) + // - Preserve during tool call sequences (required by API) + const allMessages: Anthropic.Messages.MessageParam[] = [ + { role: "user" as const, content: systemPrompt }, + ...messages, + ] + const shouldClearReasoning = isNewUserTurn(allMessages) + return convertToR1Format(allMessages, shouldClearReasoning) + })() : enabledLegacyFormat ? [systemMessage, ...convertToSimpleMessages(messages)] : [systemMessage, ...convertToOpenAiMessages(messages)], @@ -250,6 +342,12 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl }), } + // Add interleaved thinking parameter if supported + if (supportsInterleavedThinking && modelInfo?.interleavedThinkingParam) { + // @ts-ignore-next-line - extra_body is not in the type definition but is supported by OpenAI API + requestOptions.extra_body = modelInfo.interleavedThinkingParam + } + // Add max_tokens if needed this.addMaxTokensIfNeeded(requestOptions, modelInfo) @@ -278,6 +376,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } + // Handle reasoning_content for interleaved thinking models + if (supportsInterleavedThinking && "reasoning_content" in message && message.reasoning_content) { + yield { + type: "reasoning", + text: (message.reasoning_content as string | undefined) || "", + } + } + yield { type: "text", text: message?.content || "", diff --git a/src/api/transform/__tests__/clear-reasoning-content.spec.ts b/src/api/transform/__tests__/clear-reasoning-content.spec.ts new file mode 100644 index 00000000000..e2e0273306d --- /dev/null +++ b/src/api/transform/__tests__/clear-reasoning-content.spec.ts @@ -0,0 +1,280 @@ +// npx vitest run api/transform/__tests__/clear-reasoning-content.spec.ts + +import { clearReasoningContentFromMessages } from "../clear-reasoning-content" +import { Anthropic } from "@anthropic-ai/sdk" + +describe("clearReasoningContentFromMessages", () => { + it("should clear reasoning_content from single assistant message", () => { + const input: any[] = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: "Hi there", + reasoning_content: "Let me think about this...", + }, + ] + + const result = clearReasoningContentFromMessages(input) + + expect(result).toEqual([ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there" }, + ]) + expect(result[1]).not.toHaveProperty("reasoning_content") + }) + + it("should clear reasoning_content from multiple assistant messages", () => { + const input: any[] = [ + { role: "user", content: "Question 1" }, + { + role: "assistant", + content: "Answer 1", + reasoning_content: "Reasoning 1", + }, + { role: "user", content: "Question 2" }, + { + role: "assistant", + content: "Answer 2", + reasoning_content: "Reasoning 2", + }, + ] + + const result = clearReasoningContentFromMessages(input) + + expect(result).toEqual([ + { role: "user", content: "Question 1" }, + { role: "assistant", content: "Answer 1" }, + { role: "user", content: "Question 2" }, + { role: "assistant", content: "Answer 2" }, + ]) + expect(result[1]).not.toHaveProperty("reasoning_content") + expect(result[3]).not.toHaveProperty("reasoning_content") + }) + + it("should preserve reasoning_content in user messages (should not be affected)", () => { + const input: any[] = [ + { + role: "user", + content: "Hello", + reasoning_content: "User reasoning (should be preserved)", + }, + { + role: "assistant", + content: "Hi", + reasoning_content: "Assistant reasoning (should be cleared)", + }, + ] + + const result = clearReasoningContentFromMessages(input) + + // User messages are not modified + expect(result[0]).toEqual(input[0]) + expect(result[0]).toHaveProperty("reasoning_content") + + // Assistant messages have reasoning_content cleared + expect(result[1]).not.toHaveProperty("reasoning_content") + }) + + it("should preserve tool_calls in assistant messages", () => { + const input: any[] = [ + { + role: "assistant", + content: "I'll use a tool", + reasoning_content: "Let me think...", + tool_calls: [ + { + id: "call_123", + type: "function", + function: { name: "test_tool", arguments: "{}" }, + }, + ], + }, + ] + + const result = clearReasoningContentFromMessages(input) + + expect(result[0]).toEqual({ + role: "assistant", + content: "I'll use a tool", + tool_calls: [ + { + id: "call_123", + type: "function", + function: { name: "test_tool", arguments: "{}" }, + }, + ], + }) + expect(result[0]).not.toHaveProperty("reasoning_content") + expect(result[0]).toHaveProperty("tool_calls") + }) + + it("should preserve content in assistant messages", () => { + const input: any[] = [ + { + role: "assistant", + content: "This is the final answer", + reasoning_content: "This is the reasoning", + }, + ] + + const result = clearReasoningContentFromMessages(input) + + expect(result[0]).toEqual({ + role: "assistant", + content: "This is the final answer", + }) + expect(result[0].content).toBe("This is the final answer") + }) + + it("should handle messages with both reasoning_content and content", () => { + const input: any[] = [ + { + role: "assistant", + content: "Final answer", + reasoning_content: "Reasoning process", + }, + ] + + const result = clearReasoningContentFromMessages(input) + + expect(result[0]).toEqual({ + role: "assistant", + content: "Final answer", + }) + expect(result[0]).not.toHaveProperty("reasoning_content") + expect(result[0].content).toBe("Final answer") + }) + + it("should handle empty messages array", () => { + const input: Anthropic.Messages.MessageParam[] = [] + + const result = clearReasoningContentFromMessages(input) + + expect(result).toEqual([]) + }) + + it("should handle messages with array content format", () => { + const input: any[] = [ + { + role: "assistant", + content: [{ type: "text", text: "Answer" }], + reasoning_content: "Reasoning", + }, + ] + + const result = clearReasoningContentFromMessages(input) + + expect(result[0]).toEqual({ + role: "assistant", + content: [{ type: "text", text: "Answer" }], + }) + expect(result[0]).not.toHaveProperty("reasoning_content") + }) + + it("should handle messages with tool results", () => { + const input: any[] = [ + { + role: "tool", + content: "Tool result", + tool_call_id: "call_123", + }, + { + role: "assistant", + content: "Thanks for the tool result", + reasoning_content: "Reasoning after tool", + }, + ] + + const result = clearReasoningContentFromMessages(input) + + // Tool messages are not modified + expect(result[0]).toEqual(input[0]) + + // Assistant messages have reasoning_content cleared + expect(result[1]).toEqual({ + role: "assistant", + content: "Thanks for the tool result", + }) + expect(result[1]).not.toHaveProperty("reasoning_content") + }) + + it("should handle messages with only reasoning_content (no content)", () => { + const input: any[] = [ + { + role: "assistant", + content: "", + reasoning_content: "Only reasoning, no content", + }, + ] + + const result = clearReasoningContentFromMessages(input) + + expect(result[0]).toEqual({ + role: "assistant", + content: "", + }) + expect(result[0]).not.toHaveProperty("reasoning_content") + }) + + it("should handle messages with only content (no reasoning_content)", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: "Only content, no reasoning", + }, + ] + + const result = clearReasoningContentFromMessages(input) + + expect(result).toEqual(input) + expect(result[0]).not.toHaveProperty("reasoning_content") + }) + + it("should handle consecutive assistant messages with reasoning_content", () => { + const input: any[] = [ + { + role: "assistant", + content: "First response", + reasoning_content: "Reasoning 1", + }, + { + role: "assistant", + content: "Second response", + reasoning_content: "Reasoning 2", + }, + ] + + const result = clearReasoningContentFromMessages(input) + + expect(result).toEqual([ + { role: "assistant", content: "First response" }, + { role: "assistant", content: "Second response" }, + ]) + expect(result[0]).not.toHaveProperty("reasoning_content") + expect(result[1]).not.toHaveProperty("reasoning_content") + }) + + it("should preserve all other properties when clearing reasoning_content", () => { + const input: any[] = [ + { + role: "assistant", + content: "Answer", + reasoning_content: "Reasoning", + customProperty: "should be preserved", + anotherProperty: 123, + }, + ] + + const result = clearReasoningContentFromMessages(input) + + expect(result[0]).toEqual({ + role: "assistant", + content: "Answer", + customProperty: "should be preserved", + anotherProperty: 123, + }) + expect(result[0]).not.toHaveProperty("reasoning_content") + expect(result[0]).toHaveProperty("customProperty") + expect(result[0]).toHaveProperty("anotherProperty") + }) +}) diff --git a/src/api/transform/__tests__/detect-turn-boundary.spec.ts b/src/api/transform/__tests__/detect-turn-boundary.spec.ts new file mode 100644 index 00000000000..8c2d39c301c --- /dev/null +++ b/src/api/transform/__tests__/detect-turn-boundary.spec.ts @@ -0,0 +1,238 @@ +// npx vitest run api/transform/__tests__/detect-turn-boundary.spec.ts + +import { isNewUserTurn } from "../detect-turn-boundary" +import { Anthropic } from "@anthropic-ai/sdk" + +describe("isNewUserTurn", () => { + describe("new turn detection", () => { + it("should return true when last message is user message", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "assistant", content: "Previous answer" }, + { role: "user", content: "New question" }, + ] + + expect(isNewUserTurn(messages)).toBe(true) + }) + + it("should return true when only user messages exist", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "First question" }, + { role: "user", content: "Second question" }, + ] + + expect(isNewUserTurn(messages)).toBe(true) + }) + + it("should return true when assistant message without tool_calls is followed by user message", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Question" }, + { role: "assistant", content: "Answer" }, + { role: "user", content: "New question" }, + ] + + expect(isNewUserTurn(messages)).toBe(true) + }) + + it("should return true when assistant finishes answering and user asks new question", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "What is 2+2?" }, + { role: "assistant", content: "The answer is 4" }, + { role: "user", content: "What is 3+3?" }, + ] + + expect(isNewUserTurn(messages)).toBe(true) + }) + }) + + describe("tool call continuation detection", () => { + it("should return false when last message is assistant with tool_use blocks", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Get the weather" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-1", + name: "get_weather", + input: { location: "NYC" }, + }, + ], + }, + ] + + expect(isNewUserTurn(messages)).toBe(false) + }) + + it("should return false when last message is assistant with tool_use and content", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Question" }, + { + role: "assistant", + content: [ + { type: "text", text: "Let me check" }, + { + type: "tool_use", + id: "tool-1", + name: "get_info", + input: {}, + }, + ], + }, + ] + + expect(isNewUserTurn(messages)).toBe(false) + }) + + it("should return true when assistant stops making tool calls after receiving results", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Get weather" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-1", + name: "get_weather", + input: {}, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-1", + content: "Sunny, 72°F", + }, + ], + }, + { role: "assistant", content: "The weather is sunny" }, + ] + + expect(isNewUserTurn(messages)).toBe(true) + }) + + it("should return false for multiple tool call rounds in same turn", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Complex task" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-1", + name: "step1", + input: {}, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-1", + content: "Result 1", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-2", + name: "step2", + input: {}, + }, + ], + }, + ] + + expect(isNewUserTurn(messages)).toBe(false) + }) + }) + + describe("edge cases", () => { + it("should return true for empty messages array", () => { + expect(isNewUserTurn([])).toBe(true) + }) + + it("should return true when only one assistant message exists", () => { + const messages: Anthropic.Messages.MessageParam[] = [{ role: "assistant", content: "Answer" }] + + expect(isNewUserTurn(messages)).toBe(true) + }) + + it("should return false when multiple assistant messages in sequence", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Question" }, + { role: "assistant", content: "Part 1" }, + { role: "assistant", content: "Part 2" }, + ] + + expect(isNewUserTurn(messages)).toBe(false) + }) + + it("should return true when assistant completes tool call sequence without requesting more tools", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-1", + name: "get_date", + input: {}, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-1", + content: "2025-01-01", + }, + ], + }, + { role: "assistant", content: "The date is 2025-01-01" }, + ] + + expect(isNewUserTurn(messages)).toBe(true) + }) + + it("should return true when assistant ends tool sequence even with mixed content in tool results", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-1", + name: "get_info", + input: {}, + }, + ], + }, + { + role: "user", + content: [ + { type: "text", text: "Here's the result:" }, + { + type: "tool_result", + tool_use_id: "tool-1", + content: "Info", + }, + ], + }, + { role: "assistant", content: "Thanks" }, + ] + + expect(isNewUserTurn(messages)).toBe(true) + }) + }) +}) diff --git a/src/api/transform/__tests__/model-params.spec.ts b/src/api/transform/__tests__/model-params.spec.ts index 0440a3ccdb2..831180e582d 100644 --- a/src/api/transform/__tests__/model-params.spec.ts +++ b/src/api/transform/__tests__/model-params.spec.ts @@ -990,4 +990,74 @@ describe("getModelParams", () => { expect(result.reasoningBudget).toBe(8192) // Default thinking tokens }) }) + + describe("Interleaved thinking mode temperature handling", () => { + const interleavedThinkingModel: ModelInfo = { + ...baseModel, + maxTokens: 65536, + supportsInterleavedThinking: true, + } + + const nonInterleavedThinkingModel: ModelInfo = { + ...baseModel, + maxTokens: 8192, + supportsInterleavedThinking: false, + } + + it("should set temperature to undefined for interleaved thinking models", () => { + const result = getModelParams({ + format: "openai", + modelId: "deepseek-reasoner", + model: interleavedThinkingModel, + settings: {}, + }) + + expect(result.temperature).toBeUndefined() + expect(result.format).toBe("openai") + }) + + it("should set temperature to undefined for interleaved thinking models even with custom temperature setting", () => { + const result = getModelParams({ + format: "openai", + modelId: "deepseek-reasoner", + model: interleavedThinkingModel, + settings: { modelTemperature: 0.8 }, + }) + + expect(result.temperature).toBeUndefined() + }) + + it("should use normal temperature handling for non-interleaved thinking models", () => { + const result = getModelParams({ + format: "openai", + modelId: "deepseek-chat", + model: nonInterleavedThinkingModel, + settings: {}, + }) + + expect(result.temperature).toBe(0) // Default temperature + }) + + it("should respect custom temperature for non-interleaved thinking models", () => { + const result = getModelParams({ + format: "openai", + modelId: "deepseek-chat", + model: nonInterleavedThinkingModel, + settings: { modelTemperature: 0.7 }, + }) + + expect(result.temperature).toBe(0.7) + }) + + it("should not affect other openai models", () => { + const result = getModelParams({ + format: "openai", + modelId: "gpt-4", + model: baseModel, + settings: { modelTemperature: 0.5 }, + }) + + expect(result.temperature).toBe(0.5) + }) + }) }) diff --git a/src/api/transform/__tests__/r1-format.spec.ts b/src/api/transform/__tests__/r1-format.spec.ts index 80e641d94d8..0d01ccb8aea 100644 --- a/src/api/transform/__tests__/r1-format.spec.ts +++ b/src/api/transform/__tests__/r1-format.spec.ts @@ -179,4 +179,148 @@ describe("convertToR1Format", () => { expect(convertToR1Format(input)).toEqual(expected) }) + + describe("reasoning_content clearing", () => { + it("should clear reasoning_content from assistant messages", () => { + const input: any[] = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: "Hi there", + reasoning_content: "Let me think about this...", + }, + ] + + const result = convertToR1Format(input) + + expect(result).toEqual([ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there" }, + ]) + expect(result[1]).not.toHaveProperty("reasoning_content") + }) + + it("should clear reasoning_content when merging consecutive assistant messages", () => { + const input: any[] = [ + { role: "user", content: "Question" }, + { + role: "assistant", + content: "First part", + reasoning_content: "Reasoning 1", + }, + { + role: "assistant", + content: "Second part", + reasoning_content: "Reasoning 2", + }, + ] + + const result = convertToR1Format(input) + + expect(result).toEqual([ + { role: "user", content: "Question" }, + { role: "assistant", content: "First part\nSecond part" }, + ]) + expect(result[1]).not.toHaveProperty("reasoning_content") + }) + + it("should preserve content while clearing reasoning_content", () => { + const input: any[] = [ + { + role: "assistant", + content: "The final answer", + reasoning_content: "Detailed reasoning process", + }, + ] + + const result = convertToR1Format(input) + + expect(result[0]).toEqual({ + role: "assistant", + content: "The final answer", + }) + expect(result[0].content).toBe("The final answer") + expect(result[0]).not.toHaveProperty("reasoning_content") + }) + + it("should handle messages without reasoning_content (should still work)", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there" }, + ] + + const result = convertToR1Format(input) + + expect(result).toEqual([ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there" }, + ]) + }) + + it("should preserve reasoning_content when clearReasoningContent is false", () => { + const input: any[] = [ + { role: "user", content: "Question" }, + { + role: "assistant", + content: "Answer", + reasoning_content: "Let me think...", + }, + ] + + const result = convertToR1Format(input, false) + + expect(result[1]).toHaveProperty("reasoning_content") + expect((result[1] as any).reasoning_content).toBe("Let me think...") + }) + + it("should clear reasoning_content when clearReasoningContent is true (default)", () => { + const input: any[] = [ + { role: "user", content: "Question" }, + { + role: "assistant", + content: "Answer", + reasoning_content: "Let me think...", + }, + ] + + const result = convertToR1Format(input, true) + + expect(result[1]).not.toHaveProperty("reasoning_content") + }) + + it("should preserve reasoning_content during tool call sequences", () => { + const input: any[] = [ + { role: "user", content: "Get weather" }, + { + role: "assistant", + content: "Let me check", + reasoning_content: "I need to get the weather", + tool_calls: [{ id: "call-1", function: { name: "get_weather", arguments: "{}" } }], + }, + ] + + // During tool call continuation, reasoning_content should be preserved + const result = convertToR1Format(input, false) + + expect(result[1]).toHaveProperty("reasoning_content") + expect((result[1] as any).reasoning_content).toBe("I need to get the weather") + }) + + it("should clear reasoning_content for new turns (default behavior)", () => { + const input: any[] = [ + { role: "user", content: "First question" }, + { + role: "assistant", + content: "First answer", + reasoning_content: "Reasoning for first", + }, + { role: "user", content: "Second question" }, + ] + + // New turn - reasoning_content should be cleared + const result = convertToR1Format(input, true) + + expect(result[1]).not.toHaveProperty("reasoning_content") + }) + }) }) diff --git a/src/api/transform/clear-reasoning-content.ts b/src/api/transform/clear-reasoning-content.ts new file mode 100644 index 00000000000..657b05ac585 --- /dev/null +++ b/src/api/transform/clear-reasoning-content.ts @@ -0,0 +1,48 @@ +import { Anthropic } from "@anthropic-ai/sdk" + +/** + * Clears `reasoning_content` from assistant messages in a conversation history. + * + * According to API documentation for interleaved thinking, `reasoning_content` from previous turns + * should NOT be concatenated into the context for new turns - only the final `content` + * (answer) should be preserved in conversation history. + * + * This function provides defense-in-depth clearing of reasoning_content before + * sending messages to the API for new conversation turns. + * + * **When to use:** + * - Before sending messages to API when starting a NEW turn (new user message) + * - NOT during tool call sequences within the same turn (reasoning_content should be preserved) + * + * @param messages Array of Anthropic messages (may contain reasoning_content) + * @returns Array of messages with reasoning_content removed from assistant messages + * + * @example + * ```typescript + * const cleanedMessages = clearReasoningContentFromMessages(conversationHistory) + * const convertedMessages = convertToR1Format(cleanedMessages) + * ``` + */ +export function clearReasoningContentFromMessages( + messages: Anthropic.Messages.MessageParam[], +): Anthropic.Messages.MessageParam[] { + return messages.map((message) => { + // Only process assistant messages + if (message.role !== "assistant") { + return message + } + + // Check if message has reasoning_content (might exist as an extra property) + const messageWithReasoning = message as any + if (!("reasoning_content" in messageWithReasoning)) { + // No reasoning_content, return as-is + return message + } + + // Remove reasoning_content while preserving all other properties + const { reasoning_content, ...rest } = messageWithReasoning + + // Return cleaned message (preserves content, tool_calls, etc.) + return rest as Anthropic.Messages.MessageParam + }) +} diff --git a/src/api/transform/detect-turn-boundary.ts b/src/api/transform/detect-turn-boundary.ts new file mode 100644 index 00000000000..24617fd0b40 --- /dev/null +++ b/src/api/transform/detect-turn-boundary.ts @@ -0,0 +1,119 @@ +import { Anthropic } from "@anthropic-ai/sdk" + +/** + * Detects if a new user turn is starting based on message sequence analysis. + * + * According to API documentation for interleaved thinking: + * - Within a turn (tool call sequences): reasoning_content MUST be preserved + * - Between turns (new user question): reasoning_content from previous turns should be cleared + * + * Turn detection heuristics: + * - Last message is user message: + * - If user has tool_result blocks → still in tool call sequence (return false, preserve reasoning_content) + * - If user has no tool_result blocks → new turn (return true, clear reasoning_content) + * - Last message is assistant with tool_use blocks → continuation (return false, preserve reasoning_content) + * - Last message is assistant without tool_use blocks → check previous message: + * - If previous is user with tool_result blocks → model stopped sending tool calls (return true, clear reasoning_content) + * - If previous is user without tool_result blocks → new turn (return true, clear reasoning_content) + * + * @param messages Array of Anthropic messages in conversation order + * @returns true if starting a new user turn (should clear reasoning_content), + * false if continuing tool call sequence (should preserve reasoning_content) + * + * @example + * ```typescript + * // New turn: last message is user + * isNewUserTurn([ + * { role: "assistant", content: "Previous answer" }, + * { role: "user", content: "New question" } + * ]) // returns true + * + * // Continuation: last message is assistant with tool calls + * isNewUserTurn([ + * { role: "user", content: "Question" }, + * { role: "assistant", content: [{ type: "tool_use", ... }] } + * ]) // returns false + * + * // Model stopped sending tool calls: last message is assistant without tool_use, previous is user with tool results + * isNewUserTurn([ + * { role: "assistant", content: [{ type: "tool_use", ... }] }, + * { role: "user", content: [{ type: "tool_result", ... }] }, + * { role: "assistant", content: "Answer" } + * ]) // returns true (clear reasoning_content - model stopped sending tool calls) + * ``` + */ +export function isNewUserTurn(messages: Anthropic.Messages.MessageParam[]): boolean { + // Edge case: empty messages array → treat as new turn + if (messages.length === 0) { + return true + } + + const lastMessage = messages[messages.length - 1] + + // Case 1: Last message is user message + if (lastMessage.role === "user") { + // Check if user message has tool_result blocks + const hasToolResults = + Array.isArray(lastMessage.content) && + lastMessage.content.some((part): part is Anthropic.ToolResultBlockParam => part.type === "tool_result") + + // If user message has tool_result blocks → still in tool call sequence (preserve reasoning_content) + if (hasToolResults) { + return false + } + + // If user message has no tool_result blocks → new turn (clear reasoning_content) + return true + } + + // Case 2: Last message is assistant + if (lastMessage.role === "assistant") { + // Check if assistant message has tool_use blocks + const hasToolUse = + Array.isArray(lastMessage.content) && + lastMessage.content.some((part): part is Anthropic.ToolUseBlockParam => part.type === "tool_use") + + // If assistant has tool_use blocks → continuation (tool call sequence) + if (hasToolUse) { + return false + } + + // If assistant has no tool_use blocks, check previous message + if (messages.length === 1) { + // Only one message (assistant) → treat as new turn + // (This shouldn't happen in normal flow, but handle gracefully) + return true + } + + const previousMessage = messages[messages.length - 2] + + // If previous message is user, check if it has tool_result blocks + if (previousMessage.role === "user") { + const hasToolResults = + Array.isArray(previousMessage.content) && + previousMessage.content.some( + (part): part is Anthropic.ToolResultBlockParam => part.type === "tool_result", + ) + + // If previous user message has tool_result blocks AND assistant has no tool_use blocks + // → Model has stopped sending tool calls, clear reasoning_content (return true) + // If assistant still has tool_use blocks, we would have returned false earlier + // So at this point, assistant has no tool_use blocks, meaning tool call sequence has ended + if (hasToolResults) { + // Model stopped sending tool calls → clear reasoning_content + return true + } + + // If previous user message has no tool_result blocks → new turn + // (Assistant finished answering, user is asking new question) + return true + } + + // If previous message is assistant → continuation + // (Multiple assistant messages in sequence, likely tool call continuation) + return false + } + + // Edge case: unknown role → treat as new turn (conservative approach) + return true +} diff --git a/src/api/transform/model-params.ts b/src/api/transform/model-params.ts index 246fc3f1fdb..d8bf00a5618 100644 --- a/src/api/transform/model-params.ts +++ b/src/api/transform/model-params.ts @@ -157,6 +157,11 @@ export function getModelParams({ params.temperature = undefined } + // Override temperature for interleaved thinking models (temperature has no effect in thinking mode) + if (model?.supportsInterleavedThinking === true) { + params.temperature = undefined + } + return { format, ...params, diff --git a/src/api/transform/r1-format.ts b/src/api/transform/r1-format.ts index 51a4b94dbc4..1c686abdfcf 100644 --- a/src/api/transform/r1-format.ts +++ b/src/api/transform/r1-format.ts @@ -1,6 +1,8 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" +import { clearReasoningContentFromMessages } from "./clear-reasoning-content" + type ContentPartText = OpenAI.Chat.ChatCompletionContentPartText type ContentPartImage = OpenAI.Chat.ChatCompletionContentPartImage type UserMessage = OpenAI.Chat.ChatCompletionUserMessageParam @@ -10,35 +12,185 @@ type AnthropicMessage = Anthropic.Messages.MessageParam /** * 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. + * This is required for models using R1 format (e.g., interleaved thinking models) which do not support successive messages with the same role. + * + * According to API documentation for interleaved thinking: + * - `reasoning_content` from previous turns should NOT be included when sending messages for new turns + * - `reasoning_content` MUST be preserved during tool call sequences within the same turn + * - `tool_calls` MUST be preserved from assistant messages (converted from `tool_use` blocks) + * - `tool_result` blocks from user messages MUST be converted to `tool` role messages * - * @param messages Array of Anthropic messages - * @returns Array of OpenAI messages where consecutive messages with the same role are combined + * This function conditionally clears reasoning_content based on whether a new turn is starting or + * a tool call sequence is continuing, preserves tool_calls by converting tool_use blocks, and + * converts tool_result blocks to tool role messages. + * + * @param messages Array of Anthropic messages (may contain reasoning_content, tool_use, and tool_result blocks) + * @param clearReasoningContent If true, clears reasoning_content from assistant messages (default: true for backward compatibility). + * Set to false to preserve reasoning_content during tool call sequences. + * @returns Array of OpenAI messages where consecutive messages with the same role are combined, + * reasoning_content is conditionally cleared, tool_calls are preserved, and tool_result blocks are converted to tool messages */ -export function convertToR1Format(messages: AnthropicMessage[]): Message[] { - return messages.reduce((merged, message) => { +export function convertToR1Format(messages: AnthropicMessage[], clearReasoningContent: boolean = true): Message[] { + // Conditionally clear reasoning_content from assistant messages before conversion + // - If clearReasoningContent is true (new turn): clear reasoning_content + // - If clearReasoningContent is false (tool call continuation): preserve reasoning_content + const cleanedMessages = clearReasoningContent ? clearReasoningContentFromMessages(messages) : messages + + return cleanedMessages.reduce((merged, message) => { const lastMessage = merged[merged.length - 1] + + // Handle user messages with tool_result blocks - convert them to tool role messages + if (message.role === "user" && Array.isArray(message.content)) { + const { nonToolMessages, toolMessages } = message.content.reduce<{ + nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] + toolMessages: Anthropic.ToolResultBlockParam[] + }>( + (acc, part) => { + if (part.type === "tool_result") { + acc.toolMessages.push(part) + } else if (part.type === "text" || part.type === "image") { + acc.nonToolMessages.push(part) + } + return acc + }, + { nonToolMessages: [], toolMessages: [] }, + ) + + // Process tool result messages FIRST - they must follow tool_calls messages + // Convert each tool_result to a separate tool role message + toolMessages.forEach((toolMessage) => { + let content: string + if (typeof toolMessage.content === "string") { + content = toolMessage.content + } else { + // Convert array of content blocks to string (similar to openai-format.ts) + content = + toolMessage.content + ?.map((part) => { + if (part.type === "image") { + return "(see following user message for image)" + } + return part.text + }) + .join("\n") ?? "" + } + merged.push({ + role: "tool", + tool_call_id: toolMessage.tool_use_id, + content: content, + }) + }) + + // Process remaining non-tool content as user message (if any) + if (nonToolMessages.length > 0) { + let messageContent: string | (ContentPartText | ContentPartImage)[] = "" + let hasImages = false + + nonToolMessages.forEach((part) => { + if (part.type === "text") { + // Will be handled below + } else if (part.type === "image") { + hasImages = true + } + }) + + const textParts = nonToolMessages + .filter((p) => p.type === "text") + .map((p) => (p as Anthropic.TextBlockParam).text) + const imageParts: ContentPartImage[] = nonToolMessages + .filter((p) => p.type === "image") + .map((part) => ({ + type: "image_url" as const, + image_url: { + url: `data:${(part as Anthropic.Messages.ImageBlockParam).source.media_type};base64,${ + (part as Anthropic.Messages.ImageBlockParam).source.data + }`, + }, + })) + + if (hasImages) { + const parts: (ContentPartText | ContentPartImage)[] = [] + if (textParts.length > 0) { + parts.push({ type: "text", text: textParts.join("\n") }) + } + parts.push(...imageParts) + messageContent = parts + } else { + messageContent = textParts.join("\n") + } + + // Merge with last message if it's also a user message + if (lastMessage?.role === "user") { + if (typeof lastMessage.content === "string" && typeof messageContent === "string") { + lastMessage.content += `\n${messageContent}` + } else { + const lastContent = Array.isArray(lastMessage.content) + ? lastMessage.content + : [{ type: "text" as const, text: lastMessage.content || "" }] + + const newContent = Array.isArray(messageContent) + ? messageContent + : [{ type: "text" as const, text: messageContent }] + + lastMessage.content = [...lastContent, ...newContent] as UserMessage["content"] + } + } else { + // Add as new user message + merged.push({ + role: "user", + content: messageContent as UserMessage["content"], + }) + } + } + // If no non-tool content, we've already added tool messages, so we're done + return merged + } + + // Handle assistant messages let messageContent: string | (ContentPartText | ContentPartImage)[] = "" let hasImages = false + let toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] | undefined = undefined + + // Extract reasoning_content if present (for assistant messages during tool call sequences) + const messageWithReasoning = message as any + const reasoningContent = + message.role === "assistant" && "reasoning_content" in messageWithReasoning + ? messageWithReasoning.reasoning_content + : undefined // Convert content to appropriate format if (Array.isArray(message.content)) { const textParts: string[] = [] const imageParts: ContentPartImage[] = [] + const toolUseBlocks: Anthropic.Messages.ToolUseBlockParam[] = [] message.content.forEach((part) => { if (part.type === "text") { textParts.push(part.text) - } - if (part.type === "image") { + } else if (part.type === "image") { hasImages = true imageParts.push({ type: "image_url", image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, }) + } else if (part.type === "tool_use") { + // Extract tool_use blocks for assistant messages + toolUseBlocks.push(part) } }) + // Convert tool_use blocks to OpenAI tool_calls format (for assistant messages) + if (message.role === "assistant" && toolUseBlocks.length > 0) { + toolCalls = toolUseBlocks.map((toolUse) => ({ + id: toolUse.id, + type: "function" as const, + function: { + name: toolUse.name, + arguments: JSON.stringify(toolUse.input), + }, + })) + } + if (hasImages) { const parts: (ContentPartText | ContentPartImage)[] = [] if (textParts.length > 0) { @@ -71,6 +223,17 @@ export function convertToR1Format(messages: AnthropicMessage[]): Message[] { if (message.role === "assistant") { const mergedContent = [...lastContent, ...newContent] as AssistantMessage["content"] lastMessage.content = mergedContent + // Preserve reasoning_content if present (for tool call sequences) + // Note: When merging, we keep the reasoning_content from the last message + // This is correct because in tool call sequences, we want the most recent reasoning + if (reasoningContent !== undefined) { + ;(lastMessage as any).reasoning_content = reasoningContent + } + // Preserve tool_calls if present (merge with existing tool_calls if any) + if (toolCalls && toolCalls.length > 0) { + const existingToolCalls = (lastMessage as any).tool_calls || [] + ;(lastMessage as any).tool_calls = [...existingToolCalls, ...toolCalls] + } } else { const mergedContent = [...lastContent, ...newContent] as UserMessage["content"] lastMessage.content = mergedContent @@ -83,6 +246,14 @@ export function convertToR1Format(messages: AnthropicMessage[]): Message[] { role: "assistant", content: messageContent as AssistantMessage["content"], } + // Preserve reasoning_content if present (for tool call sequences) + if (reasoningContent !== undefined) { + ;(newMessage as any).reasoning_content = reasoningContent + } + // Preserve tool_calls if present + if (toolCalls && toolCalls.length > 0) { + ;(newMessage as any).tool_calls = toolCalls + } merged.push(newMessage) } else { const newMessage: UserMessage = { diff --git a/src/core/task-persistence/apiMessages.ts b/src/core/task-persistence/apiMessages.ts index 9263115c60e..1da22a7e633 100644 --- a/src/core/task-persistence/apiMessages.ts +++ b/src/core/task-persistence/apiMessages.ts @@ -20,6 +20,8 @@ export type ApiMessage = Anthropic.MessageParam & { text?: string // For OpenRouter reasoning_details array format (used by Gemini 3, etc.) reasoning_details?: any[] + // For interleaved thinking models (e.g., DeepSeek reasoner): reasoning_content field + reasoning_content?: string // For non-destructive condense: unique identifier for summary messages condenseId?: string // For non-destructive condense: points to the condenseId of the summary that replaces this message diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d084bf4b924..2b7b80117c0 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -705,6 +705,10 @@ export class Task extends EventEmitter implements TaskLike { const reasoningSummary = handler.getSummary?.() const reasoningDetails = handler.getReasoningDetails?.() + // Check if model supports interleaved thinking + const modelInfo = this.api.getModel().info + const supportsInterleavedThinking = modelInfo?.supportsInterleavedThinking === true + // Start from the original assistant message const messageWithTs: any = { ...message, @@ -717,9 +721,16 @@ export class Task extends EventEmitter implements TaskLike { messageWithTs.reasoning_details = reasoningDetails } + // Store reasoning_content as top-level field for interleaved thinking models + // (not converted to content blocks, as per interleaved thinking API format) + if (supportsInterleavedThinking && reasoning && !reasoningDetails) { + messageWithTs.reasoning_content = reasoning + } + // Store reasoning: plain text (most providers) or encrypted (OpenAI Native) // Skip if reasoning_details already contains the reasoning (to avoid duplication) - if (reasoning && !reasoningDetails) { + // Skip for interleaved thinking models (they use reasoning_content instead) + if (reasoning && !reasoningDetails && !supportsInterleavedThinking) { const reasoningBlock = { type: "reasoning", text: reasoning, @@ -3930,6 +3941,39 @@ export class Task extends EventEmitter implements TaskLike { continue } + // Check if this message has reasoning_content (interleaved thinking models like DeepSeek) + // reasoning_content is stored as a top-level field, not in content blocks + const msgWithReasoning = msg as any + if ("reasoning_content" in msgWithReasoning && typeof msgWithReasoning.reasoning_content === "string") { + // Build the assistant message with reasoning_content and tool_calls preserved + let assistantContent: Anthropic.Messages.MessageParam["content"] + + if (contentArray.length === 0) { + assistantContent = "" + } else if (contentArray.length === 1 && contentArray[0].type === "text") { + assistantContent = (contentArray[0] as Anthropic.Messages.TextBlockParam).text + } else { + assistantContent = contentArray + } + + // Create message with reasoning_content and tool_calls preserved + // Note: tool_calls may be in the message if it was stored from a tool call response + const assistantMessage: any = { + role: "assistant", + content: assistantContent, + reasoning_content: msgWithReasoning.reasoning_content, + } + + // Preserve tool_calls if present (for interleaved thinking tool call sequences) + if (msgWithReasoning.tool_calls) { + assistantMessage.tool_calls = msgWithReasoning.tool_calls + } + + cleanConversationHistory.push(assistantMessage) + + continue + } + // Embedded reasoning: encrypted (send) or plain text (skip) const hasEncryptedReasoning = first && (first as any).type === "reasoning" && typeof (first as any).encrypted_content === "string" diff --git a/src/core/task/__tests__/Task.reasoning-content.spec.ts b/src/core/task/__tests__/Task.reasoning-content.spec.ts new file mode 100644 index 00000000000..060444b9542 --- /dev/null +++ b/src/core/task/__tests__/Task.reasoning-content.spec.ts @@ -0,0 +1,469 @@ +import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest" +import type { ClineProvider } from "../../webview/ClineProvider" +import type { ProviderSettings } from "@roo-code/types" +import type { ApiMessage } from "../../task-persistence/apiMessages" + +// Mock vscode module before importing Task +vi.mock("vscode", () => ({ + workspace: { + createFileSystemWatcher: vi.fn(() => ({ + onDidCreate: vi.fn(), + onDidChange: vi.fn(), + onDidDelete: vi.fn(), + dispose: vi.fn(), + })), + getConfiguration: vi.fn(() => ({ + get: vi.fn(() => true), + })), + openTextDocument: vi.fn(), + applyEdit: vi.fn(), + }, + RelativePattern: vi.fn((base, pattern) => ({ base, pattern })), + window: { + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + dispose: vi.fn(), + })), + createTextEditorDecorationType: vi.fn(() => ({ + dispose: vi.fn(), + })), + showTextDocument: vi.fn(), + activeTextEditor: undefined, + }, + Uri: { + file: vi.fn((path) => ({ fsPath: path })), + parse: vi.fn((str) => ({ toString: () => str })), + }, + Range: vi.fn(), + Position: vi.fn(), + WorkspaceEdit: vi.fn(() => ({ + replace: vi.fn(), + insert: vi.fn(), + delete: vi.fn(), + })), + ViewColumn: { + One: 1, + Two: 2, + Three: 3, + }, +})) + +// Mock other dependencies +vi.mock("../../services/mcp/McpServerManager", () => ({ + McpServerManager: { + getInstance: vi.fn().mockResolvedValue(null), + }, +})) + +vi.mock("../../integrations/terminal/TerminalRegistry", () => ({ + TerminalRegistry: { + releaseTerminalsForTask: vi.fn(), + }, +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureTaskCreated: vi.fn(), + captureTaskRestarted: vi.fn(), + captureConversationMessage: vi.fn(), + captureLlmCompletion: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + }, + }, +})) + +// Mock task persistence +vi.mock("../../task-persistence/apiMessages", async () => { + const actual = await vi.importActual("../../task-persistence/apiMessages") + return { + ...actual, + saveApiMessages: vi.fn().mockResolvedValue(undefined), + readApiMessages: vi.fn().mockResolvedValue([]), + } +}) + +describe("Task reasoning_content persistence (interleaved thinking mode)", () => { + let mockProvider: Partial + let mockApiConfiguration: ProviderSettings + let Task: any + + beforeAll(async () => { + // Import Task after mocks are set up + const taskModule = await import("../Task") + Task = taskModule.Task + }) + + beforeEach(() => { + // Mock provider with necessary methods + mockProvider = { + postStateToWebview: vi.fn().mockResolvedValue(undefined), + getState: vi.fn().mockResolvedValue({ + mode: "code", + experiments: {}, + }), + context: { + globalStorageUri: { fsPath: "/test/storage" }, + extensionPath: "/test/extension", + } as any, + log: vi.fn(), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + } + + mockApiConfiguration = { + apiProvider: "openai", + apiKey: "test-key", + openAiModelId: "deepseek-reasoner", // Interleaved thinking model + } as ProviderSettings + }) + + it("should save reasoning_content as top-level field for interleaved thinking models", async () => { + const task = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "Test task", + startTask: false, + }) + + // Avoid disk writes in this test + ;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined) + + // Mock API handler with interleaved thinking capability + task.api = { + getModel: vi.fn().mockReturnValue({ + id: "deepseek-reasoner", + info: { + contextWindow: 65536, + supportsPromptCache: false, + supportsInterleavedThinking: true, + }, + }), + } as any + + const reasoningContent = "Let me think about this step by step. First, I need to analyze the problem..." + const assistantContent = "Here is my response to your question." + + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: assistantContent }], + }, + reasoningContent, + ) + + expect(task.apiConversationHistory).toHaveLength(1) + const stored = task.apiConversationHistory[0] as ApiMessage + + expect(stored.role).toBe("assistant") + // For interleaved thinking models, reasoning_content should be a top-level field + expect((stored as any).reasoning_content).toBe(reasoningContent) + // Content should NOT contain reasoning blocks + expect(Array.isArray(stored.content)).toBe(true) + expect(stored.content).toHaveLength(1) + expect(stored.content[0]).toMatchObject({ + type: "text", + text: assistantContent, + }) + }) + + it("should save reasoning_content with both content and tool calls", async () => { + const task = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "Test task", + startTask: false, + }) + + ;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined) + + task.api = { + getModel: vi.fn().mockReturnValue({ + id: "deepseek-reasoner", + info: { + contextWindow: 65536, + supportsPromptCache: false, + supportsInterleavedThinking: true, + }, + }), + } as any + + const reasoningContent = "I need to call a tool to get the current date." + const assistantContent = [ + { type: "text", text: "Let me check the date." }, + { + type: "tool_use", + id: "call_123", + name: "get_date", + input: {}, + }, + ] + + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: assistantContent, + }, + reasoningContent, + ) + + expect(task.apiConversationHistory).toHaveLength(1) + const stored = task.apiConversationHistory[0] as ApiMessage + + expect(stored.role).toBe("assistant") + expect((stored as any).reasoning_content).toBe(reasoningContent) + expect(Array.isArray(stored.content)).toBe(true) + expect(stored.content).toHaveLength(2) + expect(stored.content[0]).toMatchObject({ type: "text", text: "Let me check the date." }) + expect(stored.content[1]).toMatchObject({ + type: "tool_use", + id: "call_123", + name: "get_date", + }) + }) + + it("should save message with only reasoning_content (no content)", async () => { + const task = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "Test task", + startTask: false, + }) + + ;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined) + + task.api = { + getModel: vi.fn().mockReturnValue({ + id: "deepseek-reasoner", + info: { + contextWindow: 65536, + supportsPromptCache: false, + supportsInterleavedThinking: true, + }, + }), + } as any + + const reasoningContent = "I'm still thinking about this problem..." + + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [], + }, + reasoningContent, + ) + + expect(task.apiConversationHistory).toHaveLength(1) + const stored = task.apiConversationHistory[0] as ApiMessage + + expect(stored.role).toBe("assistant") + expect((stored as any).reasoning_content).toBe(reasoningContent) + expect(Array.isArray(stored.content)).toBe(true) + expect(stored.content).toHaveLength(0) + }) + + it("should NOT save reasoning_content for non-interleaved thinking models", async () => { + const nonInterleavedThinkingConfig = { + ...mockApiConfiguration, + openAiModelId: "gpt-4", + } as ProviderSettings + + const task = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: nonInterleavedThinkingConfig, + task: "Test task", + startTask: false, + }) + + ;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined) + + task.api = { + getModel: vi.fn().mockReturnValue({ + id: "gpt-4", + info: { + contextWindow: 8192, + supportsPromptCache: true, + supportsInterleavedThinking: false, + }, + }), + } as any + + const reasoningContent = "Let me think about this..." + const assistantContent = "Here is my response." + + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: assistantContent }], + }, + reasoningContent, + ) + + expect(task.apiConversationHistory).toHaveLength(1) + const stored = task.apiConversationHistory[0] as ApiMessage + + expect(stored.role).toBe("assistant") + // For non-interleaved thinking models, reasoning should be in content blocks, not as reasoning_content + expect((stored as any).reasoning_content).toBeUndefined() + expect(Array.isArray(stored.content)).toBe(true) + // Should have reasoning block + text block + expect(stored.content.length).toBeGreaterThanOrEqual(1) + // First block should be reasoning type + expect(stored.content[0]).toMatchObject({ + type: "reasoning", + text: reasoningContent, + }) + }) + + it("should handle empty reasoning_content gracefully", async () => { + const task = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "Test task", + startTask: false, + }) + + ;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined) + + task.api = { + getModel: vi.fn().mockReturnValue({ + id: "deepseek-reasoner", + info: { + contextWindow: 65536, + supportsPromptCache: false, + supportsInterleavedThinking: true, + }, + }), + } as any + + const assistantContent = "Here is my response." + + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: assistantContent }], + }, + undefined, // No reasoning + ) + + expect(task.apiConversationHistory).toHaveLength(1) + const stored = task.apiConversationHistory[0] as ApiMessage + + expect(stored.role).toBe("assistant") + expect((stored as any).reasoning_content).toBeUndefined() + expect(Array.isArray(stored.content)).toBe(true) + expect(stored.content).toHaveLength(1) + expect(stored.content[0]).toMatchObject({ + type: "text", + text: assistantContent, + }) + }) + + it("should preserve reasoning_content when message is restored from storage", async () => { + const { readApiMessages, saveApiMessages } = await import("../../task-persistence/apiMessages") + + // Create a task and save a message with reasoning_content + const task = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "Test task", + startTask: false, + }) + + task.api = { + getModel: vi.fn().mockReturnValue({ + id: "deepseek-reasoner", + info: { + contextWindow: 65536, + supportsPromptCache: false, + supportsInterleavedThinking: true, + }, + }), + } as any + + const reasoningContent = "This is the reasoning content that should be preserved." + const assistantContent = "Here is the final answer." + + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: assistantContent }], + }, + reasoningContent, + ) + + // Verify it was saved + expect(task.apiConversationHistory).toHaveLength(1) + const saved = task.apiConversationHistory[0] as ApiMessage + expect((saved as any).reasoning_content).toBe(reasoningContent) + + // Simulate restoration: read the saved messages + const savedMessages = task.apiConversationHistory + vi.mocked(readApiMessages).mockResolvedValue(savedMessages as ApiMessage[]) + + // Create a new task instance and restore + const restoredTask = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "Test task", + startTask: false, + }) + + const restoredHistory = await (restoredTask as any).getSavedApiConversationHistory() + + expect(restoredHistory).toHaveLength(1) + const restored = restoredHistory[0] as ApiMessage + + // Verify reasoning_content is preserved + expect((restored as any).reasoning_content).toBe(reasoningContent) + expect(restored.role).toBe("assistant") + expect(Array.isArray(restored.content)).toBe(true) + expect(restored.content[0]).toMatchObject({ + type: "text", + text: assistantContent, + }) + }) + + it("should work with reasoning_details (should not save reasoning_content if reasoning_details exists)", async () => { + const task = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "Test task", + startTask: false, + }) + + ;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined) + + // Mock API handler with reasoning_details (e.g., Gemini 3) + task.api = { + getModel: vi.fn().mockReturnValue({ + id: "deepseek-reasoner", + info: { + contextWindow: 65536, + supportsPromptCache: false, + supportsInterleavedThinking: true, + }, + }), + getReasoningDetails: vi + .fn() + .mockReturnValue([{ type: "reasoning", text: "Reasoning from reasoning_details" }]), + } as any + + const reasoningContent = "This should be ignored because reasoning_details exists" + + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: "Response" }], + }, + reasoningContent, + ) + + expect(task.apiConversationHistory).toHaveLength(1) + const stored = task.apiConversationHistory[0] as ApiMessage + + // Should have reasoning_details, not reasoning_content + expect(stored.reasoning_details).toBeDefined() + expect((stored as any).reasoning_content).toBeUndefined() + }) +}) diff --git a/src/core/task/__tests__/Task.resume-thinking-mode.spec.ts b/src/core/task/__tests__/Task.resume-thinking-mode.spec.ts new file mode 100644 index 00000000000..c8a472c6096 --- /dev/null +++ b/src/core/task/__tests__/Task.resume-thinking-mode.spec.ts @@ -0,0 +1,390 @@ +import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest" +import type { ClineProvider } from "../../webview/ClineProvider" +import type { ProviderSettings } from "@roo-code/types" +import type { ApiMessage } from "../../task-persistence/apiMessages" +import * as apiMessages from "../../task-persistence/apiMessages" + +// Mock vscode module before importing Task +vi.mock("vscode", () => ({ + workspace: { + createFileSystemWatcher: vi.fn(() => ({ + onDidCreate: vi.fn(), + onDidChange: vi.fn(), + onDidDelete: vi.fn(), + dispose: vi.fn(), + })), + getConfiguration: vi.fn(() => ({ + get: vi.fn(() => true), + })), + openTextDocument: vi.fn(), + applyEdit: vi.fn(), + }, + RelativePattern: vi.fn((base, pattern) => ({ base, pattern })), + window: { + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + dispose: vi.fn(), + })), + createTextEditorDecorationType: vi.fn(() => ({ + dispose: vi.fn(), + })), + showTextDocument: vi.fn(), + activeTextEditor: undefined, + }, + Uri: { + file: vi.fn((path) => ({ fsPath: path })), + parse: vi.fn((str) => ({ toString: () => str })), + }, + Range: vi.fn(), + Position: vi.fn(), + WorkspaceEdit: vi.fn(() => ({ + replace: vi.fn(), + insert: vi.fn(), + delete: vi.fn(), + })), + ViewColumn: { + One: 1, + Two: 2, + Three: 3, + }, +})) + +// Mock other dependencies +vi.mock("../../services/mcp/McpServerManager", () => ({ + McpServerManager: { + getInstance: vi.fn().mockResolvedValue(null), + }, +})) + +vi.mock("../../integrations/terminal/TerminalRegistry", () => ({ + TerminalRegistry: { + releaseTerminalsForTask: vi.fn(), + }, +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureTaskCreated: vi.fn(), + captureTaskRestarted: vi.fn(), + captureConversationMessage: vi.fn(), + captureLlmCompletion: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + }, + }, +})) + +describe("Task resumption with interleaved thinking mode", () => { + let mockProvider: Partial + let mockApiConfiguration: ProviderSettings + let Task: any + let savedMessages: ApiMessage[] = [] + + beforeAll(async () => { + // Import Task after mocks are set up + const taskModule = await import("../Task") + Task = taskModule.Task + + // Mock saveApiMessages to capture saved messages + vi.spyOn(apiMessages, "saveApiMessages").mockImplementation(async ({ messages }) => { + savedMessages = [...messages] + }) + + // Mock readApiMessages to return saved messages + vi.spyOn(apiMessages, "readApiMessages").mockImplementation(async () => { + return [...savedMessages] + }) + }) + + beforeEach(() => { + // Reset saved messages + savedMessages = [] + + // Mock provider with necessary methods + mockProvider = { + postStateToWebview: vi.fn().mockResolvedValue(undefined), + getState: vi.fn().mockResolvedValue({ + mode: "code", + experiments: {}, + }), + context: { + globalStorageUri: { fsPath: "/test/storage" }, + extensionPath: "/test/extension", + } as any, + log: vi.fn(), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + } + + mockApiConfiguration = { + apiProvider: "openai", + apiKey: "test-key", + openAiModelId: "deepseek-reasoner", + } as ProviderSettings + }) + + it("should save and restore reasoning_content during task save/restore cycle", async () => { + // Create initial task + const task = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "Test task", + startTask: false, + }) + + task.api = { + getModel: vi.fn().mockReturnValue({ + id: "deepseek-reasoner", + info: { + contextWindow: 65536, + supportsPromptCache: false, + supportsInterleavedThinking: true, + }, + }), + } as any + + // Save a message with reasoning_content + const reasoningContent = "I need to analyze this problem carefully..." + const assistantContent = "Here is my answer." + + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: assistantContent }], + }, + reasoningContent, + ) + + // Verify it was saved + expect(savedMessages).toHaveLength(1) + const saved = savedMessages[0] as ApiMessage + expect((saved as any).reasoning_content).toBe(reasoningContent) + + // Simulate task restoration + const restoredTask = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "Test task", + startTask: false, + }) + + restoredTask.api = { + getModel: vi.fn().mockReturnValue({ + id: "deepseek-reasoner", + info: { + contextWindow: 65536, + supportsPromptCache: false, + supportsInterleavedThinking: true, + }, + }), + } as any + + // Restore conversation history + const restoredHistory = await (restoredTask as any).getSavedApiConversationHistory() + + expect(restoredHistory).toHaveLength(1) + const restored = restoredHistory[0] as ApiMessage + + // Verify reasoning_content is preserved + expect((restored as any).reasoning_content).toBe(reasoningContent) + expect(restored.role).toBe("assistant") + expect(Array.isArray(restored.content)).toBe(true) + expect(restored.content[0]).toMatchObject({ + type: "text", + text: assistantContent, + }) + }) + + it("should preserve reasoning_content during tool call sequences across save/restore", async () => { + const task = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "Test task", + startTask: false, + }) + + task.api = { + getModel: vi.fn().mockReturnValue({ + id: "deepseek-reasoner", + info: { + contextWindow: 65536, + supportsPromptCache: false, + supportsInterleavedThinking: true, + }, + }), + } as any + + // Simulate tool call sequence: first call with reasoning_content + const reasoning1 = "I need to call get_date to get the current date." + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call_1", + name: "get_date", + input: {}, + }, + ], + }, + reasoning1, + ) + + // Simulate tool result + await (task as any).addToApiConversationHistory({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_1", + content: "2025-12-01", + }, + ], + }) + + // Simulate second tool call with reasoning_content (continuation) + const reasoning2 = "Now I have the date, I can call get_weather." + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call_2", + name: "get_weather", + input: { location: "Hangzhou", date: "2025-12-02" }, + }, + ], + }, + reasoning2, + ) + + // Verify all messages were saved + expect(savedMessages).toHaveLength(3) + + // First assistant message should have reasoning_content + const msg1 = savedMessages[0] as ApiMessage + expect(msg1.role).toBe("assistant") + expect((msg1 as any).reasoning_content).toBe(reasoning1) + + // Second assistant message should have reasoning_content + const msg3 = savedMessages[2] as ApiMessage + expect(msg3.role).toBe("assistant") + expect((msg3 as any).reasoning_content).toBe(reasoning2) + + // Restore and verify + const restoredTask = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "Test task", + startTask: false, + }) + + restoredTask.api = { + getModel: vi.fn().mockReturnValue({ + id: "deepseek-reasoner", + info: { + contextWindow: 65536, + supportsPromptCache: false, + supportsInterleavedThinking: true, + }, + }), + } as any + + const restoredHistory = await (restoredTask as any).getSavedApiConversationHistory() + + expect(restoredHistory).toHaveLength(3) + expect((restoredHistory[0] as any).reasoning_content).toBe(reasoning1) + expect((restoredHistory[2] as any).reasoning_content).toBe(reasoning2) + }) + + it("should handle multi-turn conversations with reasoning_content clearing", async () => { + const task = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "Test task", + startTask: false, + }) + + task.api = { + getModel: vi.fn().mockReturnValue({ + id: "deepseek-reasoner", + info: { + contextWindow: 65536, + supportsPromptCache: false, + supportsInterleavedThinking: true, + }, + }), + } as any + + // Turn 1: User message + await (task as any).addToApiConversationHistory({ + role: "user", + content: "What is 2+2?", + }) + + // Turn 1: Assistant response with reasoning_content + const reasoning1 = "I need to add 2 and 2 together." + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: "The answer is 4." }], + }, + reasoning1, + ) + + // Turn 2: New user message (reasoning_content from turn 1 should be cleared when sending to API) + // But it should still be in storage for this test + await (task as any).addToApiConversationHistory({ + role: "user", + content: "What about 3+3?", + }) + + // Turn 2: Assistant response with new reasoning_content + const reasoning2 = "I need to add 3 and 3 together." + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: "The answer is 6." }], + }, + reasoning2, + ) + + // Verify all messages were saved with reasoning_content + expect(savedMessages).toHaveLength(4) + const turn1Assistant = savedMessages[1] as ApiMessage + const turn2Assistant = savedMessages[3] as ApiMessage + + expect((turn1Assistant as any).reasoning_content).toBe(reasoning1) + expect((turn2Assistant as any).reasoning_content).toBe(reasoning2) + + // Restore and verify both reasoning_content values are preserved + const restoredTask = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "Test task", + startTask: false, + }) + + restoredTask.api = { + getModel: vi.fn().mockReturnValue({ + id: "deepseek-reasoner", + info: { + contextWindow: 65536, + supportsPromptCache: false, + supportsInterleavedThinking: true, + }, + }), + } as any + + const restoredHistory = await (restoredTask as any).getSavedApiConversationHistory() + + expect(restoredHistory).toHaveLength(4) + expect((restoredHistory[1] as any).reasoning_content).toBe(reasoning1) + expect((restoredHistory[3] as any).reasoning_content).toBe(reasoning2) + + // Note: The clearing logic will clear reasoning_content when + // sending to API for new turns, but it should remain in storage + }) +}) diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts index 7a73d2b1d07..551f9243511 100644 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ b/src/core/task/__tests__/reasoning-preservation.test.ts @@ -344,6 +344,14 @@ describe("Task reasoning preservation", () => { id: "rs_test", }), getResponseId: vi.fn().mockReturnValue("resp_test"), + getModel: vi.fn().mockReturnValue({ + id: "test-model", + info: { + contextWindow: 16000, + supportsPromptCache: true, + // supportsInterleavedThinking is undefined (not an interleaved thinking model) + }, + }), } as any await (task as any).addToApiConversationHistory({ diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx index ad338d342ab..48e9d7c4fdd 100644 --- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx @@ -420,6 +420,35 @@ export const OpenAICompatible = ({ +
+
+ { + return { + ...(apiConfiguration?.openAiCustomModelInfo || openAiModelInfoSaneDefaults), + supportsInterleavedThinking: checked, + } + })}> + + {t("settings:providers.customModel.interleavedThinking.label")} + + + + + +
+
+ {t("settings:providers.customModel.interleavedThinking.description")} +
+
+