diff --git a/src/core/condense/__tests__/condense.spec.ts b/src/core/condense/__tests__/condense.spec.ts index 2558ee5b33a..bea7d50ac17 100644 --- a/src/core/condense/__tests__/condense.spec.ts +++ b/src/core/condense/__tests__/condense.spec.ts @@ -86,7 +86,19 @@ describe("Condense", () => { // Verify we have a summary message const summaryMessage = result.messages.find((msg) => msg.isSummary) expect(summaryMessage).toBeTruthy() - expect(summaryMessage?.content).toBe("Mock summary of the conversation") + // Summary content is now always an array with a synthetic reasoning block + text block + // for DeepSeek-reasoner compatibility + expect(Array.isArray(summaryMessage?.content)).toBe(true) + const contentArray = summaryMessage?.content as Anthropic.Messages.ContentBlockParam[] + expect(contentArray).toHaveLength(2) + expect(contentArray[0]).toEqual({ + type: "reasoning", + text: "Condensing conversation context. The summary below captures the key information from the prior conversation.", + }) + expect(contentArray[1]).toEqual({ + type: "text", + text: "Mock summary of the conversation", + }) // With non-destructive condensing, all messages are retained (tagged but not deleted) // Use getEffectiveApiHistory to verify the effective view matches the old behavior diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index fe17b09ee37..1309afb2213 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -246,6 +246,94 @@ describe("getKeepMessagesWithToolBlocks", () => { expect(result.keepMessages).toEqual(messages) expect(result.toolUseBlocksToPreserve).toHaveLength(0) }) + + it("should preserve reasoning blocks alongside tool_use blocks for DeepSeek/Z.ai interleaved thinking", () => { + const reasoningBlock = { + type: "reasoning" as const, + text: "Let me think about this step by step...", + } + const toolUseBlock = { + type: "tool_use" as const, + id: "toolu_deepseek_123", + name: "read_file", + input: { path: "test.txt" }, + } + const toolResultBlock = { + type: "tool_result" as const, + tool_use_id: "toolu_deepseek_123", + content: "file contents", + } + + const messages: ApiMessage[] = [ + { role: "user", content: "Hello", ts: 1 }, + { role: "assistant", content: "Let me help", ts: 2 }, + { role: "user", content: "Please read the file", ts: 3 }, + { + role: "assistant", + // DeepSeek stores reasoning as content blocks alongside tool_use + content: [reasoningBlock as any, { type: "text" as const, text: "Reading file..." }, toolUseBlock], + ts: 4, + }, + { + role: "user", + content: [toolResultBlock, { type: "text" as const, text: "Continue" }], + ts: 5, + }, + { role: "assistant", content: "Got it, the file says...", ts: 6 }, + { role: "user", content: "Thanks", ts: 7 }, + ] + + const result = getKeepMessagesWithToolBlocks(messages, 3) + + // keepMessages should be the last 3 messages + expect(result.keepMessages).toHaveLength(3) + expect(result.keepMessages[0].ts).toBe(5) + + // Should preserve the tool_use block + expect(result.toolUseBlocksToPreserve).toHaveLength(1) + expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock) + + // Should preserve the reasoning block for DeepSeek/Z.ai interleaved thinking + expect(result.reasoningBlocksToPreserve).toHaveLength(1) + expect((result.reasoningBlocksToPreserve[0] as any).type).toBe("reasoning") + expect((result.reasoningBlocksToPreserve[0] as any).text).toBe("Let me think about this step by step...") + }) + + it("should return empty reasoningBlocksToPreserve when no reasoning blocks present", () => { + const toolUseBlock = { + type: "tool_use" as const, + id: "toolu_123", + name: "read_file", + input: { path: "test.txt" }, + } + const toolResultBlock = { + type: "tool_result" as const, + tool_use_id: "toolu_123", + content: "file contents", + } + + const messages: ApiMessage[] = [ + { role: "user", content: "Hello", ts: 1 }, + { + role: "assistant", + // No reasoning block, just text and tool_use + content: [{ type: "text" as const, text: "Reading file..." }, toolUseBlock], + ts: 2, + }, + { + role: "user", + content: [toolResultBlock], + ts: 3, + }, + { role: "assistant", content: "Done", ts: 4 }, + { role: "user", content: "Thanks", ts: 5 }, + ] + + const result = getKeepMessagesWithToolBlocks(messages, 3) + + expect(result.toolUseBlocksToPreserve).toHaveLength(1) + expect(result.reasoningBlocksToPreserve).toHaveLength(0) + }) }) describe("getMessagesSinceLastSummary", () => { @@ -422,7 +510,14 @@ describe("summarizeConversation", () => { const summaryMessage = result.messages.find((m) => m.isSummary) expect(summaryMessage).toBeDefined() expect(summaryMessage!.role).toBe("assistant") - expect(summaryMessage!.content).toBe("This is a summary") + // Summary content is now always an array with [synthetic reasoning, text] + // for DeepSeek-reasoner compatibility (requires reasoning_content on all assistant messages) + expect(Array.isArray(summaryMessage!.content)).toBe(true) + const content = summaryMessage!.content as any[] + expect(content).toHaveLength(2) + expect(content[0].type).toBe("reasoning") + expect(content[1].type).toBe("text") + expect(content[1].text).toBe("This is a summary") expect(summaryMessage!.isSummary).toBe(true) // Verify that the effective API history matches expected: first + summary + last N messages @@ -827,14 +922,16 @@ describe("summarizeConversation", () => { expect(summaryMessage!.isSummary).toBe(true) expect(Array.isArray(summaryMessage!.content)).toBe(true) - // Content should be [text block, tool_use block] + // Content should be [synthetic reasoning, text block, tool_use block] + // The synthetic reasoning is always added for DeepSeek-reasoner compatibility const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] - expect(content).toHaveLength(2) - expect(content[0].type).toBe("text") - expect((content[0] as Anthropic.Messages.TextBlockParam).text).toBe("Summary of conversation") - expect(content[1].type).toBe("tool_use") - expect((content[1] as Anthropic.Messages.ToolUseBlockParam).id).toBe("toolu_123") - expect((content[1] as Anthropic.Messages.ToolUseBlockParam).name).toBe("read_file") + expect(content).toHaveLength(3) + expect((content[0] as any).type).toBe("reasoning") // Synthetic reasoning for DeepSeek + expect(content[1].type).toBe("text") + expect((content[1] as Anthropic.Messages.TextBlockParam).text).toBe("Summary of conversation") + expect(content[2].type).toBe("tool_use") + expect((content[2] as Anthropic.Messages.ToolUseBlockParam).id).toBe("toolu_123") + expect((content[2] as Anthropic.Messages.ToolUseBlockParam).name).toBe("read_file") // With non-destructive condensing, all messages are retained plus the summary expect(result.messages.length).toBe(messages.length + 1) // all original + summary @@ -981,7 +1078,10 @@ describe("summarizeConversation", () => { expect(summaryMessage).toBeDefined() expect(Array.isArray(summaryMessage!.content)).toBe(true) const summaryContent = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] - expect(summaryContent[0]).toEqual({ type: "text", text: "This is a summary" }) + // First block is synthetic reasoning for DeepSeek-reasoner compatibility + expect((summaryContent[0] as any).type).toBe("reasoning") + // Second block is the text summary + expect(summaryContent[1]).toEqual({ type: "text", text: "This is a summary" }) const preservedToolUses = summaryContent.filter( (block): block is Anthropic.Messages.ToolUseBlockParam => block.type === "tool_use", @@ -989,6 +1089,153 @@ describe("summarizeConversation", () => { expect(preservedToolUses).toHaveLength(2) expect(preservedToolUses.map((block) => block.id)).toEqual(["toolu_parallel_1", "toolu_parallel_2"]) }) + + it("should preserve reasoning blocks in summary message for DeepSeek/Z.ai interleaved thinking", async () => { + const reasoningBlock = { + type: "reasoning" as const, + text: "Let me think about this step by step...", + } + const toolUseBlock = { + type: "tool_use" as const, + id: "toolu_deepseek_reason", + name: "read_file", + input: { path: "test.txt" }, + } + const toolResultBlock = { + type: "tool_result" as const, + tool_use_id: "toolu_deepseek_reason", + content: "file contents", + } + + const messages: ApiMessage[] = [ + { role: "user", content: "Hello", ts: 1 }, + { role: "assistant", content: "Let me help", ts: 2 }, + { role: "user", content: "Please read the file", ts: 3 }, + { + role: "assistant", + // DeepSeek stores reasoning as content blocks alongside tool_use + content: [reasoningBlock as any, { type: "text" as const, text: "Reading file..." }, toolUseBlock], + ts: 4, + }, + { + role: "user", + content: [toolResultBlock, { type: "text" as const, text: "Continue" }], + ts: 5, + }, + { role: "assistant", content: "Got it, the file says...", ts: 6 }, + { role: "user", content: "Thanks", ts: 7 }, + ] + + // Create a stream with usage information + const streamWithUsage = (async function* () { + yield { type: "text" as const, text: "Summary of conversation" } + yield { type: "usage" as const, totalCost: 0.05, outputTokens: 100 } + })() + + mockApiHandler.createMessage = vi.fn().mockReturnValue(streamWithUsage) as any + mockApiHandler.countTokens = vi.fn().mockImplementation(() => Promise.resolve(50)) as any + + const result = await summarizeConversation( + messages, + mockApiHandler, + defaultSystemPrompt, + taskId, + DEFAULT_PREV_CONTEXT_TOKENS, + false, // isAutomaticTrigger + undefined, // customCondensingPrompt + undefined, // condensingApiHandler + true, // useNativeTools - required for tool_use block preservation + ) + + // Find the summary message + const summaryMessage = result.messages.find((m) => m.isSummary) + expect(summaryMessage).toBeDefined() + expect(summaryMessage!.role).toBe("assistant") + expect(summaryMessage!.isSummary).toBe(true) + expect(Array.isArray(summaryMessage!.content)).toBe(true) + + // Content should be [synthetic reasoning, preserved reasoning, text block, tool_use block] + // - Synthetic reasoning is always added for DeepSeek-reasoner compatibility + // - Preserved reasoning from the condensed assistant message + // This order ensures reasoning_content is always present for DeepSeek/Z.ai + const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] + expect(content).toHaveLength(4) + + // First block should be synthetic reasoning + expect((content[0] as any).type).toBe("reasoning") + expect((content[0] as any).text).toContain("Condensing conversation context") + + // Second block should be preserved reasoning from the condensed message + expect((content[1] as any).type).toBe("reasoning") + expect((content[1] as any).text).toBe("Let me think about this step by step...") + + // Third block should be text (the summary) + expect(content[2].type).toBe("text") + expect((content[2] as Anthropic.Messages.TextBlockParam).text).toBe("Summary of conversation") + + // Fourth block should be tool_use + expect(content[3].type).toBe("tool_use") + expect((content[3] as Anthropic.Messages.ToolUseBlockParam).id).toBe("toolu_deepseek_reason") + + expect(result.error).toBeUndefined() + }) + + it("should include synthetic reasoning block in summary for DeepSeek-reasoner compatibility even without tool_use blocks", async () => { + // This test verifies the fix for the DeepSeek-reasoner 400 error: + // "Missing `reasoning_content` field in the assistant message at message index 1" + // DeepSeek-reasoner requires reasoning_content on ALL assistant messages, not just those with tool_calls. + // After condensation, the summary becomes an assistant message that needs reasoning_content. + const messages: ApiMessage[] = [ + { role: "user", content: "Tell me a joke", ts: 1 }, + { role: "assistant", content: "Why did the programmer quit?", ts: 2 }, + { role: "user", content: "I don't know, why?", ts: 3 }, + { role: "assistant", content: "He didn't get arrays!", ts: 4 }, + { role: "user", content: "Another one please", ts: 5 }, + { role: "assistant", content: "Why do programmers prefer dark mode?", ts: 6 }, + { role: "user", content: "Why?", ts: 7 }, + ] + + // Create a stream with usage information (no tool calls in this conversation) + const streamWithUsage = (async function* () { + yield { type: "text" as const, text: "Summary: User requested jokes." } + yield { type: "usage" as const, totalCost: 0.05, outputTokens: 100 } + })() + + mockApiHandler.createMessage = vi.fn().mockReturnValue(streamWithUsage) as any + mockApiHandler.countTokens = vi.fn().mockImplementation(() => Promise.resolve(50)) as any + + const result = await summarizeConversation( + messages, + mockApiHandler, + defaultSystemPrompt, + taskId, + DEFAULT_PREV_CONTEXT_TOKENS, + false, // isAutomaticTrigger + undefined, // customCondensingPrompt + undefined, // condensingApiHandler + false, // useNativeTools - not using tools in this test + ) + + // Find the summary message + const summaryMessage = result.messages.find((m) => m.isSummary) + expect(summaryMessage).toBeDefined() + expect(summaryMessage!.role).toBe("assistant") + expect(summaryMessage!.isSummary).toBe(true) + + // CRITICAL: Content must be an array with a synthetic reasoning block + // This is required for DeepSeek-reasoner which needs reasoning_content on all assistant messages + expect(Array.isArray(summaryMessage!.content)).toBe(true) + const content = summaryMessage!.content as any[] + + // Should have [synthetic reasoning, text] + expect(content).toHaveLength(2) + expect(content[0].type).toBe("reasoning") + expect(content[0].text).toContain("Condensing conversation context") + expect(content[1].type).toBe("text") + expect(content[1].text).toBe("Summary: User requested jokes.") + + expect(result.error).toBeUndefined() + }) }) describe("summarizeConversation with custom settings", () => { diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index b8af4d1de24..3238eca7071 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -30,22 +30,49 @@ function getToolUseBlocks(message: ApiMessage): Anthropic.Messages.ToolUseBlock[ return message.content.filter((block) => block.type === "tool_use") as Anthropic.Messages.ToolUseBlock[] } +/** + * Gets reasoning blocks from a message's content array. + * Task stores reasoning as {type: "reasoning", text: "..."} blocks, + * which convertToR1Format and convertToZAiFormat already know how to extract. + */ +function getReasoningBlocks(message: ApiMessage): Anthropic.Messages.ContentBlockParam[] { + if (message.role !== "assistant" || typeof message.content === "string") { + return [] + } + // Filter for reasoning blocks and cast to ContentBlockParam (the type field is compatible) + return message.content.filter((block) => (block as any).type === "reasoning") as any[] +} + +/** + * Result of getKeepMessagesWithToolBlocks + */ +export type KeepMessagesResult = { + keepMessages: ApiMessage[] + toolUseBlocksToPreserve: Anthropic.Messages.ToolUseBlock[] + // Reasoning blocks from the preceding assistant message, needed for DeepSeek/Z.ai + // when tool_use blocks are preserved. Task stores reasoning as {type: "reasoning", text: "..."} + // blocks, and convertToR1Format/convertToZAiFormat already extract these. + reasoningBlocksToPreserve: Anthropic.Messages.ContentBlockParam[] +} + /** * Extracts tool_use blocks that need to be preserved to match tool_result blocks in keepMessages. * When the first kept message is a user message with tool_result blocks, * we need to find the corresponding tool_use blocks from the preceding assistant message. * These tool_use blocks will be appended to the summary message to maintain proper pairing. * + * Also extracts reasoning blocks from the preceding assistant message, which are required + * by DeepSeek and Z.ai for interleaved thinking mode. Without these, the API returns a 400 error + * "Missing reasoning_content field in the assistant message". + * See: https://api-docs.deepseek.com/guides/thinking_mode#tool-calls + * * @param messages - The full conversation messages * @param keepCount - The number of messages to keep from the end - * @returns Object containing keepMessages and any tool_use blocks to preserve + * @returns Object containing keepMessages, tool_use blocks, and reasoning blocks to preserve */ -export function getKeepMessagesWithToolBlocks( - messages: ApiMessage[], - keepCount: number, -): { keepMessages: ApiMessage[]; toolUseBlocksToPreserve: Anthropic.Messages.ToolUseBlock[] } { +export function getKeepMessagesWithToolBlocks(messages: ApiMessage[], keepCount: number): KeepMessagesResult { if (messages.length <= keepCount) { - return { keepMessages: messages, toolUseBlocksToPreserve: [] } + return { keepMessages: messages, toolUseBlocksToPreserve: [], reasoningBlocksToPreserve: [] } } const startIndex = messages.length - keepCount @@ -59,13 +86,20 @@ export function getKeepMessagesWithToolBlocks( const precedingMessage = messages[precedingIndex] const toolUseBlocks = getToolUseBlocks(precedingMessage) if (toolUseBlocks.length > 0) { - // Return the tool_use blocks to be merged into the summary message - return { keepMessages, toolUseBlocksToPreserve: toolUseBlocks } + // Also extract reasoning blocks for DeepSeek/Z.ai interleaved thinking + // Task stores reasoning as {type: "reasoning", text: "..."} content blocks + const reasoningBlocks = getReasoningBlocks(precedingMessage) + // Return the tool_use blocks and reasoning blocks to be merged into the summary message + return { + keepMessages, + toolUseBlocksToPreserve: toolUseBlocks, + reasoningBlocksToPreserve: reasoningBlocks, + } } } } - return { keepMessages, toolUseBlocksToPreserve: [] } + return { keepMessages, toolUseBlocksToPreserve: [], reasoningBlocksToPreserve: [] } } export const N_MESSAGES_TO_KEEP = 3 @@ -168,11 +202,15 @@ export async function summarizeConversation( // Always preserve the first message (which may contain slash command content) const firstMessage = messages[0] - // Get keepMessages and any tool_use blocks that need to be preserved for tool_result pairing - // Only preserve tool_use blocks when using native tools protocol (XML protocol doesn't need them) - const { keepMessages, toolUseBlocksToPreserve } = useNativeTools + // Get keepMessages and any tool_use/reasoning blocks that need to be preserved for tool_result pairing + // Only preserve these blocks when using native tools protocol (XML protocol doesn't need them) + const { keepMessages, toolUseBlocksToPreserve, reasoningBlocksToPreserve } = useNativeTools ? getKeepMessagesWithToolBlocks(messages, N_MESSAGES_TO_KEEP) - : { keepMessages: messages.slice(-N_MESSAGES_TO_KEEP), toolUseBlocksToPreserve: [] } + : { + keepMessages: messages.slice(-N_MESSAGES_TO_KEEP), + toolUseBlocksToPreserve: [], + reasoningBlocksToPreserve: [], + } const keepStartIndex = Math.max(messages.length - N_MESSAGES_TO_KEEP, 0) const includeFirstKeptMessageInSummary = toolUseBlocksToPreserve.length > 0 @@ -257,15 +295,39 @@ export async function summarizeConversation( } // Build the summary message content - // If there are tool_use blocks to preserve (for tool_result pairing), append them to the summary - let summaryContent: string | Anthropic.Messages.ContentBlockParam[] + // CRITICAL: Always include a reasoning block in the summary for DeepSeek-reasoner compatibility. + // DeepSeek-reasoner requires `reasoning_content` on ALL assistant messages, not just those with tool_calls. + // Without this, we get: "400 Missing `reasoning_content` field in the assistant message" + // See: https://api-docs.deepseek.com/guides/thinking_mode + // + // The summary content structure is: + // 1. Synthetic reasoning block (always present) - for DeepSeek-reasoner compatibility + // 2. Any preserved reasoning blocks from the condensed assistant message (if tool_use blocks are preserved) + // 3. Text block with the summary + // 4. Tool_use blocks (if any need to be preserved for tool_result pairing) + + // Create a synthetic reasoning block that explains the summary + // This is minimal but satisfies DeepSeek's requirement for reasoning_content on all assistant messages + const syntheticReasoningBlock = { + type: "reasoning" as const, + text: "Condensing conversation context. The summary below captures the key information from the prior conversation.", + } + + const textBlock: Anthropic.Messages.TextBlockParam = { type: "text", text: summary } + + let summaryContent: Anthropic.Messages.ContentBlockParam[] if (toolUseBlocksToPreserve.length > 0) { - // Create content array with text block followed by tool_use blocks - // Use TextBlockParam which doesn't require citations field - const textBlock: Anthropic.Messages.TextBlockParam = { type: "text", text: summary } - summaryContent = [textBlock, ...toolUseBlocksToPreserve] + // Include: synthetic reasoning, preserved reasoning (if any), summary text, and tool_use blocks + summaryContent = [ + syntheticReasoningBlock as unknown as Anthropic.Messages.ContentBlockParam, + ...reasoningBlocksToPreserve, + textBlock, + ...toolUseBlocksToPreserve, + ] } else { - summaryContent = summary + // Include: synthetic reasoning and summary text + // This ensures the summary always has reasoning_content for DeepSeek-reasoner + summaryContent = [syntheticReasoningBlock as unknown as Anthropic.Messages.ContentBlockParam, textBlock] } // Generate a unique condenseId for this summary diff --git a/src/core/task-persistence/apiMessages.ts b/src/core/task-persistence/apiMessages.ts index 9263115c60e..097679e4a7b 100644 --- a/src/core/task-persistence/apiMessages.ts +++ b/src/core/task-persistence/apiMessages.ts @@ -20,6 +20,9 @@ export type ApiMessage = Anthropic.MessageParam & { text?: string // For OpenRouter reasoning_details array format (used by Gemini 3, etc.) reasoning_details?: any[] + // For DeepSeek/Z.ai interleaved thinking: reasoning_content that must be preserved during tool call sequences + // See: https://api-docs.deepseek.com/guides/thinking_mode#tool-calls + 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