Skip to content

Commit 89e9261

Browse files
fix: preserve reasoning_content in condense summary for DeepSeek-reasoner (#10292)
1 parent a8ac2ce commit 89e9261

File tree

4 files changed

+354
-30
lines changed

4 files changed

+354
-30
lines changed

src/core/condense/__tests__/condense.spec.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,19 @@ describe("Condense", () => {
8686
// Verify we have a summary message
8787
const summaryMessage = result.messages.find((msg) => msg.isSummary)
8888
expect(summaryMessage).toBeTruthy()
89-
expect(summaryMessage?.content).toBe("Mock summary of the conversation")
89+
// Summary content is now always an array with a synthetic reasoning block + text block
90+
// for DeepSeek-reasoner compatibility
91+
expect(Array.isArray(summaryMessage?.content)).toBe(true)
92+
const contentArray = summaryMessage?.content as Anthropic.Messages.ContentBlockParam[]
93+
expect(contentArray).toHaveLength(2)
94+
expect(contentArray[0]).toEqual({
95+
type: "reasoning",
96+
text: "Condensing conversation context. The summary below captures the key information from the prior conversation.",
97+
})
98+
expect(contentArray[1]).toEqual({
99+
type: "text",
100+
text: "Mock summary of the conversation",
101+
})
90102

91103
// With non-destructive condensing, all messages are retained (tagged but not deleted)
92104
// Use getEffectiveApiHistory to verify the effective view matches the old behavior

src/core/condense/__tests__/index.spec.ts

Lines changed: 256 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,94 @@ describe("getKeepMessagesWithToolBlocks", () => {
246246
expect(result.keepMessages).toEqual(messages)
247247
expect(result.toolUseBlocksToPreserve).toHaveLength(0)
248248
})
249+
250+
it("should preserve reasoning blocks alongside tool_use blocks for DeepSeek/Z.ai interleaved thinking", () => {
251+
const reasoningBlock = {
252+
type: "reasoning" as const,
253+
text: "Let me think about this step by step...",
254+
}
255+
const toolUseBlock = {
256+
type: "tool_use" as const,
257+
id: "toolu_deepseek_123",
258+
name: "read_file",
259+
input: { path: "test.txt" },
260+
}
261+
const toolResultBlock = {
262+
type: "tool_result" as const,
263+
tool_use_id: "toolu_deepseek_123",
264+
content: "file contents",
265+
}
266+
267+
const messages: ApiMessage[] = [
268+
{ role: "user", content: "Hello", ts: 1 },
269+
{ role: "assistant", content: "Let me help", ts: 2 },
270+
{ role: "user", content: "Please read the file", ts: 3 },
271+
{
272+
role: "assistant",
273+
// DeepSeek stores reasoning as content blocks alongside tool_use
274+
content: [reasoningBlock as any, { type: "text" as const, text: "Reading file..." }, toolUseBlock],
275+
ts: 4,
276+
},
277+
{
278+
role: "user",
279+
content: [toolResultBlock, { type: "text" as const, text: "Continue" }],
280+
ts: 5,
281+
},
282+
{ role: "assistant", content: "Got it, the file says...", ts: 6 },
283+
{ role: "user", content: "Thanks", ts: 7 },
284+
]
285+
286+
const result = getKeepMessagesWithToolBlocks(messages, 3)
287+
288+
// keepMessages should be the last 3 messages
289+
expect(result.keepMessages).toHaveLength(3)
290+
expect(result.keepMessages[0].ts).toBe(5)
291+
292+
// Should preserve the tool_use block
293+
expect(result.toolUseBlocksToPreserve).toHaveLength(1)
294+
expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock)
295+
296+
// Should preserve the reasoning block for DeepSeek/Z.ai interleaved thinking
297+
expect(result.reasoningBlocksToPreserve).toHaveLength(1)
298+
expect((result.reasoningBlocksToPreserve[0] as any).type).toBe("reasoning")
299+
expect((result.reasoningBlocksToPreserve[0] as any).text).toBe("Let me think about this step by step...")
300+
})
301+
302+
it("should return empty reasoningBlocksToPreserve when no reasoning blocks present", () => {
303+
const toolUseBlock = {
304+
type: "tool_use" as const,
305+
id: "toolu_123",
306+
name: "read_file",
307+
input: { path: "test.txt" },
308+
}
309+
const toolResultBlock = {
310+
type: "tool_result" as const,
311+
tool_use_id: "toolu_123",
312+
content: "file contents",
313+
}
314+
315+
const messages: ApiMessage[] = [
316+
{ role: "user", content: "Hello", ts: 1 },
317+
{
318+
role: "assistant",
319+
// No reasoning block, just text and tool_use
320+
content: [{ type: "text" as const, text: "Reading file..." }, toolUseBlock],
321+
ts: 2,
322+
},
323+
{
324+
role: "user",
325+
content: [toolResultBlock],
326+
ts: 3,
327+
},
328+
{ role: "assistant", content: "Done", ts: 4 },
329+
{ role: "user", content: "Thanks", ts: 5 },
330+
]
331+
332+
const result = getKeepMessagesWithToolBlocks(messages, 3)
333+
334+
expect(result.toolUseBlocksToPreserve).toHaveLength(1)
335+
expect(result.reasoningBlocksToPreserve).toHaveLength(0)
336+
})
249337
})
250338

