Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/api/transform/__tests__/openai-format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
{
Expand Down
20 changes: 19 additions & 1 deletion src/api/transform/openai-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
49 changes: 31 additions & 18 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4355,6 +4355,17 @@ export class Task extends EventEmitter<TaskEvents> 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) {
Expand Down Expand Up @@ -4442,40 +4453,42 @@ export class Task extends EventEmitter<TaskEvents> 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 } : {}),
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepareApiConversationMessage() only stores top-level reasoning_content for apiProtocol === "openai" (src/core/task/apiConversationHistory.ts). Anthropic-style thinking providers in this repo still set info.preserveReasoning (for example MiniMax and the Bedrock Kimi/MiniMax models) but persist their reasoning as embedded blocks, so this branch now strips the reasoning those continuations need.

}

cleanConversationHistory.push(assistantMessage)

continue
}
}

// 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)
}
}

Expand Down
48 changes: 48 additions & 0 deletions src/core/task/__tests__/Task.persistence.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
28 changes: 26 additions & 2 deletions src/core/task/__tests__/apiConversationHistory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/core/task/apiConversationHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}

Expand Down
Loading