Skip to content

Commit eed0aaf

Browse files
committed
fix: preserve reasoning_content in mixed-model DeepSeek history
1 parent b5c5e21 commit eed0aaf

6 files changed

Lines changed: 147 additions & 21 deletions

File tree

src/api/transform/__tests__/openai-format.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,26 @@ describe("convertToOpenAiMessages", () => {
112112
})
113113
})
114114

115+
it("preserves assistant reasoning_content for OpenAI-compatible replay", () => {
116+
const anthropicMessages = [
117+
{
118+
role: "assistant",
119+
content: "First answer",
120+
reasoning_content: "First reasoning",
121+
},
122+
{
123+
role: "assistant",
124+
content: [{ type: "text", text: "Second answer" }],
125+
reasoning_content: "Second reasoning",
126+
},
127+
] as unknown as Anthropic.Messages.MessageParam[]
128+
129+
const openAiMessages = convertToOpenAiMessages(anthropicMessages)
130+
131+
expect((openAiMessages[0] as any).reasoning_content).toBe("First reasoning")
132+
expect((openAiMessages[1] as any).reasoning_content).toBe("Second reasoning")
133+
})
134+
115135
it("should handle user messages with tool results (no normalization without normalizeToolCallId)", () => {
116136
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
117137
{

src/api/transform/openai-format.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,10 @@ export function convertToOpenAiMessages(
307307
// If a message also contains reasoning_details (Gemini 3 / xAI / o-series, etc.),
308308
// we must preserve it here as well.
309309
const messageWithDetails = anthropicMessage as any
310-
const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & { reasoning_details?: any[] } = {
310+
const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & {
311+
reasoning_details?: any[]
312+
reasoning_content?: string
313+
} = {
311314
role: anthropicMessage.role,
312315
content: anthropicMessage.content,
313316
}
@@ -317,6 +320,13 @@ export function convertToOpenAiMessages(
317320
if (mapped) {
318321
;(baseMessage as any).reasoning_details = mapped
319322
}
323+
324+
if (
325+
typeof messageWithDetails.reasoning_content === "string" &&
326+
messageWithDetails.reasoning_content.trim().length > 0
327+
) {
328+
baseMessage.reasoning_content = messageWithDetails.reasoning_content
329+
}
320330
}
321331

322332
openAiMessages.push(baseMessage)
@@ -480,6 +490,7 @@ export function convertToOpenAiMessages(
480490
// when sending messages back to some APIs.
481491
const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & {
482492
reasoning_details?: any[]
493+
reasoning_content?: string
483494
} = {
484495
role: "assistant",
485496
// Use empty string instead of undefined for providers like Gemini (via OpenRouter)
@@ -494,6 +505,13 @@ export function convertToOpenAiMessages(
494505
baseMessage.reasoning_details = mapped
495506
}
496507

508+
if (
509+
typeof messageWithDetails.reasoning_content === "string" &&
510+
messageWithDetails.reasoning_content.trim().length > 0
511+
) {
512+
baseMessage.reasoning_content = messageWithDetails.reasoning_content
513+
}
514+
497515
// Add tool_calls after reasoning_details
498516
// Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty
499517
if (tool_calls.length > 0) {

src/core/task/Task.ts

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4355,6 +4355,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
43554355
const cleanConversationHistory: (Anthropic.Messages.MessageParam | ReasoningItemForRequest)[] = []
43564356

43574357
for (const msg of messages) {
4358+
const preservedReasoningContent =
4359+
msg.role === "assistant" &&
4360+
typeof (msg as ApiMessage).reasoning_content === "string" &&
4361+
(msg as ApiMessage).reasoning_content!.trim().length > 0
4362+
? (msg as ApiMessage).reasoning_content
4363+
: undefined
4364+
const shouldReplayReasoningContent =
4365+
msg.role === "assistant" &&
4366+
this.api.getModel().info.preserveReasoning === true &&
4367+
!!preservedReasoningContent
4368+
43584369
// Standalone reasoning: send encrypted, skip plain text
43594370
if (msg.type === "reasoning") {
43604371
if (msg.encrypted_content) {
@@ -4442,40 +4453,42 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
44424453
continue
44434454
} else if (hasPlainTextReasoning) {
44444455
// Check if the model's preserveReasoning flag is set
4445-
// If true, include the reasoning block in API requests
4446-
// If false/undefined, strip it out (stored for history only, not sent back to API)
4447-
const shouldPreserveForApi = this.api.getModel().info.preserveReasoning === true
4456+
// Replay preserved reasoning_content only when this exact message was
4457+
// stored with an explicit reasoning_content payload. This avoids
4458+
// converting unrelated reasoning blocks from other models into
4459+
// DeepSeek/Z.ai/MiMo continuation history during mixed-model tasks.
44484460
let assistantContent: Anthropic.Messages.MessageParam["content"]
44494461

4450-
if (shouldPreserveForApi) {
4451-
// Include reasoning block in the content sent to API
4452-
assistantContent = contentArray
4462+
if (rest.length === 0) {
4463+
assistantContent = ""
4464+
} else if (rest.length === 1 && rest[0].type === "text") {
4465+
assistantContent = (rest[0] as Anthropic.Messages.TextBlockParam).text
44534466
} else {
4454-
// Strip reasoning out - stored for history only, not sent back to API
4455-
if (rest.length === 0) {
4456-
assistantContent = ""
4457-
} else if (rest.length === 1 && rest[0].type === "text") {
4458-
assistantContent = (rest[0] as Anthropic.Messages.TextBlockParam).text
4459-
} else {
4460-
assistantContent = rest
4461-
}
4467+
assistantContent = rest
44624468
}
44634469

4464-
cleanConversationHistory.push({
4470+
const assistantMessage: Anthropic.Messages.MessageParam & { reasoning_content?: string } = {
44654471
role: "assistant",
44664472
content: assistantContent,
4467-
} satisfies Anthropic.Messages.MessageParam)
4473+
...(shouldReplayReasoningContent ? { reasoning_content: preservedReasoningContent } : {}),
4474+
}
4475+
4476+
cleanConversationHistory.push(assistantMessage)
44684477

44694478
continue
44704479
}
44714480
}
44724481

44734482
// Default path for regular messages (no embedded reasoning)
44744483
if (msg.role) {
4475-
cleanConversationHistory.push({
4484+
const messageForRequest: Anthropic.Messages.MessageParam & { reasoning_content?: string } = {
44764485
role: msg.role,
44774486
content: msg.content as Anthropic.Messages.ContentBlockParam[] | string,
4478-
})
4487+
...(msg.role === "assistant" && shouldReplayReasoningContent
4488+
? { reasoning_content: preservedReasoningContent }
4489+
: {}),
4490+
}
4491+
cleanConversationHistory.push(messageForRequest)
44794492
}
44804493
}
44814494

src/core/task/__tests__/Task.persistence.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,54 @@ describe("Task persistence", () => {
416416
})
417417
})
418418

419+
describe("buildCleanConversationHistory", () => {
420+
it("replays reasoning_content only for assistant turns that were explicitly marked for it", () => {
421+
const task = new Task({
422+
provider: mockProvider,
423+
apiConfiguration: mockApiConfig,
424+
task: "test task",
425+
startTask: false,
426+
})
427+
428+
task.api = {
429+
getModel: vi.fn().mockReturnValue({
430+
id: "deepseek-v4-pro",
431+
info: { preserveReasoning: true },
432+
}),
433+
} as any
434+
435+
const result = (task as any).buildCleanConversationHistory([
436+
{
437+
role: "assistant",
438+
content: [
439+
{ type: "reasoning", text: "Codex reasoning", summary: [] },
440+
{ type: "text", text: "Codex answer" },
441+
],
442+
},
443+
{
444+
role: "assistant",
445+
content: [
446+
{ type: "reasoning", text: "DeepSeek reasoning", summary: [] },
447+
{ type: "text", text: "DeepSeek answer" },
448+
],
449+
reasoning_content: "DeepSeek reasoning",
450+
},
451+
])
452+
453+
expect(result).toEqual([
454+
{
455+
role: "assistant",
456+
content: "Codex answer",
457+
},
458+
{
459+
role: "assistant",
460+
content: "DeepSeek answer",
461+
reasoning_content: "DeepSeek reasoning",
462+
},
463+
])
464+
})
465+
})
466+
419467
// ── flushPendingToolResultsToHistory — save failure/success ───────────
420468

421469
describe("flushPendingToolResultsToHistory persistence", () => {

src/core/task/__tests__/apiConversationHistory.spec.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ describe("prepareApiConversationMessage", () => {
3939
const result = prepareApiConversationMessage({
4040
message: { role: "assistant", content: "answer" },
4141
reasoning: "visible reasoning",
42-
api: {} as any,
42+
api: {
43+
getModel: () => ({ info: {} }),
44+
} as any,
4345
apiConfiguration: { apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" } as any,
4446
apiConversationHistory: [],
4547
}) as any
@@ -50,11 +52,31 @@ describe("prepareApiConversationMessage", () => {
5052
])
5153
})
5254

55+
it("stores reasoning_content for OpenAI-format models that need reasoning replay", () => {
56+
const result = prepareApiConversationMessage({
57+
message: { role: "assistant", content: "answer" },
58+
reasoning: "visible reasoning",
59+
api: {
60+
getModel: () => ({ info: { preserveReasoning: true } }),
61+
} as any,
62+
apiConfiguration: { apiProvider: "deepseek", apiModelId: "deepseek-v4-pro" } as any,
63+
apiConversationHistory: [],
64+
}) as any
65+
66+
expect(result.reasoning_content).toBe("visible reasoning")
67+
expect(result.content).toEqual([
68+
{ type: "reasoning", text: "visible reasoning", summary: [] },
69+
{ type: "text", text: "answer" },
70+
])
71+
})
72+
5373
it("falls back to generic reasoning blocks for Anthropic messages without thought signatures", () => {
5474
const result = prepareApiConversationMessage({
5575
message: { role: "assistant", content: "answer" },
5676
reasoning: "private reasoning",
57-
api: {} as any,
77+
api: {
78+
getModel: () => ({ info: {} }),
79+
} as any,
5880
apiConfiguration: { apiProvider: "anthropic", apiModelId: "claude-3-5-sonnet" } as any,
5981
apiConversationHistory: [],
6082
}) as any
@@ -70,6 +92,7 @@ describe("prepareApiConversationMessage", () => {
7092
const result = prepareApiConversationMessage({
7193
message: { role: "assistant", content: [{ type: "text", text: "answer" }] },
7294
api: {
95+
getModel: () => ({ info: {} }),
7396
getEncryptedContent: () => ({ encrypted_content: "encrypted", id: "reasoning-1" }),
7497
} as any,
7598
apiConfiguration: { apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" } as any,
@@ -86,6 +109,7 @@ describe("prepareApiConversationMessage", () => {
86109
const result = prepareApiConversationMessage({
87110
message: { role: "assistant", content: "answer" },
88111
api: {
112+
getModel: () => ({ info: {} }),
89113
getThoughtSignature: () => "signature-1",
90114
getReasoningDetails: () => [{ type: "reasoning", text: "detail" }],
91115
} as any,

src/core/task/apiConversationHistory.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,13 @@ function prepareAssistantMessage(
5454
modelId,
5555
)
5656
const isAnthropicProtocol = apiProtocol === "anthropic"
57+
const shouldPersistReasoningContent =
58+
apiProtocol === "openai" && reasoning && !reasoningDetails && handler.getModel().info.preserveReasoning === true
5759

5860
const messageWithTs: any = {
5961
...message,
6062
...(responseId ? { id: responseId } : {}),
63+
...(shouldPersistReasoningContent ? { reasoning_content: reasoning } : {}),
6164
ts: Date.now(),
6265
}
6366

0 commit comments

Comments
 (0)