From 4ec9473ba04902646e6cb2bc7012f927e7187ff1 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 20 Jan 2026 17:01:06 -0700 Subject: [PATCH 01/17] Refactor condense logic to improve message handling and summary generation - Update tests to reflect changes in message tagging with condenseParent. - Modify summarizeConversation function to embed last N messages as blocks. - Ensure only the final summary message is effective for API calls, filtering out previous messages with the same condenseParent. - Adjust effective history retrieval to accommodate new condense behavior. - Enhance webview message handler tests to validate correct message tagging and behavior after condensing. --- src/core/condense/__tests__/condense.spec.ts | 84 ++++---- src/core/condense/__tests__/index.spec.ts | 204 +++++++----------- .../__tests__/rewind-after-condense.spec.ts | 139 +++++------- .../webviewMessageHandler.delete.spec.ts | 11 +- 4 files changed, 184 insertions(+), 254 deletions(-) diff --git a/src/core/condense/__tests__/condense.spec.ts b/src/core/condense/__tests__/condense.spec.ts index bea7d50ac17..e93199c40fa 100644 --- a/src/core/condense/__tests__/condense.spec.ts +++ b/src/core/condense/__tests__/condense.spec.ts @@ -64,7 +64,7 @@ describe("Condense", () => { }) describe("summarizeConversation", () => { - it("should preserve the first message when summarizing", async () => { + it("should not preserve the first message when summarizing", async () => { const messages: ApiMessage[] = [ { role: "user", content: "First message with /prr command content" }, { role: "assistant", content: "Second message" }, @@ -79,36 +79,38 @@ describe("Condense", () => { const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false) - // Verify the first message is preserved - expect(result.messages[0]).toEqual(messages[0]) + // Verify the first message is tagged for condensing expect(result.messages[0].content).toBe("First message with /prr command content") + expect(result.messages[0].condenseParent).toBeDefined() - // Verify we have a summary message + // Verify we have a final condensed summary message (role=user) const summaryMessage = result.messages.find((msg) => msg.isSummary) expect(summaryMessage).toBeTruthy() - // 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(summaryMessage!.role).toBe("user") + expect(Array.isArray(summaryMessage!.content)).toBe(true) + const contentArray = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] + expect(contentArray).toHaveLength(4) 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", + text: expect.stringContaining("Mock summary of the conversation"), }) + for (const reminderBlock of contentArray.slice(1)) { + expect(reminderBlock.type).toBe("text") + expect((reminderBlock as Anthropic.Messages.TextBlockParam).text).toContain(" { @@ -127,9 +129,14 @@ describe("Condense", () => { const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false) - // The first message with slash command should be intact + // The first message with slash command should still be intact (but tagged for condensing) expect(result.messages[0].content).toBe(slashCommandContent) - expect(result.messages[0]).toEqual(messages[0]) + expect(result.messages[0].condenseParent).toBeDefined() + + // Effective history should contain only the final condensed message + const effectiveHistory = getEffectiveApiHistory(result.messages) + expect(effectiveHistory).toHaveLength(1) + expect(effectiveHistory[0].role).toBe("user") }) it("should handle complex first message content", async () => { @@ -152,9 +159,14 @@ describe("Condense", () => { const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false) - // The first message with complex content should be preserved + // The first message with complex content should still be present (but tagged for condensing) expect(result.messages[0].content).toEqual(complexContent) - expect(result.messages[0]).toEqual(messages[0]) + expect(result.messages[0].condenseParent).toBeDefined() + + // Effective history should contain only the final condensed message + const effectiveHistory = getEffectiveApiHistory(result.messages) + expect(effectiveHistory).toHaveLength(1) + expect(effectiveHistory[0].role).toBe("user") }) it("should return error when not enough messages to summarize", async () => { @@ -248,12 +260,10 @@ describe("Condense", () => { const result = getMessagesSinceLastSummary(messages) - // Should include the original first user message for context preservation, the summary, and messages after - expect(result[0].role).toBe("user") - expect(result[0].content).toBe("First message") // Preserves original first message - expect(result[1]).toEqual(messages[2]) // The summary - expect(result[2]).toEqual(messages[3]) - expect(result[3]).toEqual(messages[4]) + // Starts at the summary and includes messages after + expect(result[0]).toEqual(messages[2]) // The summary + expect(result[1]).toEqual(messages[3]) + expect(result[2]).toEqual(messages[4]) }) it("should handle multiple summaries and return from the last one", () => { @@ -268,12 +278,10 @@ describe("Condense", () => { const result = getMessagesSinceLastSummary(messages) - // Should only include from the last summary with original first message preserved - expect(result[0].role).toBe("user") - expect(result[0].content).toBe("First message") // Preserves original first message - expect(result[1]).toEqual(messages[3]) // Second summary - expect(result[2]).toEqual(messages[4]) - expect(result[3]).toEqual(messages[5]) + // Starts at the last summary and includes messages after + expect(result[0]).toEqual(messages[3]) // Second summary + expect(result[1]).toEqual(messages[4]) + expect(result[2]).toEqual(messages[5]) }) }) }) diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index 2947d19ff1e..ab48fdd0719 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -596,7 +596,7 @@ describe("getMessagesSinceLastSummary", () => { expect(result).toEqual(messages) }) - it("should return messages since the last summary with original first user message", () => { + it("should return messages since the last summary (does not preserve original first user message)", () => { const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi there", ts: 2 }, @@ -607,14 +607,13 @@ describe("getMessagesSinceLastSummary", () => { const result = getMessagesSinceLastSummary(messages) expect(result).toEqual([ - { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Summary of conversation", ts: 3, isSummary: true }, { role: "user", content: "How are you?", ts: 4 }, { role: "assistant", content: "I'm good", ts: 5 }, ]) }) - it("should handle multiple summary messages and return since the last one with original first user message", () => { + it("should handle multiple summary messages and return since the last one (does not preserve original first user message)", () => { const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "First summary", ts: 2, isSummary: true }, @@ -625,7 +624,6 @@ describe("getMessagesSinceLastSummary", () => { const result = getMessagesSinceLastSummary(messages) expect(result).toEqual([ - { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Second summary", ts: 4, isSummary: true }, { role: "user", content: "What's new?", ts: 5 }, ]) @@ -746,40 +744,40 @@ describe("summarizeConversation", () => { expect(mockApiHandler.createMessage).toHaveBeenCalled() expect(maybeRemoveImageBlocks).toHaveBeenCalled() - // With non-destructive condensing, the result contains ALL original messages - // plus the summary message. Condensed messages are tagged but not deleted. - // Use getEffectiveApiHistory to verify the effective API view matches the old behavior. - expect(result.messages.length).toBe(messages.length + 1) // All original messages + summary + // With the new condense output, the result contains all original messages (tagged) + // plus a final condensed message. + expect(result.messages.length).toBe(messages.length + 1) - // Check that the first message is preserved - expect(result.messages[0]).toEqual(messages[0]) - - // Find the summary message (it has isSummary: true) + // All prior messages should be tagged const summaryMessage = result.messages.find((m) => m.isSummary) expect(summaryMessage).toBeDefined() - expect(summaryMessage!.role).toBe("assistant") - // Summary content is now always an array with [synthetic reasoning, text] - // for DeepSeek-reasoner compatibility (requires reasoning_content on all assistant messages) + const condenseId = summaryMessage!.condenseId + expect(condenseId).toBeDefined() + for (const msg of result.messages.slice(0, -1)) { + expect(msg.condenseParent).toBe(condenseId) + } + + // Final condensed message is role=user with 4 text blocks: summary + 3 reminders + expect(summaryMessage!.role).toBe("user") 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) + expect(content).toHaveLength(4) + expect(content[0].type).toBe("text") + expect(content[0].text).toContain("Condensing conversation context") + expect(content[0].text).toContain("This is a summary") + for (const reminder of content.slice(1)) { + expect(reminder.type).toBe("text") + expect(reminder.text).toContain(" m.condenseParent !== undefined) - expect(condensedMessages.length).toBeGreaterThan(0) + expect(effectiveHistory).toEqual([summaryMessage]) // Check the cost and token counts expect(result.cost).toBe(0.05) expect(result.summary).toBe("This is a summary") - expect(result.newContextTokens).toBe(250) // 150 output tokens + 100 from countTokens + expect(result.newContextTokens).toBe(100) // countTokens(systemPrompt + final condensed message) expect(result.error).toBeUndefined() }) @@ -912,11 +910,11 @@ describe("summarizeConversation", () => { DEFAULT_PREV_CONTEXT_TOKENS, ) - // Verify that countTokens was called with the correct messages including system prompt + // Verify that countTokens was called with system prompt + final condensed message expect(mockApiHandler.countTokens).toHaveBeenCalled() - // Check the newContextTokens calculation includes system prompt - expect(result.newContextTokens).toBe(300) // 200 output tokens + 100 from countTokens + // newContextTokens is now derived solely from countTokens(systemPrompt + condensed message) + expect(result.newContextTokens).toBe(100) expect(result.cost).toBe(0.06) expect(result.summary).toBe("This is a summary with system prompt") expect(result.error).toBeUndefined() @@ -942,9 +940,8 @@ describe("summarizeConversation", () => { // Override the mock for this test mockApiHandler.createMessage = vi.fn().mockReturnValue(streamWithLargeTokens) as any - // Mock countTokens to return a high value that when added to outputTokens (500) - // will be >= prevContextTokens (600) - mockApiHandler.countTokens = vi.fn().mockImplementation(() => Promise.resolve(200)) as any + // Mock countTokens to return a value >= prevContextTokens + mockApiHandler.countTokens = vi.fn().mockImplementation(() => Promise.resolve(600)) as any const prevContextTokens = 600 const result = await summarizeConversation( @@ -955,7 +952,7 @@ describe("summarizeConversation", () => { prevContextTokens, ) - // Should return original messages when context would grow + // Should return original messages when context would not shrink expect(result.messages).toEqual(messages) expect(result.cost).toBe(0.08) expect(result.summary).toBe("") @@ -995,15 +992,14 @@ describe("summarizeConversation", () => { prevContextTokens, ) - // With non-destructive condensing, result contains all messages plus summary - // Use getEffectiveApiHistory to verify the effective API view - expect(result.messages.length).toBe(messages.length + 1) // All messages + summary + // With the new condense output, result contains all messages plus final condensed message + expect(result.messages.length).toBe(messages.length + 1) const effectiveHistory = getEffectiveApiHistory(result.messages) - expect(effectiveHistory.length).toBe(1 + 1 + N_MESSAGES_TO_KEEP) // First + summary + last N + expect(effectiveHistory.length).toBe(1) expect(result.cost).toBe(0.03) expect(result.summary).toBe("Concise summary") expect(result.error).toBeUndefined() - expect(result.newContextTokens).toBe(80) // 50 output tokens + 30 from countTokens + expect(result.newContextTokens).toBe(30) // countTokens(systemPrompt + condensed message) expect(result.newContextTokens).toBeLessThan(prevContextTokens) }) @@ -1100,7 +1096,7 @@ describe("summarizeConversation", () => { console.error = originalError }) - it("should append tool_use blocks to summary message when first kept message has tool_result blocks", async () => { + it("should NOT preserve tool_use blocks; tool_result is captured in ", async () => { const toolUseBlock = { type: "tool_use" as const, id: "toolu_123", @@ -1148,33 +1144,27 @@ describe("summarizeConversation", () => { DEFAULT_PREV_CONTEXT_TOKENS, ) - // Find the summary message + // Find the final condensed message const summaryMessage = result.messages.find((m) => m.isSummary) expect(summaryMessage).toBeDefined() - expect(summaryMessage!.role).toBe("assistant") + expect(summaryMessage!.role).toBe("user") expect(summaryMessage!.isSummary).toBe(true) expect(Array.isArray(summaryMessage!.content)).toBe(true) - // Content should be [synthetic reasoning, text block, tool_use block] - // The synthetic reasoning is always added for DeepSeek-reasoner compatibility + // Content is 4 text blocks; tool_result should appear in the reminder JSON const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] - 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 - // Verify effective history matches expected + expect(content).toHaveLength(4) + const reminderText = (content[1] as Anthropic.Messages.TextBlockParam).text + expect(reminderText).toContain("tool_result") + expect(reminderText).toContain(toolResultBlock.tool_use_id) + + // Effective history should be only the final condensed message const effectiveHistory = getEffectiveApiHistory(result.messages) - expect(effectiveHistory.length).toBe(1 + 1 + N_MESSAGES_TO_KEEP) // first + summary + last 3 + expect(effectiveHistory).toEqual([summaryMessage]) expect(result.error).toBeUndefined() }) - it("should include user tool_result message in summarize request when preserving tool_use blocks", async () => { + it("should include user tool_result message in summarize request", async () => { const toolUseBlock = { type: "tool_use" as const, id: "toolu_history_fix", @@ -1235,27 +1225,18 @@ describe("summarizeConversation", () => { const historyMessages = requestMessages.slice(0, -1) expect(historyMessages.length).toBeGreaterThanOrEqual(2) - const assistantMessage = historyMessages[historyMessages.length - 2] - const userMessage = historyMessages[historyMessages.length - 1] - - expect(assistantMessage.role).toBe("assistant") - expect(Array.isArray(assistantMessage.content)).toBe(true) - expect( - (assistantMessage.content as any[]).some( - (block) => block.type === "tool_use" && block.id === toolUseBlock.id, - ), - ).toBe(true) - - expect(userMessage.role).toBe("user") - expect(Array.isArray(userMessage.content)).toBe(true) - expect( - (userMessage.content as any[]).some( - (block) => block.type === "tool_result" && block.tool_use_id === toolUseBlock.id, - ), - ).toBe(true) + const hasToolResultUserMessage = historyMessages.some( + (m) => + m.role === "user" && + Array.isArray(m.content) && + (m.content as any[]).some( + (block) => block.type === "tool_result" && block.tool_use_id === toolUseBlock.id, + ), + ) + expect(hasToolResultUserMessage).toBe(true) }) - it("should append multiple tool_use blocks for parallel tool calls", async () => { + it("should not append tool_use blocks for parallel tool calls (captured in reminders)", async () => { const toolUseBlockA = { type: "tool_use" as const, id: "toolu_parallel_1", @@ -1298,24 +1279,19 @@ describe("summarizeConversation", () => { DEFAULT_PREV_CONTEXT_TOKENS, ) - // Find the summary message (it has isSummary: true) const summaryMessage = result.messages.find((m) => m.isSummary) expect(summaryMessage).toBeDefined() + expect(summaryMessage!.role).toBe("user") expect(Array.isArray(summaryMessage!.content)).toBe(true) const summaryContent = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] - // 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", - ) - expect(preservedToolUses).toHaveLength(2) - expect(preservedToolUses.map((block) => block.id)).toEqual(["toolu_parallel_1", "toolu_parallel_2"]) + expect(summaryContent).toHaveLength(4) + // tool_use blocks should not appear directly; they should only appear inside reminder JSON + for (const block of summaryContent) { + expect(block.type).toBe("text") + } }) - it("should preserve reasoning blocks in summary message for DeepSeek/Z.ai interleaved thinking", async () => { + it("should not include reasoning blocks in condensed message (all text blocks)", async () => { const reasoningBlock = { type: "reasoning" as const, text: "Let me think about this step by step...", @@ -1368,44 +1344,20 @@ describe("summarizeConversation", () => { DEFAULT_PREV_CONTEXT_TOKENS, ) - // Find the summary message const summaryMessage = result.messages.find((m) => m.isSummary) expect(summaryMessage).toBeDefined() - expect(summaryMessage!.role).toBe("assistant") + expect(summaryMessage!.role).toBe("user") 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") - + for (const block of content) { + expect(block.type).toBe("text") + } 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. + it("should produce 4 text blocks in condensed message even without tool calls", async () => { const messages: ApiMessage[] = [ { role: "user", content: "Tell me a joke", ts: 1 }, { role: "assistant", content: "Why did the programmer quit?", ts: 2 }, @@ -1433,23 +1385,17 @@ describe("summarizeConversation", () => { DEFAULT_PREV_CONTEXT_TOKENS, ) - // Find the summary message const summaryMessage = result.messages.find((m) => m.isSummary) expect(summaryMessage).toBeDefined() - expect(summaryMessage!.role).toBe("assistant") + expect(summaryMessage!.role).toBe("user") 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(content).toHaveLength(4) + for (const block of content) { + expect(block.type).toBe("text") + } + expect(content[0].text).toContain("Summary: User requested jokes.") expect(result.error).toBeUndefined() }) diff --git a/src/core/condense/__tests__/rewind-after-condense.spec.ts b/src/core/condense/__tests__/rewind-after-condense.spec.ts index f5f1a09380c..ae8e8387ba9 100644 --- a/src/core/condense/__tests__/rewind-after-condense.spec.ts +++ b/src/core/condense/__tests__/rewind-after-condense.spec.ts @@ -25,22 +25,19 @@ describe("Rewind After Condense - Issue #8295", () => { it("should filter out messages tagged with condenseParent", () => { const condenseId = "summary-123" const messages: ApiMessage[] = [ - { role: "user", content: "First message", ts: 1 }, + { role: "user", content: "First message", ts: 1, condenseParent: condenseId }, { role: "assistant", content: "First response", ts: 2, condenseParent: condenseId }, { role: "user", content: "Second message", ts: 3, condenseParent: condenseId }, - { role: "assistant", content: "Summary", ts: 4, isSummary: true, condenseId }, - { role: "user", content: "Third message", ts: 5 }, - { role: "assistant", content: "Third response", ts: 6 }, + { role: "user", content: "Summary", ts: 4, isSummary: true, condenseId }, + { role: "user", content: "Third message", ts: 5, condenseParent: condenseId }, + { role: "assistant", content: "Third response", ts: 6, condenseParent: condenseId }, ] const effective = getEffectiveApiHistory(messages) - // Effective history should be: first message, summary, third message, third response - expect(effective.length).toBe(4) - expect(effective[0].content).toBe("First message") - expect(effective[1].isSummary).toBe(true) - expect(effective[2].content).toBe("Third message") - expect(effective[3].content).toBe("Third response") + // Effective history should be: summary only + expect(effective.length).toBe(1) + expect(effective[0].isSummary).toBe(true) }) it("should include messages without condenseParent", () => { @@ -131,44 +128,32 @@ describe("Rewind After Condense - Issue #8295", () => { it("should reactivate condensed messages when their summary is deleted via truncation", () => { const condenseId = "summary-abc" - // Simulate a conversation after condensing + // Simulate a conversation after condensing (all prior messages tagged) const fullHistory: ApiMessage[] = [ - { role: "user", content: "Initial task", ts: 1 }, + { role: "user", content: "Initial task", ts: 1, condenseParent: condenseId }, { role: "assistant", content: "Working on it", ts: 2, condenseParent: condenseId }, { role: "user", content: "Continue", ts: 3, condenseParent: condenseId }, - { role: "assistant", content: "Summary of work so far", ts: 4, isSummary: true, condenseId }, - { role: "user", content: "Now do this", ts: 5 }, - { role: "assistant", content: "Done", ts: 6 }, - { role: "user", content: "And this", ts: 7 }, - { role: "assistant", content: "Also done", ts: 8 }, + { role: "user", content: "Summary of work so far", ts: 4, isSummary: true, condenseId }, ] // Verify effective history before truncation const effectiveBefore = getEffectiveApiHistory(fullHistory) - // Should be: first message, summary, last 4 messages - expect(effectiveBefore.length).toBe(6) - - // Simulate rewind: user truncates back to message ts=4 (keeping 0-3) - const truncatedHistory = fullHistory.slice(0, 4) // Keep first, condensed1, condensed2, summary - - // After truncation, the summary is still there, so condensed messages remain condensed - const cleanedAfterKeepingSummary = cleanupAfterTruncation(truncatedHistory) - expect(cleanedAfterKeepingSummary[1].condenseParent).toBe(condenseId) - expect(cleanedAfterKeepingSummary[2].condenseParent).toBe(condenseId) + // Should be: summary only + expect(effectiveBefore.length).toBe(1) - // Now simulate a more aggressive rewind: delete back to message ts=2 - const aggressiveTruncate = fullHistory.slice(0, 2) // Keep only first message and first response + // Simulate rewind: delete the summary message + const withoutSummary = fullHistory.filter((m) => !m.isSummary) + const cleanedAfterDeletingSummary = cleanupAfterTruncation(withoutSummary) + for (const msg of cleanedAfterDeletingSummary) { + expect(msg.condenseParent).toBeUndefined() + } - // The condensed messages should now be reactivated since summary is gone - const cleanedAfterDeletingSummary = cleanupAfterTruncation(aggressiveTruncate) - expect(cleanedAfterDeletingSummary[1].condenseParent).toBeUndefined() - - // Verify effective history after cleanup + // Verify effective history after cleanup: all messages should be visible now const effectiveAfterCleanup = getEffectiveApiHistory(cleanedAfterDeletingSummary) - // Now both messages should be active (no condensed filtering) - expect(effectiveAfterCleanup.length).toBe(2) + expect(effectiveAfterCleanup.length).toBe(3) expect(effectiveAfterCleanup[0].content).toBe("Initial task") expect(effectiveAfterCleanup[1].content).toBe("Working on it") + expect(effectiveAfterCleanup[2].content).toBe("Continue") }) it("should properly restore context after rewind when summary was deleted", () => { @@ -315,8 +300,7 @@ describe("Rewind After Condense - Issue #8295", () => { /** * These tests verify that the correct user and assistant messages are preserved * and sent to the LLM after condense operations. With N_MESSAGES_TO_KEEP = 3, - * condense should always preserve: - * - The first message (never condensed) + * condense should always preserve (for effective history): * - The active summary * - The last 3 kept messages */ @@ -332,7 +316,7 @@ describe("Rewind After Condense - Issue #8295", () => { // - summary inserted with ts = msg8.ts - 1 // - msg8, msg9, msg10 kept const storageAfterCondense: ApiMessage[] = [ - { role: "user", content: "Task: Build a feature", ts: 100 }, + { role: "user", content: "Task: Build a feature", ts: 100, condenseParent: condenseId }, { role: "assistant", content: "I'll help with that", ts: 200, condenseParent: condenseId }, { role: "user", content: "Start with the API", ts: 300, condenseParent: condenseId }, { role: "assistant", content: "Creating API endpoints", ts: 400, condenseParent: condenseId }, @@ -355,28 +339,24 @@ describe("Rewind After Condense - Issue #8295", () => { const effective = getEffectiveApiHistory(storageAfterCondense) - // Should send exactly 5 messages to LLM: - // 1. First message (user) - preserved - // 2. Summary (assistant) - // 3-5. Last 3 kept messages - expect(effective.length).toBe(5) + // Should send exactly 4 messages to LLM: + // 1. Summary (assistant) + // 2-4. Last 3 kept messages + expect(effective.length).toBe(4) // Verify exact order and content - expect(effective[0].role).toBe("user") - expect(effective[0].content).toBe("Task: Build a feature") + expect(effective[0].role).toBe("assistant") + expect(effective[0].isSummary).toBe(true) + expect(effective[0].content).toBe("Summary: Built API with validation, working on tests") expect(effective[1].role).toBe("assistant") - expect(effective[1].isSummary).toBe(true) - expect(effective[1].content).toBe("Summary: Built API with validation, working on tests") - - expect(effective[2].role).toBe("assistant") - expect(effective[2].content).toBe("Writing unit tests now") + expect(effective[1].content).toBe("Writing unit tests now") - expect(effective[3].role).toBe("user") - expect(effective[3].content).toBe("Include edge cases") + expect(effective[2].role).toBe("user") + expect(effective[2].content).toBe("Include edge cases") - expect(effective[4].role).toBe("assistant") - expect(effective[4].content).toBe("Added edge case tests") + expect(effective[3].role).toBe("assistant") + expect(effective[3].content).toBe("Added edge case tests") // Verify condensed messages are NOT in effective history const condensedContents = ["I'll help with that", "Start with the API", "Creating API endpoints"] @@ -396,8 +376,8 @@ describe("Rewind After Condense - Issue #8295", () => { // // Storage after double condense: const storageAfterDoubleCondense: ApiMessage[] = [ - // First message - never condensed - { role: "user", content: "Initial task: Build a full app", ts: 100 }, + // First message - condensed during the first condense + { role: "user", content: "Initial task: Build a full app", ts: 100, condenseParent: condenseId1 }, // Messages from first condense (tagged with condenseId1) { role: "assistant", content: "Starting the project", ts: 200, condenseParent: condenseId1 }, @@ -446,29 +426,25 @@ describe("Rewind After Condense - Issue #8295", () => { const effective = getEffectiveApiHistory(storageAfterDoubleCondense) - // Should send exactly 5 messages to LLM: - // 1. First message (user) - preserved - // 2. Summary2 (assistant) - the ACTIVE summary - // 3-5. Last 3 kept messages - expect(effective.length).toBe(5) + // Should send exactly 4 messages to LLM: + // 1. Summary2 (assistant) - the ACTIVE summary + // 2-4. Last 3 kept messages + expect(effective.length).toBe(4) // Verify exact order and content - expect(effective[0].role).toBe("user") - expect(effective[0].content).toBe("Initial task: Build a full app") + expect(effective[0].role).toBe("assistant") + expect(effective[0].isSummary).toBe(true) + expect(effective[0].condenseId).toBe(condenseId2) // Must be the SECOND summary + expect(effective[0].content).toContain("Summary2") expect(effective[1].role).toBe("assistant") - expect(effective[1].isSummary).toBe(true) - expect(effective[1].condenseId).toBe(condenseId2) // Must be the SECOND summary - expect(effective[1].content).toContain("Summary2") - - expect(effective[2].role).toBe("assistant") - expect(effective[2].content).toBe("Writing integration tests") + expect(effective[1].content).toBe("Writing integration tests") - expect(effective[3].role).toBe("user") - expect(effective[3].content).toBe("Test the auth flow") + expect(effective[2].role).toBe("user") + expect(effective[2].content).toBe("Test the auth flow") - expect(effective[4].role).toBe("assistant") - expect(effective[4].content).toBe("Auth tests passing") + expect(effective[3].role).toBe("assistant") + expect(effective[3].content).toBe("Auth tests passing") // Verify Summary1 is NOT in effective history (it's tagged with condenseParent) const summary1 = effective.find((m) => m.content?.toString().includes("Summary1")) @@ -493,7 +469,7 @@ describe("Rewind After Condense - Issue #8295", () => { // Verify that after condense, the effective history maintains proper // user/assistant message alternation (important for API compatibility) const storage: ApiMessage[] = [ - { role: "user", content: "Start task", ts: 100 }, + { role: "user", content: "Start task", ts: 100, condenseParent: condenseId }, { role: "assistant", content: "Response 1", ts: 200, condenseParent: condenseId }, { role: "user", content: "Continue", ts: 300, condenseParent: condenseId }, { role: "assistant", content: "Summary text", ts: 399, isSummary: true, condenseId }, @@ -505,22 +481,21 @@ describe("Rewind After Condense - Issue #8295", () => { const effective = getEffectiveApiHistory(storage) - // Verify the sequence: user, assistant(summary), assistant, user, assistant + // Verify the sequence: assistant(summary), assistant, user, assistant // Note: Having two assistant messages in a row (summary + next response) is valid // because the summary replaces what would have been multiple messages - expect(effective[0].role).toBe("user") + expect(effective[0].role).toBe("assistant") + expect(effective[0].isSummary).toBe(true) expect(effective[1].role).toBe("assistant") - expect(effective[1].isSummary).toBe(true) - expect(effective[2].role).toBe("assistant") - expect(effective[3].role).toBe("user") - expect(effective[4].role).toBe("assistant") + expect(effective[2].role).toBe("user") + expect(effective[3].role).toBe("assistant") }) it("should preserve timestamps in chronological order in effective history", () => { const condenseId = "summary-timestamps" const storage: ApiMessage[] = [ - { role: "user", content: "First", ts: 100 }, + { role: "user", content: "First", ts: 100, condenseParent: condenseId }, { role: "assistant", content: "Condensed", ts: 200, condenseParent: condenseId }, { role: "assistant", content: "Summary", ts: 299, isSummary: true, condenseId }, { role: "user", content: "Kept 1", ts: 300 }, diff --git a/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts index 4d57608e9c7..541a7106212 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts @@ -264,7 +264,7 @@ describe("webviewMessageHandler delete functionality", () => { // API history after condense: msg1, msg2(tagged), msg3(tagged), summary, kept1, kept2, kept3 getCurrentTaskMock.apiConversationHistory = [ - { ts: 100, role: "user", content: "First message" }, + { ts: 100, role: "user", content: "First message", condenseParent: condenseId }, { ts: 200, role: "assistant", content: "Response 1", condenseParent: condenseId }, { ts: 300, role: "user", content: "Second message", condenseParent: condenseId }, { ts: 799, role: "assistant", content: "Summary", isSummary: true, condenseId }, @@ -286,6 +286,7 @@ describe("webviewMessageHandler delete functionality", () => { // Expected: [msg1, msg2(tagged), msg3(tagged), summary, kept1] expect(result.length).toBe(5) expect(result[0].content).toBe("First message") + expect(result[0].condenseParent).toBe(condenseId) // Tag preserved expect(result[1].content).toBe("Response 1") expect(result[1].condenseParent).toBe(condenseId) // Tag preserved expect(result[2].content).toBe("Second message") @@ -310,7 +311,7 @@ describe("webviewMessageHandler delete functionality", () => { // API history with condensed messages and summary getCurrentTaskMock.apiConversationHistory = [ - { ts: 100, role: "user", content: "Task start" }, + { ts: 100, role: "user", content: "Task start", condenseParent: condenseId }, { ts: 200, role: "assistant", content: "Response 1", condenseParent: condenseId }, { ts: 300, role: "user", content: "Message 2", condenseParent: condenseId }, { ts: 999, role: "assistant", content: "Summary", isSummary: true, condenseId }, @@ -349,7 +350,7 @@ describe("webviewMessageHandler delete functionality", () => { ] getCurrentTaskMock.apiConversationHistory = [ - { ts: 100, role: "user", content: "First message" }, + { ts: 100, role: "user", content: "First message", condenseParent: condenseId1 }, // Messages from first condense (tagged with condenseId1) { ts: 200, role: "assistant", content: "Msg2", condenseParent: condenseId1 }, { ts: 300, role: "user", content: "Msg3", condenseParent: condenseId1 }, @@ -446,7 +447,7 @@ describe("webviewMessageHandler delete functionality", () => { // Summary has ts=299 (before first kept message), so it would survive basic truncation // But since condense_context (ts=500) is being removed, Summary should be removed too getCurrentTaskMock.apiConversationHistory = [ - { ts: 100, role: "user", content: "Task start" }, + { ts: 100, role: "user", content: "Task start", condenseParent: condenseId }, { ts: 200, role: "assistant", content: "Response 1", condenseParent: condenseId }, // Summary timestamp is BEFORE the kept messages (this is the bug scenario) { ts: 299, role: "assistant", content: "Summary text", isSummary: true, condenseId }, @@ -503,7 +504,7 @@ describe("webviewMessageHandler delete functionality", () => { ] getCurrentTaskMock.apiConversationHistory = [ - { ts: 100, role: "user", content: "First message" }, + { ts: 100, role: "user", content: "First message", condenseParent: condenseId1 }, // Messages from first condense (tagged with condenseId1) { ts: 200, role: "assistant", content: "Response 1", condenseParent: condenseId1 }, // First summary (also tagged with condenseId2 from second condense) From 23da730ab69e9e0cb9b4d04c2f781ff5fec6ca41 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 20 Jan 2026 18:54:19 -0700 Subject: [PATCH 02/17] Fix post-condense consecutive user turns without mutating history --- src/core/task/Task.ts | 28 +++++++-- .../mergeConsecutiveApiMessages.spec.ts | 30 ++++++++++ src/core/task/mergeConsecutiveApiMessages.ts | 59 +++++++++++++++++++ 3 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 src/core/task/__tests__/mergeConsecutiveApiMessages.spec.ts create mode 100644 src/core/task/mergeConsecutiveApiMessages.ts diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 5109f96507f..f740b0fe7c6 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -132,6 +132,7 @@ import { MessageQueueService } from "../message-queue/MessageQueueService" import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval" import { MessageManager } from "../message-manager" import { validateAndFixToolResultIds } from "./validateToolResultIds" +import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages" const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds @@ -1019,8 +1020,15 @@ export class Task extends EventEmitter implements TaskLike { this.apiConversationHistory.push(messageWithTs) } else { - // For user messages, validate and fix tool_result IDs against the previous assistant message - const validatedMessage = validateAndFixToolResultIds(message, this.apiConversationHistory) + // For user messages, validate tool_result IDs ONLY when the immediately previous *effective* message + // is an assistant message. + // + // If the previous effective message is also a user message (e.g., summary + a new user message), + // validating against any earlier assistant message can incorrectly inject placeholder tool_results. + const effectiveHistoryForValidation = getEffectiveApiHistory(this.apiConversationHistory) + const lastEffective = effectiveHistoryForValidation[effectiveHistoryForValidation.length - 1] + const historyForValidation = lastEffective?.role === "assistant" ? effectiveHistoryForValidation : [] + const validatedMessage = validateAndFixToolResultIds(message, historyForValidation) const messageWithTs = { ...validatedMessage, ts: Date.now() } this.apiConversationHistory.push(messageWithTs) } @@ -1028,6 +1036,10 @@ export class Task extends EventEmitter implements TaskLike { await this.saveApiConversationHistory() } + // NOTE: We intentionally do NOT mutate stored messages to merge consecutive user turns. + // For API requests, consecutive same-role messages are merged via mergeConsecutiveApiMessages() + // so rewind/edit behavior can still reference original message boundaries. + async overwriteApiConversationHistory(newHistory: ApiMessage[]) { this.apiConversationHistory = newHistory await this.saveApiConversationHistory() @@ -1060,8 +1072,11 @@ export class Task extends EventEmitter implements TaskLike { content: this.userMessageContent, } - // Validate and fix tool_result IDs against the previous assistant message - const validatedMessage = validateAndFixToolResultIds(userMessage, this.apiConversationHistory) + // Validate and fix tool_result IDs when the previous *effective* message is an assistant message. + const effectiveHistoryForValidation = getEffectiveApiHistory(this.apiConversationHistory) + const lastEffective = effectiveHistoryForValidation[effectiveHistoryForValidation.length - 1] + const historyForValidation = lastEffective?.role === "assistant" ? effectiveHistoryForValidation : [] + const validatedMessage = validateAndFixToolResultIds(userMessage, historyForValidation) const userMessageWithTs = { ...validatedMessage, ts: Date.now() } this.apiConversationHistory.push(userMessageWithTs as ApiMessage) @@ -3928,7 +3943,10 @@ export class Task extends EventEmitter implements TaskLike { // enabling accurate rewind operations while still sending condensed history to the API. const effectiveHistory = getEffectiveApiHistory(this.apiConversationHistory) const messagesSinceLastSummary = getMessagesSinceLastSummary(effectiveHistory) - const messagesWithoutImages = maybeRemoveImageBlocks(messagesSinceLastSummary, this.api) + // For API only: merge consecutive user messages (e.g., summary + the next user message) + // without mutating stored history. + const mergedForApi = mergeConsecutiveApiMessages(messagesSinceLastSummary, { roles: ["user"] }) + const messagesWithoutImages = maybeRemoveImageBlocks(mergedForApi, this.api) const cleanConversationHistory = this.buildCleanConversationHistory(messagesWithoutImages as ApiMessage[]) // Check auto-approval limits diff --git a/src/core/task/__tests__/mergeConsecutiveApiMessages.spec.ts b/src/core/task/__tests__/mergeConsecutiveApiMessages.spec.ts new file mode 100644 index 00000000000..e48a52e8c94 --- /dev/null +++ b/src/core/task/__tests__/mergeConsecutiveApiMessages.spec.ts @@ -0,0 +1,30 @@ +// npx vitest run core/task/__tests__/mergeConsecutiveApiMessages.spec.ts + +import { mergeConsecutiveApiMessages } from "../mergeConsecutiveApiMessages" + +describe("mergeConsecutiveApiMessages", () => { + it("merges consecutive user messages by default", () => { + const merged = mergeConsecutiveApiMessages([ + { role: "user", content: "A", ts: 1 }, + { role: "user", content: [{ type: "text", text: "B" }], ts: 2 }, + { role: "assistant", content: "C", ts: 3 }, + ]) + + expect(merged).toHaveLength(2) + expect(merged[0].role).toBe("user") + expect(merged[0].content).toEqual([ + { type: "text", text: "A" }, + { type: "text", text: "B" }, + ]) + expect(merged[1].role).toBe("assistant") + }) + + it("does not merge summary messages", () => { + const merged = mergeConsecutiveApiMessages([ + { role: "user", content: [{ type: "text", text: "Summary" }], ts: 1, isSummary: true, condenseId: "s" }, + { role: "user", content: [{ type: "text", text: "After" }], ts: 2 }, + ]) + + expect(merged).toHaveLength(2) + }) +}) diff --git a/src/core/task/mergeConsecutiveApiMessages.ts b/src/core/task/mergeConsecutiveApiMessages.ts new file mode 100644 index 00000000000..da1e33da718 --- /dev/null +++ b/src/core/task/mergeConsecutiveApiMessages.ts @@ -0,0 +1,59 @@ +import { Anthropic } from "@anthropic-ai/sdk" + +import type { ApiMessage } from "../task-persistence" + +type Role = ApiMessage["role"] + +function normalizeContentToBlocks(content: ApiMessage["content"]): Anthropic.Messages.ContentBlockParam[] { + if (Array.isArray(content)) { + return content as Anthropic.Messages.ContentBlockParam[] + } + if (content === undefined || content === null) { + return [] + } + return [{ type: "text", text: String(content) }] +} + +/** + * Non-destructively merges consecutive messages with the same role. + * + * Used for *API request shaping only* (do not use for storage), so rewind/edit operations + * can still reference the original individual messages. + */ +export function mergeConsecutiveApiMessages(messages: ApiMessage[], options?: { roles?: Role[] }): ApiMessage[] { + if (messages.length <= 1) { + return messages + } + + const mergeRoles = new Set(options?.roles ?? ["user"]) // default: user only + const out: ApiMessage[] = [] + + for (const msg of messages) { + const prev = out[out.length - 1] + const canMerge = + prev && + prev.role === msg.role && + mergeRoles.has(msg.role) && + // Keep summary/truncation markers isolated so rewind semantics stay clear. + !prev.isSummary && + !msg.isSummary && + !prev.isTruncationMarker && + !msg.isTruncationMarker + + if (!canMerge) { + out.push(msg) + continue + } + + const mergedContent = [...normalizeContentToBlocks(prev.content), ...normalizeContentToBlocks(msg.content)] + + // Preserve the newest ts to keep chronological ordering for downstream logic. + out[out.length - 1] = { + ...prev, + content: mergedContent, + ts: Math.max(prev.ts ?? 0, msg.ts ?? 0) || prev.ts || msg.ts, + } + } + + return out +} From e1700aa0e9ad742d9844835ab0809f8ecd75a0e6 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 21 Jan 2026 12:40:37 -0700 Subject: [PATCH 03/17] feat: enhance slash command processing and summary generation in mentions --- src/__tests__/command-mentions.spec.ts | 74 ++++---- src/core/condense/__tests__/condense.spec.ts | 35 ++++ src/core/condense/index.ts | 1 - .../processUserContentMentions.spec.ts | 117 +++++++++++++ src/core/mentions/index.ts | 8 +- .../mentions/processUserContentMentions.ts | 165 +++++++++++------- 6 files changed, 294 insertions(+), 106 deletions(-) diff --git a/src/__tests__/command-mentions.spec.ts b/src/__tests__/command-mentions.spec.ts index d309045dc90..1b3ccc01aab 100644 --- a/src/__tests__/command-mentions.spec.ts +++ b/src/__tests__/command-mentions.spec.ts @@ -27,7 +27,7 @@ describe("Command Mentions", () => { // Helper function to call parseMentions with required parameters const callParseMentions = async (text: string) => { - const result = await parseMentions( + return parseMentions( text, "/test/cwd", // cwd mockUrlContentFetcher, // urlContentFetcher @@ -38,8 +38,6 @@ describe("Command Mentions", () => { 50, // maxDiagnosticMessages undefined, // maxReadFileLine ) - // Return just the text for backward compatibility with existing tests - return result.text } describe("parseMentions with command support", () => { @@ -56,10 +54,10 @@ describe("Command Mentions", () => { const result = await callParseMentions(input) expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup") - expect(result).toContain('') - expect(result).toContain(commandContent) - expect(result).toContain("") - expect(result).toContain("Please help me set up the project") + expect(result.slashCommandHelp).toContain('') + expect(result.slashCommandHelp).toContain(commandContent) + expect(result.slashCommandHelp).toContain("") + expect(result.text).toContain("Please help me set up the project") }) it("should handle multiple commands in message", async () => { @@ -99,10 +97,10 @@ describe("Command Mentions", () => { expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup") expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "deploy") expect(mockGetCommand).toHaveBeenCalledTimes(2) // Each unique command called once (optimized) - expect(result).toContain('') - expect(result).toContain("# Setup Environment") - expect(result).toContain('') - expect(result).toContain("# Deploy Environment") + expect(result.slashCommandHelp).toContain('') + expect(result.slashCommandHelp).toContain("# Setup Environment") + expect(result.slashCommandHelp).toContain('') + expect(result.slashCommandHelp).toContain("# Deploy Environment") }) it("should leave non-existent commands unchanged", async () => { @@ -114,10 +112,10 @@ describe("Command Mentions", () => { expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "nonexistent") // The command should remain unchanged in the text - expect(result).toBe("/nonexistent command") + expect(result.text).toBe("/nonexistent command") // Should not contain any command tags - expect(result).not.toContain('') - expect(result).not.toContain("Command 'nonexistent' not found") + expect(result.slashCommandHelp).toBeUndefined() + expect(result.text).not.toContain("Command 'nonexistent' not found") }) it("should handle command loading errors during existence check", async () => { @@ -129,8 +127,8 @@ describe("Command Mentions", () => { // When getCommand throws an error during existence check, // the command is treated as non-existent and left unchanged - expect(result).toBe("/error-command test") - expect(result).not.toContain('') + expect(result.text).toBe("/error-command test") + expect(result.slashCommandHelp).toBeUndefined() }) it("should handle command loading errors during processing", async () => { @@ -145,9 +143,9 @@ describe("Command Mentions", () => { const input = "/error-command test" const result = await callParseMentions(input) - expect(result).toContain('') - expect(result).toContain("# Error command") - expect(result).toContain("") + expect(result.slashCommandHelp).toContain('') + expect(result.slashCommandHelp).toContain("# Error command") + expect(result.slashCommandHelp).toContain("") }) it("should handle command names with hyphens and underscores at start", async () => { @@ -162,8 +160,8 @@ describe("Command Mentions", () => { const result = await callParseMentions(input) expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup-dev") - expect(result).toContain('') - expect(result).toContain("# Dev setup") + expect(result.slashCommandHelp).toContain('') + expect(result.slashCommandHelp).toContain("# Dev setup") }) it("should preserve command content formatting", async () => { @@ -192,13 +190,13 @@ npm install const input = "/complex command" const result = await callParseMentions(input) - expect(result).toContain('') - expect(result).toContain("# Complex Command") - expect(result).toContain("```bash") - expect(result).toContain("npm install") - expect(result).toContain("- Check file1.js") - expect(result).toContain("> **Note**: This is important!") - expect(result).toContain("") + expect(result.slashCommandHelp).toContain('') + expect(result.slashCommandHelp).toContain("# Complex Command") + expect(result.slashCommandHelp).toContain("```bash") + expect(result.slashCommandHelp).toContain("npm install") + expect(result.slashCommandHelp).toContain("- Check file1.js") + expect(result.slashCommandHelp).toContain("> **Note**: This is important!") + expect(result.slashCommandHelp).toContain("") }) it("should handle empty command content", async () => { @@ -212,8 +210,8 @@ npm install const input = "/empty command" const result = await callParseMentions(input) - expect(result).toContain('') - expect(result).toContain("") + expect(result.slashCommandHelp).toContain('') + expect(result.slashCommandHelp).toContain("") // Should still include the command tags even with empty content }) }) @@ -295,7 +293,7 @@ npm install const input = "/setup the project" const result = await callParseMentions(input) - expect(result).toContain("Command 'setup' (see below for command content)") + expect(result.text).toContain("Command 'setup' (see below for command content)") }) it("should leave non-existent command mentions unchanged", async () => { @@ -304,7 +302,7 @@ npm install const input = "/nonexistent the project" const result = await callParseMentions(input) - expect(result).toBe("/nonexistent the project") + expect(result.text).toBe("/nonexistent the project") }) it("should process multiple commands in message", async () => { @@ -325,8 +323,8 @@ npm install const input = "/setup the project\nThen /deploy later" const result = await callParseMentions(input) - expect(result).toContain("Command 'setup' (see below for command content)") - expect(result).toContain("Command 'deploy' (see below for command content)") + expect(result.text).toContain("Command 'setup' (see below for command content)") + expect(result.text).toContain("Command 'deploy' (see below for command content)") }) it("should match commands anywhere with proper word boundaries", async () => { @@ -340,22 +338,22 @@ npm install // At the beginning - should match let input = "/build the project" let result = await callParseMentions(input) - expect(result).toContain("Command 'build'") + expect(result.text).toContain("Command 'build'") // After space - should match input = "Please /build and test" result = await callParseMentions(input) - expect(result).toContain("Command 'build'") + expect(result.text).toContain("Command 'build'") // At the end - should match input = "Run the /build" result = await callParseMentions(input) - expect(result).toContain("Command 'build'") + expect(result.text).toContain("Command 'build'") // At start of new line - should match input = "Some text\n/build the project" result = await callParseMentions(input) - expect(result).toContain("Command 'build'") + expect(result.text).toContain("Command 'build'") }) }) }) diff --git a/src/core/condense/__tests__/condense.spec.ts b/src/core/condense/__tests__/condense.spec.ts index e93199c40fa..456b0b56a78 100644 --- a/src/core/condense/__tests__/condense.spec.ts +++ b/src/core/condense/__tests__/condense.spec.ts @@ -139,6 +139,41 @@ describe("Condense", () => { expect(effectiveHistory[0].role).toBe("user") }) + it("should include slash command content in the summary message", async () => { + const messages: ApiMessage[] = [ + { + role: "user", + content: [ + { type: "text", text: "Some user text" }, + { type: "text", text: 'Help content' }, + ], + }, + { role: "assistant", content: "Second message" }, + { role: "user", content: "Third message" }, + { role: "assistant", content: "Fourth message" }, + { role: "user", content: "Fifth message" }, + { role: "assistant", content: "Sixth message" }, + { role: "user", content: "Seventh message" }, + { role: "assistant", content: "Eighth message" }, + { role: "user", content: "Ninth message" }, + ] + + const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false) + + const summaryMessage = result.messages.find((msg) => msg.isSummary) + const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] + + // Should have: summary text block, slash command block, ...reminders + expect(content[0]).toEqual({ + type: "text", + text: expect.stringContaining("Mock summary of the conversation"), + }) + expect(content[1]).toEqual({ + type: "text", + text: '\nHelp content\n', + }) + }) + it("should handle complex first message content", async () => { const complexContent: Anthropic.Messages.ContentBlockParam[] = [ { type: "text", text: "/mode code" }, diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 9c4617c7a68..b03d434a2e4 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -305,7 +305,6 @@ export async function summarizeConversation( // 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 const condenseId = crypto.randomUUID() diff --git a/src/core/mentions/__tests__/processUserContentMentions.spec.ts b/src/core/mentions/__tests__/processUserContentMentions.spec.ts index b10edb0ddbb..4f45e404cc1 100644 --- a/src/core/mentions/__tests__/processUserContentMentions.spec.ts +++ b/src/core/mentions/__tests__/processUserContentMentions.spec.ts @@ -335,4 +335,121 @@ describe("processUserContentMentions", () => { ) }) }) + + describe("slash command content processing", () => { + it("should separate slash command content into a new block", async () => { + vi.mocked(parseMentions).mockResolvedValueOnce({ + text: "parsed text", + slashCommandHelp: "command help", + mode: undefined, + }) + + const userContent = [ + { + type: "text" as const, + text: "Run command", + }, + ] + + const result = await processUserContentMentions({ + userContent, + cwd: "/test", + urlContentFetcher: mockUrlContentFetcher, + fileContextTracker: mockFileContextTracker, + }) + + expect(result.content).toHaveLength(2) + expect(result.content[0]).toEqual({ + type: "text", + text: "parsed text", + }) + expect(result.content[1]).toEqual({ + type: "text", + text: "command help", + }) + }) + + it("should include slash command content in tool_result string content", async () => { + vi.mocked(parseMentions).mockResolvedValueOnce({ + text: "parsed tool output", + slashCommandHelp: "command help", + mode: undefined, + }) + + const userContent = [ + { + type: "tool_result" as const, + tool_use_id: "123", + content: "Tool output", + }, + ] + + const result = await processUserContentMentions({ + userContent, + cwd: "/test", + urlContentFetcher: mockUrlContentFetcher, + fileContextTracker: mockFileContextTracker, + }) + + expect(result.content).toHaveLength(1) + expect(result.content[0]).toEqual({ + type: "tool_result", + tool_use_id: "123", + content: [ + { + type: "text", + text: "parsed tool output", + }, + { + type: "text", + text: "command help", + }, + ], + }) + }) + + it("should include slash command content in tool_result array content", async () => { + vi.mocked(parseMentions).mockResolvedValueOnce({ + text: "parsed array item", + slashCommandHelp: "command help", + mode: undefined, + }) + + const userContent = [ + { + type: "tool_result" as const, + tool_use_id: "123", + content: [ + { + type: "text" as const, + text: "Array item", + }, + ], + }, + ] + + const result = await processUserContentMentions({ + userContent, + cwd: "/test", + urlContentFetcher: mockUrlContentFetcher, + fileContextTracker: mockFileContextTracker, + }) + + expect(result.content).toHaveLength(1) + expect(result.content[0]).toEqual({ + type: "tool_result", + tool_use_id: "123", + content: [ + { + type: "text", + text: "parsed array item", + }, + { + type: "text", + text: "command help", + }, + ], + }) + }) + }) }) diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index 2bbbf9ed0dc..ebff1bcd8c7 100644 --- a/src/core/mentions/index.ts +++ b/src/core/mentions/index.ts @@ -73,6 +73,7 @@ export async function openMention(cwd: string, mention?: string): Promise export interface ParseMentionsResult { text: string + slashCommandHelp?: string mode?: string // Mode from the first slash command that has one } @@ -246,6 +247,7 @@ export async function parseMentions( } // Process valid command mentions using cached results + let slashCommandHelp = "" for (const [commandName, command] of validCommands) { try { let commandOutput = "" @@ -253,9 +255,9 @@ export async function parseMentions( commandOutput += `Description: ${command.description}\n\n` } commandOutput += command.content - parsedText += `\n\n\n${commandOutput}\n` + slashCommandHelp += `\n\n\n${commandOutput}\n` } catch (error) { - parsedText += `\n\n\nError loading command '${commandName}': ${error.message}\n` + slashCommandHelp += `\n\n\nError loading command '${commandName}': ${error.message}\n` } } @@ -267,7 +269,7 @@ export async function parseMentions( } } - return { text: parsedText, mode: commandMode } + return { text: parsedText, mode: commandMode, slashCommandHelp: slashCommandHelp.trim() || undefined } } async function getFileOrFolderContent( diff --git a/src/core/mentions/processUserContentMentions.ts b/src/core/mentions/processUserContentMentions.ts index 0793a0fba35..79911adcb91 100644 --- a/src/core/mentions/processUserContentMentions.ts +++ b/src/core/mentions/processUserContentMentions.ts @@ -42,39 +42,15 @@ export async function processUserContentMentions({ // 2. ToolResultBlockParam's content/context text arrays if it contains // "" - we place all user generated content in this tag // so it can effectively be used as a marker for when we should parse mentions. - const content = await Promise.all( - userContent.map(async (block) => { - const shouldProcessMentions = (text: string) => text.includes("") + const content = ( + await Promise.all( + userContent.map(async (block) => { + const shouldProcessMentions = (text: string) => text.includes("") - if (block.type === "text") { - if (shouldProcessMentions(block.text)) { - const result = await parseMentions( - block.text, - cwd, - urlContentFetcher, - fileContextTracker, - rooIgnoreController, - showRooIgnoredFiles, - includeDiagnosticMessages, - maxDiagnosticMessages, - maxReadFileLine, - ) - // Capture the first mode found - if (!commandMode && result.mode) { - commandMode = result.mode - } - return { - ...block, - text: result.text, - } - } - - return block - } else if (block.type === "tool_result") { - if (typeof block.content === "string") { - if (shouldProcessMentions(block.content)) { + if (block.type === "text") { + if (shouldProcessMentions(block.text)) { const result = await parseMentions( - block.content, + block.text, cwd, urlContentFetcher, fileContextTracker, @@ -88,51 +64,112 @@ export async function processUserContentMentions({ if (!commandMode && result.mode) { commandMode = result.mode } - return { - ...block, - content: result.text, + const blocks: Anthropic.Messages.ContentBlockParam[] = [ + { + ...block, + text: result.text, + }, + ] + if (result.slashCommandHelp) { + blocks.push({ + type: "text" as const, + text: result.slashCommandHelp, + }) } + return blocks } return block - } else if (Array.isArray(block.content)) { - const parsedContent = await Promise.all( - block.content.map(async (contentBlock) => { - if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) { - const result = await parseMentions( - contentBlock.text, - cwd, - urlContentFetcher, - fileContextTracker, - rooIgnoreController, - showRooIgnoredFiles, - includeDiagnosticMessages, - maxDiagnosticMessages, - maxReadFileLine, - ) - // Capture the first mode found - if (!commandMode && result.mode) { - commandMode = result.mode - } + } else if (block.type === "tool_result") { + if (typeof block.content === "string") { + if (shouldProcessMentions(block.content)) { + const result = await parseMentions( + block.content, + cwd, + urlContentFetcher, + fileContextTracker, + rooIgnoreController, + showRooIgnoredFiles, + includeDiagnosticMessages, + maxDiagnosticMessages, + maxReadFileLine, + ) + // Capture the first mode found + if (!commandMode && result.mode) { + commandMode = result.mode + } + if (result.slashCommandHelp) { return { - ...contentBlock, - text: result.text, + ...block, + content: [ + { + type: "text" as const, + text: result.text, + }, + { + type: "text" as const, + text: result.slashCommandHelp, + }, + ], } } + return { + ...block, + content: result.text, + } + } - return contentBlock - }), - ) + return block + } else if (Array.isArray(block.content)) { + const parsedContent = ( + await Promise.all( + block.content.map(async (contentBlock) => { + if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) { + const result = await parseMentions( + contentBlock.text, + cwd, + urlContentFetcher, + fileContextTracker, + rooIgnoreController, + showRooIgnoredFiles, + includeDiagnosticMessages, + maxDiagnosticMessages, + maxReadFileLine, + ) + // Capture the first mode found + if (!commandMode && result.mode) { + commandMode = result.mode + } + const blocks = [ + { + ...contentBlock, + text: result.text, + }, + ] + if (result.slashCommandHelp) { + blocks.push({ + type: "text" as const, + text: result.slashCommandHelp, + }) + } + return blocks + } - return { ...block, content: parsedContent } + return contentBlock + }), + ) + ).flat() + + return { ...block, content: parsedContent } + } + + return block } return block - } - - return block - }), - ) + }), + ) + ).flat() return { content, mode: commandMode } } From defdf0cc4c7fe9800b2ceede5bd5ee75c4cc0d64 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 21 Jan 2026 18:36:21 -0700 Subject: [PATCH 04/17] fix(condense): update comments and remove redundant loop in getEffectiveApiHistory - Update comment to accurately describe variable content structure (optional command block) - Remove redundant O(n*m) loop that duplicated the .has() check for condenseParent filtering --- src/core/condense/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index b03d434a2e4..65d78986e0f 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -435,7 +435,7 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] { // Filter out messages whose condenseParent points to an existing summary // or whose truncationParent points to an existing truncation marker. - // Messages with orphaned parents (summary/marker was deleted) are included + // Messages with orphaned parents (summary/marker was deleted) are included. return messages.filter((msg) => { // Filter out condensed messages if their summary exists if (msg.condenseParent && existingSummaryIds.has(msg.condenseParent)) { From ff6a3697eb2f0520b496028e291a5b9d9a280c50 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 21 Jan 2026 19:16:33 -0700 Subject: [PATCH 05/17] refactor: swap prompt values - detailed instructions as user message, simple instruction as system prompt --- src/core/condense/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 65d78986e0f..62b71b67885 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -154,7 +154,7 @@ export const N_MESSAGES_TO_KEEP = 3 export const MIN_CONDENSE_THRESHOLD = 5 // Minimum percentage of context window to trigger condensing export const MAX_CONDENSE_THRESHOLD = 100 // Maximum percentage of context window to trigger condensing -const SUMMARY_PROMPT = supportPrompt.default.CONDENSE +const SUMMARY_PROMPT = "Summarize the conversation so far, as described in the prompt instructions." export type SummarizeResponse = { messages: ApiMessage[] // The messages after summarization @@ -229,7 +229,7 @@ export async function summarizeConversation( const finalRequestMessage: Anthropic.MessageParam = { role: "user", - content: "Summarize the conversation so far, as described in the prompt instructions.", + content: supportPrompt.default.CONDENSE, } const requestMessages = maybeRemoveImageBlocks([...messagesToSummarize, finalRequestMessage], apiHandler).map( From 54f967867cdee7e8d128a3ce0796f23a955cdaa1 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 21 Jan 2026 19:19:16 -0700 Subject: [PATCH 06/17] test: update condense tests for swapped prompt values --- src/core/condense/__tests__/index.spec.ts | 35 ++++++++++++----------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index ab48fdd0719..c5836b78187 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -837,21 +837,18 @@ describe("summarizeConversation", () => { await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId, DEFAULT_PREV_CONTEXT_TOKENS) - // Verify the final request message - const expectedFinalMessage = { - role: "user", - content: "Summarize the conversation so far, as described in the prompt instructions.", - } - - // Verify that createMessage was called with the correct prompt + // Verify that createMessage was called with the simple system prompt expect(mockApiHandler.createMessage).toHaveBeenCalledWith( - expect.stringContaining("Your task is to create a detailed summary of the conversation"), + "Summarize the conversation so far, as described in the prompt instructions.", expect.any(Array), ) // Check that maybeRemoveImageBlocks was called with the correct messages + // The final request message now contains the detailed CONDENSE instructions const mockCallArgs = (maybeRemoveImageBlocks as Mock).mock.calls[0][0] as any[] - expect(mockCallArgs[mockCallArgs.length - 1]).toEqual(expectedFinalMessage) + const finalMessage = mockCallArgs[mockCallArgs.length - 1] + expect(finalMessage.role).toBe("user") + expect(finalMessage.content).toContain("Your task is to create a detailed summary of the conversation") }) it("should include the original first user message in summarization input", async () => { const messages: ApiMessage[] = [ @@ -1217,10 +1214,10 @@ describe("summarizeConversation", () => { expect(capturedRequestMessages).toBeDefined() const requestMessages = capturedRequestMessages! - expect(requestMessages[requestMessages.length - 1]).toEqual({ - role: "user", - content: "Summarize the conversation so far, as described in the prompt instructions.", - }) + // The final request message now contains the detailed CONDENSE instructions + const finalMessage = requestMessages[requestMessages.length - 1] + expect(finalMessage.role).toBe("user") + expect(finalMessage.content).toContain("Your task is to create a detailed summary of the conversation") const historyMessages = requestMessages.slice(0, -1) expect(historyMessages.length).toBeGreaterThanOrEqual(2) @@ -1487,10 +1484,12 @@ describe("summarizeConversation with custom settings", () => { " ", // Empty custom prompt ) - // Verify the default prompt was used + // Verify the default prompt was used (simple system prompt) let createMessageCalls = (mockMainApiHandler.createMessage as Mock).mock.calls expect(createMessageCalls.length).toBe(1) - expect(createMessageCalls[0][0]).toContain("Your task is to create a detailed summary") + expect(createMessageCalls[0][0]).toBe( + "Summarize the conversation so far, as described in the prompt instructions.", + ) // Reset mock and test with undefined vi.clearAllMocks() @@ -1504,10 +1503,12 @@ describe("summarizeConversation with custom settings", () => { undefined, // No custom prompt ) - // Verify the default prompt was used again + // Verify the default prompt was used again (simple system prompt) createMessageCalls = (mockMainApiHandler.createMessage as Mock).mock.calls expect(createMessageCalls.length).toBe(1) - expect(createMessageCalls[0][0]).toContain("Your task is to create a detailed summary") + expect(createMessageCalls[0][0]).toBe( + "Summarize the conversation so far, as described in the prompt instructions.", + ) }) /** From 926afbe9d84255a1002c1f8bf500762140741862 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 21 Jan 2026 19:25:51 -0700 Subject: [PATCH 07/17] feat(condense): improve CONDENSE prompt with structured analysis and clearer sections - Add step before summary for structured thinking - Restructure sections for clarity and completeness - Add new sections: 'Errors and fixes', 'All user messages' - Separate 'Pending Tasks' and 'Optional Next Step' sections - Add example output formatting with XML-style tags - Include instructions for handling additional summarization instructions --- src/shared/support-prompt.ts | 127 +++++++++++++++++++++++++---------- 1 file changed, 93 insertions(+), 34 deletions(-) diff --git a/src/shared/support-prompt.ts b/src/shared/support-prompt.ts index 51f4310fc2e..bbf180d3b69 100644 --- a/src/shared/support-prompt.ts +++ b/src/shared/support-prompt.ts @@ -53,42 +53,101 @@ const supportPromptConfigs: Record = { }, CONDENSE: { template: `Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. -This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing with the conversation and supporting any continuing tasks. - -Your summary should be structured as follows: -Context: The context to continue the conversation with. If applicable based on the current task, this should include: - 1. Previous Conversation: High level details about what was discussed throughout the entire conversation with the user. This should be written to allow someone to be able to follow the general overarching conversation flow. - 2. Current Work: Describe in detail what was being worked on prior to this request to summarize the conversation. Pay special attention to the more recent messages in the conversation. - 3. Key Technical Concepts: List all important technical concepts, technologies, coding conventions, and frameworks discussed, which might be relevant for continuing with this work. - 4. Relevant Files and Code: If applicable, enumerate specific files and code sections examined, modified, or created for the task continuation. Pay special attention to the most recent messages and changes. - 5. Problem Solving: Document problems solved thus far and any ongoing troubleshooting efforts. - 6. Pending Tasks and Next Steps: Outline all pending tasks that you have explicitly been asked to work on, as well as list the next steps you will take for all outstanding work, if applicable. Include code snippets where they add clarity. For any next steps, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no information loss in context between tasks. - -Example summary structure: -1. Previous Conversation: - [Detailed description] -2. Current Work: - [Detailed description] -3. Key Technical Concepts: - - [Concept 1] - - [Concept 2] - - [...] -4. Relevant Files and Code: - - [File Name 1] - - [Summary of why this file is important] - - [Summary of the changes made to this file, if any] - - [Important Code Snippet] - - [File Name 2] - - [Important Code Snippet] - - [...] +This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context. + +Before providing your final summary, wrap your analysis in tags to organize your thoughts and ensure you've covered all necessary points. In your analysis process: + +1. Chronologically analyze each message and section of the conversation. For each section thoroughly identify: + - The user's explicit requests and intents + - Your approach to addressing the user's requests + - Key decisions, technical concepts and code patterns + - Specific details like: + - file names + - full code snippets + - function signatures + - file edits + - Errors that you ran into and how you fixed them + - Pay special attention to specific user feedback that you received, especially if the user told you to do something differently. +2. Double-check for technical accuracy and completeness, addressing each required element thoroughly. + +Your summary should include the following sections: + +1. Primary Request and Intent: Capture all of the user's explicit requests and intents in detail +2. Key Technical Concepts: List all important technical concepts, technologies, and frameworks discussed. +3. Files and Code Sections: Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages and include full code snippets where applicable and include a summary of why this file read or edit is important. +4. Errors and fixes: List all errors that you ran into, and how you fixed them. Pay special attention to specific user feedback that you received, especially if the user told you to do something differently. +5. Problem Solving: Document problems solved and any ongoing troubleshooting efforts. +6. All user messages: List ALL user messages that are not tool results. These are critical for understanding the users' feedback and changing intent. +6. Pending Tasks: Outline any pending tasks that you have explicitly been asked to work on. +7. Current Work: Describe in detail precisely what was being worked on immediately before this summary request, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable. +8. Optional Next Step: List the next step that you will take that is related to the most recent work you were doing. IMPORTANT: ensure that this step is DIRECTLY in line with the user's most recent explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the users request. Do not start on tangential requests or really old requests that were already completed without confirming with the user first. + +If there is a next step, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no drift in task interpretation. + +Here's an example of how your output should be structured: + + + +[Your thought process, ensuring all points are covered thoroughly and accurately] + + + +1. Primary Request and Intent: + [Detailed description] + +2. Key Technical Concepts: + - [Concept 1] + - [Concept 2] + - [...] + +3. Files and Code Sections: + - [File Name 1] + - [Summary of why this file is important] + - [Summary of the changes made to this file, if any] + - [Important Code Snippet] + - [File Name 2] + - [Important Code Snippet] + - [...] + +4. Errors and fixes: + - [Detailed description of error 1]: + - [How you fixed the error] + - [User feedback on the error if any] + - [...] + 5. Problem Solving: - [Detailed description] -6. Pending Tasks and Next Steps: - - [Task 1 details & next steps] - - [Task 2 details & next steps] - - [...] + [Description of solved problems and ongoing troubleshooting] + +6. All user messages: + - [Detailed non tool use user message] + - [...] + +7. Pending Tasks: + - [Task 1] + - [Task 2] + - [...] + +8. Current Work: + [Precise description of current work] + +9. Optional Next Step: + [Optional Next step to take] + + + + +Please provide your summary based on the conversation so far, following this structure and ensuring precision and thoroughness in your response. + +There may be additional summarization instructions provided in the included context. If so, remember to follow these instructions when creating the above summary. Examples of instructions include: + +## Compact Instructions +When summarizing the conversation focus on typescript code changes and also remember the mistakes you made and how you fixed them. + -Output only the summary of the conversation so far, without any additional commentary or explanation.`, + +# Summary instructions +When you are using compact - please focus on test output and code changes. Include file reads verbatim. +`, }, EXPLAIN: { template: `Explain the following code from file path \${filePath}:\${startLine}-\${endLine} From c96010fc0257ec8519e426a680d6b4685f402334 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 21 Jan 2026 21:45:00 -0700 Subject: [PATCH 08/17] fix(condense): update SUMMARY_PROMPT to use general AI assistant introduction Replace task-specific summarization instruction with a more neutral AI assistant prompt that describes the assistant's role for summarization. --- src/core/condense/__tests__/index.spec.ts | 10 +++------- src/core/condense/index.ts | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index c5836b78187..639b6460c08 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -839,7 +839,7 @@ describe("summarizeConversation", () => { // Verify that createMessage was called with the simple system prompt expect(mockApiHandler.createMessage).toHaveBeenCalledWith( - "Summarize the conversation so far, as described in the prompt instructions.", + "You are a helpful AI assistant tasked with summarizing conversations.", expect.any(Array), ) @@ -1487,9 +1487,7 @@ describe("summarizeConversation with custom settings", () => { // Verify the default prompt was used (simple system prompt) let createMessageCalls = (mockMainApiHandler.createMessage as Mock).mock.calls expect(createMessageCalls.length).toBe(1) - expect(createMessageCalls[0][0]).toBe( - "Summarize the conversation so far, as described in the prompt instructions.", - ) + expect(createMessageCalls[0][0]).toBe("You are a helpful AI assistant tasked with summarizing conversations.") // Reset mock and test with undefined vi.clearAllMocks() @@ -1506,9 +1504,7 @@ describe("summarizeConversation with custom settings", () => { // Verify the default prompt was used again (simple system prompt) createMessageCalls = (mockMainApiHandler.createMessage as Mock).mock.calls expect(createMessageCalls.length).toBe(1) - expect(createMessageCalls[0][0]).toBe( - "Summarize the conversation so far, as described in the prompt instructions.", - ) + expect(createMessageCalls[0][0]).toBe("You are a helpful AI assistant tasked with summarizing conversations.") }) /** diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 62b71b67885..64ba4da6c69 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -154,7 +154,7 @@ export const N_MESSAGES_TO_KEEP = 3 export const MIN_CONDENSE_THRESHOLD = 5 // Minimum percentage of context window to trigger condensing export const MAX_CONDENSE_THRESHOLD = 100 // Maximum percentage of context window to trigger condensing -const SUMMARY_PROMPT = "Summarize the conversation so far, as described in the prompt instructions." +const SUMMARY_PROMPT = "You are a helpful AI assistant tasked with summarizing conversations." export type SummarizeResponse = { messages: ApiMessage[] // The messages after summarization From b773ddcda1f557a83f8347b77f4ab8e2635d3444 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 22 Jan 2026 14:05:27 -0700 Subject: [PATCH 09/17] test(condense): update expectations for condense-rework --- src/core/condense/__tests__/index.spec.ts | 112 ++++++++++++---------- 1 file changed, 62 insertions(+), 50 deletions(-) diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index 639b6460c08..54dfa4ad0f6 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -32,6 +32,15 @@ vi.mock("@roo-code/telemetry", () => ({ const taskId = "test-task-id" const DEFAULT_PREV_CONTEXT_TOKENS = 1000 +type ReasoningBlock = { type: "reasoning"; text: string } + +function isReasoningBlock(block: unknown): block is ReasoningBlock { + if (typeof block !== "object" || block === null) { + return false + } + return (block as Record).type === "reasoning" +} + describe("getKeepMessagesWithToolBlocks", () => { it("should return keepMessages without tool blocks when no tool_result blocks in first kept message", () => { const messages: ApiMessage[] = [ @@ -607,6 +616,7 @@ describe("getMessagesSinceLastSummary", () => { const result = getMessagesSinceLastSummary(messages) expect(result).toEqual([ + { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Summary of conversation", ts: 3, isSummary: true }, { role: "user", content: "How are you?", ts: 4 }, { role: "assistant", content: "I'm good", ts: 5 }, @@ -624,6 +634,7 @@ describe("getMessagesSinceLastSummary", () => { const result = getMessagesSinceLastSummary(messages) expect(result).toEqual([ + { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Second summary", ts: 4, isSummary: true }, { role: "user", content: "What's new?", ts: 5 }, ]) @@ -745,39 +756,43 @@ describe("summarizeConversation", () => { expect(maybeRemoveImageBlocks).toHaveBeenCalled() // With the new condense output, the result contains all original messages (tagged) - // plus a final condensed message. + // plus a condensed summary message inserted before the last N kept messages. expect(result.messages.length).toBe(messages.length + 1) - // All prior messages should be tagged + // Middle messages should be tagged (excluding the first message and the last N kept messages) const summaryMessage = result.messages.find((m) => m.isSummary) expect(summaryMessage).toBeDefined() const condenseId = summaryMessage!.condenseId expect(condenseId).toBeDefined() - for (const msg of result.messages.slice(0, -1)) { + const keepStartIndex = Math.max(messages.length - N_MESSAGES_TO_KEEP, 0) + const middleTagged = result.messages.slice(1, keepStartIndex) + for (const msg of middleTagged) { expect(msg.condenseParent).toBe(condenseId) } + const untagged = [result.messages[0], ...result.messages.slice(keepStartIndex)] + for (const msg of untagged) { + expect(msg.condenseParent).toBeUndefined() + } - // Final condensed message is role=user with 4 text blocks: summary + 3 reminders - expect(summaryMessage!.role).toBe("user") + // Summary message is an assistant message with a synthetic reasoning block + summary text + expect(summaryMessage!.role).toBe("assistant") expect(Array.isArray(summaryMessage!.content)).toBe(true) const content = summaryMessage!.content as any[] - expect(content).toHaveLength(4) - expect(content[0].type).toBe("text") + expect(content).toHaveLength(2) + expect(content[0].type).toBe("reasoning") expect(content[0].text).toContain("Condensing conversation context") - expect(content[0].text).toContain("This is a summary") - for (const reminder of content.slice(1)) { - expect(reminder.type).toBe("text") - expect(reminder.text).toContain(" { // Verify that countTokens was called with system prompt + final condensed message expect(mockApiHandler.countTokens).toHaveBeenCalled() - // newContextTokens is now derived solely from countTokens(systemPrompt + condensed message) - expect(result.newContextTokens).toBe(100) + // newContextTokens includes the summary output tokens plus countTokens(systemPrompt + kept messages) + expect(result.newContextTokens).toBe(300) expect(result.cost).toBe(0.06) expect(result.summary).toBe("This is a summary with system prompt") expect(result.error).toBeUndefined() @@ -989,14 +1004,14 @@ describe("summarizeConversation", () => { prevContextTokens, ) - // With the new condense output, result contains all messages plus final condensed message + // With the new condense output, result contains all messages plus a condensed summary message expect(result.messages.length).toBe(messages.length + 1) const effectiveHistory = getEffectiveApiHistory(result.messages) - expect(effectiveHistory.length).toBe(1) + expect(effectiveHistory.length).toBe(2 + N_MESSAGES_TO_KEEP) expect(result.cost).toBe(0.03) expect(result.summary).toBe("Concise summary") expect(result.error).toBeUndefined() - expect(result.newContextTokens).toBe(30) // countTokens(systemPrompt + condensed message) + expect(result.newContextTokens).toBe(80) // outputTokens(50) + countTokens(systemPrompt + kept messages)(30) expect(result.newContextTokens).toBeLessThan(prevContextTokens) }) @@ -1093,7 +1108,7 @@ describe("summarizeConversation", () => { console.error = originalError }) - it("should NOT preserve tool_use blocks; tool_result is captured in ", async () => { + it("should preserve tool_use blocks when needed for tool_result pairing (native tools)", async () => { const toolUseBlock = { type: "tool_use" as const, id: "toolu_123", @@ -1141,23 +1156,23 @@ describe("summarizeConversation", () => { DEFAULT_PREV_CONTEXT_TOKENS, ) - // Find the final condensed message + // Find the condensed summary message const summaryMessage = result.messages.find((m) => m.isSummary) expect(summaryMessage).toBeDefined() - expect(summaryMessage!.role).toBe("user") + expect(summaryMessage!.role).toBe("assistant") expect(summaryMessage!.isSummary).toBe(true) expect(Array.isArray(summaryMessage!.content)).toBe(true) - // Content is 4 text blocks; tool_result should appear in the reminder JSON + // Content should include synthetic reasoning, summary text, and the preserved tool_use block const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] - expect(content).toHaveLength(4) - const reminderText = (content[1] as Anthropic.Messages.TextBlockParam).text - expect(reminderText).toContain("tool_result") - expect(reminderText).toContain(toolResultBlock.tool_use_id) + expect(content.some((b) => isReasoningBlock(b))).toBe(true) + expect(content.some((b) => b.type === "text")).toBe(true) + expect(content.some((b) => b.type === "tool_use")).toBe(true) - // Effective history should be only the final condensed message + // Effective history should contain: first message + summary + last N kept messages const effectiveHistory = getEffectiveApiHistory(result.messages) - expect(effectiveHistory).toEqual([summaryMessage]) + const keepMessages = messages.slice(-N_MESSAGES_TO_KEEP) + expect(effectiveHistory).toEqual([messages[0], summaryMessage!, ...keepMessages]) expect(result.error).toBeUndefined() }) @@ -1233,7 +1248,7 @@ describe("summarizeConversation", () => { expect(hasToolResultUserMessage).toBe(true) }) - it("should not append tool_use blocks for parallel tool calls (captured in reminders)", async () => { + it("should preserve multiple tool_use blocks for parallel tool calls when tool_results are kept", async () => { const toolUseBlockA = { type: "tool_use" as const, id: "toolu_parallel_1", @@ -1278,17 +1293,15 @@ describe("summarizeConversation", () => { const summaryMessage = result.messages.find((m) => m.isSummary) expect(summaryMessage).toBeDefined() - expect(summaryMessage!.role).toBe("user") + expect(summaryMessage!.role).toBe("assistant") expect(Array.isArray(summaryMessage!.content)).toBe(true) const summaryContent = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] - expect(summaryContent).toHaveLength(4) - // tool_use blocks should not appear directly; they should only appear inside reminder JSON - for (const block of summaryContent) { - expect(block.type).toBe("text") - } + // Should include the preserved tool_use blocks directly + expect(summaryContent.some((b) => b.type === "tool_use" && b.id === "toolu_parallel_1")).toBe(true) + expect(summaryContent.some((b) => b.type === "tool_use" && b.id === "toolu_parallel_2")).toBe(true) }) - it("should not include reasoning blocks in condensed message (all text blocks)", async () => { + it("should include synthetic reasoning (and preserve reasoning when tool_use blocks are preserved)", async () => { const reasoningBlock = { type: "reasoning" as const, text: "Let me think about this step by step...", @@ -1343,18 +1356,18 @@ describe("summarizeConversation", () => { const summaryMessage = result.messages.find((m) => m.isSummary) expect(summaryMessage).toBeDefined() - expect(summaryMessage!.role).toBe("user") + expect(summaryMessage!.role).toBe("assistant") expect(summaryMessage!.isSummary).toBe(true) expect(Array.isArray(summaryMessage!.content)).toBe(true) const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] - expect(content).toHaveLength(4) - for (const block of content) { - expect(block.type).toBe("text") - } + // synthetic reasoning + preserved reasoning + summary text + tool_use + expect(content.some((b) => isReasoningBlock(b))).toBe(true) + expect(content.some((b) => b.type === "tool_use")).toBe(true) + expect(content.some((b) => b.type === "text")).toBe(true) expect(result.error).toBeUndefined() }) - it("should produce 4 text blocks in condensed message even without tool calls", async () => { + it("should produce reasoning + summary text blocks in condensed message without tool calls", async () => { const messages: ApiMessage[] = [ { role: "user", content: "Tell me a joke", ts: 1 }, { role: "assistant", content: "Why did the programmer quit?", ts: 2 }, @@ -1384,15 +1397,14 @@ describe("summarizeConversation", () => { const summaryMessage = result.messages.find((m) => m.isSummary) expect(summaryMessage).toBeDefined() - expect(summaryMessage!.role).toBe("user") + expect(summaryMessage!.role).toBe("assistant") expect(summaryMessage!.isSummary).toBe(true) expect(Array.isArray(summaryMessage!.content)).toBe(true) const content = summaryMessage!.content as any[] - expect(content).toHaveLength(4) - for (const block of content) { - expect(block.type).toBe("text") - } - expect(content[0].text).toContain("Summary: User requested jokes.") + expect(content).toHaveLength(2) + expect(content[0].type).toBe("reasoning") + expect(content[1].type).toBe("text") + expect(content[1].text).toContain("Summary: User requested jokes.") expect(result.error).toBeUndefined() }) From fb5393562126acf1da248a9b936f5ad2a37d9fad Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 22 Jan 2026 14:08:19 -0700 Subject: [PATCH 10/17] test(condense): align condense.spec with condense-rework behavior --- src/core/condense/__tests__/condense.spec.ts | 95 +++++++++----------- 1 file changed, 42 insertions(+), 53 deletions(-) diff --git a/src/core/condense/__tests__/condense.spec.ts b/src/core/condense/__tests__/condense.spec.ts index 456b0b56a78..c2ac88a65d2 100644 --- a/src/core/condense/__tests__/condense.spec.ts +++ b/src/core/condense/__tests__/condense.spec.ts @@ -64,7 +64,7 @@ describe("Condense", () => { }) describe("summarizeConversation", () => { - it("should not preserve the first message when summarizing", async () => { + it("should preserve the first message and insert a summary before the last N messages", async () => { const messages: ApiMessage[] = [ { role: "user", content: "First message with /prr command content" }, { role: "assistant", content: "Second message" }, @@ -79,37 +79,28 @@ describe("Condense", () => { const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false) - // Verify the first message is tagged for condensing + // Verify the first message is preserved and NOT tagged for condensing expect(result.messages[0].content).toBe("First message with /prr command content") - expect(result.messages[0].condenseParent).toBeDefined() + expect(result.messages[0].condenseParent).toBeUndefined() - // Verify we have a final condensed summary message (role=user) + // Verify we have a summary message (role=assistant) const summaryMessage = result.messages.find((msg) => msg.isSummary) expect(summaryMessage).toBeTruthy() - expect(summaryMessage!.role).toBe("user") + expect(summaryMessage!.role).toBe("assistant") expect(Array.isArray(summaryMessage!.content)).toBe(true) - const contentArray = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] - expect(contentArray).toHaveLength(4) - expect(contentArray[0]).toEqual({ - type: "text", - text: expect.stringContaining("Mock summary of the conversation"), - }) - for (const reminderBlock of contentArray.slice(1)) { - expect(reminderBlock.type).toBe("text") - expect((reminderBlock as Anthropic.Messages.TextBlockParam).text).toContain(" b.type === "text")).toBe(true) + expect(contentArray.some((b) => b.type === "reasoning")).toBe(true) - // With the new condense output, only the final summary message is effective for API + // With condense-rework, the effective API history is: + // [firstMessage, summaryMessage, ...last N kept messages] expect(result.messages.length).toBe(messages.length + 1) // All original messages + final summary const effectiveHistory = getEffectiveApiHistory(result.messages) - expect(effectiveHistory.length).toBe(1) - expect(effectiveHistory[0]).toBe(summaryMessage) - - // Verify the last N messages are ALSO tagged (embedded as reminders, not kept in effective history) - const condenseId = summaryMessage!.condenseId - expect(condenseId).toBeDefined() - for (const msg of result.messages.slice(0, -1).slice(-N_MESSAGES_TO_KEEP)) { - expect(msg.condenseParent).toBe(condenseId) + expect(effectiveHistory.length).toBe(2 + N_MESSAGES_TO_KEEP) + expect(effectiveHistory[0]).toEqual(messages[0]) + expect(effectiveHistory[1]).toBe(summaryMessage) + for (const msg of effectiveHistory.slice(2)) { + expect(messages.slice(-N_MESSAGES_TO_KEEP)).toContainEqual(msg) } }) @@ -129,17 +120,19 @@ describe("Condense", () => { const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false) - // The first message with slash command should still be intact (but tagged for condensing) + // The first message with slash command should still be intact (and NOT tagged for condensing) expect(result.messages[0].content).toBe(slashCommandContent) - expect(result.messages[0].condenseParent).toBeDefined() + expect(result.messages[0].condenseParent).toBeUndefined() - // Effective history should contain only the final condensed message + // Effective history should contain the original first message, the summary, and the last N messages const effectiveHistory = getEffectiveApiHistory(result.messages) - expect(effectiveHistory).toHaveLength(1) + expect(effectiveHistory).toHaveLength(2 + N_MESSAGES_TO_KEEP) expect(effectiveHistory[0].role).toBe("user") + expect(effectiveHistory[0].content).toBe(slashCommandContent) + expect(effectiveHistory[1].isSummary).toBe(true) }) - it("should include slash command content in the summary message", async () => { + it("should keep blocks in the preserved first message", async () => { const messages: ApiMessage[] = [ { role: "user", @@ -160,18 +153,12 @@ describe("Condense", () => { const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false) - const summaryMessage = result.messages.find((msg) => msg.isSummary) - const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] - - // Should have: summary text block, slash command block, ...reminders - expect(content[0]).toEqual({ - type: "text", - text: expect.stringContaining("Mock summary of the conversation"), - }) - expect(content[1]).toEqual({ - type: "text", - text: '\nHelp content\n', - }) + const effectiveHistory = getEffectiveApiHistory(result.messages) + const first = effectiveHistory[0] + expect(first.role).toBe("user") + expect(Array.isArray(first.content)).toBe(true) + const firstContent = first.content as any[] + expect(firstContent.some((b) => b.type === "text" && b.text.includes(' { @@ -194,13 +181,13 @@ describe("Condense", () => { const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false) - // The first message with complex content should still be present (but tagged for condensing) + // The first message with complex content should still be present (and NOT tagged) expect(result.messages[0].content).toEqual(complexContent) - expect(result.messages[0].condenseParent).toBeDefined() + expect(result.messages[0].condenseParent).toBeUndefined() - // Effective history should contain only the final condensed message + // Effective history should contain the first message + summary + last N kept messages const effectiveHistory = getEffectiveApiHistory(result.messages) - expect(effectiveHistory).toHaveLength(1) + expect(effectiveHistory).toHaveLength(2 + N_MESSAGES_TO_KEEP) expect(effectiveHistory[0].role).toBe("user") }) @@ -295,10 +282,11 @@ describe("Condense", () => { const result = getMessagesSinceLastSummary(messages) - // Starts at the summary and includes messages after - expect(result[0]).toEqual(messages[2]) // The summary - expect(result[1]).toEqual(messages[3]) - expect(result[2]).toEqual(messages[4]) + // Preserves the original first user message when the slice would start with an assistant message + expect(result[0]).toEqual(messages[0]) + expect(result[1]).toEqual(messages[2]) // The summary + expect(result[2]).toEqual(messages[3]) + expect(result[3]).toEqual(messages[4]) }) it("should handle multiple summaries and return from the last one", () => { @@ -313,10 +301,11 @@ describe("Condense", () => { const result = getMessagesSinceLastSummary(messages) - // Starts at the last summary and includes messages after - expect(result[0]).toEqual(messages[3]) // Second summary - expect(result[1]).toEqual(messages[4]) - expect(result[2]).toEqual(messages[5]) + // Preserves the original first user message when the slice would start with an assistant message + expect(result[0]).toEqual(messages[0]) + expect(result[1]).toEqual(messages[3]) // Second summary + expect(result[2]).toEqual(messages[4]) + expect(result[3]).toEqual(messages[5]) }) }) }) From b375498f5162d28ca38c23542bb61be90033095c Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 22 Jan 2026 19:17:45 -0700 Subject: [PATCH 11/17] fix: improve condensation robustness and error handling - Fix orphan tool_result filtering after fresh start condensation - Add CRITICAL instructions to SUMMARY_PROMPT and CONDENSE template to prevent tool calls during condensing - Inject synthetic tool_results for orphan tool_calls before condensing - Pass tools/metadata to condensation API call - Use standard ErrorRow with Details button for condensation errors - Remove stack trace from error details - Separate summary content into multiple text blocks - Update tests for fresh start model behavior --- src/core/condense/__tests__/condense.spec.ts | 291 +++- src/core/condense/__tests__/index.spec.ts | 1408 +++++++---------- .../__tests__/rewind-after-condense.spec.ts | 25 +- src/core/condense/index.ts | 390 +++-- .../__tests__/context-management.spec.ts | 2 + src/core/context-management/index.ts | 11 +- src/core/message-manager/index.spec.ts | 12 +- src/core/task/Task.ts | 355 +++-- src/i18n/locales/ca/common.json | 1 + src/i18n/locales/de/common.json | 1 + src/i18n/locales/en/common.json | 1 + src/i18n/locales/es/common.json | 1 + src/i18n/locales/fr/common.json | 1 + src/i18n/locales/hi/common.json | 1 + src/i18n/locales/id/common.json | 1 + src/i18n/locales/it/common.json | 1 + src/i18n/locales/ja/common.json | 1 + src/i18n/locales/ko/common.json | 1 + src/i18n/locales/nl/common.json | 1 + src/i18n/locales/pl/common.json | 1 + src/i18n/locales/pt-BR/common.json | 1 + src/i18n/locales/ru/common.json | 1 + src/i18n/locales/tr/common.json | 1 + src/i18n/locales/vi/common.json | 1 + src/i18n/locales/zh-CN/common.json | 1 + src/i18n/locales/zh-TW/common.json | 1 + src/shared/support-prompt.ts | 11 +- .../CondensationErrorRow.tsx | 36 +- 28 files changed, 1398 insertions(+), 1161 deletions(-) diff --git a/src/core/condense/__tests__/condense.spec.ts b/src/core/condense/__tests__/condense.spec.ts index c2ac88a65d2..52175448174 100644 --- a/src/core/condense/__tests__/condense.spec.ts +++ b/src/core/condense/__tests__/condense.spec.ts @@ -10,7 +10,7 @@ import { summarizeConversation, getMessagesSinceLastSummary, getEffectiveApiHistory, - N_MESSAGES_TO_KEEP, + extractCommandBlocks, } from "../index" // Create a mock ApiHandler for testing @@ -63,8 +63,67 @@ describe("Condense", () => { } }) + describe("extractCommandBlocks", () => { + it("should extract command blocks from string content", () => { + const message: ApiMessage = { + role: "user", + content: 'Some text /prr #123 more text', + } + + const result = extractCommandBlocks(message) + expect(result).toBe('/prr #123') + }) + + it("should extract multiple command blocks", () => { + const message: ApiMessage = { + role: "user", + content: '/prr #123 text /mode code', + } + + const result = extractCommandBlocks(message) + expect(result).toBe('/prr #123\n/mode code') + }) + + it("should extract command blocks from array content", () => { + const message: ApiMessage = { + role: "user", + content: [ + { type: "text", text: "Some user text" }, + { type: "text", text: 'Help content' }, + ], + } + + const result = extractCommandBlocks(message) + expect(result).toBe('Help content') + }) + + it("should return empty string when no command blocks found", () => { + const message: ApiMessage = { + role: "user", + content: "Just regular text without commands", + } + + const result = extractCommandBlocks(message) + expect(result).toBe("") + }) + + it("should handle multiline command blocks", () => { + const message: ApiMessage = { + role: "user", + content: ` +Line 1 +Line 2 +`, + } + + const result = extractCommandBlocks(message) + expect(result).toContain("Line 1") + expect(result).toContain("Line 2") + }) + }) + describe("summarizeConversation", () => { - it("should preserve the first message and insert a summary before the last N messages", async () => { + it("should create a summary message with role user (fresh start model)", async () => { const messages: ApiMessage[] = [ { role: "user", content: "First message with /prr command content" }, { role: "assistant", content: "Second message" }, @@ -79,60 +138,43 @@ describe("Condense", () => { const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false) - // Verify the first message is preserved and NOT tagged for condensing - expect(result.messages[0].content).toBe("First message with /prr command content") - expect(result.messages[0].condenseParent).toBeUndefined() - - // Verify we have a summary message (role=assistant) + // Verify we have a summary message with role "user" (fresh start model) const summaryMessage = result.messages.find((msg) => msg.isSummary) expect(summaryMessage).toBeTruthy() - expect(summaryMessage!.role).toBe("assistant") + expect(summaryMessage!.role).toBe("user") expect(Array.isArray(summaryMessage!.content)).toBe(true) const contentArray = summaryMessage!.content as any[] expect(contentArray.some((b) => b.type === "text")).toBe(true) - expect(contentArray.some((b) => b.type === "reasoning")).toBe(true) + // Should NOT have reasoning blocks (no longer needed for user messages) + expect(contentArray.some((b) => b.type === "reasoning")).toBe(false) - // With condense-rework, the effective API history is: - // [firstMessage, summaryMessage, ...last N kept messages] - expect(result.messages.length).toBe(messages.length + 1) // All original messages + final summary + // Fresh start model: effective history should only contain the summary const effectiveHistory = getEffectiveApiHistory(result.messages) - expect(effectiveHistory.length).toBe(2 + N_MESSAGES_TO_KEEP) - expect(effectiveHistory[0]).toEqual(messages[0]) - expect(effectiveHistory[1]).toBe(summaryMessage) - for (const msg of effectiveHistory.slice(2)) { - expect(messages.slice(-N_MESSAGES_TO_KEEP)).toContainEqual(msg) - } + expect(effectiveHistory.length).toBe(1) + expect(effectiveHistory[0].isSummary).toBe(true) + expect(effectiveHistory[0].role).toBe("user") }) - it("should preserve slash command content in the first message", async () => { - const slashCommandContent = "/prr #123 - Fix authentication bug" + it("should tag ALL messages with condenseParent", async () => { const messages: ApiMessage[] = [ - { role: "user", content: slashCommandContent }, - { role: "assistant", content: "I'll help you fix that authentication bug" }, - { role: "user", content: "The issue is with JWT tokens" }, - { role: "assistant", content: "Let me examine the JWT implementation" }, - { role: "user", content: "It's failing on refresh" }, - { role: "assistant", content: "I found the issue" }, - { role: "user", content: "Great, can you fix it?" }, - { role: "assistant", content: "Here's the fix" }, - { role: "user", content: "Thanks!" }, + { role: "user", content: "First message with /prr command content" }, + { role: "assistant", content: "Second message" }, + { role: "user", content: "Third message" }, + { role: "assistant", content: "Fourth message" }, + { role: "user", content: "Fifth message" }, ] const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false) - // The first message with slash command should still be intact (and NOT tagged for condensing) - expect(result.messages[0].content).toBe(slashCommandContent) - expect(result.messages[0].condenseParent).toBeUndefined() - - // Effective history should contain the original first message, the summary, and the last N messages - const effectiveHistory = getEffectiveApiHistory(result.messages) - expect(effectiveHistory).toHaveLength(2 + N_MESSAGES_TO_KEEP) - expect(effectiveHistory[0].role).toBe("user") - expect(effectiveHistory[0].content).toBe(slashCommandContent) - expect(effectiveHistory[1].isSummary).toBe(true) + // All original messages should be tagged with condenseParent + const taggedMessages = result.messages.filter((msg) => !msg.isSummary) + expect(taggedMessages.length).toBe(messages.length) + for (const msg of taggedMessages) { + expect(msg.condenseParent).toBeDefined() + } }) - it("should keep blocks in the preserved first message", async () => { + it("should preserve blocks in the summary", async () => { const messages: ApiMessage[] = [ { role: "user", @@ -153,12 +195,18 @@ describe("Condense", () => { const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false) - const effectiveHistory = getEffectiveApiHistory(result.messages) - const first = effectiveHistory[0] - expect(first.role).toBe("user") - expect(Array.isArray(first.content)).toBe(true) - const firstContent = first.content as any[] - expect(firstContent.some((b) => b.type === "text" && b.text.includes(' msg.isSummary) + expect(summaryMessage).toBeTruthy() + + const contentArray = summaryMessage!.content as any[] + // Summary content is split into separate text blocks: + // - First block: "## Conversation Summary\n..." + // - Second block: "..." with command blocks + expect(contentArray).toHaveLength(2) + expect(contentArray[0].text).toContain("## Conversation Summary") + expect(contentArray[1].text).toContain('') + expect(contentArray[1].text).toContain("") + expect(contentArray[1].text).toContain("Active Workflows") }) it("should handle complex first message content", async () => { @@ -181,46 +229,33 @@ describe("Condense", () => { const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false) - // The first message with complex content should still be present (and NOT tagged) - expect(result.messages[0].content).toEqual(complexContent) - expect(result.messages[0].condenseParent).toBeUndefined() - - // Effective history should contain the first message + summary + last N kept messages + // Effective history should contain only the summary (fresh start) const effectiveHistory = getEffectiveApiHistory(result.messages) - expect(effectiveHistory).toHaveLength(2 + N_MESSAGES_TO_KEEP) + expect(effectiveHistory).toHaveLength(1) + expect(effectiveHistory[0].isSummary).toBe(true) expect(effectiveHistory[0].role).toBe("user") }) it("should return error when not enough messages to summarize", async () => { - const messages: ApiMessage[] = [ - { role: "user", content: "First message with /command" }, - { role: "assistant", content: "Second message" }, - { role: "user", content: "Third message" }, - { role: "assistant", content: "Fourth message" }, - ] + const messages: ApiMessage[] = [{ role: "user", content: "Only one message" }] const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false) - // Should return an error since we have only 4 messages (first + 3 to keep) + // Should return an error since we have only 1 message expect(result.error).toBeDefined() expect(result.messages).toEqual(messages) // Original messages unchanged expect(result.summary).toBe("") }) - it("should not summarize messages that already contain a recent summary", async () => { + it("should not summarize messages that already contain a recent summary with no new messages", async () => { const messages: ApiMessage[] = [ { role: "user", content: "First message with /command" }, - { role: "assistant", content: "Old message" }, - { role: "user", content: "Message before summary" }, - { role: "assistant", content: "Response" }, - { role: "user", content: "Another message" }, - { role: "assistant", content: "Previous summary", isSummary: true }, // Summary in last N messages - { role: "user", content: "Final message" }, + { role: "assistant", content: "Previous summary", isSummary: true }, ] const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false) - // Should return an error due to recent summary in last N messages + // Should return an error due to recent summary with no substantial messages after expect(result.error).toBeDefined() expect(result.messages).toEqual(messages) expect(result.summary).toBe("") @@ -259,6 +294,81 @@ describe("Condense", () => { }) }) + describe("getEffectiveApiHistory", () => { + it("should return only summary when summary exists (fresh start)", () => { + const condenseId = "test-condense-id" + const messages: ApiMessage[] = [ + { role: "user", content: "First", condenseParent: condenseId }, + { role: "assistant", content: "Second", condenseParent: condenseId }, + { role: "user", content: "Third", condenseParent: condenseId }, + { + role: "user", + content: [{ type: "text", text: "Summary content" }], + isSummary: true, + condenseId, + }, + ] + + const result = getEffectiveApiHistory(messages) + + expect(result).toHaveLength(1) + expect(result[0].isSummary).toBe(true) + }) + + it("should include messages after summary in fresh start model", () => { + const condenseId = "test-condense-id" + const messages: ApiMessage[] = [ + { role: "user", content: "First", condenseParent: condenseId }, + { role: "assistant", content: "Second", condenseParent: condenseId }, + { + role: "user", + content: [{ type: "text", text: "Summary content" }], + isSummary: true, + condenseId, + }, + { role: "assistant", content: "New response after summary" }, + { role: "user", content: "New user message" }, + ] + + const result = getEffectiveApiHistory(messages) + + expect(result).toHaveLength(3) + expect(result[0].isSummary).toBe(true) + expect(result[1].content).toBe("New response after summary") + expect(result[2].content).toBe("New user message") + }) + + it("should return all messages when no summary exists", () => { + const messages: ApiMessage[] = [ + { role: "user", content: "First" }, + { role: "assistant", content: "Second" }, + { role: "user", content: "Third" }, + ] + + const result = getEffectiveApiHistory(messages) + + expect(result).toEqual(messages) + }) + + it("should restore messages when summary is deleted (rewind)", () => { + // After rewind, summary is deleted but condenseParent tags remain as orphans + // The cleanupAfterTruncation function would normally clear these, + // but even without cleanup, getEffectiveApiHistory should handle orphaned tags + const orphanedCondenseId = "deleted-summary-id" + const messages: ApiMessage[] = [ + { role: "user", content: "First", condenseParent: orphanedCondenseId }, + { role: "assistant", content: "Second", condenseParent: orphanedCondenseId }, + { role: "user", content: "Third", condenseParent: orphanedCondenseId }, + // Summary was deleted - no isSummary message exists + ] + + const result = getEffectiveApiHistory(messages) + + // With no summary, all messages should be included (orphaned condenseParent is ignored) + expect(result).toHaveLength(3) + }) + }) + describe("getMessagesSinceLastSummary", () => { it("should return all messages when no summary exists", () => { const messages: ApiMessage[] = [ @@ -275,37 +385,48 @@ describe("Condense", () => { const messages: ApiMessage[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "Second message" }, - { role: "assistant", content: "Summary content", isSummary: true }, - { role: "user", content: "Message after summary" }, - { role: "assistant", content: "Final message" }, + { role: "user", content: "Summary content", isSummary: true }, + { role: "assistant", content: "Message after summary" }, + { role: "user", content: "Final message" }, ] const result = getMessagesSinceLastSummary(messages) - // Preserves the original first user message when the slice would start with an assistant message - expect(result[0]).toEqual(messages[0]) - expect(result[1]).toEqual(messages[2]) // The summary - expect(result[2]).toEqual(messages[3]) - expect(result[3]).toEqual(messages[4]) + expect(result[0]).toEqual(messages[2]) // The summary + expect(result[1]).toEqual(messages[3]) + expect(result[2]).toEqual(messages[4]) }) it("should handle multiple summaries and return from the last one", () => { const messages: ApiMessage[] = [ { role: "user", content: "First message" }, - { role: "assistant", content: "First summary", isSummary: true }, - { role: "user", content: "Middle message" }, - { role: "assistant", content: "Second summary", isSummary: true }, - { role: "user", content: "Recent message" }, - { role: "assistant", content: "Final message" }, + { role: "user", content: "First summary", isSummary: true }, + { role: "assistant", content: "Middle message" }, + { role: "user", content: "Second summary", isSummary: true }, + { role: "assistant", content: "Recent message" }, + { role: "user", content: "Final message" }, ] const result = getMessagesSinceLastSummary(messages) - // Preserves the original first user message when the slice would start with an assistant message - expect(result[0]).toEqual(messages[0]) - expect(result[1]).toEqual(messages[3]) // Second summary - expect(result[2]).toEqual(messages[4]) - expect(result[3]).toEqual(messages[5]) + expect(result[0]).toEqual(messages[3]) // Second summary + expect(result[1]).toEqual(messages[4]) + expect(result[2]).toEqual(messages[5]) + }) + + it("should prepend first user message when summary starts with assistant", () => { + const messages: ApiMessage[] = [ + { role: "user", content: "Original first message" }, + { role: "assistant", content: "Summary content", isSummary: true }, + { role: "user", content: "After summary" }, + ] + + const result = getMessagesSinceLastSummary(messages) + + // Should prepend original first message for Bedrock compatibility + expect(result[0]).toEqual(messages[0]) // Original first user message + expect(result[1]).toEqual(messages[1]) // The summary + expect(result[2]).toEqual(messages[2]) }) }) }) diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index 54dfa4ad0f6..865535d449a 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -11,10 +11,10 @@ import { maybeRemoveImageBlocks } from "../../../api/transform/image-cleaning" import { summarizeConversation, getMessagesSinceLastSummary, - getKeepMessagesWithToolBlocks, getEffectiveApiHistory, cleanupAfterTruncation, - N_MESSAGES_TO_KEEP, + extractCommandBlocks, + injectSyntheticToolResults, } from "../index" vi.mock("../../../api/transform/image-cleaning", () => ({ @@ -32,617 +32,642 @@ vi.mock("@roo-code/telemetry", () => ({ const taskId = "test-task-id" const DEFAULT_PREV_CONTEXT_TOKENS = 1000 -type ReasoningBlock = { type: "reasoning"; text: string } - -function isReasoningBlock(block: unknown): block is ReasoningBlock { - if (typeof block !== "object" || block === null) { - return false - } - return (block as Record).type === "reasoning" -} +describe("extractCommandBlocks", () => { + it("should extract command blocks from string content", () => { + const message: ApiMessage = { + role: "user", + content: 'Some text /prr #123 more text', + } -describe("getKeepMessagesWithToolBlocks", () => { - it("should return keepMessages without tool blocks when no tool_result blocks in first kept message", () => { - const messages: ApiMessage[] = [ - { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "Hi there", ts: 2 }, - { role: "user", content: "How are you?", ts: 3 }, - { role: "assistant", content: "I'm good", ts: 4 }, - { role: "user", content: "What's new?", ts: 5 }, - ] + const result = extractCommandBlocks(message) + expect(result).toBe('/prr #123') + }) - const result = getKeepMessagesWithToolBlocks(messages, 3) + it("should extract multiple command blocks", () => { + const message: ApiMessage = { + role: "user", + content: '/prr #123 text /mode code', + } - expect(result.keepMessages).toHaveLength(3) - expect(result.toolUseBlocksToPreserve).toHaveLength(0) + const result = extractCommandBlocks(message) + expect(result).toBe('/prr #123\n/mode code') }) - it("should return all messages when messages.length <= keepCount", () => { - const messages: ApiMessage[] = [ - { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "Hi there", ts: 2 }, - ] + it("should extract command blocks from array content", () => { + const message: ApiMessage = { + role: "user", + content: [ + { type: "text", text: "Some user text" }, + { type: "text", text: 'Help content' }, + ], + } - const result = getKeepMessagesWithToolBlocks(messages, 3) + const result = extractCommandBlocks(message) + expect(result).toBe('Help content') + }) - expect(result.keepMessages).toEqual(messages) - expect(result.toolUseBlocksToPreserve).toHaveLength(0) + it("should return empty string when no command blocks found", () => { + const message: ApiMessage = { + role: "user", + content: "Just regular text without commands", + } + + const result = extractCommandBlocks(message) + expect(result).toBe("") }) - it("should preserve tool_use blocks when first kept message has tool_result blocks", () => { - const toolUseBlock = { - type: "tool_use" as const, - id: "toolu_123", - name: "read_file", - input: { path: "test.txt" }, + it("should handle multiline command blocks", () => { + const message: ApiMessage = { + role: "user", + content: ` +Line 1 +Line 2 +`, } - const toolResultBlock = { - type: "tool_result" as const, - tool_use_id: "toolu_123", - content: "file contents", + + const result = extractCommandBlocks(message) + expect(result).toContain("Line 1") + expect(result).toContain("Line 2") + }) + + it("should handle command blocks with attributes", () => { + const message: ApiMessage = { + role: "user", + content: 'content', } + const result = extractCommandBlocks(message) + expect(result).toContain('name="test"') + expect(result).toContain('attr1="value1"') + }) +}) + +describe("injectSyntheticToolResults", () => { + it("should return messages unchanged when no orphan tool_calls exist", () => { const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "Let me read that file", ts: 2 }, - { role: "user", content: "Please continue", ts: 3 }, { role: "assistant", - content: [{ type: "text" as const, text: "Reading file..." }, toolUseBlock], - ts: 4, + content: [{ type: "tool_use", id: "tool-1", name: "read_file", input: { path: "test.ts" } }], + ts: 2, }, { role: "user", - content: [toolResultBlock, { type: "text" as const, text: "Continue" }], - ts: 5, + content: [{ type: "tool_result", tool_use_id: "tool-1", content: "file contents" }], + ts: 3, }, - { 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) - expect(result.keepMessages[1].ts).toBe(6) - expect(result.keepMessages[2].ts).toBe(7) - - // Should preserve the tool_use block from the preceding assistant message - expect(result.toolUseBlocksToPreserve).toHaveLength(1) - expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock) + const result = injectSyntheticToolResults(messages) + expect(result).toEqual(messages) }) - it("should not preserve tool_use blocks when first kept message is assistant role", () => { - const toolUseBlock = { - type: "tool_use" as const, - id: "toolu_123", - name: "read_file", - input: { path: "test.txt" }, - } - + it("should inject synthetic tool_result for orphan tool_call", () => { const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "Hi there", ts: 2 }, - { role: "user", content: "Please read", ts: 3 }, { role: "assistant", - content: [{ type: "text" as const, text: "Reading..." }, toolUseBlock], - ts: 4, + content: [ + { type: "tool_use", id: "tool-orphan", name: "attempt_completion", input: { result: "Done" } }, + ], + ts: 2, }, - { role: "user", content: "Continue", ts: 5 }, - { role: "assistant", content: "Done", ts: 6 }, + // No tool_result for tool-orphan ] - const result = getKeepMessagesWithToolBlocks(messages, 3) + const result = injectSyntheticToolResults(messages) + + expect(result.length).toBe(3) + expect(result[2].role).toBe("user") - // First kept message is assistant, not user with tool_result - expect(result.keepMessages).toHaveLength(3) - expect(result.keepMessages[0].role).toBe("assistant") - expect(result.toolUseBlocksToPreserve).toHaveLength(0) + const content = result[2].content as any[] + expect(content.length).toBe(1) + expect(content[0].type).toBe("tool_result") + expect(content[0].tool_use_id).toBe("tool-orphan") + expect(content[0].content).toBe("Context condensation triggered. Tool execution deferred.") }) - it("should not preserve tool_use blocks when first kept user message has string content", () => { + it("should inject synthetic tool_results for multiple orphan tool_calls", () => { const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "Hi there", ts: 2 }, - { role: "user", content: "How are you?", ts: 3 }, - { role: "assistant", content: "Good", ts: 4 }, - { role: "user", content: "Simple text message", ts: 5 }, // String content, not array - { role: "assistant", content: "Response", ts: 6 }, - { role: "user", content: "More text", ts: 7 }, + { + role: "assistant", + content: [ + { type: "tool_use", id: "tool-1", name: "read_file", input: { path: "test.ts" } }, + { type: "tool_use", id: "tool-2", name: "write_file", input: { path: "out.ts", content: "code" } }, + ], + ts: 2, + }, + // No tool_results for either ] - const result = getKeepMessagesWithToolBlocks(messages, 3) + const result = injectSyntheticToolResults(messages) - expect(result.keepMessages).toHaveLength(3) - expect(result.toolUseBlocksToPreserve).toHaveLength(0) + expect(result.length).toBe(3) + const content = result[2].content as any[] + expect(content.length).toBe(2) + expect(content[0].tool_use_id).toBe("tool-1") + expect(content[1].tool_use_id).toBe("tool-2") }) - it("should handle multiple tool_use blocks that need to be preserved", () => { - const toolUseBlock1 = { - type: "tool_use" as const, - id: "toolu_123", - name: "read_file", - input: { path: "file1.txt" }, - } - const toolUseBlock2 = { - type: "tool_use" as const, - id: "toolu_456", - name: "read_file", - input: { path: "file2.txt" }, - } - const toolResultBlock1 = { - type: "tool_result" as const, - tool_use_id: "toolu_123", - content: "contents 1", - } - const toolResultBlock2 = { - type: "tool_result" as const, - tool_use_id: "toolu_456", - content: "contents 2", - } - + it("should only inject for orphan tool_calls, not matched ones", () => { const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", - content: [{ type: "text" as const, text: "Reading files..." }, toolUseBlock1, toolUseBlock2], + content: [ + { type: "tool_use", id: "matched-tool", name: "read_file", input: { path: "test.ts" } }, + { type: "tool_use", id: "orphan-tool", name: "attempt_completion", input: { result: "Done" } }, + ], ts: 2, }, { role: "user", - content: [toolResultBlock1, toolResultBlock2], + content: [{ type: "tool_result", tool_use_id: "matched-tool", content: "file contents" }], ts: 3, }, - { role: "assistant", content: "Got both files", ts: 4 }, - { role: "user", content: "Thanks", ts: 5 }, + // No tool_result for orphan-tool ] - const result = getKeepMessagesWithToolBlocks(messages, 3) + const result = injectSyntheticToolResults(messages) - // Should preserve both tool_use blocks - expect(result.toolUseBlocksToPreserve).toHaveLength(2) - expect(result.toolUseBlocksToPreserve).toContainEqual(toolUseBlock1) - expect(result.toolUseBlocksToPreserve).toContainEqual(toolUseBlock2) + expect(result.length).toBe(4) + const syntheticContent = result[3].content as any[] + expect(syntheticContent.length).toBe(1) + expect(syntheticContent[0].tool_use_id).toBe("orphan-tool") }) - it("should not preserve tool_use blocks when preceding message has no tool_use blocks", () => { - const toolResultBlock = { - type: "tool_result" as const, - tool_use_id: "toolu_123", - content: "file contents", - } + it("should handle messages with string content (no tool_use/tool_result)", () => { + const messages: ApiMessage[] = [ + { role: "user", content: "Hello", ts: 1 }, + { role: "assistant", content: "Hi there!", ts: 2 }, + ] + + const result = injectSyntheticToolResults(messages) + expect(result).toEqual(messages) + }) + + it("should handle empty messages array", () => { + const result = injectSyntheticToolResults([]) + expect(result).toEqual([]) + }) + it("should handle tool_results spread across multiple user messages", () => { const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "Plain text response", ts: 2 }, // No tool_use blocks + { + role: "assistant", + content: [ + { type: "tool_use", id: "tool-1", name: "read_file", input: { path: "a.ts" } }, + { type: "tool_use", id: "tool-2", name: "read_file", input: { path: "b.ts" } }, + ], + ts: 2, + }, { role: "user", - content: [toolResultBlock], // Has tool_result but preceding message has no tool_use + content: [{ type: "tool_result", tool_use_id: "tool-1", content: "contents a" }], ts: 3, }, - { role: "assistant", content: "Response", ts: 4 }, - { role: "user", content: "Thanks", ts: 5 }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "tool-2", content: "contents b" }], + ts: 4, + }, ] - const result = getKeepMessagesWithToolBlocks(messages, 3) + const result = injectSyntheticToolResults(messages) + // Both tool_uses have matching tool_results, no injection needed + expect(result).toEqual(messages) + }) +}) + +describe("getMessagesSinceLastSummary", () => { + it("should return all messages when there is no summary", () => { + const messages: ApiMessage[] = [ + { role: "user", content: "Hello", ts: 1 }, + { role: "assistant", content: "Hi there", ts: 2 }, + { role: "user", content: "How are you?", ts: 3 }, + ] - expect(result.keepMessages).toHaveLength(3) - expect(result.toolUseBlocksToPreserve).toHaveLength(0) + const result = getMessagesSinceLastSummary(messages) + expect(result).toEqual(messages) }) - it("should handle edge case when startIndex - 1 is negative", () => { - const toolResultBlock = { - type: "tool_result" as const, - tool_use_id: "toolu_123", - content: "file contents", - } + it("should return messages since the last summary (preserves original first user message when needed)", () => { + const messages: ApiMessage[] = [ + { role: "user", content: "Hello", ts: 1 }, + { role: "assistant", content: "Hi there", ts: 2 }, + { role: "assistant", content: "Summary of conversation", ts: 3, isSummary: true }, + { role: "user", content: "How are you?", ts: 4 }, + { role: "assistant", content: "I'm good", ts: 5 }, + ] - // Only 3 messages total, so startIndex = 0 and precedingIndex would be -1 + const result = getMessagesSinceLastSummary(messages) + expect(result).toEqual([ + { role: "user", content: "Hello", ts: 1 }, + { role: "assistant", content: "Summary of conversation", ts: 3, isSummary: true }, + { role: "user", content: "How are you?", ts: 4 }, + { role: "assistant", content: "I'm good", ts: 5 }, + ]) + }) + + it("should handle multiple summary messages and return since the last one", () => { const messages: ApiMessage[] = [ - { - role: "user", - content: [toolResultBlock], - ts: 1, - }, - { role: "assistant", content: "Response", ts: 2 }, - { role: "user", content: "Thanks", ts: 3 }, + { role: "user", content: "Hello", ts: 1 }, + { role: "assistant", content: "First summary", ts: 2, isSummary: true }, + { role: "user", content: "How are you?", ts: 3 }, + { role: "assistant", content: "Second summary", ts: 4, isSummary: true }, + { role: "user", content: "What's new?", ts: 5 }, ] - const result = getKeepMessagesWithToolBlocks(messages, 3) + const result = getMessagesSinceLastSummary(messages) + expect(result).toEqual([ + { role: "user", content: "Hello", ts: 1 }, + { role: "assistant", content: "Second summary", ts: 4, isSummary: true }, + { role: "user", content: "What's new?", ts: 5 }, + ]) + }) - expect(result.keepMessages).toEqual(messages) - expect(result.toolUseBlocksToPreserve).toHaveLength(0) + it("should handle empty messages array", () => { + const result = getMessagesSinceLastSummary([]) + expect(result).toEqual([]) }) - 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", - } + it("should return messages from user summary (fresh start model)", () => { + const messages: ApiMessage[] = [ + { role: "user", content: "Hello", ts: 1, condenseParent: "cond-1" }, + { role: "assistant", content: "Hi there", ts: 2, condenseParent: "cond-1" }, + { role: "user", content: "Summary content", ts: 3, isSummary: true, condenseId: "cond-1" }, + { role: "assistant", content: "Response after summary", ts: 4 }, + ] + + const result = getMessagesSinceLastSummary(messages) + expect(result[0].isSummary).toBe(true) + expect(result[0].role).toBe("user") + }) +}) +describe("getEffectiveApiHistory", () => { + it("should return only summary when summary exists (fresh start model)", () => { + const condenseId = "test-condense-id" 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: "user", content: "First", condenseParent: condenseId }, + { role: "assistant", content: "Second", condenseParent: condenseId }, + { role: "user", content: "Third", condenseParent: condenseId }, { - 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: [{ type: "text", text: "Summary content" }], + isSummary: true, + condenseId, }, + ] + + const result = getEffectiveApiHistory(messages) + + expect(result).toHaveLength(1) + expect(result[0].isSummary).toBe(true) + }) + + it("should include messages after summary in fresh start model", () => { + const condenseId = "test-condense-id" + const messages: ApiMessage[] = [ + { role: "user", content: "First", condenseParent: condenseId }, + { role: "assistant", content: "Second", condenseParent: condenseId }, { role: "user", - content: [toolResultBlock, { type: "text" as const, text: "Continue" }], - ts: 5, + content: [{ type: "text", text: "Summary content" }], + isSummary: true, + condenseId, }, - { role: "assistant", content: "Got it, the file says...", ts: 6 }, - { role: "user", content: "Thanks", ts: 7 }, + { role: "assistant", content: "New response after summary" }, + { role: "user", content: "New user message" }, ] - const result = getKeepMessagesWithToolBlocks(messages, 3) + const result = getEffectiveApiHistory(messages) + + expect(result).toHaveLength(3) + expect(result[0].isSummary).toBe(true) + expect(result[1].content).toBe("New response after summary") + expect(result[2].content).toBe("New user message") + }) - // keepMessages should be the last 3 messages - expect(result.keepMessages).toHaveLength(3) - expect(result.keepMessages[0].ts).toBe(5) + it("should return all messages when no summary exists", () => { + const messages: ApiMessage[] = [ + { role: "user", content: "First" }, + { role: "assistant", content: "Second" }, + { role: "user", content: "Third" }, + ] - // Should preserve the tool_use block - expect(result.toolUseBlocksToPreserve).toHaveLength(1) - expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock) + const result = getEffectiveApiHistory(messages) - // 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...") + expect(result).toEqual(messages) }) - 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", - } + it("should restore messages when summary is deleted (rewind - orphaned condenseParent)", () => { + const orphanedCondenseId = "deleted-summary-id" + const messages: ApiMessage[] = [ + { role: "user", content: "First", condenseParent: orphanedCondenseId }, + { role: "assistant", content: "Second", condenseParent: orphanedCondenseId }, + { role: "user", content: "Third", condenseParent: orphanedCondenseId }, + // Summary was deleted - no isSummary message exists + ] + + const result = getEffectiveApiHistory(messages) + // With no summary, all messages should be included (orphaned condenseParent is ignored) + expect(result).toHaveLength(3) + }) + + it("should filter out truncated messages within summary range", () => { + const condenseId = "cond-1" + const truncationId = "trunc-1" const messages: ApiMessage[] = [ - { role: "user", content: "Hello", ts: 1 }, + { role: "user", content: "First", condenseParent: condenseId }, { - role: "assistant", - // No reasoning block, just text and tool_use - content: [{ type: "text" as const, text: "Reading file..." }, toolUseBlock], - ts: 2, + role: "user", + content: [{ type: "text", text: "Summary" }], + isSummary: true, + condenseId, }, + { role: "assistant", content: "Response", truncationParent: truncationId }, { - role: "user", - content: [toolResultBlock], - ts: 3, + role: "assistant", + content: [{ type: "text", text: "..." }], + isTruncationMarker: true, + truncationId, }, - { role: "assistant", content: "Done", ts: 4 }, - { role: "user", content: "Thanks", ts: 5 }, + { role: "user", content: "After truncation" }, ] - const result = getKeepMessagesWithToolBlocks(messages, 3) + const result = getEffectiveApiHistory(messages) - expect(result.toolUseBlocksToPreserve).toHaveLength(1) - expect(result.reasoningBlocksToPreserve).toHaveLength(0) + // Summary + truncation marker + after truncation (the truncated response is filtered out) + expect(result).toHaveLength(3) + expect(result[0].isSummary).toBe(true) + expect(result[1].isTruncationMarker).toBe(true) + expect(result[2].content).toBe("After truncation") }) - it("should preserve tool_use when tool_result is in 2nd kept message and tool_use is 2 messages before boundary", () => { - const toolUseBlock = { - type: "tool_use" as const, - id: "toolu_second_kept", - name: "read_file", - input: { path: "test.txt" }, - } - const toolResultBlock = { - type: "tool_result" as const, - tool_use_id: "toolu_second_kept", - content: "file contents", - } - + it("should filter out orphan tool_result blocks after fresh start condensation", () => { + const condenseId = "cond-1" const messages: ApiMessage[] = [ - { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "Let me help", ts: 2 }, + { role: "user", content: "Hello", condenseParent: condenseId }, { role: "assistant", - content: [{ type: "text" as const, text: "Reading file..." }, toolUseBlock], - ts: 3, + content: [ + { type: "tool_use", id: "tool-orphan", name: "attempt_completion", input: { result: "Done" } }, + ], + condenseParent: condenseId, }, - { role: "user", content: "Some other message", ts: 4 }, - { role: "assistant", content: "First kept message", ts: 5 }, + // Summary comes after the tool_use (so tool_use is condensed away) { role: "user", - content: [toolResultBlock, { type: "text" as const, text: "Continue" }], - ts: 6, + content: [{ type: "text", text: "Summary content" }], + isSummary: true, + condenseId, + }, + // This tool_result references a tool_use that was condensed away (orphan!) + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "tool-orphan", content: "Rejected by user" }], }, - { role: "assistant", content: "Third kept message", ts: 7 }, ] - const result = getKeepMessagesWithToolBlocks(messages, 3) - - // keepMessages should be the last 3 messages (ts: 5, 6, 7) - expect(result.keepMessages).toHaveLength(3) - expect(result.keepMessages[0].ts).toBe(5) - expect(result.keepMessages[1].ts).toBe(6) - expect(result.keepMessages[2].ts).toBe(7) + const result = getEffectiveApiHistory(messages) - // Should preserve the tool_use block from message at ts:3 (2 messages before boundary) - expect(result.toolUseBlocksToPreserve).toHaveLength(1) - expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock) + // Should only return the summary, orphan tool_result message should be filtered out + expect(result).toHaveLength(1) + expect(result[0].isSummary).toBe(true) }) - it("should preserve tool_use when tool_result is in 3rd kept message and tool_use is at boundary edge", () => { - const toolUseBlock = { - type: "tool_use" as const, - id: "toolu_third_kept", - name: "search", - input: { query: "test" }, - } - const toolResultBlock = { - type: "tool_result" as const, - tool_use_id: "toolu_third_kept", - content: "search results", - } - + it("should keep tool_result blocks that have matching tool_use in fresh start", () => { + const condenseId = "cond-1" const messages: ApiMessage[] = [ - { role: "user", content: "Start", ts: 1 }, + { role: "user", content: "Hello", condenseParent: condenseId }, + { + role: "user", + content: [{ type: "text", text: "Summary content" }], + isSummary: true, + condenseId, + }, + // This tool_use is AFTER the summary, so it's not condensed away { role: "assistant", - content: [{ type: "text" as const, text: "Searching..." }, toolUseBlock], - ts: 2, + content: [{ type: "tool_use", id: "tool-valid", name: "read_file", input: { path: "test.ts" } }], }, - { role: "user", content: "First kept message", ts: 3 }, - { role: "assistant", content: "Second kept message", ts: 4 }, + // This tool_result has a matching tool_use, so it should be kept { role: "user", - content: [toolResultBlock, { type: "text" as const, text: "Done" }], - ts: 5, + content: [{ type: "tool_result", tool_use_id: "tool-valid", content: "file contents" }], }, ] - const result = getKeepMessagesWithToolBlocks(messages, 3) + const result = getEffectiveApiHistory(messages) - // keepMessages should be the last 3 messages (ts: 3, 4, 5) - expect(result.keepMessages).toHaveLength(3) - expect(result.keepMessages[0].ts).toBe(3) - expect(result.keepMessages[1].ts).toBe(4) - expect(result.keepMessages[2].ts).toBe(5) - - // Should preserve the tool_use block from message at ts:2 (at the search boundary edge) - expect(result.toolUseBlocksToPreserve).toHaveLength(1) - expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock) + // All messages after summary should be included + expect(result).toHaveLength(3) + expect(result[0].isSummary).toBe(true) + expect((result[1].content as any[])[0].id).toBe("tool-valid") + expect((result[2].content as any[])[0].tool_use_id).toBe("tool-valid") }) - it("should preserve multiple tool_uses when tool_results are in different kept messages", () => { - const toolUseBlock1 = { - type: "tool_use" as const, - id: "toolu_multi_1", - name: "read_file", - input: { path: "file1.txt" }, - } - const toolUseBlock2 = { - type: "tool_use" as const, - id: "toolu_multi_2", - name: "read_file", - input: { path: "file2.txt" }, - } - const toolResultBlock1 = { - type: "tool_result" as const, - tool_use_id: "toolu_multi_1", - content: "contents 1", - } - const toolResultBlock2 = { - type: "tool_result" as const, - tool_use_id: "toolu_multi_2", - content: "contents 2", - } - + it("should filter orphan tool_results but keep other content in mixed user message", () => { + const condenseId = "cond-1" const messages: ApiMessage[] = [ - { role: "user", content: "Start", ts: 1 }, + { role: "user", content: "Hello", condenseParent: condenseId }, { role: "assistant", - content: [{ type: "text" as const, text: "Reading file 1..." }, toolUseBlock1], - ts: 2, + content: [ + { type: "tool_use", id: "tool-orphan", name: "attempt_completion", input: { result: "Done" } }, + ], + condenseParent: condenseId, }, - { role: "user", content: "Some message", ts: 3 }, { - role: "assistant", - content: [{ type: "text" as const, text: "Reading file 2..." }, toolUseBlock2], - ts: 4, + role: "user", + content: [{ type: "text", text: "Summary content" }], + isSummary: true, + condenseId, }, + // This tool_use is AFTER the summary { - role: "user", - content: [toolResultBlock1, { type: "text" as const, text: "First result" }], - ts: 5, + role: "assistant", + content: [{ type: "tool_use", id: "tool-valid", name: "read_file", input: { path: "test.ts" } }], }, + // Mixed content: one orphan tool_result and one valid tool_result { role: "user", - content: [toolResultBlock2, { type: "text" as const, text: "Second result" }], - ts: 6, + content: [ + { type: "tool_result", tool_use_id: "tool-orphan", content: "Orphan result" }, + { type: "tool_result", tool_use_id: "tool-valid", content: "Valid result" }, + ], }, - { role: "assistant", content: "Got both files", ts: 7 }, ] - const result = getKeepMessagesWithToolBlocks(messages, 3) - - // keepMessages should be the last 3 messages (ts: 5, 6, 7) - expect(result.keepMessages).toHaveLength(3) + const result = getEffectiveApiHistory(messages) - // Should preserve both tool_use blocks - expect(result.toolUseBlocksToPreserve).toHaveLength(2) - expect(result.toolUseBlocksToPreserve).toContainEqual(toolUseBlock1) - expect(result.toolUseBlocksToPreserve).toContainEqual(toolUseBlock2) + // Summary + assistant with tool_use + filtered user message + expect(result).toHaveLength(3) + expect(result[0].isSummary).toBe(true) + // The user message should only contain the valid tool_result + const userContent = result[2].content as any[] + expect(userContent).toHaveLength(1) + expect(userContent[0].tool_use_id).toBe("tool-valid") }) - it("should not crash when tool_result references tool_use beyond search boundary", () => { - const toolResultBlock = { - type: "tool_result" as const, - tool_use_id: "toolu_beyond_boundary", - content: "result", - } - - // Tool_use is at ts:1, but with N_MESSAGES_TO_KEEP=3, we only search back 3 messages - // from startIndex-1. StartIndex is 7 (messages.length=10, keepCount=3, startIndex=7). - // So we search from index 6 down to index 4 (7-1 down to 7-3). - // The tool_use at index 0 (ts:1) is beyond the search boundary. + it("should handle multiple orphan tool_results in a single message", () => { + const condenseId = "cond-1" const messages: ApiMessage[] = [ { role: "assistant", content: [ - { type: "text" as const, text: "Way back..." }, - { - type: "tool_use" as const, - id: "toolu_beyond_boundary", - name: "old_tool", - input: {}, - }, + { type: "tool_use", id: "orphan-1", name: "read_file", input: { path: "a.ts" } }, + { type: "tool_use", id: "orphan-2", name: "write_file", input: { path: "b.ts", content: "code" } }, ], - ts: 1, + condenseParent: condenseId, }, - { role: "user", content: "Message 2", ts: 2 }, - { role: "assistant", content: "Message 3", ts: 3 }, - { role: "user", content: "Message 4", ts: 4 }, - { role: "assistant", content: "Message 5", ts: 5 }, - { role: "user", content: "Message 6", ts: 6 }, - { role: "assistant", content: "Message 7", ts: 7 }, { role: "user", - content: [toolResultBlock], - ts: 8, + content: [{ type: "text", text: "Summary content" }], + isSummary: true, + condenseId, + }, + // Multiple orphan tool_results - entire message should be removed + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "orphan-1", content: "Result 1" }, + { type: "tool_result", tool_use_id: "orphan-2", content: "Result 2" }, + ], }, - { role: "assistant", content: "Message 9", ts: 9 }, - { role: "user", content: "Message 10", ts: 10 }, ] - // Should not crash - const result = getKeepMessagesWithToolBlocks(messages, 3) - - // keepMessages should be the last 3 messages - expect(result.keepMessages).toHaveLength(3) - expect(result.keepMessages[0].ts).toBe(8) - expect(result.keepMessages[1].ts).toBe(9) - expect(result.keepMessages[2].ts).toBe(10) + const result = getEffectiveApiHistory(messages) - // Should not preserve the tool_use since it's beyond the search boundary - expect(result.toolUseBlocksToPreserve).toHaveLength(0) + // Only summary should remain + expect(result).toHaveLength(1) + expect(result[0].isSummary).toBe(true) }) - it("should not duplicate tool_use blocks when same tool_result ID appears multiple times", () => { - const toolUseBlock = { - type: "tool_use" as const, - id: "toolu_duplicate", - name: "read_file", - input: { path: "test.txt" }, - } - const toolResultBlock1 = { - type: "tool_result" as const, - tool_use_id: "toolu_duplicate", - content: "result 1", - } - const toolResultBlock2 = { - type: "tool_result" as const, - tool_use_id: "toolu_duplicate", - content: "result 2", - } - + it("should preserve non-tool_result content in user messages", () => { + const condenseId = "cond-1" const messages: ApiMessage[] = [ - { role: "user", content: "Start", ts: 1 }, { role: "assistant", - content: [{ type: "text" as const, text: "Using tool..." }, toolUseBlock], - ts: 2, + content: [ + { type: "tool_use", id: "tool-orphan", name: "attempt_completion", input: { result: "Done" } }, + ], + condenseParent: condenseId, }, { role: "user", - content: [toolResultBlock1], - ts: 3, + content: [{ type: "text", text: "Summary content" }], + isSummary: true, + condenseId, }, - { role: "assistant", content: "Processing", ts: 4 }, + // User message with text content and orphan tool_result { role: "user", - content: [toolResultBlock2], // Same tool_use_id as first result - ts: 5, + content: [ + { type: "text", text: "User added some text" }, + { type: "tool_result", tool_use_id: "tool-orphan", content: "Orphan result" }, + ], }, ] - const result = getKeepMessagesWithToolBlocks(messages, 3) + const result = getEffectiveApiHistory(messages) - // keepMessages should be the last 3 messages (ts: 3, 4, 5) - expect(result.keepMessages).toHaveLength(3) - - // Should only preserve the tool_use block once, not twice - expect(result.toolUseBlocksToPreserve).toHaveLength(1) - expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock) + // Summary + user message with only text (orphan tool_result filtered) + expect(result).toHaveLength(2) + expect(result[0].isSummary).toBe(true) + const userContent = result[1].content as any[] + expect(userContent).toHaveLength(1) + expect(userContent[0].type).toBe("text") + expect(userContent[0].text).toBe("User added some text") }) }) -describe("getMessagesSinceLastSummary", () => { - it("should return all messages when there is no summary", () => { +describe("cleanupAfterTruncation", () => { + it("should clear orphaned condenseParent references", () => { + const orphanedCondenseId = "deleted-summary" const messages: ApiMessage[] = [ - { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "Hi there", ts: 2 }, - { role: "user", content: "How are you?", ts: 3 }, + { role: "user", content: "First", condenseParent: orphanedCondenseId }, + { role: "assistant", content: "Second", condenseParent: orphanedCondenseId }, + { role: "user", content: "Third" }, ] - const result = getMessagesSinceLastSummary(messages) - expect(result).toEqual(messages) + const result = cleanupAfterTruncation(messages) + + expect(result[0].condenseParent).toBeUndefined() + expect(result[1].condenseParent).toBeUndefined() + expect(result[2].condenseParent).toBeUndefined() }) - it("should return messages since the last summary (does not preserve original first user message)", () => { + it("should keep condenseParent when summary still exists", () => { + const condenseId = "existing-summary" const messages: ApiMessage[] = [ - { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "Hi there", ts: 2 }, - { role: "assistant", content: "Summary of conversation", ts: 3, isSummary: true }, - { role: "user", content: "How are you?", ts: 4 }, - { role: "assistant", content: "I'm good", ts: 5 }, + { role: "user", content: "First", condenseParent: condenseId }, + { role: "assistant", content: "Second", condenseParent: condenseId }, + { + role: "user", + content: [{ type: "text", text: "Summary" }], + isSummary: true, + condenseId, + }, ] - const result = getMessagesSinceLastSummary(messages) - expect(result).toEqual([ - { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "Summary of conversation", ts: 3, isSummary: true }, - { role: "user", content: "How are you?", ts: 4 }, - { role: "assistant", content: "I'm good", ts: 5 }, - ]) + const result = cleanupAfterTruncation(messages) + + expect(result[0].condenseParent).toBe(condenseId) + expect(result[1].condenseParent).toBe(condenseId) }) - it("should handle multiple summary messages and return since the last one (does not preserve original first user message)", () => { + it("should clear orphaned truncationParent references", () => { + const orphanedTruncationId = "deleted-truncation" const messages: ApiMessage[] = [ - { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "First summary", ts: 2, isSummary: true }, - { role: "user", content: "How are you?", ts: 3 }, - { role: "assistant", content: "Second summary", ts: 4, isSummary: true }, - { role: "user", content: "What's new?", ts: 5 }, + { role: "user", content: "First", truncationParent: orphanedTruncationId }, + { role: "assistant", content: "Second" }, ] - const result = getMessagesSinceLastSummary(messages) - expect(result).toEqual([ - { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "Second summary", ts: 4, isSummary: true }, - { role: "user", content: "What's new?", ts: 5 }, - ]) + const result = cleanupAfterTruncation(messages) + + expect(result[0].truncationParent).toBeUndefined() }) - it("should handle empty messages array", () => { - const result = getMessagesSinceLastSummary([]) - expect(result).toEqual([]) + it("should keep truncationParent when marker still exists", () => { + const truncationId = "existing-truncation" + const messages: ApiMessage[] = [ + { role: "user", content: "First", truncationParent: truncationId }, + { + role: "assistant", + content: [{ type: "text", text: "..." }], + isTruncationMarker: true, + truncationId, + }, + ] + + const result = cleanupAfterTruncation(messages) + + expect(result[0].truncationParent).toBe(truncationId) + }) + + it("should handle mixed orphaned and valid references", () => { + const validCondenseId = "valid-cond" + const orphanedCondenseId = "orphaned-cond" + const messages: ApiMessage[] = [ + { role: "user", content: "First", condenseParent: orphanedCondenseId }, + { role: "assistant", content: "Second", condenseParent: validCondenseId }, + { + role: "user", + content: [{ type: "text", text: "Summary" }], + isSummary: true, + condenseId: validCondenseId, + }, + ] + + const result = cleanupAfterTruncation(messages) + + expect(result[0].condenseParent).toBeUndefined() // orphaned, cleared + expect(result[1].condenseParent).toBe(validCondenseId) // valid, kept }) }) @@ -682,40 +707,11 @@ describe("summarizeConversation", () => { } as unknown as ApiHandler }) - // Default system prompt for tests - const defaultSystemPrompt = "You are a helpful assistant." - - it("should not summarize when there are not enough messages", async () => { - const messages: ApiMessage[] = [ - { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "Hi there", ts: 2 }, - ] - - const result = await summarizeConversation( - messages, - mockApiHandler, - defaultSystemPrompt, - taskId, - DEFAULT_PREV_CONTEXT_TOKENS, - ) - expect(result.messages).toEqual(messages) - expect(result.cost).toBe(0) - expect(result.summary).toBe("") - expect(result.newContextTokens).toBeUndefined() - expect(result.error).toBeTruthy() // Error should be set for not enough messages - expect(mockApiHandler.createMessage).not.toHaveBeenCalled() - }) - - it("should not summarize when there was a recent summary", async () => { - const messages: ApiMessage[] = [ - { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "Hi there", ts: 2 }, - { role: "user", content: "How are you?", ts: 3 }, - { role: "assistant", content: "I'm good", ts: 4 }, - { role: "user", content: "What's new?", ts: 5 }, - { role: "assistant", content: "Not much", ts: 6, isSummary: true }, // Recent summary - { role: "user", content: "Tell me more", ts: 7 }, - ] + // Default system prompt for tests + const defaultSystemPrompt = "You are a helpful assistant." + + it("should not summarize when there are not enough messages", async () => { + const messages: ApiMessage[] = [{ role: "user", content: "Hello", ts: 1 }] const result = await summarizeConversation( messages, @@ -728,11 +724,11 @@ describe("summarizeConversation", () => { expect(result.cost).toBe(0) expect(result.summary).toBe("") expect(result.newContextTokens).toBeUndefined() - expect(result.error).toBeTruthy() // Error should be set for recent summary + expect(result.error).toBeTruthy() // Error should be set for not enough messages expect(mockApiHandler.createMessage).not.toHaveBeenCalled() }) - it("should summarize conversation and insert summary message", async () => { + it("should create summary with user role (fresh start model)", async () => { const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi there", ts: 2 }, @@ -755,47 +751,98 @@ describe("summarizeConversation", () => { expect(mockApiHandler.createMessage).toHaveBeenCalled() expect(maybeRemoveImageBlocks).toHaveBeenCalled() - // With the new condense output, the result contains all original messages (tagged) - // plus a condensed summary message inserted before the last N kept messages. + // Result contains all original messages (tagged) plus summary at end expect(result.messages.length).toBe(messages.length + 1) - // Middle messages should be tagged (excluding the first message and the last N kept messages) + // All original messages should be tagged with condenseParent const summaryMessage = result.messages.find((m) => m.isSummary) expect(summaryMessage).toBeDefined() const condenseId = summaryMessage!.condenseId expect(condenseId).toBeDefined() - const keepStartIndex = Math.max(messages.length - N_MESSAGES_TO_KEEP, 0) - const middleTagged = result.messages.slice(1, keepStartIndex) - for (const msg of middleTagged) { + for (const msg of result.messages.filter((m) => !m.isSummary)) { expect(msg.condenseParent).toBe(condenseId) } - const untagged = [result.messages[0], ...result.messages.slice(keepStartIndex)] - for (const msg of untagged) { - expect(msg.condenseParent).toBeUndefined() - } - // Summary message is an assistant message with a synthetic reasoning block + summary text - expect(summaryMessage!.role).toBe("assistant") + // Summary message is a user message with just text (fresh start model) + expect(summaryMessage!.role).toBe("user") 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[0].text).toContain("Condensing conversation context") - expect(content[1].type).toBe("text") - expect(content[1].text).toContain("This is a summary") + expect(content).toHaveLength(1) + expect(content[0].type).toBe("text") + expect(content[0].text).toContain("## Conversation Summary") + expect(content[0].text).toContain("This is a summary") - // Effective API history should contain: first message + summary + last N kept messages + // Fresh start: effective API history should contain only the summary const effectiveHistory = getEffectiveApiHistory(result.messages) - const keepMessages = messages.slice(-N_MESSAGES_TO_KEEP) - expect(effectiveHistory).toEqual([messages[0], summaryMessage!, ...keepMessages]) + expect(effectiveHistory).toHaveLength(1) + expect(effectiveHistory[0].isSummary).toBe(true) + expect(effectiveHistory[0].role).toBe("user") // Check the cost and token counts expect(result.cost).toBe(0.05) expect(result.summary).toBe("This is a summary") - expect(result.newContextTokens).toBe(250) // outputTokens(150) + countTokens(systemPrompt + kept messages)(100) + expect(result.newContextTokens).toBe(250) // outputTokens(150) + countTokens(100) expect(result.error).toBeUndefined() }) + it("should preserve command blocks from first message in summary", async () => { + const messages: ApiMessage[] = [ + { + role: "user", + content: 'Hello /prr #123', + ts: 1, + }, + { role: "assistant", content: "Hi there", ts: 2 }, + { role: "user", content: "How are you?", ts: 3 }, + { role: "assistant", content: "I'm good", ts: 4 }, + { role: "user", content: "What's new?", ts: 5 }, + ] + + const result = await summarizeConversation( + messages, + mockApiHandler, + defaultSystemPrompt, + taskId, + DEFAULT_PREV_CONTEXT_TOKENS, + ) + + const summaryMessage = result.messages.find((m) => m.isSummary) + expect(summaryMessage).toBeDefined() + + const content = summaryMessage!.content as any[] + // Summary content is now split into separate text blocks + expect(content).toHaveLength(2) + expect(content[0].text).toContain("## Conversation Summary") + expect(content[1].text).toContain("") + expect(content[1].text).toContain("Active Workflows") + expect(content[1].text).toContain('') + }) + + it("should not include command blocks wrapper when no commands in first message", async () => { + const messages: ApiMessage[] = [ + { role: "user", content: "Hello", ts: 1 }, + { role: "assistant", content: "Hi there", ts: 2 }, + { role: "user", content: "How are you?", ts: 3 }, + { role: "assistant", content: "I'm good", ts: 4 }, + { role: "user", content: "What's new?", ts: 5 }, + ] + + const result = await summarizeConversation( + messages, + mockApiHandler, + defaultSystemPrompt, + taskId, + DEFAULT_PREV_CONTEXT_TOKENS, + ) + + const summaryMessage = result.messages.find((m) => m.isSummary) + expect(summaryMessage).toBeDefined() + + const content = summaryMessage!.content as any[] + expect(content[0].text).not.toContain("") + expect(content[0].text).not.toContain("Active Workflows") + }) + it("should handle empty summary response and return error", async () => { // We need enough messages to trigger summarization const messages: ApiMessage[] = [ @@ -852,11 +899,16 @@ describe("summarizeConversation", () => { await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId, DEFAULT_PREV_CONTEXT_TOKENS) - // Verify that createMessage was called with the simple system prompt + // Verify that createMessage was called with the SUMMARY_PROMPT (which contains CRITICAL instructions), messages array, and optional metadata expect(mockApiHandler.createMessage).toHaveBeenCalledWith( - "You are a helpful AI assistant tasked with summarizing conversations.", + expect.stringContaining("You are a helpful AI assistant tasked with summarizing conversations."), expect.any(Array), + undefined, // metadata is undefined when not passed to summarizeConversation ) + // Verify the CRITICAL instructions are included in the prompt + const actualPrompt = (mockApiHandler.createMessage as Mock).mock.calls[0][0] + expect(actualPrompt).toContain("CRITICAL: This is a summarization-only request") + expect(actualPrompt).toContain("CRITICAL: This summarization request is a SYSTEM OPERATION") // Check that maybeRemoveImageBlocks was called with the correct messages // The final request message now contains the detailed CONDENSE instructions @@ -865,6 +917,7 @@ describe("summarizeConversation", () => { expect(finalMessage.role).toBe("user") expect(finalMessage.content).toContain("Your task is to create a detailed summary of the conversation") }) + it("should include the original first user message in summarization input", async () => { const messages: ApiMessage[] = [ { role: "user", content: "Initial ask", ts: 1 }, @@ -922,11 +975,11 @@ describe("summarizeConversation", () => { DEFAULT_PREV_CONTEXT_TOKENS, ) - // Verify that countTokens was called with system prompt + final condensed message + // Verify that countTokens was called with system prompt expect(mockApiHandler.countTokens).toHaveBeenCalled() - // newContextTokens includes the summary output tokens plus countTokens(systemPrompt + kept messages) - expect(result.newContextTokens).toBe(300) + // newContextTokens includes the summary output tokens plus countTokens(systemPrompt) + expect(result.newContextTokens).toBe(300) // outputTokens(200) + countTokens(100) expect(result.cost).toBe(0.06) expect(result.summary).toBe("This is a summary with system prompt") expect(result.error).toBeUndefined() @@ -1004,65 +1057,21 @@ describe("summarizeConversation", () => { prevContextTokens, ) - // With the new condense output, result contains all messages plus a condensed summary message + // Result contains all messages plus summary expect(result.messages.length).toBe(messages.length + 1) + + // Fresh start: effective history should contain only the summary const effectiveHistory = getEffectiveApiHistory(result.messages) - expect(effectiveHistory.length).toBe(2 + N_MESSAGES_TO_KEEP) + expect(effectiveHistory.length).toBe(1) + expect(effectiveHistory[0].isSummary).toBe(true) + expect(result.cost).toBe(0.03) expect(result.summary).toBe("Concise summary") expect(result.error).toBeUndefined() - expect(result.newContextTokens).toBe(80) // outputTokens(50) + countTokens(systemPrompt + kept messages)(30) + expect(result.newContextTokens).toBe(80) // outputTokens(50) + countTokens(30) expect(result.newContextTokens).toBeLessThan(prevContextTokens) }) - it("should return error when not enough messages to summarize", async () => { - const messages: ApiMessage[] = [{ role: "user", content: "Hello", ts: 1 }] - - const result = await summarizeConversation( - messages, - mockApiHandler, - defaultSystemPrompt, - taskId, - DEFAULT_PREV_CONTEXT_TOKENS, - ) - - // Should return original messages when not enough to summarize - expect(result.messages).toEqual(messages) - expect(result.cost).toBe(0) - expect(result.summary).toBe("") - expect(result.error).toBeTruthy() // Error should be set - expect(result.newContextTokens).toBeUndefined() - expect(mockApiHandler.createMessage).not.toHaveBeenCalled() - }) - - it("should return error when recent summary exists in kept messages", async () => { - const messages: ApiMessage[] = [ - { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "Hi there", ts: 2 }, - { role: "user", content: "How are you?", ts: 3 }, - { role: "assistant", content: "I'm good", ts: 4 }, - { role: "user", content: "What's new?", ts: 5 }, - { role: "assistant", content: "Recent summary", ts: 6, isSummary: true }, // Summary in last 3 messages - { role: "user", content: "Tell me more", ts: 7 }, - ] - - const result = await summarizeConversation( - messages, - mockApiHandler, - defaultSystemPrompt, - taskId, - DEFAULT_PREV_CONTEXT_TOKENS, - ) - - // Should return original messages when recent summary exists - expect(result.messages).toEqual(messages) - expect(result.cost).toBe(0) - expect(result.summary).toBe("") - expect(result.error).toBeTruthy() // Error should be set - expect(result.newContextTokens).toBeUndefined() - expect(mockApiHandler.createMessage).not.toHaveBeenCalled() - }) - it("should return error when API handler is invalid", async () => { const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, @@ -1108,46 +1117,15 @@ describe("summarizeConversation", () => { console.error = originalError }) - it("should preserve tool_use blocks when needed for tool_result pairing (native tools)", async () => { - 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", - } - + it("should tag all messages with condenseParent (fresh start model)", async () => { const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "Let me read that file", ts: 2 }, - { role: "user", content: "Please continue", ts: 3 }, - { - role: "assistant", - content: [{ 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 }, + { role: "assistant", content: "Hi there", ts: 2 }, + { role: "user", content: "How are you?", ts: 3 }, + { role: "assistant", content: "I'm good", ts: 4 }, + { role: "user", content: "Thanks", ts: 5 }, ] - // 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, @@ -1156,237 +1134,25 @@ describe("summarizeConversation", () => { DEFAULT_PREV_CONTEXT_TOKENS, ) - // Find the condensed 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 include synthetic reasoning, summary text, and the preserved tool_use block - const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] - expect(content.some((b) => isReasoningBlock(b))).toBe(true) - expect(content.some((b) => b.type === "text")).toBe(true) - expect(content.some((b) => b.type === "tool_use")).toBe(true) - - // Effective history should contain: first message + summary + last N kept messages - const effectiveHistory = getEffectiveApiHistory(result.messages) - const keepMessages = messages.slice(-N_MESSAGES_TO_KEEP) - expect(effectiveHistory).toEqual([messages[0], summaryMessage!, ...keepMessages]) - expect(result.error).toBeUndefined() - }) - - it("should include user tool_result message in summarize request", async () => { - const toolUseBlock = { - type: "tool_use" as const, - id: "toolu_history_fix", - name: "read_file", - input: { path: "sample.txt" }, - } - const toolResultBlock = { - type: "tool_result" as const, - tool_use_id: "toolu_history_fix", - content: "file contents", - } - - const messages: ApiMessage[] = [ - { role: "user", content: "Hello", ts: 1 }, - { role: "assistant", content: "Let me help", ts: 2 }, - { - role: "assistant", - content: [{ type: "text" as const, text: "Running tool..." }, toolUseBlock], - ts: 3, - }, - { - role: "user", - content: [toolResultBlock, { type: "text" as const, text: "Thanks" }], - ts: 4, - }, - { role: "assistant", content: "Anything else?", ts: 5 }, - { role: "user", content: "Nope", ts: 6 }, - ] - - let capturedRequestMessages: any[] | undefined - const customStream = (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().mockImplementation((_prompt, requestMessagesParam) => { - capturedRequestMessages = requestMessagesParam - return customStream - }) as any - - const result = await summarizeConversation( - messages, - mockApiHandler, - defaultSystemPrompt, - taskId, - DEFAULT_PREV_CONTEXT_TOKENS, - ) - - expect(result.error).toBeUndefined() - expect(capturedRequestMessages).toBeDefined() - - const requestMessages = capturedRequestMessages! - // The final request message now contains the detailed CONDENSE instructions - const finalMessage = requestMessages[requestMessages.length - 1] - expect(finalMessage.role).toBe("user") - expect(finalMessage.content).toContain("Your task is to create a detailed summary of the conversation") - - const historyMessages = requestMessages.slice(0, -1) - expect(historyMessages.length).toBeGreaterThanOrEqual(2) - - const hasToolResultUserMessage = historyMessages.some( - (m) => - m.role === "user" && - Array.isArray(m.content) && - (m.content as any[]).some( - (block) => block.type === "tool_result" && block.tool_use_id === toolUseBlock.id, - ), - ) - expect(hasToolResultUserMessage).toBe(true) - }) + const condenseId = summaryMessage!.condenseId - it("should preserve multiple tool_use blocks for parallel tool calls when tool_results are kept", async () => { - const toolUseBlockA = { - type: "tool_use" as const, - id: "toolu_parallel_1", - name: "search", - input: { query: "foo" }, - } - const toolUseBlockB = { - type: "tool_use" as const, - id: "toolu_parallel_2", - name: "search", - input: { query: "bar" }, + // ALL original messages should be tagged (fresh start model tags everything) + for (const msg of result.messages.filter((m) => !m.isSummary)) { + expect(msg.condenseParent).toBe(condenseId) } - - const messages: ApiMessage[] = [ - { role: "user", content: "Start", ts: 1 }, - { role: "assistant", content: "Working...", ts: 2 }, - { - role: "assistant", - content: [{ type: "text" as const, text: "Launching parallel tools" }, toolUseBlockA, toolUseBlockB], - ts: 3, - }, - { - role: "user", - content: [ - { type: "tool_result" as const, tool_use_id: "toolu_parallel_1", content: "result A" }, - { type: "tool_result" as const, tool_use_id: "toolu_parallel_2", content: "result B" }, - { type: "text" as const, text: "Continue" }, - ], - ts: 4, - }, - { role: "assistant", content: "Processing results", ts: 5 }, - { role: "user", content: "Thanks", ts: 6 }, - ] - - const result = await summarizeConversation( - messages, - mockApiHandler, - defaultSystemPrompt, - taskId, - DEFAULT_PREV_CONTEXT_TOKENS, - ) - - const summaryMessage = result.messages.find((m) => m.isSummary) - expect(summaryMessage).toBeDefined() - expect(summaryMessage!.role).toBe("assistant") - expect(Array.isArray(summaryMessage!.content)).toBe(true) - const summaryContent = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] - // Should include the preserved tool_use blocks directly - expect(summaryContent.some((b) => b.type === "tool_use" && b.id === "toolu_parallel_1")).toBe(true) - expect(summaryContent.some((b) => b.type === "tool_use" && b.id === "toolu_parallel_2")).toBe(true) }) - it("should include synthetic reasoning (and preserve reasoning when tool_use blocks are preserved)", 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", - } - + it("should place summary message at end of messages array", async () => { 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, - ) - - 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) - const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] - // synthetic reasoning + preserved reasoning + summary text + tool_use - expect(content.some((b) => isReasoningBlock(b))).toBe(true) - expect(content.some((b) => b.type === "tool_use")).toBe(true) - expect(content.some((b) => b.type === "text")).toBe(true) - expect(result.error).toBeUndefined() - }) - - it("should produce reasoning + summary text blocks in condensed message without tool calls", async () => { - 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 }, + { role: "assistant", content: "Hi there", ts: 2 }, + { role: "user", content: "How are you?", ts: 3 }, + { role: "assistant", content: "I'm good", ts: 4 }, + { role: "user", content: "Thanks", ts: 5 }, ] - // 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, @@ -1395,18 +1161,10 @@ describe("summarizeConversation", () => { DEFAULT_PREV_CONTEXT_TOKENS, ) - 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) - 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).toContain("Summary: User requested jokes.") - - expect(result.error).toBeUndefined() + // Summary should be the last message + const lastMessage = result.messages[result.messages.length - 1] + expect(lastMessage.isSummary).toBe(true) + expect(lastMessage.role).toBe("user") }) }) @@ -1414,7 +1172,7 @@ describe("summarizeConversation with custom settings", () => { // Mock necessary dependencies let mockMainApiHandler: ApiHandler const defaultSystemPrompt = "Default prompt" - const taskId = "test-task" + const localTaskId = "test-task" // Sample messages for testing const sampleMessages: ApiMessage[] = [ @@ -1469,7 +1227,7 @@ describe("summarizeConversation with custom settings", () => { sampleMessages, mockMainApiHandler, defaultSystemPrompt, - taskId, + localTaskId, DEFAULT_PREV_CONTEXT_TOKENS, false, customPrompt, @@ -1490,16 +1248,19 @@ describe("summarizeConversation with custom settings", () => { sampleMessages, mockMainApiHandler, defaultSystemPrompt, - taskId, + localTaskId, DEFAULT_PREV_CONTEXT_TOKENS, false, " ", // Empty custom prompt ) - // Verify the default prompt was used (simple system prompt) + // Verify the default SUMMARY_PROMPT was used (contains CRITICAL instructions) let createMessageCalls = (mockMainApiHandler.createMessage as Mock).mock.calls expect(createMessageCalls.length).toBe(1) - expect(createMessageCalls[0][0]).toBe("You are a helpful AI assistant tasked with summarizing conversations.") + expect(createMessageCalls[0][0]).toContain( + "You are a helpful AI assistant tasked with summarizing conversations.", + ) + expect(createMessageCalls[0][0]).toContain("CRITICAL: This is a summarization-only request") // Reset mock and test with undefined vi.clearAllMocks() @@ -1507,16 +1268,19 @@ describe("summarizeConversation with custom settings", () => { sampleMessages, mockMainApiHandler, defaultSystemPrompt, - taskId, + localTaskId, DEFAULT_PREV_CONTEXT_TOKENS, false, undefined, // No custom prompt ) - // Verify the default prompt was used again (simple system prompt) + // Verify the default SUMMARY_PROMPT was used again (contains CRITICAL instructions) createMessageCalls = (mockMainApiHandler.createMessage as Mock).mock.calls expect(createMessageCalls.length).toBe(1) - expect(createMessageCalls[0][0]).toBe("You are a helpful AI assistant tasked with summarizing conversations.") + expect(createMessageCalls[0][0]).toContain( + "You are a helpful AI assistant tasked with summarizing conversations.", + ) + expect(createMessageCalls[0][0]).toContain("CRITICAL: This is a summarization-only request") }) /** @@ -1527,7 +1291,7 @@ describe("summarizeConversation with custom settings", () => { sampleMessages, mockMainApiHandler, defaultSystemPrompt, - taskId, + localTaskId, DEFAULT_PREV_CONTEXT_TOKENS, false, "Custom prompt", @@ -1535,7 +1299,7 @@ describe("summarizeConversation with custom settings", () => { // Verify telemetry was called with custom prompt flag expect(TelemetryService.instance.captureContextCondensed).toHaveBeenCalledWith( - taskId, + localTaskId, false, true, // usedCustomPrompt ) @@ -1549,7 +1313,7 @@ describe("summarizeConversation with custom settings", () => { sampleMessages, mockMainApiHandler, defaultSystemPrompt, - taskId, + localTaskId, DEFAULT_PREV_CONTEXT_TOKENS, true, // isAutomaticTrigger "Custom prompt", @@ -1557,7 +1321,7 @@ describe("summarizeConversation with custom settings", () => { // Verify telemetry was called with isAutomaticTrigger flag expect(TelemetryService.instance.captureContextCondensed).toHaveBeenCalledWith( - taskId, + localTaskId, true, // isAutomaticTrigger true, // usedCustomPrompt ) diff --git a/src/core/condense/__tests__/rewind-after-condense.spec.ts b/src/core/condense/__tests__/rewind-after-condense.spec.ts index ae8e8387ba9..84fdb63ca86 100644 --- a/src/core/condense/__tests__/rewind-after-condense.spec.ts +++ b/src/core/condense/__tests__/rewind-after-condense.spec.ts @@ -22,22 +22,25 @@ describe("Rewind After Condense - Issue #8295", () => { }) describe("getEffectiveApiHistory", () => { - it("should filter out messages tagged with condenseParent", () => { + it("should return summary and messages after summary (fresh start model)", () => { const condenseId = "summary-123" const messages: ApiMessage[] = [ { role: "user", content: "First message", ts: 1, condenseParent: condenseId }, { role: "assistant", content: "First response", ts: 2, condenseParent: condenseId }, { role: "user", content: "Second message", ts: 3, condenseParent: condenseId }, { role: "user", content: "Summary", ts: 4, isSummary: true, condenseId }, + // Messages after summary are included even if they have condenseParent { role: "user", content: "Third message", ts: 5, condenseParent: condenseId }, { role: "assistant", content: "Third response", ts: 6, condenseParent: condenseId }, ] const effective = getEffectiveApiHistory(messages) - // Effective history should be: summary only - expect(effective.length).toBe(1) + // Fresh start model: summary + all messages after it + expect(effective.length).toBe(3) expect(effective[0].isSummary).toBe(true) + expect(effective[1].content).toBe("Third message") + expect(effective[2].content).toBe("Third response") }) it("should include messages without condenseParent", () => { @@ -191,10 +194,11 @@ describe("Rewind After Condense - Issue #8295", () => { expect(effectiveAfter.length).toBe(5) // All messages visible }) - it("should hide condensed messages when their summary still exists", () => { + it("should hide condensed messages when their summary still exists (fresh start)", () => { const condenseId = "summary-exists" - // Scenario: Messages were condensed and summary exists - condensed messages should be hidden + // Scenario: Messages were condensed and summary exists - fresh start model returns + // only the summary and messages after it, NOT messages before the summary const messages: ApiMessage[] = [ { role: "user", content: "Start", ts: 1 }, { role: "assistant", content: "Response 1", ts: 2, condenseParent: condenseId }, @@ -203,12 +207,13 @@ describe("Rewind After Condense - Issue #8295", () => { { role: "user", content: "After summary", ts: 5 }, ] - // Effective history should hide condensed messages since summary exists + // Fresh start model: effective history is summary + messages after it + // "Start" is NOT included because it's before the summary const effective = getEffectiveApiHistory(messages) - expect(effective.length).toBe(3) // Start, Summary, After summary - expect(effective[0].content).toBe("Start") - expect(effective[1].content).toBe("Summary") - expect(effective[2].content).toBe("After summary") + expect(effective.length).toBe(2) // Summary, After summary (NOT Start) + expect(effective[0].content).toBe("Summary") + expect(effective[0].isSummary).toBe(true) + expect(effective[1].content).toBe("After summary") // cleanupAfterTruncation should NOT clear condenseParent since summary exists const cleaned = cleanupAfterTruncation(messages) diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 64ba4da6c69..54a4068697f 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -4,7 +4,7 @@ import crypto from "crypto" import { TelemetryService } from "@roo-code/telemetry" import { t } from "../../i18n" -import { ApiHandler } from "../../api" +import { ApiHandler, ApiHandlerCreateMessageMetadata } from "../../api" import { ApiMessage } from "../task-persistence/apiMessages" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" import { findLast } from "../../shared/array" @@ -21,16 +21,6 @@ function hasToolResultBlocks(message: ApiMessage): boolean { return message.content.some((block) => block.type === "tool_result") } -/** - * Gets the tool_use blocks from a message. - */ -function getToolUseBlocks(message: ApiMessage): Anthropic.Messages.ToolUseBlock[] { - if (message.role !== "assistant" || typeof message.content === "string") { - return [] - } - return message.content.filter((block) => block.type === "tool_use") as Anthropic.Messages.ToolUseBlock[] -} - /** * Gets the tool_result blocks from a message. */ @@ -62,7 +52,6 @@ function getReasoningBlocks(message: ApiMessage): Anthropic.Messages.ContentBloc 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[] } @@ -88,10 +77,6 @@ export type KeepMessagesResult = { * 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, tool_use blocks, and reasoning blocks to preserve */ export function getKeepMessagesWithToolBlocks(messages: ApiMessage[], keepCount: number): KeepMessagesResult { if (messages.length <= keepCount) { @@ -105,7 +90,6 @@ export function getKeepMessagesWithToolBlocks(messages: ApiMessage[], keepCount: const reasoningBlocksToPreserve: Anthropic.Messages.ContentBlockParam[] = [] const preservedToolUseIds = new Set() - // Check ALL kept messages for tool_result blocks for (const keepMsg of keepMessages) { if (!hasToolResultBlocks(keepMsg)) { continue @@ -154,7 +138,105 @@ export const N_MESSAGES_TO_KEEP = 3 export const MIN_CONDENSE_THRESHOLD = 5 // Minimum percentage of context window to trigger condensing export const MAX_CONDENSE_THRESHOLD = 100 // Maximum percentage of context window to trigger condensing -const SUMMARY_PROMPT = "You are a helpful AI assistant tasked with summarizing conversations." +const SUMMARY_PROMPT = `You are a helpful AI assistant tasked with summarizing conversations. + +CRITICAL: This is a summarization-only request. DO NOT call any tools or functions. +Your ONLY task is to analyze the conversation and produce a text summary. +Respond with text only - no tool calls will be processed. + +CRITICAL: This summarization request is a SYSTEM OPERATION, not a user message. +When analyzing "user requests" and "user intent", completely EXCLUDE this summarization message. +The "most recent user request" and "next step" must be based on what the user was doing BEFORE this system message appeared. +The goal is for work to continue seamlessly after condensation - as if it never happened.` + +/** + * Injects synthetic tool_results for orphan tool_calls that don't have matching results. + * This is necessary because OpenAI's Responses API rejects conversations with orphan tool_calls. + * This can happen when the user triggers condense after receiving a tool_call (like attempt_completion) + * but before responding to it. + * + * @param messages - The conversation messages to process + * @returns The messages with synthetic tool_results appended if needed + */ +export function injectSyntheticToolResults(messages: ApiMessage[]): ApiMessage[] { + // Find all tool_call IDs in assistant messages + const toolCallIds = new Set() + // Find all tool_result IDs in user messages + const toolResultIds = new Set() + + for (const msg of messages) { + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_use") { + toolCallIds.add(block.id) + } + } + } + if (msg.role === "user" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_result") { + toolResultIds.add(block.tool_use_id) + } + } + } + } + + // Find orphans (tool_calls without matching tool_results) + const orphanIds = [...toolCallIds].filter((id) => !toolResultIds.has(id)) + + if (orphanIds.length === 0) { + return messages + } + + // Inject synthetic tool_results as a new user message + const syntheticResults: Anthropic.Messages.ToolResultBlockParam[] = orphanIds.map((id) => ({ + type: "tool_result" as const, + tool_use_id: id, + content: "Context condensation triggered. Tool execution deferred.", + })) + + const syntheticMessage: ApiMessage = { + role: "user", + content: syntheticResults, + ts: Date.now(), + } + + return [...messages, syntheticMessage] +} + +/** + * Extracts blocks from a message's content. + * These blocks represent active workflows that must be preserved across condensings. + * + * @param message - The message to extract command blocks from + * @returns A string containing all command blocks found, or empty string if none + */ +export function extractCommandBlocks(message: ApiMessage): string { + const content = message.content + let text: string + + if (typeof content === "string") { + text = content + } else if (Array.isArray(content)) { + // Concatenate all text blocks + text = content + .filter((block): block is Anthropic.Messages.TextBlockParam => block.type === "text") + .map((block) => block.text) + .join("\n") + } else { + return "" + } + + // Match all blocks including their content + const commandRegex = /]*>[\s\S]*?<\/command>/g + const matches = text.match(commandRegex) + + if (!matches || matches.length === 0) { + return "" + } + + return matches.join("\n") +} export type SummarizeResponse = { messages: ApiMessage[] // The messages after summarization @@ -162,11 +244,18 @@ export type SummarizeResponse = { cost: number // The cost of the summarization operation newContextTokens?: number // The number of tokens in the context for the next API request error?: string // Populated iff the operation fails: error message shown to the user on failure (see Task.ts) + errorDetails?: string // Detailed error information including stack trace and API error info condenseId?: string // The unique ID of the created Summary message, for linking to condense_context clineMessage } /** - * Summarizes the conversation messages using an LLM call + * Summarizes the conversation messages using an LLM call. + * + * This implements the "fresh start" model where: + * - The summary becomes a user message (not assistant) + * - Post-condense, the model sees only the summary (true fresh start) + * - All messages are still stored but tagged with condenseParent + * - blocks from the original task are preserved across condensings * * @param {ApiMessage[]} messages - The conversation messages * @param {ApiHandler} apiHandler - The API handler to use for summarization and token counting @@ -175,6 +264,7 @@ export type SummarizeResponse = { * @param {number} prevContextTokens - The number of tokens currently in the context, used to ensure we don't grow the context * @param {boolean} isAutomaticTrigger - Whether the summarization is triggered automatically * @param {string} customCondensingPrompt - Optional custom prompt to use for condensing + * @param {ApiHandlerCreateMessageMetadata} metadata - Optional metadata to pass to createMessage (tools, taskId, etc.) * @returns {SummarizeResponse} - The result of the summarization operation (see above) */ export async function summarizeConversation( @@ -185,6 +275,7 @@ export async function summarizeConversation( prevContextTokens: number, isAutomaticTrigger?: boolean, customCondensingPrompt?: string, + metadata?: ApiHandlerCreateMessageMetadata, ): Promise { TelemetryService.instance.captureContextCondensed( taskId, @@ -194,35 +285,21 @@ export async function summarizeConversation( const response: SummarizeResponse = { messages, cost: 0, summary: "" } - // Always preserve the first message (which may contain slash command content) - const firstMessage = messages[0] - - // Get keepMessages and any tool_use/reasoning blocks that need to be preserved for tool_result pairing. - const { keepMessages, toolUseBlocksToPreserve, reasoningBlocksToPreserve } = getKeepMessagesWithToolBlocks( - messages, - N_MESSAGES_TO_KEEP, - ) - - const keepStartIndex = Math.max(messages.length - N_MESSAGES_TO_KEEP, 0) - const includeFirstKeptMessageInSummary = toolUseBlocksToPreserve.length > 0 - const summarySliceEnd = includeFirstKeptMessageInSummary ? keepStartIndex + 1 : keepStartIndex - const messagesBeforeKeep = summarySliceEnd > 0 ? messages.slice(0, summarySliceEnd) : [] - - // Get messages to summarize, including the first message and excluding the last N messages - const messagesToSummarize = getMessagesSinceLastSummary(messagesBeforeKeep) + // Get messages to summarize (all messages since the last summary, if any) + const messagesToSummarize = getMessagesSinceLastSummary(messages) if (messagesToSummarize.length <= 1) { const error = - messages.length <= N_MESSAGES_TO_KEEP + 1 + messages.length <= 1 ? t("common:errors.condense_not_enough_messages") : t("common:errors.condensed_recently") return { ...response, error } } - // Check if there's a recent summary in the messages we're keeping - const recentSummaryExists = keepMessages.some((message: ApiMessage) => message.isSummary) + // Check if there's a recent summary in the messages (edge case) + const recentSummaryExists = messagesToSummarize.some((message: ApiMessage) => message.isSummary) - if (recentSummaryExists) { + if (recentSummaryExists && messagesToSummarize.length <= 2) { const error = t("common:errors.condensed_recently") return { ...response, error } } @@ -232,7 +309,11 @@ export async function summarizeConversation( content: supportPrompt.default.CONDENSE, } - const requestMessages = maybeRemoveImageBlocks([...messagesToSummarize, finalRequestMessage], apiHandler).map( + // Inject synthetic tool_results for orphan tool_calls to prevent API rejections + // (e.g., when user triggers condense after receiving attempt_completion but before responding) + const messagesWithToolResults = injectSyntheticToolResults(messagesToSummarize) + + const requestMessages = maybeRemoveImageBlocks([...messagesWithToolResults, finalRequestMessage], apiHandler).map( ({ role, content }) => ({ role, content }), ) @@ -247,19 +328,61 @@ export async function summarizeConversation( return { ...response, error } } - const stream = apiHandler.createMessage(promptToUse, requestMessages) - let summary = "" let cost = 0 let outputTokens = 0 - for await (const chunk of stream) { - if (chunk.type === "text") { - summary += chunk.text - } else if (chunk.type === "usage") { - // Record final usage chunk only - cost = chunk.totalCost ?? 0 - outputTokens = chunk.outputTokens ?? 0 + try { + const stream = apiHandler.createMessage(promptToUse, requestMessages, metadata) + + for await (const chunk of stream) { + if (chunk.type === "text") { + summary += chunk.text + } else if (chunk.type === "usage") { + // Record final usage chunk only + cost = chunk.totalCost ?? 0 + outputTokens = chunk.outputTokens ?? 0 + } + } + } catch (error) { + console.error("Error during condensing API call:", error) + const errorMessage = error instanceof Error ? error.message : String(error) + + // Capture detailed error information for debugging + let errorDetails = "" + if (error instanceof Error) { + errorDetails = `Error: ${error.message}` + // Capture any additional API error properties + const anyError = error as unknown as Record + if (anyError.status) { + errorDetails += `\n\nHTTP Status: ${anyError.status}` + } + if (anyError.code) { + errorDetails += `\nError Code: ${anyError.code}` + } + if (anyError.response) { + try { + errorDetails += `\n\nAPI Response:\n${JSON.stringify(anyError.response, null, 2)}` + } catch { + errorDetails += `\n\nAPI Response: [Unable to serialize]` + } + } + if (anyError.body) { + try { + errorDetails += `\n\nResponse Body:\n${JSON.stringify(anyError.body, null, 2)}` + } catch { + errorDetails += `\n\nResponse Body: [Unable to serialize]` + } + } + } else { + errorDetails = String(error) + } + + return { + ...response, + cost, + error: t("common:errors.condense_api_failed", { message: errorMessage }), + errorDetails, } } @@ -270,94 +393,71 @@ export async function summarizeConversation( return { ...response, cost, error } } - // Build the summary message content - // 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.", + // Extract command blocks from the first message (original task) + // These represent active workflows that must persist across condensings + const firstMessage = messages[0] + const commandBlocks = firstMessage ? extractCommandBlocks(firstMessage) : "" + + // Build the summary content as separate text blocks + const summaryContent: Anthropic.Messages.ContentBlockParam[] = [ + { type: "text", text: `## Conversation Summary\n${summary}` }, + ] + + // Add command blocks as a separate text block if present + if (commandBlocks) { + summaryContent.push({ + type: "text", + text: ` +## Active Workflows +The following directives must be maintained across all future condensings: +${commandBlocks} +`, + }) } - const textBlock: Anthropic.Messages.TextBlockParam = { type: "text", text: summary } - - let summaryContent: Anthropic.Messages.ContentBlockParam[] - if (toolUseBlocksToPreserve.length > 0) { - // 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 { - // 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 const condenseId = crypto.randomUUID() - // Use first kept message's timestamp minus 1 to ensure unique timestamp for summary. - // Fallback to Date.now() if keepMessages is empty (shouldn't happen due to earlier checks). - const firstKeptTs = keepMessages[0]?.ts ?? Date.now() + // Use the last message's timestamp + 1 to ensure unique timestamp for summary. + // The summary goes at the end of all messages. + const lastMsgTs = messages[messages.length - 1]?.ts ?? Date.now() const summaryMessage: ApiMessage = { - role: "assistant", + role: "user", // Fresh start model: summary is a user message content: summaryContent, - ts: firstKeptTs - 1, // Unique timestamp before first kept message to avoid collision + ts: lastMsgTs + 1, // Unique timestamp after last message isSummary: true, condenseId, // Unique ID for this summary, used to track which messages it replaces } // NON-DESTRUCTIVE CONDENSE: - // Instead of deleting middle messages, tag them with condenseParent so they can be - // restored if the user rewinds to a point before the summary. + // Tag ALL existing messages with condenseParent so they are filtered out when + // the effective history is computed. The summary message is the only message + // that will be visible to the API after condensing (fresh start model). // // Storage structure after condense: - // [firstMessage, msg2(parent=X), ..., msg8(parent=X), summary(id=X), msg9, msg10, msg11] + // [msg1(parent=X), msg2(parent=X), ..., msgN(parent=X), summary(id=X)] // // Effective for API (filtered by getEffectiveApiHistory): - // [firstMessage, summary, msg9, msg10, msg11] + // [summary] ← Fresh start! - // Tag middle messages with condenseParent (skip first message, skip last N messages) - const newMessages = messages.map((msg, index) => { - // First message stays as-is - if (index === 0) { - return msg - } - // Messages in the "keep" range stay as-is - if (index >= keepStartIndex) { - return msg - } - // Middle messages get tagged with condenseParent (unless they already have one from a previous condense) - // If they already have a condenseParent, we leave it - nested condense is handled by filtering + // Tag ALL messages with condenseParent + const newMessages = messages.map((msg) => { + // If message already has a condenseParent, we leave it - nested condense is handled by filtering if (!msg.condenseParent) { return { ...msg, condenseParent: condenseId } } return msg }) - // Insert the summary message right before the keep messages - newMessages.splice(keepStartIndex, 0, summaryMessage) + // Append the summary message at the end + newMessages.push(summaryMessage) // Count the tokens in the context for the next API request - // We only estimate the tokens in summaryMesage if outputTokens is 0, otherwise we use outputTokens + // After condense, the context will only contain the system prompt and the summary const systemPromptMessage: ApiMessage = { role: "user", content: systemPrompt } - const contextMessages = outputTokens - ? [systemPromptMessage, ...keepMessages] - : [systemPromptMessage, summaryMessage, ...keepMessages] + const contextMessages = outputTokens ? [systemPromptMessage] : [systemPromptMessage, summaryMessage] const contextBlocks = contextMessages.flatMap((message) => typeof message.content === "string" ? [{ text: message.content, type: "text" as const }] : message.content, @@ -407,8 +507,11 @@ export function getMessagesSinceLastSummary(messages: ApiMessage[]): ApiMessage[ /** * Filters the API conversation history to get the "effective" messages to send to the API. - * Messages with a condenseParent that points to an existing summary are filtered out, - * as they have been replaced by that summary. + * + * Fresh Start Model: + * - When a summary exists, return only messages from the summary onwards (fresh start) + * - Messages with a condenseParent pointing to an existing summary are filtered out + * * Messages with a truncationParent that points to an existing truncation marker are also filtered out, * as they have been hidden by sliding window truncation. * @@ -419,6 +522,71 @@ export function getMessagesSinceLastSummary(messages: ApiMessage[]): ApiMessage[ * @returns The filtered history that should be sent to the API */ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] { + // Find the most recent summary message + const lastSummary = findLast(messages, (msg) => msg.isSummary === true) + + if (lastSummary) { + // Fresh start model: return only messages from the summary onwards + const summaryIndex = messages.indexOf(lastSummary) + let messagesFromSummary = messages.slice(summaryIndex) + + // Collect all tool_use IDs from assistant messages in the result + // This is needed to filter out orphan tool_result blocks that reference + // tool_use IDs from messages that were condensed away + const toolUseIds = new Set() + for (const msg of messagesFromSummary) { + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_use" && (block as Anthropic.Messages.ToolUseBlockParam).id) { + toolUseIds.add((block as Anthropic.Messages.ToolUseBlockParam).id) + } + } + } + } + + // Filter out orphan tool_result blocks from user messages + messagesFromSummary = messagesFromSummary + .map((msg) => { + if (msg.role === "user" && Array.isArray(msg.content)) { + const filteredContent = msg.content.filter((block) => { + if (block.type === "tool_result") { + return toolUseIds.has((block as Anthropic.Messages.ToolResultBlockParam).tool_use_id) + } + return true + }) + // If all content was filtered out, mark for removal + if (filteredContent.length === 0) { + return null + } + // If some content was filtered, return updated message + if (filteredContent.length !== msg.content.length) { + return { ...msg, content: filteredContent } + } + } + return msg + }) + .filter((msg): msg is ApiMessage => msg !== null) + + // Still need to filter out any truncated messages within this range + const existingTruncationIds = new Set() + for (const msg of messagesFromSummary) { + if (msg.isTruncationMarker && msg.truncationId) { + existingTruncationIds.add(msg.truncationId) + } + } + + return messagesFromSummary.filter((msg) => { + // Filter out truncated messages if their truncation marker exists + if (msg.truncationParent && existingTruncationIds.has(msg.truncationParent)) { + return false + } + return true + }) + } + + // No summary - filter based on condenseParent and truncationParent as before + // This handles the case of orphaned condenseParent tags (summary was deleted via rewind) + // Collect all condenseIds of summaries that exist in the current history const existingSummaryIds = new Set() // Collect all truncationIds of truncation markers that exist in the current history diff --git a/src/core/context-management/__tests__/context-management.spec.ts b/src/core/context-management/__tests__/context-management.spec.ts index cbd4c6b795c..85697d416aa 100644 --- a/src/core/context-management/__tests__/context-management.spec.ts +++ b/src/core/context-management/__tests__/context-management.spec.ts @@ -620,6 +620,7 @@ describe("Context Management", () => { 70001, true, undefined, // customCondensingPrompt + undefined, // metadata ) // Verify the result contains the summary information @@ -794,6 +795,7 @@ describe("Context Management", () => { 60000, true, undefined, // customCondensingPrompt + undefined, // metadata ) // Verify the result contains the summary information diff --git a/src/core/context-management/index.ts b/src/core/context-management/index.ts index 78be5d404d9..b781a7aa89d 100644 --- a/src/core/context-management/index.ts +++ b/src/core/context-management/index.ts @@ -3,7 +3,7 @@ import crypto from "crypto" import { TelemetryService } from "@roo-code/telemetry" -import { ApiHandler } from "../../api" +import { ApiHandler, ApiHandlerCreateMessageMetadata } from "../../api" import { MAX_CONDENSE_THRESHOLD, MIN_CONDENSE_THRESHOLD, summarizeConversation, SummarizeResponse } from "../condense" import { ApiMessage } from "../task-persistence/apiMessages" import { ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types" @@ -218,6 +218,8 @@ export type ContextManagementOptions = { customCondensingPrompt?: string profileThresholds: Record currentProfileId: string + /** Optional metadata to pass through to the condensing API call (tools, taskId, etc.) */ + metadata?: ApiHandlerCreateMessageMetadata } export type ContextManagementResult = SummarizeResponse & { @@ -246,8 +248,10 @@ export async function manageContext({ customCondensingPrompt, profileThresholds, currentProfileId, + metadata, }: ContextManagementOptions): Promise { let error: string | undefined + let errorDetails: string | undefined let cost = 0 // Calculate the maximum tokens reserved for response const reservedTokens = maxTokens || ANTHROPIC_DEFAULT_MAX_TOKENS @@ -298,9 +302,11 @@ export async function manageContext({ prevContextTokens, true, // automatic trigger customCondensingPrompt, + metadata, ) if (result.error) { error = result.error + errorDetails = result.errorDetails cost = result.cost } else { return { ...result, prevContextTokens } @@ -343,11 +349,12 @@ export async function manageContext({ summary: "", cost, error, + errorDetails, truncationId: truncationResult.truncationId, messagesRemoved: truncationResult.messagesRemoved, newContextTokensAfterTruncation, } } // No truncation or condensation needed - return { messages, summary: "", cost, prevContextTokens, error } + return { messages, summary: "", cost, prevContextTokens, error, errorDetails } } diff --git a/src/core/message-manager/index.spec.ts b/src/core/message-manager/index.spec.ts index e2c11db3b7e..3fd99793bfe 100644 --- a/src/core/message-manager/index.spec.ts +++ b/src/core/message-manager/index.spec.ts @@ -146,7 +146,7 @@ describe("MessageManager", () => { }, { ts: 299, - role: "assistant", + role: "user", content: [{ type: "text", text: "Summary" }], isSummary: true, condenseId, @@ -184,7 +184,7 @@ describe("MessageManager", () => { }, { ts: 299, - role: "assistant", + role: "user", content: [{ type: "text", text: "Summary" }], isSummary: true, condenseId, @@ -220,7 +220,7 @@ describe("MessageManager", () => { }, { ts: 199, - role: "assistant", + role: "user", content: [{ type: "text", text: "Summary" }], isSummary: true, condenseId, @@ -258,7 +258,7 @@ describe("MessageManager", () => { { ts: 100, role: "user", content: [{ type: "text", text: "First" }] }, { ts: 199, - role: "assistant", + role: "user", content: [{ type: "text", text: "Summary 1" }], isSummary: true, condenseId: condenseId1, @@ -266,7 +266,7 @@ describe("MessageManager", () => { { ts: 300, role: "user", content: [{ type: "text", text: "Second" }] }, { ts: 399, - role: "assistant", + role: "user", content: [{ type: "text", text: "Summary 2" }], isSummary: true, condenseId: condenseId2, @@ -448,7 +448,7 @@ describe("MessageManager", () => { }, { ts: 499, - role: "assistant", + role: "user", content: [{ type: "text", text: "Summary" }], isSummary: true, condenseId, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index f740b0fe7c6..20c196f29bd 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1588,14 +1588,51 @@ export class Task extends EventEmitter implements TaskLike { // Get condensing configuration const state = await this.providerRef.deref()?.getState() const customCondensingPrompt = state?.customSupportPrompts?.CONDENSE + const { mode, apiConfiguration } = state ?? {} const { contextTokens: prevContextTokens } = this.getTokenUsage() + + // Build tools for condensing metadata (same tools used for normal API calls) + const provider = this.providerRef.deref() + let allTools: import("openai").default.Chat.ChatCompletionTool[] = [] + if (provider) { + const modelInfo = this.api.getModel().info + const toolsResult = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: this.cwd, + mode, + customModes: state?.customModes, + experiments: state?.experiments, + apiConfiguration, + maxReadFileLine: state?.maxReadFileLine ?? -1, + maxConcurrentFileReads: state?.maxConcurrentFileReads ?? 5, + browserToolEnabled: state?.browserToolEnabled ?? true, + modelInfo, + diffEnabled: this.diffEnabled, + includeAllToolsWithRestrictions: false, + }) + allTools = toolsResult.tools + } + + // Build metadata with tools and taskId for the condensing API call + const metadata: ApiHandlerCreateMessageMetadata = { + mode, + taskId: this.taskId, + ...(allTools.length > 0 + ? { + tools: allTools, + tool_choice: "auto", + parallelToolCalls: false, + } + : {}), + } const { messages, summary, cost, newContextTokens = 0, error, + errorDetails, condenseId, } = await summarizeConversation( this.apiConversationHistory, @@ -1605,11 +1642,17 @@ export class Task extends EventEmitter implements TaskLike { prevContextTokens, false, // manual trigger customCondensingPrompt, // User's custom prompt + metadata, // Pass metadata with tools ) if (error) { + // Format error as JSON with message and details for ErrorRow component + const errorJson = JSON.stringify({ + message: error, + details: errorDetails, + }) this.say( "condense_context_error", - error, + errorJson, undefined /* images */, false /* partial */, undefined /* checkpoint */, @@ -3674,7 +3717,7 @@ export class Task extends EventEmitter implements TaskLike { private async handleContextWindowExceededError(): Promise { const state = await this.providerRef.deref()?.getState() - const { profileThresholds = {} } = state ?? {} + const { profileThresholds = {}, mode, apiConfiguration } = state ?? {} const { contextTokens } = this.getTokenUsage() const modelInfo = this.api.getModel().info @@ -3699,61 +3742,101 @@ export class Task extends EventEmitter implements TaskLike { // Send condenseTaskContextStarted to show in-progress indicator await this.providerRef.deref()?.postMessageToWebview({ type: "condenseTaskContextStarted", text: this.taskId }) - // Force aggressive truncation by keeping only 75% of the conversation history - const truncateResult = await manageContext({ - messages: this.apiConversationHistory, - totalTokens: contextTokens || 0, - maxTokens, - contextWindow, - apiHandler: this.api, - autoCondenseContext: true, - autoCondenseContextPercent: FORCED_CONTEXT_REDUCTION_PERCENT, - systemPrompt: await this.getSystemPrompt(), - taskId: this.taskId, - profileThresholds, - currentProfileId, - }) + // Build tools for condensing metadata (same tools used for normal API calls) + const provider = this.providerRef.deref() + let allTools: import("openai").default.Chat.ChatCompletionTool[] = [] + if (provider) { + const toolsResult = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: this.cwd, + mode, + customModes: state?.customModes, + experiments: state?.experiments, + apiConfiguration, + maxReadFileLine: state?.maxReadFileLine ?? -1, + maxConcurrentFileReads: state?.maxConcurrentFileReads ?? 5, + browserToolEnabled: state?.browserToolEnabled ?? true, + modelInfo, + diffEnabled: this.diffEnabled, + includeAllToolsWithRestrictions: false, + }) + allTools = toolsResult.tools + } - if (truncateResult.messages !== this.apiConversationHistory) { - await this.overwriteApiConversationHistory(truncateResult.messages) + // Build metadata with tools and taskId for the condensing API call + const metadata: ApiHandlerCreateMessageMetadata = { + mode, + taskId: this.taskId, + ...(allTools.length > 0 + ? { + tools: allTools, + tool_choice: "auto", + parallelToolCalls: false, + } + : {}), } - if (truncateResult.summary) { - const { summary, cost, prevContextTokens, newContextTokens = 0 } = truncateResult - const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens } - await this.say( - "condense_context", - undefined /* text */, - undefined /* images */, - false /* partial */, - undefined /* checkpoint */, - undefined /* progressStatus */, - { isNonInteractive: true } /* options */, - contextCondense, - ) - } else if (truncateResult.truncationId) { - // Sliding window truncation occurred (fallback when condensing fails or is disabled) - const contextTruncation: ContextTruncation = { - truncationId: truncateResult.truncationId, - messagesRemoved: truncateResult.messagesRemoved ?? 0, - prevContextTokens: truncateResult.prevContextTokens, - newContextTokens: truncateResult.newContextTokensAfterTruncation ?? 0, + try { + // Force aggressive truncation by keeping only 75% of the conversation history + const truncateResult = await manageContext({ + messages: this.apiConversationHistory, + totalTokens: contextTokens || 0, + maxTokens, + contextWindow, + apiHandler: this.api, + autoCondenseContext: true, + autoCondenseContextPercent: FORCED_CONTEXT_REDUCTION_PERCENT, + systemPrompt: await this.getSystemPrompt(), + taskId: this.taskId, + profileThresholds, + currentProfileId, + metadata, + }) + + if (truncateResult.messages !== this.apiConversationHistory) { + await this.overwriteApiConversationHistory(truncateResult.messages) } - await this.say( - "sliding_window_truncation", - undefined /* text */, - undefined /* images */, - false /* partial */, - undefined /* checkpoint */, - undefined /* progressStatus */, - { isNonInteractive: true } /* options */, - undefined /* contextCondense */, - contextTruncation, - ) - } - // Notify webview that context management is complete (removes in-progress spinner) - await this.providerRef.deref()?.postMessageToWebview({ type: "condenseTaskContextResponse", text: this.taskId }) + if (truncateResult.summary) { + const { summary, cost, prevContextTokens, newContextTokens = 0 } = truncateResult + const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens } + await this.say( + "condense_context", + undefined /* text */, + undefined /* images */, + false /* partial */, + undefined /* checkpoint */, + undefined /* progressStatus */, + { isNonInteractive: true } /* options */, + contextCondense, + ) + } else if (truncateResult.truncationId) { + // Sliding window truncation occurred (fallback when condensing fails or is disabled) + const contextTruncation: ContextTruncation = { + truncationId: truncateResult.truncationId, + messagesRemoved: truncateResult.messagesRemoved ?? 0, + prevContextTokens: truncateResult.prevContextTokens, + newContextTokens: truncateResult.newContextTokensAfterTruncation ?? 0, + } + await this.say( + "sliding_window_truncation", + undefined /* text */, + undefined /* images */, + false /* partial */, + undefined /* checkpoint */, + undefined /* progressStatus */, + { isNonInteractive: true } /* options */, + undefined /* contextCondense */, + contextTruncation, + ) + } + } finally { + // Notify webview that context management is complete (removes in-progress spinner) + // IMPORTANT: Must always be sent to dismiss the spinner, even on error + await this.providerRef + .deref() + ?.postMessageToWebview({ type: "condenseTaskContextResponse", text: this.taskId }) + } } /** @@ -3870,71 +3953,117 @@ export class Task extends EventEmitter implements TaskLike { ?.postMessageToWebview({ type: "condenseTaskContextStarted", text: this.taskId }) } - const truncateResult = await manageContext({ - messages: this.apiConversationHistory, - totalTokens: contextTokens, - maxTokens, - contextWindow, - apiHandler: this.api, - autoCondenseContext, - autoCondenseContextPercent, - systemPrompt, + // Build tools for condensing metadata (same tools used for normal API calls) + // This ensures the condensing API call includes tool definitions for providers that need them + let contextMgmtTools: import("openai").default.Chat.ChatCompletionTool[] = [] + { + const provider = this.providerRef.deref() + if (provider) { + const toolsResult = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: this.cwd, + mode, + customModes: state?.customModes, + experiments: state?.experiments, + apiConfiguration, + maxReadFileLine: state?.maxReadFileLine ?? -1, + maxConcurrentFileReads: state?.maxConcurrentFileReads ?? 5, + browserToolEnabled: state?.browserToolEnabled ?? true, + modelInfo, + diffEnabled: this.diffEnabled, + includeAllToolsWithRestrictions: false, + }) + contextMgmtTools = toolsResult.tools + } + } + + // Build metadata with tools and taskId for the condensing API call + const contextMgmtMetadata: ApiHandlerCreateMessageMetadata = { + mode, taskId: this.taskId, - customCondensingPrompt, - profileThresholds, - currentProfileId, - }) - if (truncateResult.messages !== this.apiConversationHistory) { - await this.overwriteApiConversationHistory(truncateResult.messages) + ...(contextMgmtTools.length > 0 + ? { + tools: contextMgmtTools, + tool_choice: "auto", + parallelToolCalls: false, + } + : {}), } - if (truncateResult.error) { - await this.say("condense_context_error", truncateResult.error) - } else if (truncateResult.summary) { - const { summary, cost, prevContextTokens, newContextTokens = 0, condenseId } = truncateResult - const contextCondense: ContextCondense = { - summary, - cost, - newContextTokens, - prevContextTokens, - condenseId, + + try { + const truncateResult = await manageContext({ + messages: this.apiConversationHistory, + totalTokens: contextTokens, + maxTokens, + contextWindow, + apiHandler: this.api, + autoCondenseContext, + autoCondenseContextPercent, + systemPrompt, + taskId: this.taskId, + customCondensingPrompt, + profileThresholds, + currentProfileId, + metadata: contextMgmtMetadata, + }) + if (truncateResult.messages !== this.apiConversationHistory) { + await this.overwriteApiConversationHistory(truncateResult.messages) } - await this.say( - "condense_context", - undefined /* text */, - undefined /* images */, - false /* partial */, - undefined /* checkpoint */, - undefined /* progressStatus */, - { isNonInteractive: true } /* options */, - contextCondense, - ) - } else if (truncateResult.truncationId) { - // Sliding window truncation occurred (fallback when condensing fails or is disabled) - const contextTruncation: ContextTruncation = { - truncationId: truncateResult.truncationId, - messagesRemoved: truncateResult.messagesRemoved ?? 0, - prevContextTokens: truncateResult.prevContextTokens, - newContextTokens: truncateResult.newContextTokensAfterTruncation ?? 0, + if (truncateResult.error) { + // Format error as JSON with message and details for ErrorRow component + const errorJson = JSON.stringify({ + message: truncateResult.error, + details: truncateResult.errorDetails, + }) + await this.say("condense_context_error", errorJson) + } else if (truncateResult.summary) { + const { summary, cost, prevContextTokens, newContextTokens = 0, condenseId } = truncateResult + const contextCondense: ContextCondense = { + summary, + cost, + newContextTokens, + prevContextTokens, + condenseId, + } + await this.say( + "condense_context", + undefined /* text */, + undefined /* images */, + false /* partial */, + undefined /* checkpoint */, + undefined /* progressStatus */, + { isNonInteractive: true } /* options */, + contextCondense, + ) + } else if (truncateResult.truncationId) { + // Sliding window truncation occurred (fallback when condensing fails or is disabled) + const contextTruncation: ContextTruncation = { + truncationId: truncateResult.truncationId, + messagesRemoved: truncateResult.messagesRemoved ?? 0, + prevContextTokens: truncateResult.prevContextTokens, + newContextTokens: truncateResult.newContextTokensAfterTruncation ?? 0, + } + await this.say( + "sliding_window_truncation", + undefined /* text */, + undefined /* images */, + false /* partial */, + undefined /* checkpoint */, + undefined /* progressStatus */, + { isNonInteractive: true } /* options */, + undefined /* contextCondense */, + contextTruncation, + ) + } + } finally { + // Notify webview that context management is complete (sets isCondensing = false) + // This removes the in-progress spinner and allows the completed result to show + // IMPORTANT: Must always be sent to dismiss the spinner, even on error + if (contextManagementWillRun && autoCondenseContext) { + await this.providerRef + .deref() + ?.postMessageToWebview({ type: "condenseTaskContextResponse", text: this.taskId }) } - await this.say( - "sliding_window_truncation", - undefined /* text */, - undefined /* images */, - false /* partial */, - undefined /* checkpoint */, - undefined /* progressStatus */, - { isNonInteractive: true } /* options */, - undefined /* contextCondense */, - contextTruncation, - ) - } - - // Notify webview that context management is complete (sets isCondensing = false) - // This removes the in-progress spinner and allows the completed result to show - if (contextManagementWillRun && autoCondenseContext) { - await this.providerRef - .deref() - ?.postMessageToWebview({ type: "condenseTaskContextResponse", text: this.taskId }) } } diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 321a1aa3a06..f240dc0a196 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -67,6 +67,7 @@ "condensed_recently": "El context s'ha condensat recentment; s'omet aquest intent", "condense_handler_invalid": "El gestor de l'API per condensar el context no és vàlid", "condense_context_grew": "La mida del context ha augmentat durant la condensació; s'omet aquest intent", + "condense_api_failed": "La crida a l'API de condensació ha fallat: {{message}}", "url_timeout": "El lloc web ha trigat massa a carregar (timeout). Això pot ser degut a una connexió lenta, un lloc web pesat o temporalment no disponible. Pots tornar-ho a provar més tard o comprovar si la URL és correcta.", "url_not_found": "No s'ha pogut trobar l'adreça del lloc web. Comprova si la URL és correcta i torna-ho a provar.", "no_internet": "No hi ha connexió a internet. Comprova la teva connexió de xarxa i torna-ho a provar.", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 0611cf889af..681a57d6b86 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -63,6 +63,7 @@ "condensed_recently": "Kontext wurde kürzlich verdichtet; dieser Versuch wird übersprungen", "condense_handler_invalid": "API-Handler zum Verdichten des Kontexts ist ungültig", "condense_context_grew": "Kontextgröße ist während der Verdichtung gewachsen; dieser Versuch wird übersprungen", + "condense_api_failed": "Verdichtungs-API-Aufruf fehlgeschlagen: {{message}}", "url_timeout": "Die Website hat zu lange zum Laden gebraucht (Timeout). Das könnte an einer langsamen Verbindung, einer schweren Website oder vorübergehender Nichtverfügbarkeit liegen. Du kannst es später nochmal versuchen oder prüfen, ob die URL korrekt ist.", "url_not_found": "Die Website-Adresse konnte nicht gefunden werden. Bitte prüfe, ob die URL korrekt ist und versuche es erneut.", "no_internet": "Keine Internetverbindung. Bitte prüfe deine Netzwerkverbindung und versuche es erneut.", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 90c409feb76..c70556b2d2b 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -63,6 +63,7 @@ "condensed_recently": "Context was condensed recently; skipping this attempt", "condense_handler_invalid": "API handler for condensing context is invalid", "condense_context_grew": "Context size increased during condensing; skipping this attempt", + "condense_api_failed": "Condensing API call failed: {{message}}", "url_timeout": "The website took too long to load (timeout). This could be due to a slow connection, heavy website, or the site being temporarily unavailable. You can try again later or check if the URL is correct.", "url_not_found": "The website address could not be found. Please check if the URL is correct and try again.", "no_internet": "No internet connection. Please check your network connection and try again.", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index d0a086173e9..053cbdc3cd1 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -63,6 +63,7 @@ "condensed_recently": "El contexto se condensó recientemente; se omite este intento", "condense_handler_invalid": "El manejador de API para condensar el contexto no es válido", "condense_context_grew": "El tamaño del contexto aumentó durante la condensación; se omite este intento", + "condense_api_failed": "La llamada API de condensación falló: {{message}}", "url_timeout": "El sitio web tardó demasiado en cargar (timeout). Esto podría deberse a una conexión lenta, un sitio web pesado o que esté temporalmente no disponible. Puedes intentarlo más tarde o verificar si la URL es correcta.", "url_not_found": "No se pudo encontrar la dirección del sitio web. Por favor verifica si la URL es correcta e inténtalo de nuevo.", "no_internet": "Sin conexión a internet. Por favor verifica tu conexión de red e inténtalo de nuevo.", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 58350ef02ba..d3c9a287219 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -63,6 +63,7 @@ "condensed_recently": "Le contexte a été condensé récemment ; cette tentative est ignorée", "condense_handler_invalid": "Le gestionnaire d'API pour condenser le contexte est invalide", "condense_context_grew": "La taille du contexte a augmenté pendant la condensation ; cette tentative est ignorée", + "condense_api_failed": "L'appel API de condensation a échoué : {{message}}", "url_timeout": "Le site web a pris trop de temps à charger (timeout). Cela pourrait être dû à une connexion lente, un site web lourd ou temporairement indisponible. Tu peux réessayer plus tard ou vérifier si l'URL est correcte.", "url_not_found": "L'adresse du site web n'a pas pu être trouvée. Vérifie si l'URL est correcte et réessaie.", "no_internet": "Pas de connexion internet. Vérifie ta connexion réseau et réessaie.", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 33277c71623..23e4379c24b 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -63,6 +63,7 @@ "condensed_recently": "संदर्भ हाल ही में संक्षिप्त किया गया था; इस प्रयास को छोड़ा जा रहा है", "condense_handler_invalid": "संदर्भ को संक्षिप्त करने के लिए API हैंडलर अमान्य है", "condense_context_grew": "संक्षिप्तीकरण के दौरान संदर्भ का आकार बढ़ गया; इस प्रयास को छोड़ा जा रहा है", + "condense_api_failed": "संक्षिप्तीकरण API कॉल विफल: {{message}}", "url_timeout": "वेबसाइट लोड होने में बहुत समय लगा (टाइमआउट)। यह धीमे कनेक्शन, भारी वेबसाइट या अस्थायी रूप से अनुपलब्ध होने के कारण हो सकता है। आप बाद में फिर से कोशिश कर सकते हैं या जांच सकते हैं कि URL सही है या नहीं।", "url_not_found": "वेबसाइट का पता नहीं मिल सका। कृपया जांचें कि URL सही है और फिर से कोशिश करें।", "no_internet": "इंटरनेट कनेक्शन नहीं है। कृपया अपना नेटवर्क कनेक्शन जांचें और फिर से कोशिश करें।", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index c10532beef9..3ddfefafb0f 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -63,6 +63,7 @@ "condensed_recently": "Konteks baru saja dikompres; melewati percobaan ini", "condense_handler_invalid": "Handler API untuk mengompres konteks tidak valid", "condense_context_grew": "Ukuran konteks bertambah saat mengompres; melewati percobaan ini", + "condense_api_failed": "Panggilan API pengompresan gagal: {{message}}", "url_timeout": "Situs web membutuhkan waktu terlalu lama untuk dimuat (timeout). Ini bisa disebabkan oleh koneksi lambat, situs web berat, atau sementara tidak tersedia. Kamu bisa mencoba lagi nanti atau memeriksa apakah URL sudah benar.", "url_not_found": "Alamat situs web tidak dapat ditemukan. Silakan periksa apakah URL sudah benar dan coba lagi.", "no_internet": "Tidak ada koneksi internet. Silakan periksa koneksi jaringan kamu dan coba lagi.", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index a75dccd3875..06d43563279 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -63,6 +63,7 @@ "condensed_recently": "Il contesto è stato condensato di recente; questo tentativo viene saltato", "condense_handler_invalid": "Il gestore API per condensare il contesto non è valido", "condense_context_grew": "La dimensione del contesto è aumentata durante la condensazione; questo tentativo viene saltato", + "condense_api_failed": "Chiamata API di condensazione fallita: {{message}}", "url_timeout": "Il sito web ha impiegato troppo tempo a caricarsi (timeout). Questo potrebbe essere dovuto a una connessione lenta, un sito web pesante o temporaneamente non disponibile. Puoi riprovare più tardi o verificare se l'URL è corretto.", "url_not_found": "L'indirizzo del sito web non è stato trovato. Verifica se l'URL è corretto e riprova.", "no_internet": "Nessuna connessione internet. Verifica la tua connessione di rete e riprova.", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index b378f00b03f..28ef44fe67b 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -63,6 +63,7 @@ "condensed_recently": "コンテキストは最近圧縮されました;この試行をスキップします", "condense_handler_invalid": "コンテキストを圧縮するためのAPIハンドラーが無効です", "condense_context_grew": "圧縮中にコンテキストサイズが増加しました;この試行をスキップします", + "condense_api_failed": "圧縮API呼び出しが失敗しました:{{message}}", "url_timeout": "ウェブサイトの読み込みがタイムアウトしました。接続が遅い、ウェブサイトが重い、または一時的に利用できない可能性があります。後でもう一度試すか、URLが正しいか確認してください。", "url_not_found": "ウェブサイトのアドレスが見つかりませんでした。URLが正しいか確認してもう一度試してください。", "no_internet": "インターネット接続がありません。ネットワーク接続を確認してもう一度試してください。", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index e7afdceabce..e2e837400e6 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -63,6 +63,7 @@ "condensed_recently": "컨텍스트가 최근 압축되었습니다; 이 시도를 건너뜁니다", "condense_handler_invalid": "컨텍스트 압축을 위한 API 핸들러가 유효하지 않습니다", "condense_context_grew": "압축 중 컨텍스트 크기가 증가했습니다; 이 시도를 건너뜁니다", + "condense_api_failed": "압축 API 호출 실패: {{message}}", "url_timeout": "웹사이트 로딩이 너무 오래 걸렸습니다(타임아웃). 느린 연결, 무거운 웹사이트 또는 일시적으로 사용할 수 없는 상태일 수 있습니다. 나중에 다시 시도하거나 URL이 올바른지 확인해 주세요.", "url_not_found": "웹사이트 주소를 찾을 수 없습니다. URL이 올바른지 확인하고 다시 시도해 주세요.", "no_internet": "인터넷 연결이 없습니다. 네트워크 연결을 확인하고 다시 시도해 주세요.", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 889cd4b3ab6..14211ffc004 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -63,6 +63,7 @@ "condensed_recently": "Context is recent gecomprimeerd; deze poging wordt overgeslagen", "condense_handler_invalid": "API-handler voor het comprimeren van context is ongeldig", "condense_context_grew": "Contextgrootte nam toe tijdens comprimeren; deze poging wordt overgeslagen", + "condense_api_failed": "Comprimeer API-oproep mislukt: {{message}}", "url_timeout": "De website deed er te lang over om te laden (timeout). Dit kan komen door een trage verbinding, een zware website of tijdelijke onbeschikbaarheid. Je kunt het later opnieuw proberen of controleren of de URL correct is.", "url_not_found": "Het websiteadres kon niet worden gevonden. Controleer of de URL correct is en probeer opnieuw.", "no_internet": "Geen internetverbinding. Controleer je netwerkverbinding en probeer opnieuw.", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index faa4e9ed3a5..9c7074d5719 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -63,6 +63,7 @@ "condensed_recently": "Kontekst został niedawno skondensowany; pomijanie tej próby", "condense_handler_invalid": "Nieprawidłowy handler API do kondensowania kontekstu", "condense_context_grew": "Rozmiar kontekstu wzrósł podczas kondensacji; pomijanie tej próby", + "condense_api_failed": "Wywołanie API kondensacji nie powiodło się: {{message}}", "url_timeout": "Strona internetowa ładowała się zbyt długo (timeout). Może to być spowodowane wolnym połączeniem, ciężką stroną lub tymczasową niedostępnością. Możesz spróbować ponownie później lub sprawdzić, czy URL jest poprawny.", "url_not_found": "Nie można znaleźć adresu strony internetowej. Sprawdź, czy URL jest poprawny i spróbuj ponownie.", "no_internet": "Brak połączenia z internetem. Sprawdź połączenie sieciowe i spróbuj ponownie.", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index f41a379acbc..e8a23d1013a 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -67,6 +67,7 @@ "condensed_recently": "O contexto foi condensado recentemente; pulando esta tentativa", "condense_handler_invalid": "O manipulador de API para condensar o contexto é inválido", "condense_context_grew": "O tamanho do contexto aumentou durante a condensação; pulando esta tentativa", + "condense_api_failed": "Chamada de API de condensação falhou: {{message}}", "url_timeout": "O site demorou muito para carregar (timeout). Isso pode ser devido a uma conexão lenta, site pesado ou temporariamente indisponível. Você pode tentar novamente mais tarde ou verificar se a URL está correta.", "url_not_found": "O endereço do site não pôde ser encontrado. Verifique se a URL está correta e tente novamente.", "no_internet": "Sem conexão com a internet. Verifique sua conexão de rede e tente novamente.", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 751637f19e0..4467ceaeed2 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -63,6 +63,7 @@ "condensed_recently": "Контекст был недавно сжат; пропускаем эту попытку", "condense_handler_invalid": "Обработчик API для сжатия контекста недействителен", "condense_context_grew": "Размер контекста увеличился во время сжатия; пропускаем эту попытку", + "condense_api_failed": "Ошибка вызова API сжатия: {{message}}", "url_timeout": "Веб-сайт слишком долго загружался (таймаут). Это может быть из-за медленного соединения, тяжелого веб-сайта или временной недоступности. Ты можешь попробовать позже или проверить правильность URL.", "url_not_found": "Адрес веб-сайта не найден. Проверь правильность URL и попробуй снова.", "no_internet": "Нет подключения к интернету. Проверь сетевое подключение и попробуй снова.", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 7b2ac152a9b..5112581a1f0 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -63,6 +63,7 @@ "condensed_recently": "Bağlam yakın zamanda sıkıştırıldı; bu deneme atlanıyor", "condense_handler_invalid": "Bağlamı sıkıştırmak için API işleyicisi geçersiz", "condense_context_grew": "Sıkıştırma sırasında bağlam boyutu arttı; bu deneme atlanıyor", + "condense_api_failed": "Sıkıştırma API çağrısı başarısız oldu: {{message}}", "url_timeout": "Web sitesi yüklenmesi çok uzun sürdü (zaman aşımı). Bu yavaş bağlantı, ağır web sitesi veya geçici olarak kullanılamama nedeniyle olabilir. Daha sonra tekrar deneyebilir veya URL'nin doğru olup olmadığını kontrol edebilirsin.", "url_not_found": "Web sitesi adresi bulunamadı. URL'nin doğru olup olmadığını kontrol et ve tekrar dene.", "no_internet": "İnternet bağlantısı yok. Ağ bağlantını kontrol et ve tekrar dene.", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 0d88ba07808..1540019ae1c 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -63,6 +63,7 @@ "condensed_recently": "Ngữ cảnh đã được nén gần đây; bỏ qua lần thử này", "condense_handler_invalid": "Trình xử lý API để nén ngữ cảnh không hợp lệ", "condense_context_grew": "Kích thước ngữ cảnh tăng lên trong quá trình nén; bỏ qua lần thử này", + "condense_api_failed": "Cuộc gọi API nén thất bại: {{message}}", "url_timeout": "Trang web mất quá nhiều thời gian để tải (timeout). Điều này có thể do kết nối chậm, trang web nặng hoặc tạm thời không khả dụng. Bạn có thể thử lại sau hoặc kiểm tra xem URL có đúng không.", "url_not_found": "Không thể tìm thấy địa chỉ trang web. Vui lòng kiểm tra URL có đúng không và thử lại.", "no_internet": "Không có kết nối internet. Vui lòng kiểm tra kết nối mạng và thử lại.", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 133b3de0794..71e1c2fd6c0 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -68,6 +68,7 @@ "condensed_recently": "上下文最近已压缩;跳过此次尝试", "condense_handler_invalid": "压缩上下文的API处理程序无效", "condense_context_grew": "压缩过程中上下文大小增加;跳过此次尝试", + "condense_api_failed": "压缩 API 调用失败:{{message}}", "url_timeout": "网站加载超时。这可能是由于网络连接缓慢、网站负载过重或暂时不可用。你可以稍后重试或检查 URL 是否正确。", "url_not_found": "找不到网站地址。请检查 URL 是否正确并重试。", "no_internet": "无网络连接。请检查网络连接并重试。", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 8039f203b62..f7df6e8fb18 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -63,6 +63,7 @@ "condensed_recently": "上下文最近已壓縮;跳過此次嘗試", "condense_handler_invalid": "壓縮上下文的 API 處理程式無效", "condense_context_grew": "壓縮過程中上下文大小增加;跳過此次嘗試", + "condense_api_failed": "壓縮 API 呼叫失敗:{{message}}", "url_timeout": "網站載入超時。這可能是由於網路連線緩慢、網站負載過重或暫時無法使用。你可以稍後重試或檢查 URL 是否正確。", "url_not_found": "找不到網站位址。請檢查 URL 是否正確並重試。", "no_internet": "無網路連線。請檢查網路連線並重試。", diff --git a/src/shared/support-prompt.ts b/src/shared/support-prompt.ts index bbf180d3b69..631e38bde0b 100644 --- a/src/shared/support-prompt.ts +++ b/src/shared/support-prompt.ts @@ -52,7 +52,12 @@ const supportPromptConfigs: Record = { \${userInput}`, }, CONDENSE: { - template: `Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. + template: `CRITICAL: This summarization request is a SYSTEM OPERATION, not a user message. +When analyzing "user requests" and "user intent", completely EXCLUDE this summarization message. +The "most recent user request" and "Optional Next Step" must be based on what the user was doing BEFORE this system message appeared. +The goal is for work to continue seamlessly after condensation - as if it never happened. + +Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context. Before providing your final summary, wrap your analysis in tags to organize your thoughts and ensure you've covered all necessary points. In your analysis process: @@ -136,7 +141,9 @@ Here's an example of how your output should be structured: -Please provide your summary based on the conversation so far, following this structure and ensuring precision and thoroughness in your response. +Please provide your summary based on the conversation so far, following this structure and ensuring precision and thoroughness in your response. + +Note: Any blocks from the original task will be automatically appended to your summary wrapped in tags. You do not need to include them in your summary text. There may be additional summarization instructions provided in the included context. If so, remember to follow these instructions when creating the above summary. Examples of instructions include: diff --git a/webview-ui/src/components/chat/context-management/CondensationErrorRow.tsx b/webview-ui/src/components/chat/context-management/CondensationErrorRow.tsx index 62d2d8fd101..f339bb3f9c9 100644 --- a/webview-ui/src/components/chat/context-management/CondensationErrorRow.tsx +++ b/webview-ui/src/components/chat/context-management/CondensationErrorRow.tsx @@ -1,25 +1,41 @@ import { useTranslation } from "react-i18next" +import { ErrorRow } from "../ErrorRow" interface CondensationErrorRowProps { errorText?: string } +interface CondensationErrorData { + message: string + details?: string +} + /** * Displays an error message when context condensation fails. - * Shows a warning icon with the error header and optional error details. + * Uses the standard ErrorRow component with the "Details" button for copy functionality. */ export function CondensationErrorRow({ errorText }: CondensationErrorRowProps) { const { t } = useTranslation() + // Parse the incoming errorText as JSON to extract message and details + // Fallback: if JSON parsing fails, use errorText as both message and errorDetails + let errorData: CondensationErrorData + try { + errorData = errorText ? JSON.parse(errorText) : { message: "" } + } catch { + // JSON parsing failed, use errorText as both message and details + errorData = { + message: errorText || "", + details: errorText, + } + } + return ( -
-
- - - {t("chat:contextManagement.condensation.errorHeader")} - -
- {errorText && {errorText}} -
+ ) } From b38b9617b25ac02ef2965832e2e028fe13171508 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 22 Jan 2026 19:37:29 -0700 Subject: [PATCH 12/17] fix: correct CONDENSE prompt section numbering --- src/shared/support-prompt.ts | 42 ++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/shared/support-prompt.ts b/src/shared/support-prompt.ts index 631e38bde0b..da14c4367fa 100644 --- a/src/shared/support-prompt.ts +++ b/src/shared/support-prompt.ts @@ -74,18 +74,18 @@ Before providing your final summary, wrap your analysis in tags to or - Errors that you ran into and how you fixed them - Pay special attention to specific user feedback that you received, especially if the user told you to do something differently. 2. Double-check for technical accuracy and completeness, addressing each required element thoroughly. - -Your summary should include the following sections: + + Your summary should include the following sections: 1. Primary Request and Intent: Capture all of the user's explicit requests and intents in detail 2. Key Technical Concepts: List all important technical concepts, technologies, and frameworks discussed. 3. Files and Code Sections: Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages and include full code snippets where applicable and include a summary of why this file read or edit is important. 4. Errors and fixes: List all errors that you ran into, and how you fixed them. Pay special attention to specific user feedback that you received, especially if the user told you to do something differently. -5. Problem Solving: Document problems solved and any ongoing troubleshooting efforts. -6. All user messages: List ALL user messages that are not tool results. These are critical for understanding the users' feedback and changing intent. -6. Pending Tasks: Outline any pending tasks that you have explicitly been asked to work on. -7. Current Work: Describe in detail precisely what was being worked on immediately before this summary request, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable. -8. Optional Next Step: List the next step that you will take that is related to the most recent work you were doing. IMPORTANT: ensure that this step is DIRECTLY in line with the user's most recent explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the users request. Do not start on tangential requests or really old requests that were already completed without confirming with the user first. + 5. Problem Solving: Document problems solved and any ongoing troubleshooting efforts. + 6. All user messages: List ALL user messages that are not tool results. These are critical for understanding the users' feedback and changing intent. + 7. Pending Tasks: Outline any pending tasks that you have explicitly been asked to work on. + 8. Current Work: Describe in detail precisely what was being worked on immediately before this summary request, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable. + 9. Optional Next Step: List the next step that you will take that is related to the most recent work you were doing. IMPORTANT: ensure that this step is DIRECTLY in line with the user's most recent explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the users request. Do not start on tangential requests or really old requests that were already completed without confirming with the user first. If there is a next step, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no drift in task interpretation. @@ -123,20 +123,20 @@ Here's an example of how your output should be structured: 5. Problem Solving: [Description of solved problems and ongoing troubleshooting] -6. All user messages: - - [Detailed non tool use user message] - - [...] - -7. Pending Tasks: - - [Task 1] - - [Task 2] - - [...] - -8. Current Work: - [Precise description of current work] - -9. Optional Next Step: - [Optional Next step to take] + 6. All user messages: + - [Detailed non tool use user message] + - [...] + + 7. Pending Tasks: + - [Task 1] + - [Task 2] + - [...] + + 8. Current Work: + [Precise description of current work] + + 9. Optional Next Step: + [Optional Next step to take]
From 48974b39a02b244a1c7a07c0219e86b9db4798d5 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 22 Jan 2026 20:11:50 -0700 Subject: [PATCH 13/17] fix: await condense_context_error say() call and correct merge comment - Add missing await to this.say() in condenseContext() error path to prevent race conditions with persistence/UI updates - Update comment at line 4075 to accurately reflect that mergeConsecutiveApiMessages() excludes summary messages --- src/core/task/Task.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 20c196f29bd..cbbddb95432 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1650,7 +1650,7 @@ export class Task extends EventEmitter implements TaskLike { message: error, details: errorDetails, }) - this.say( + await this.say( "condense_context_error", errorJson, undefined /* images */, @@ -4072,8 +4072,8 @@ export class Task extends EventEmitter implements TaskLike { // enabling accurate rewind operations while still sending condensed history to the API. const effectiveHistory = getEffectiveApiHistory(this.apiConversationHistory) const messagesSinceLastSummary = getMessagesSinceLastSummary(effectiveHistory) - // For API only: merge consecutive user messages (e.g., summary + the next user message) - // without mutating stored history. + // For API only: merge consecutive user messages (excludes summary messages per + // mergeConsecutiveApiMessages implementation) without mutating stored history. const mergedForApi = mergeConsecutiveApiMessages(messagesSinceLastSummary, { roles: ["user"] }) const messagesWithoutImages = maybeRemoveImageBlocks(mergedForApi, this.api) const cleanConversationHistory = this.buildCleanConversationHistory(messagesWithoutImages as ApiMessage[]) From 757dfcfb1d88cebb97f9c6e60db310447baa76f0 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 22 Jan 2026 22:28:16 -0700 Subject: [PATCH 14/17] fix: allow merging regular user messages into summary in API shaping The mergeConsecutiveApiMessages function now allows merging a regular user message that follows a summary message. Since this is API-only shaping (storage is unaffected), rewind semantics remain intact. - Remove !prev.isSummary guard to allow merge INTO summary - Keep !msg.isSummary guard to prevent merging a summary INTO something - Update tests to reflect new expected behavior --- .../mergeConsecutiveApiMessages.spec.ts | 18 +++++++++++++++++- src/core/task/mergeConsecutiveApiMessages.ts | 4 ++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/core/task/__tests__/mergeConsecutiveApiMessages.spec.ts b/src/core/task/__tests__/mergeConsecutiveApiMessages.spec.ts index e48a52e8c94..94b0159c484 100644 --- a/src/core/task/__tests__/mergeConsecutiveApiMessages.spec.ts +++ b/src/core/task/__tests__/mergeConsecutiveApiMessages.spec.ts @@ -19,12 +19,28 @@ describe("mergeConsecutiveApiMessages", () => { expect(merged[1].role).toBe("assistant") }) - it("does not merge summary messages", () => { + it("merges regular user message into a summary (API shaping only)", () => { const merged = mergeConsecutiveApiMessages([ { role: "user", content: [{ type: "text", text: "Summary" }], ts: 1, isSummary: true, condenseId: "s" }, { role: "user", content: [{ type: "text", text: "After" }], ts: 2 }, ]) + expect(merged).toHaveLength(1) + expect(merged[0].isSummary).toBe(true) + expect(merged[0].content).toEqual([ + { type: "text", text: "Summary" }, + { type: "text", text: "After" }, + ]) + }) + + it("does not merge a summary into a preceding message", () => { + const merged = mergeConsecutiveApiMessages([ + { role: "user", content: [{ type: "text", text: "Before" }], ts: 1 }, + { role: "user", content: [{ type: "text", text: "Summary" }], ts: 2, isSummary: true, condenseId: "s" }, + ]) + expect(merged).toHaveLength(2) + expect(merged[0].isSummary).toBeUndefined() + expect(merged[1].isSummary).toBe(true) }) }) diff --git a/src/core/task/mergeConsecutiveApiMessages.ts b/src/core/task/mergeConsecutiveApiMessages.ts index da1e33da718..d46d681a94c 100644 --- a/src/core/task/mergeConsecutiveApiMessages.ts +++ b/src/core/task/mergeConsecutiveApiMessages.ts @@ -34,8 +34,8 @@ export function mergeConsecutiveApiMessages(messages: ApiMessage[], options?: { prev && prev.role === msg.role && mergeRoles.has(msg.role) && - // Keep summary/truncation markers isolated so rewind semantics stay clear. - !prev.isSummary && + // Allow merging regular messages into a summary (API-only shaping), + // but never merge a summary into something else. !msg.isSummary && !prev.isTruncationMarker && !msg.isTruncationMarker From b95584e7d2a009d0b434321adff790da28b99e4a Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 22 Jan 2026 23:21:00 -0700 Subject: [PATCH 15/17] refactor: remove dead code from condense module Remove unused functions and types that were part of the old condensation model: - getKeepMessagesWithToolBlocks function - KeepMessagesResult type - N_MESSAGES_TO_KEEP constant - hasToolResultBlocks helper - getToolResultBlocks helper - findToolUseBlockById helper - getReasoningBlocks helper These were relics of the old model that kept trailing N messages after the summary. The fresh start model no longer needs them. --- src/core/condense/index.ts | 125 ------------------------------------- 1 file changed, 125 deletions(-) diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 54a4068697f..7e5680c0ca8 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -10,131 +10,6 @@ import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" import { findLast } from "../../shared/array" import { supportPrompt } from "../../shared/support-prompt" -/** - * Checks if a message contains tool_result blocks. - * User messages with tool_result blocks require corresponding tool_use blocks from the previous assistant turn. - */ -function hasToolResultBlocks(message: ApiMessage): boolean { - if (message.role !== "user" || typeof message.content === "string") { - return false - } - return message.content.some((block) => block.type === "tool_result") -} - -/** - * Gets the tool_result blocks from a message. - */ -function getToolResultBlocks(message: ApiMessage): Anthropic.ToolResultBlockParam[] { - if (message.role !== "user" || typeof message.content === "string") { - return [] - } - return message.content.filter((block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result") -} - -/** - * Finds a tool_use block by ID in a message. - */ -function findToolUseBlockById(message: ApiMessage, toolUseId: string): Anthropic.Messages.ToolUseBlock | undefined { - if (message.role !== "assistant" || typeof message.content === "string") { - return undefined - } - return message.content.find( - (block): block is Anthropic.Messages.ToolUseBlock => block.type === "tool_use" && block.id === toolUseId, - ) -} - -/** - * 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 [] - } - 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. - * Checks ALL kept messages for tool_result blocks and searches backwards through the condensed - * region (bounded by N_MESSAGES_TO_KEEP) to find the matching tool_use blocks by ID. - * These tool_use blocks will be appended to the summary message to maintain proper pairing. - * - * Also extracts reasoning blocks from messages containing preserved tool_uses, 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 - */ -export function getKeepMessagesWithToolBlocks(messages: ApiMessage[], keepCount: number): KeepMessagesResult { - if (messages.length <= keepCount) { - return { keepMessages: messages, toolUseBlocksToPreserve: [], reasoningBlocksToPreserve: [] } - } - - const startIndex = messages.length - keepCount - const keepMessages = messages.slice(startIndex) - - const toolUseBlocksToPreserve: Anthropic.Messages.ToolUseBlock[] = [] - const reasoningBlocksToPreserve: Anthropic.Messages.ContentBlockParam[] = [] - const preservedToolUseIds = new Set() - - for (const keepMsg of keepMessages) { - if (!hasToolResultBlocks(keepMsg)) { - continue - } - - const toolResults = getToolResultBlocks(keepMsg) - - for (const toolResult of toolResults) { - const toolUseId = toolResult.tool_use_id - - // Skip if we've already found this tool_use - if (preservedToolUseIds.has(toolUseId)) { - continue - } - - // Search backwards through the condensed region (bounded) - const searchStart = startIndex - 1 - const searchEnd = Math.max(0, startIndex - N_MESSAGES_TO_KEEP) - const messagesToSearch = messages.slice(searchEnd, searchStart + 1) - - // Find the message containing this tool_use - const messageWithToolUse = findLast(messagesToSearch, (msg) => { - return findToolUseBlockById(msg, toolUseId) !== undefined - }) - - if (messageWithToolUse) { - const toolUse = findToolUseBlockById(messageWithToolUse, toolUseId)! - toolUseBlocksToPreserve.push(toolUse) - preservedToolUseIds.add(toolUseId) - - // Also preserve reasoning blocks from that message - const reasoning = getReasoningBlocks(messageWithToolUse) - reasoningBlocksToPreserve.push(...reasoning) - } - } - } - - return { - keepMessages, - toolUseBlocksToPreserve, - reasoningBlocksToPreserve, - } -} - -export const N_MESSAGES_TO_KEEP = 3 export const MIN_CONDENSE_THRESHOLD = 5 // Minimum percentage of context window to trigger condensing export const MAX_CONDENSE_THRESHOLD = 100 // Maximum percentage of context window to trigger condensing From 25a4e2fede52df60c4c135e827f330a6997929f6 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 22 Jan 2026 23:29:13 -0700 Subject: [PATCH 16/17] refactor: simplify CondensationErrorRow and remove JSON encoding - Remove unnecessary JSON encoding in Task.ts when calling say() for condense_context_error - now passes error message directly - Simplify CondensationErrorRow component: remove JSON parsing logic and ErrorRow dependency, just display errorText directly with inline styling - Reduces code complexity while maintaining the same user-facing behavior --- src/core/task/Task.ts | 14 ++------ .../CondensationErrorRow.tsx | 36 ++++++------------- 2 files changed, 12 insertions(+), 38 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index cbbddb95432..2513040e6d4 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1645,14 +1645,9 @@ export class Task extends EventEmitter implements TaskLike { metadata, // Pass metadata with tools ) if (error) { - // Format error as JSON with message and details for ErrorRow component - const errorJson = JSON.stringify({ - message: error, - details: errorDetails, - }) await this.say( "condense_context_error", - errorJson, + error, undefined /* images */, false /* partial */, undefined /* checkpoint */, @@ -4010,12 +4005,7 @@ export class Task extends EventEmitter implements TaskLike { await this.overwriteApiConversationHistory(truncateResult.messages) } if (truncateResult.error) { - // Format error as JSON with message and details for ErrorRow component - const errorJson = JSON.stringify({ - message: truncateResult.error, - details: truncateResult.errorDetails, - }) - await this.say("condense_context_error", errorJson) + await this.say("condense_context_error", truncateResult.error) } else if (truncateResult.summary) { const { summary, cost, prevContextTokens, newContextTokens = 0, condenseId } = truncateResult const contextCondense: ContextCondense = { diff --git a/webview-ui/src/components/chat/context-management/CondensationErrorRow.tsx b/webview-ui/src/components/chat/context-management/CondensationErrorRow.tsx index f339bb3f9c9..62d2d8fd101 100644 --- a/webview-ui/src/components/chat/context-management/CondensationErrorRow.tsx +++ b/webview-ui/src/components/chat/context-management/CondensationErrorRow.tsx @@ -1,41 +1,25 @@ import { useTranslation } from "react-i18next" -import { ErrorRow } from "../ErrorRow" interface CondensationErrorRowProps { errorText?: string } -interface CondensationErrorData { - message: string - details?: string -} - /** * Displays an error message when context condensation fails. - * Uses the standard ErrorRow component with the "Details" button for copy functionality. + * Shows a warning icon with the error header and optional error details. */ export function CondensationErrorRow({ errorText }: CondensationErrorRowProps) { const { t } = useTranslation() - // Parse the incoming errorText as JSON to extract message and details - // Fallback: if JSON parsing fails, use errorText as both message and errorDetails - let errorData: CondensationErrorData - try { - errorData = errorText ? JSON.parse(errorText) : { message: "" } - } catch { - // JSON parsing failed, use errorText as both message and details - errorData = { - message: errorText || "", - details: errorText, - } - } - return ( - +
+
+ + + {t("chat:contextManagement.condensation.errorHeader")} + +
+ {errorText && {errorText}} +
) } From 7a361ae5b4e69a6a74f07489684d34e4a36e507d Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 22 Jan 2026 23:38:56 -0700 Subject: [PATCH 17/17] fix: use customCondensingPrompt for user message content When a user sets a custom condensing prompt, it should control what instructions are sent to the model. Previously, the code used the custom prompt only for the system prompt while the user message content was hardcoded to supportPrompt.default.CONDENSE. Now the custom prompt (or default CONDENSE) is used as the finalRequestMessage content, ensuring user customization is respected. Updated tests to verify the custom prompt appears in the user message. --- src/core/condense/__tests__/index.spec.ts | 8 ++++++-- src/core/condense/index.ts | 9 ++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index 865535d449a..70c3e089ee2 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -1233,10 +1233,14 @@ describe("summarizeConversation with custom settings", () => { customPrompt, ) - // Verify the custom prompt was used + // Verify the custom prompt was used in the user message content const createMessageCalls = (mockMainApiHandler.createMessage as Mock).mock.calls expect(createMessageCalls.length).toBe(1) - expect(createMessageCalls[0][0]).toBe(customPrompt) + // The custom prompt should be in the last message (the finalRequestMessage) + const requestMessages = createMessageCalls[0][1] + const lastMessage = requestMessages[requestMessages.length - 1] + expect(lastMessage.role).toBe("user") + expect(lastMessage.content).toBe(customPrompt) }) /** diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 7e5680c0ca8..9d76eae6557 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -179,9 +179,13 @@ export async function summarizeConversation( return { ...response, error } } + // Use custom prompt if provided and non-empty, otherwise use the default CONDENSE prompt + // This respects user's custom condensing prompt setting + const condenseInstructions = customCondensingPrompt?.trim() || supportPrompt.default.CONDENSE + const finalRequestMessage: Anthropic.MessageParam = { role: "user", - content: supportPrompt.default.CONDENSE, + content: condenseInstructions, } // Inject synthetic tool_results for orphan tool_calls to prevent API rejections @@ -193,8 +197,7 @@ export async function summarizeConversation( ) // Note: this doesn't need to be a stream, consider using something like apiHandler.completePrompt - // Use custom prompt if provided and non-empty, otherwise use the default SUMMARY_PROMPT - const promptToUse = customCondensingPrompt?.trim() ? customCondensingPrompt.trim() : SUMMARY_PROMPT + const promptToUse = SUMMARY_PROMPT // Validate that the API handler supports message creation if (!apiHandler || typeof apiHandler.createMessage !== "function") {