251339
describe("getMessagesSinceLastSummary", () => {
@@ -422,7 +510,14 @@ describe("summarizeConversation", () => {
422510
const summaryMessage = result.messages.find((m) => m.isSummary)
423511
expect(summaryMessage).toBeDefined()
424512
expect(summaryMessage!.role).toBe("assistant")
425-
expect(summaryMessage!.content).toBe("This is a summary")
513+
// Summary content is now always an array with [synthetic reasoning, text]
514+
// for DeepSeek-reasoner compatibility (requires reasoning_content on all assistant messages)
515+
expect(Array.isArray(summaryMessage!.content)).toBe(true)
516+
const content = summaryMessage!.content as any[]
517+
expect(content).toHaveLength(2)
518+
expect(content[0].type).toBe("reasoning")
519+
expect(content[1].type).toBe("text")
520+
expect(content[1].text).toBe("This is a summary")
426521
expect(summaryMessage!.isSummary).toBe(true)
427522

428523
// Verify that the effective API history matches expected: first + summary + last N messages
@@ -827,14 +922,16 @@ describe("summarizeConversation", () => {
827922
expect(summaryMessage!.isSummary).toBe(true)
828923
expect(Array.isArray(summaryMessage!.content)).toBe(true)
829924

830-
// Content should be [text block, tool_use block]
925+
// Content should be [synthetic reasoning, text block, tool_use block]
926+
// The synthetic reasoning is always added for DeepSeek-reasoner compatibility
831927
const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[]
832-
expect(content).toHaveLength(2)
833-
expect(content[0].type).toBe("text")
834-
expect((content[0] as Anthropic.Messages.TextBlockParam).text).toBe("Summary of conversation")
835-
expect(content[1].type).toBe("tool_use")
836-
expect((content[1] as Anthropic.Messages.ToolUseBlockParam).id).toBe("toolu_123")
837-
expect((content[1] as Anthropic.Messages.ToolUseBlockParam).name).toBe("read_file")
928+
expect(content).toHaveLength(3)
929+
expect((content[0] as any).type).toBe("reasoning") // Synthetic reasoning for DeepSeek
930+
expect(content[1].type).toBe("text")
931+
expect((content[1] as Anthropic.Messages.TextBlockParam).text).toBe("Summary of conversation")
932+
expect(content[2].type).toBe("tool_use")
933+
expect((content[2] as Anthropic.Messages.ToolUseBlockParam).id).toBe("toolu_123")
934+
expect((content[2] as Anthropic.Messages.ToolUseBlockParam).name).toBe("read_file")
838935

839936
// With non-destructive condensing, all messages are retained plus the summary
840937
expect(result.messages.length).toBe(messages.length + 1) // all original + summary
@@ -981,14 +1078,164 @@ describe("summarizeConversation", () => {
9811078
expect(summaryMessage).toBeDefined()
9821079
expect(Array.isArray(summaryMessage!.content)).toBe(true)
9831080
const summaryContent = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[]
984-
expect(summaryContent[0]).toEqual({ type: "text", text: "This is a summary" })
1081+
// First block is synthetic reasoning for DeepSeek-reasoner compatibility
1082+
expect((summaryContent[0] as any).type).toBe("reasoning")
1083+
// Second block is the text summary
1084+
expect(summaryContent[1]).toEqual({ type: "text", text: "This is a summary" })
9851085

9861086
const preservedToolUses = summaryContent.filter(
9871087
(block): block is Anthropic.Messages.ToolUseBlockParam => block.type === "tool_use",
9881088
)
9891089
expect(preservedToolUses).toHaveLength(2)
9901090
expect(preservedToolUses.map((block) => block.id)).toEqual(["toolu_parallel_1", "toolu_parallel_2"])
9911091
})
1092+
1093+
it("should preserve reasoning blocks in summary message for DeepSeek/Z.ai interleaved thinking", async () => {
1094+
const reasoningBlock = {
1095+
type: "reasoning" as const,
1096+
text: "Let me think about this step by step...",
1097+
}
1098+
const toolUseBlock = {
1099+
type: "tool_use" as const,
1100+
id: "toolu_deepseek_reason",
1101+
name: "read_file",
1102+
input: { path: "test.txt" },
1103+
}
1104+
const toolResultBlock = {
1105+
type: "tool_result" as const,
1106+
tool_use_id: "toolu_deepseek_reason",
1107+
content: "file contents",
1108+
}
1109+
1110+
const messages: ApiMessage[] = [
1111+
{ role: "user", content: "Hello", ts: 1 },
1112+
{ role: "assistant", content: "Let me help", ts: 2 },
1113+
{ role: "user", content: "Please read the file", ts: 3 },
1114+
{
1115+
role: "assistant",
1116+
// DeepSeek stores reasoning as content blocks alongside tool_use
1117+
content: [reasoningBlock as any, { type: "text" as const, text: "Reading file..." }, toolUseBlock],
1118+
ts: 4,
1119+
},
1120+
{
1121+
role: "user",
1122+
content: [toolResultBlock, { type: "text" as const, text: "Continue" }],
1123+
ts: 5,
1124+
},
1125+
{ role: "assistant", content: "Got it, the file says...", ts: 6 },
1126+
{ role: "user", content: "Thanks", ts: 7 },
1127+
]
1128+
1129+
// Create a stream with usage information
1130+
const streamWithUsage = (async function* () {
1131+
yield { type: "text" as const, text: "Summary of conversation" }
1132+
yield { type: "usage" as const, totalCost: 0.05, outputTokens: 100 }
1133+
})()
1134+
1135+
mockApiHandler.createMessage = vi.fn().mockReturnValue(streamWithUsage) as any
1136+
mockApiHandler.countTokens = vi.fn().mockImplementation(() => Promise.resolve(50)) as any
1137+
1138+
const result = await summarizeConversation(
1139+
messages,
1140+
mockApiHandler,
1141+
defaultSystemPrompt,
1142+
taskId,
1143+
DEFAULT_PREV_CONTEXT_TOKENS,
1144+
false, // isAutomaticTrigger
1145+
undefined, // customCondensingPrompt
1146+
undefined, // condensingApiHandler
1147+
true, // useNativeTools - required for tool_use block preservation
1148+
)
1149+
1150+
// Find the summary message
1151+
const summaryMessage = result.messages.find((m) => m.isSummary)
1152+
expect(summaryMessage).toBeDefined()
1153+
expect(summaryMessage!.role).toBe("assistant")
1154+
expect(summaryMessage!.isSummary).toBe(true)
1155+
expect(Array.isArray(summaryMessage!.content)).toBe(true)
1156+
1157+
// Content should be [synthetic reasoning, preserved reasoning, text block, tool_use block]
1158+
// - Synthetic reasoning is always added for DeepSeek-reasoner compatibility
1159+
// - Preserved reasoning from the condensed assistant message
1160+
// This order ensures reasoning_content is always present for DeepSeek/Z.ai
1161+
const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[]
1162+
expect(content).toHaveLength(4)
1163+
1164+
// First block should be synthetic reasoning
1165+
expect((content[0] as any).type).toBe("reasoning")
1166+
expect((content[0] as any).text).toContain("Condensing conversation context")
1167+
1168+
// Second block should be preserved reasoning from the condensed message
1169+
expect((content[1] as any).type).toBe("reasoning")
1170+
expect((content[1] as any).text).toBe("Let me think about this step by step...")
1171+
1172+
// Third block should be text (the summary)
1173+
expect(content[2].type).toBe("text")
1174+
expect((content[2] as Anthropic.Messages.TextBlockParam).text).toBe("Summary of conversation")
1175+
1176+
// Fourth block should be tool_use
1177+
expect(content[3].type).toBe("tool_use")
1178+
expect((content[3] as Anthropic.Messages.ToolUseBlockParam).id).toBe("toolu_deepseek_reason")
1179+
1180+
expect(result.error).toBeUndefined()
1181+
})
1182+
1183+
it("should include synthetic reasoning block in summary for DeepSeek-reasoner compatibility even without tool_use blocks", async () => {
1184+
// This test verifies the fix for the DeepSeek-reasoner 400 error:
1185+
// "Missing `reasoning_content` field in the assistant message at message index 1"
1186+
// DeepSeek-reasoner requires reasoning_content on ALL assistant messages, not just those with tool_calls.
1187+
// After condensation, the summary becomes an assistant message that needs reasoning_content.
1188+
const messages: ApiMessage[] = [
1189+
{ role: "user", content: "Tell me a joke", ts: 1 },
1190+
{ role: "assistant", content: "Why did the programmer quit?", ts: 2 },
1191+
{ role: "user", content: "I don't know, why?", ts: 3 },
1192+
{ role: "assistant", content: "He didn't get arrays!", ts: 4 },
1193+
{ role: "user", content: "Another one please", ts: 5 },
1194+
{ role: "assistant", content: "Why do programmers prefer dark mode?", ts: 6 },
1195+
{ role: "user", content: "Why?", ts: 7 },
1196+
]
1197+
1198+
// Create a stream with usage information (no tool calls in this conversation)
1199+
const streamWithUsage = (async function* () {
1200+
yield { type: "text" as const, text: "Summary: User requested jokes." }
1201+
yield { type: "usage" as const, totalCost: 0.05, outputTokens: 100 }
1202+
})()
1203+
1204+
mockApiHandler.createMessage = vi.fn().mockReturnValue(streamWithUsage) as any
1205+
mockApiHandler.countTokens = vi.fn().mockImplementation(() => Promise.resolve(50)) as any
1206+
1207+
const result = await summarizeConversation(
1208+
messages,
1209+
mockApiHandler,
1210+
defaultSystemPrompt,
1211+
taskId,
1212+
DEFAULT_PREV_CONTEXT_TOKENS,
1213+
false, // isAutomaticTrigger
1214+
undefined, // customCondensingPrompt
1215+
undefined, // condensingApiHandler
1216+
false, // useNativeTools - not using tools in this test
1217+
)
1218+
1219+
// Find the summary message
1220+
const summaryMessage = result.messages.find((m) => m.isSummary)
1221+
expect(summaryMessage).toBeDefined()
1222+
expect(summaryMessage!.role).toBe("assistant")
1223+
expect(summaryMessage!.isSummary).toBe(true)
1224+
1225+
// CRITICAL: Content must be an array with a synthetic reasoning block
1226+
// This is required for DeepSeek-reasoner which needs reasoning_content on all assistant messages
1227+
expect(Array.isArray(summaryMessage!.content)).toBe(true)
1228+
const content = summaryMessage!.content as any[]
1229+
1230+
// Should have [synthetic reasoning, text]
1231+
expect(content).toHaveLength(2)
1232+
expect(content[0].type).toBe("reasoning")
1233+
expect(content[0].text).toContain("Condensing conversation context")
1234+
expect(content[1].type).toBe("text")
1235+
expect(content[1].text).toBe("Summary: User requested jokes.")
1236+
1237+
expect(result.error).toBeUndefined()
1238+
})
9921239
})
9931240

9941241
describe("summarizeConversation with custom settings", () => {

0 commit comments

Comments
 (0)