diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts index 1a4c7f6518..90e34ba1a3 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -112,6 +112,26 @@ describe("convertToOpenAiMessages", () => { }) }) + it("preserves assistant reasoning_content for OpenAI-compatible replay", () => { + const anthropicMessages = [ + { + role: "assistant", + content: "First answer", + reasoning_content: "First reasoning", + }, + { + role: "assistant", + content: [{ type: "text", text: "Second answer" }], + reasoning_content: "Second reasoning", + }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect((openAiMessages[0] as any).reasoning_content).toBe("First reasoning") + expect((openAiMessages[1] as any).reasoning_content).toBe("Second reasoning") + }) + it("should handle user messages with tool results (no normalization without normalizeToolCallId)", () => { const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 8974dd599b..f2932c83bf 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -307,7 +307,10 @@ export function convertToOpenAiMessages( // If a message also contains reasoning_details (Gemini 3 / xAI / o-series, etc.), // we must preserve it here as well. const messageWithDetails = anthropicMessage as any - const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & { reasoning_details?: any[] } = { + const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & { + reasoning_details?: any[] + reasoning_content?: string + } = { role: anthropicMessage.role, content: anthropicMessage.content, } @@ -317,6 +320,13 @@ export function convertToOpenAiMessages( if (mapped) { ;(baseMessage as any).reasoning_details = mapped } + + if ( + typeof messageWithDetails.reasoning_content === "string" && + messageWithDetails.reasoning_content.trim().length > 0 + ) { + baseMessage.reasoning_content = messageWithDetails.reasoning_content + } } openAiMessages.push(baseMessage) @@ -480,6 +490,7 @@ export function convertToOpenAiMessages( // when sending messages back to some APIs. const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & { reasoning_details?: any[] + reasoning_content?: string } = { role: "assistant", // Use empty string instead of undefined for providers like Gemini (via OpenRouter) @@ -494,6 +505,13 @@ export function convertToOpenAiMessages( baseMessage.reasoning_details = mapped } + if ( + typeof messageWithDetails.reasoning_content === "string" && + messageWithDetails.reasoning_content.trim().length > 0 + ) { + baseMessage.reasoning_content = messageWithDetails.reasoning_content + } + // Add tool_calls after reasoning_details // Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty if (tool_calls.length > 0) { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fcdfd0263d..e6dc3aefd4 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4355,6 +4355,17 @@ export class Task extends EventEmitter implements TaskLike { const cleanConversationHistory: (Anthropic.Messages.MessageParam | ReasoningItemForRequest)[] = [] for (const msg of messages) { + const preservedReasoningContent = + msg.role === "assistant" && + typeof (msg as ApiMessage).reasoning_content === "string" && + (msg as ApiMessage).reasoning_content!.trim().length > 0 + ? (msg as ApiMessage).reasoning_content + : undefined + const shouldReplayReasoningContent = + msg.role === "assistant" && + this.api.getModel().info.preserveReasoning === true && + !!preservedReasoningContent + // Standalone reasoning: send encrypted, skip plain text if (msg.type === "reasoning") { if (msg.encrypted_content) { @@ -4442,29 +4453,27 @@ export class Task extends EventEmitter implements TaskLike { continue } else if (hasPlainTextReasoning) { // Check if the model's preserveReasoning flag is set - // If true, include the reasoning block in API requests - // If false/undefined, strip it out (stored for history only, not sent back to API) - const shouldPreserveForApi = this.api.getModel().info.preserveReasoning === true + // Replay preserved reasoning_content only when this exact message was + // stored with an explicit reasoning_content payload. This avoids + // converting unrelated reasoning blocks from other models into + // DeepSeek/Z.ai/MiMo continuation history during mixed-model tasks. let assistantContent: Anthropic.Messages.MessageParam["content"] - if (shouldPreserveForApi) { - // Include reasoning block in the content sent to API - assistantContent = contentArray + if (rest.length === 0) { + assistantContent = "" + } else if (rest.length === 1 && rest[0].type === "text") { + assistantContent = (rest[0] as Anthropic.Messages.TextBlockParam).text } else { - // Strip reasoning out - stored for history only, not sent back to API - if (rest.length === 0) { - assistantContent = "" - } else if (rest.length === 1 && rest[0].type === "text") { - assistantContent = (rest[0] as Anthropic.Messages.TextBlockParam).text - } else { - assistantContent = rest - } + assistantContent = rest } - cleanConversationHistory.push({ + const assistantMessage: Anthropic.Messages.MessageParam & { reasoning_content?: string } = { role: "assistant", content: assistantContent, - } satisfies Anthropic.Messages.MessageParam) + ...(shouldReplayReasoningContent ? { reasoning_content: preservedReasoningContent } : {}), + } + + cleanConversationHistory.push(assistantMessage) continue } @@ -4472,10 +4481,14 @@ export class Task extends EventEmitter implements TaskLike { // Default path for regular messages (no embedded reasoning) if (msg.role) { - cleanConversationHistory.push({ + const messageForRequest: Anthropic.Messages.MessageParam & { reasoning_content?: string } = { role: msg.role, content: msg.content as Anthropic.Messages.ContentBlockParam[] | string, - }) + ...(msg.role === "assistant" && shouldReplayReasoningContent + ? { reasoning_content: preservedReasoningContent } + : {}), + } + cleanConversationHistory.push(messageForRequest) } } diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index e73638d8ad..c0972131ee 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -416,6 +416,54 @@ describe("Task persistence", () => { }) }) + describe("buildCleanConversationHistory", () => { + it("replays reasoning_content only for assistant turns that were explicitly marked for it", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + task.api = { + getModel: vi.fn().mockReturnValue({ + id: "deepseek-v4-pro", + info: { preserveReasoning: true }, + }), + } as any + + const result = (task as any).buildCleanConversationHistory([ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Codex reasoning", summary: [] }, + { type: "text", text: "Codex answer" }, + ], + }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "DeepSeek reasoning", summary: [] }, + { type: "text", text: "DeepSeek answer" }, + ], + reasoning_content: "DeepSeek reasoning", + }, + ]) + + expect(result).toEqual([ + { + role: "assistant", + content: "Codex answer", + }, + { + role: "assistant", + content: "DeepSeek answer", + reasoning_content: "DeepSeek reasoning", + }, + ]) + }) + }) + // ── flushPendingToolResultsToHistory — save failure/success ─────────── describe("flushPendingToolResultsToHistory persistence", () => { diff --git a/src/core/task/__tests__/apiConversationHistory.spec.ts b/src/core/task/__tests__/apiConversationHistory.spec.ts index 76a3ac69fd..42d6919cc9 100644 --- a/src/core/task/__tests__/apiConversationHistory.spec.ts +++ b/src/core/task/__tests__/apiConversationHistory.spec.ts @@ -39,7 +39,9 @@ describe("prepareApiConversationMessage", () => { const result = prepareApiConversationMessage({ message: { role: "assistant", content: "answer" }, reasoning: "visible reasoning", - api: {} as any, + api: { + getModel: () => ({ info: {} }), + } as any, apiConfiguration: { apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" } as any, apiConversationHistory: [], }) as any @@ -50,11 +52,31 @@ describe("prepareApiConversationMessage", () => { ]) }) + it("stores reasoning_content for OpenAI-format models that need reasoning replay", () => { + const result = prepareApiConversationMessage({ + message: { role: "assistant", content: "answer" }, + reasoning: "visible reasoning", + api: { + getModel: () => ({ info: { preserveReasoning: true } }), + } as any, + apiConfiguration: { apiProvider: "deepseek", apiModelId: "deepseek-v4-pro" } as any, + apiConversationHistory: [], + }) as any + + expect(result.reasoning_content).toBe("visible reasoning") + expect(result.content).toEqual([ + { type: "reasoning", text: "visible reasoning", summary: [] }, + { type: "text", text: "answer" }, + ]) + }) + it("falls back to generic reasoning blocks for Anthropic messages without thought signatures", () => { const result = prepareApiConversationMessage({ message: { role: "assistant", content: "answer" }, reasoning: "private reasoning", - api: {} as any, + api: { + getModel: () => ({ info: {} }), + } as any, apiConfiguration: { apiProvider: "anthropic", apiModelId: "claude-3-5-sonnet" } as any, apiConversationHistory: [], }) as any @@ -70,6 +92,7 @@ describe("prepareApiConversationMessage", () => { const result = prepareApiConversationMessage({ message: { role: "assistant", content: [{ type: "text", text: "answer" }] }, api: { + getModel: () => ({ info: {} }), getEncryptedContent: () => ({ encrypted_content: "encrypted", id: "reasoning-1" }), } as any, apiConfiguration: { apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" } as any, @@ -86,6 +109,7 @@ describe("prepareApiConversationMessage", () => { const result = prepareApiConversationMessage({ message: { role: "assistant", content: "answer" }, api: { + getModel: () => ({ info: {} }), getThoughtSignature: () => "signature-1", getReasoningDetails: () => [{ type: "reasoning", text: "detail" }], } as any, diff --git a/src/core/task/apiConversationHistory.ts b/src/core/task/apiConversationHistory.ts index b0b9959f47..f6bdc725b5 100644 --- a/src/core/task/apiConversationHistory.ts +++ b/src/core/task/apiConversationHistory.ts @@ -54,10 +54,13 @@ function prepareAssistantMessage( modelId, ) const isAnthropicProtocol = apiProtocol === "anthropic" + const shouldPersistReasoningContent = + apiProtocol === "openai" && reasoning && !reasoningDetails && handler.getModel().info.preserveReasoning === true const messageWithTs: any = { ...message, ...(responseId ? { id: responseId } : {}), + ...(shouldPersistReasoningContent ? { reasoning_content: reasoning } : {}), ts: Date.now(), }