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 bea7d50ac17..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 when summarizing", 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,57 +138,75 @@ 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]) - expect(result.messages[0].content).toBe("First message with /prr command content") - - // Verify we have a summary message + // Verify we have a summary message with role "user" (fresh start model) 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(contentArray[0]).toEqual({ - type: "reasoning", - text: "Condensing conversation context. The summary below captures the key information from the prior conversation.", - }) - expect(contentArray[1]).toEqual({ - type: "text", - text: "Mock summary of the conversation", - }) - - // With non-destructive condensing, all messages are retained (tagged but not deleted) - // Use getEffectiveApiHistory to verify the effective view matches the old behavior - expect(result.messages.length).toBe(messages.length + 1) // All original messages + summary + 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) + // Should NOT have reasoning blocks (no longer needed for user messages) + expect(contentArray.some((b) => b.type === "reasoning")).toBe(false) + + // Fresh start model: effective history should only contain the summary 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(effectiveHistory[0].isSummary).toBe(true) + expect(effectiveHistory[0].role).toBe("user") + }) - // Verify the last N messages are preserved (same messages by reference) - const lastMessages = result.messages.slice(-N_MESSAGES_TO_KEEP) - expect(lastMessages).toEqual(messages.slice(-N_MESSAGES_TO_KEEP)) + it("should tag ALL messages with condenseParent", async () => { + const messages: ApiMessage[] = [ + { 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) + + // 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 preserve slash command content in the first message", async () => { - const slashCommandContent = "/prr #123 - Fix authentication bug" + it("should preserve blocks in the summary", 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: [ + { 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) - // The first message with slash command should be intact - expect(result.messages[0].content).toBe(slashCommandContent) - expect(result.messages[0]).toEqual(messages[0]) + const summaryMessage = result.messages.find((msg) => 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 () => { @@ -152,41 +229,33 @@ describe("Condense", () => { const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false) - // The first message with complex content should be preserved - expect(result.messages[0].content).toEqual(complexContent) - expect(result.messages[0]).toEqual(messages[0]) + // Effective history should contain only the summary (fresh start) + const effectiveHistory = getEffectiveApiHistory(result.messages) + 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("") @@ -225,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[] = [ @@ -241,39 +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) - // 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]) + 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) - // 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]) + 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 2947d19ff1e..70c3e089ee2 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,608 +32,642 @@ vi.mock("@roo-code/telemetry", () => ({ const taskId = "test-task-id" const DEFAULT_PREV_CONTEXT_TOKENS = 1000 -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 }, - ] +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') + }) - 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) - // 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) + expect(result.length).toBe(3) + expect(result[2].role).toBe("user") + + 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, + }, + // Summary comes after the tool_use (so tool_use is condensed away) + { + role: "user", + content: [{ type: "text", text: "Summary content" }], + isSummary: true, + condenseId, }, - { role: "user", content: "Some other message", ts: 4 }, - { role: "assistant", content: "First kept message", ts: 5 }, + // This tool_result references a tool_use that was condensed away (orphan!) { role: "user", - content: [toolResultBlock, { type: "text" as const, text: "Continue" }], - ts: 6, + 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) - - // keepMessages should be the last 3 messages (ts: 3, 4, 5) - expect(result.keepMessages).toHaveLength(3) + const result = getEffectiveApiHistory(messages) - // 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 with 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 with 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 }) }) @@ -677,36 +711,7 @@ describe("summarizeConversation", () => { 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 }, - ] + const messages: ApiMessage[] = [{ role: "user", content: "Hello", ts: 1 }] const result = await summarizeConversation( messages, @@ -719,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 }, @@ -746,43 +751,98 @@ 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 + // Result contains all original messages (tagged) plus summary at end + 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 original messages should be tagged with condenseParent 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.filter((m) => !m.isSummary)) { + expect(msg.condenseParent).toBe(condenseId) + } + + // 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[1].type).toBe("text") - expect(content[1].text).toBe("This is a summary") - expect(summaryMessage!.isSummary).toBe(true) + 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") - // Verify that the effective API history matches expected: first + summary + last N messages + // Fresh start: effective API history should contain only the summary const effectiveHistory = getEffectiveApiHistory(result.messages) - expect(effectiveHistory.length).toBe(1 + 1 + N_MESSAGES_TO_KEEP) // First + summary + last N - - // Check that condensed messages are properly tagged - const condensedMessages = result.messages.filter((m) => m.condenseParent !== undefined) - expect(condensedMessages.length).toBeGreaterThan(0) + 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) // 150 output tokens + 100 from countTokens + 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[] = [ @@ -839,22 +899,25 @@ 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 SUMMARY_PROMPT (which contains CRITICAL instructions), messages array, and optional metadata expect(mockApiHandler.createMessage).toHaveBeenCalledWith( - expect.stringContaining("Your task is to create a detailed summary of the conversation"), + 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 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[] = [ { role: "user", content: "Initial ask", ts: 1 }, @@ -912,11 +975,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 expect(mockApiHandler.countTokens).toHaveBeenCalled() - // Check the newContextTokens calculation includes system prompt - expect(result.newContextTokens).toBe(300) // 200 output tokens + 100 from countTokens + // 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() @@ -942,9 +1005,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 +1017,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,66 +1057,21 @@ 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 + // 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(1 + 1 + N_MESSAGES_TO_KEEP) // First + summary + last N + 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) // 50 output tokens + 30 from countTokens + 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 }, @@ -1100,46 +1117,15 @@ describe("summarizeConversation", () => { console.error = originalError }) - it("should append tool_use blocks to summary message when first kept message has tool_result blocks", 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, @@ -1148,283 +1134,25 @@ 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!.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 - 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 - const effectiveHistory = getEffectiveApiHistory(result.messages) - expect(effectiveHistory.length).toBe(1 + 1 + N_MESSAGES_TO_KEEP) // first + summary + last 3 - expect(result.error).toBeUndefined() - }) - - it("should include user tool_result message in summarize request when preserving tool_use blocks", 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! - expect(requestMessages[requestMessages.length - 1]).toEqual({ - role: "user", - content: "Summarize the conversation so far, as described in the prompt instructions.", - }) + const condenseId = summaryMessage!.condenseId - 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) - }) - - it("should append multiple tool_use blocks for parallel tool calls", async () => { - const toolUseBlockA = { - type: "tool_use" as const, - id: "toolu_parallel_1", - name: "search", - input: { query: "foo" }, + // 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 toolUseBlockB = { - type: "tool_use" as const, - id: "toolu_parallel_2", - name: "search", - input: { query: "bar" }, - } - - 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, - ) - - // Find the summary message (it has isSummary: true) - const summaryMessage = result.messages.find((m) => m.isSummary) - expect(summaryMessage).toBeDefined() - 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"]) }) - it("should preserve reasoning blocks in summary message for DeepSeek/Z.ai interleaved thinking", async () => { - const reasoningBlock = { - type: "reasoning" as const, - text: "Let me think about this step by step...", - } - const toolUseBlock = { - type: "tool_use" as const, - id: "toolu_deepseek_reason", - name: "read_file", - input: { path: "test.txt" }, - } - const toolResultBlock = { - type: "tool_result" as const, - tool_use_id: "toolu_deepseek_reason", - content: "file contents", - } - + 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, - ) - - // Find the summary message - const summaryMessage = result.messages.find((m) => m.isSummary) - expect(summaryMessage).toBeDefined() - expect(summaryMessage!.role).toBe("assistant") - expect(summaryMessage!.isSummary).toBe(true) - expect(Array.isArray(summaryMessage!.content)).toBe(true) - - // Content should be [synthetic reasoning, preserved reasoning, text block, tool_use block] - // - Synthetic reasoning is always added for DeepSeek-reasoner compatibility - // - Preserved reasoning from the condensed assistant message - // This order ensures reasoning_content is always present for DeepSeek/Z.ai - const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] - expect(content).toHaveLength(4) - - // First block should be synthetic reasoning - expect((content[0] as any).type).toBe("reasoning") - expect((content[0] as any).text).toContain("Condensing conversation context") - - // Second block should be preserved reasoning from the condensed message - expect((content[1] as any).type).toBe("reasoning") - expect((content[1] as any).text).toBe("Let me think about this step by step...") - - // Third block should be text (the summary) - expect(content[2].type).toBe("text") - expect((content[2] as Anthropic.Messages.TextBlockParam).text).toBe("Summary of conversation") - - // Fourth block should be tool_use - expect(content[3].type).toBe("tool_use") - expect((content[3] as Anthropic.Messages.ToolUseBlockParam).id).toBe("toolu_deepseek_reason") - - expect(result.error).toBeUndefined() - }) - - it("should include synthetic reasoning block in summary for DeepSeek-reasoner compatibility even without tool_use blocks", async () => { - // This test verifies the fix for the DeepSeek-reasoner 400 error: - // "Missing `reasoning_content` field in the assistant message at message index 1" - // DeepSeek-reasoner requires reasoning_content on ALL assistant messages, not just those with tool_calls. - // After condensation, the summary becomes an assistant message that needs reasoning_content. - const messages: ApiMessage[] = [ - { role: "user", content: "Tell me a joke", ts: 1 }, - { role: "assistant", content: "Why did the programmer quit?", ts: 2 }, - { role: "user", content: "I don't know, why?", ts: 3 }, - { role: "assistant", content: "He didn't get arrays!", ts: 4 }, - { role: "user", content: "Another one please", ts: 5 }, - { role: "assistant", content: "Why do programmers prefer dark mode?", ts: 6 }, - { role: "user", content: "Why?", ts: 7 }, + { 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, @@ -1433,25 +1161,10 @@ 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!.isSummary).toBe(true) - - // CRITICAL: Content must be an array with a synthetic reasoning block - // This is required for DeepSeek-reasoner which needs reasoning_content on all assistant messages - expect(Array.isArray(summaryMessage!.content)).toBe(true) - const content = summaryMessage!.content as any[] - - // Should have [synthetic reasoning, text] - expect(content).toHaveLength(2) - expect(content[0].type).toBe("reasoning") - expect(content[0].text).toContain("Condensing conversation context") - expect(content[1].type).toBe("text") - expect(content[1].text).toBe("Summary: User requested jokes.") - - expect(result.error).toBeUndefined() + // Summary should be the last message + const lastMessage = result.messages[result.messages.length - 1] + expect(lastMessage.isSummary).toBe(true) + expect(lastMessage.role).toBe("user") }) }) @@ -1459,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[] = [ @@ -1514,16 +1227,20 @@ describe("summarizeConversation with custom settings", () => { sampleMessages, mockMainApiHandler, defaultSystemPrompt, - taskId, + localTaskId, DEFAULT_PREV_CONTEXT_TOKENS, false, 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) }) /** @@ -1535,16 +1252,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 + // 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]).toContain("Your task is to create a detailed summary") + 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() @@ -1552,16 +1272,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 + // 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]).toContain("Your task is to create a detailed summary") + 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") }) /** @@ -1572,7 +1295,7 @@ describe("summarizeConversation with custom settings", () => { sampleMessages, mockMainApiHandler, defaultSystemPrompt, - taskId, + localTaskId, DEFAULT_PREV_CONTEXT_TOKENS, false, "Custom prompt", @@ -1580,7 +1303,7 @@ describe("summarizeConversation with custom settings", () => { // Verify telemetry was called with custom prompt flag expect(TelemetryService.instance.captureContextCondensed).toHaveBeenCalledWith( - taskId, + localTaskId, false, true, // usedCustomPrompt ) @@ -1594,7 +1317,7 @@ describe("summarizeConversation with custom settings", () => { sampleMessages, mockMainApiHandler, defaultSystemPrompt, - taskId, + localTaskId, DEFAULT_PREV_CONTEXT_TOKENS, true, // isAutomaticTrigger "Custom prompt", @@ -1602,7 +1325,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 f5f1a09380c..84fdb63ca86 100644 --- a/src/core/condense/__tests__/rewind-after-condense.spec.ts +++ b/src/core/condense/__tests__/rewind-after-condense.spec.ts @@ -22,25 +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 }, + { 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 }, + // 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: 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") + // 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", () => { @@ -131,44 +131,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", () => { @@ -206,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 }, @@ -218,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) @@ -315,8 +305,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 +321,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 +344,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 +381,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 +431,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 +474,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 +486,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/condense/index.ts b/src/core/condense/index.ts index 9c4617c7a68..9d76eae6557 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -4,169 +4,133 @@ 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" 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") -} +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 -/** - * 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[] -} +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.` /** - * Gets the tool_result blocks from a message. + * 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 */ -function getToolResultBlocks(message: ApiMessage): Anthropic.ToolResultBlockParam[] { - if (message.role !== "user" || typeof message.content === "string") { - return [] +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) + } + } + } } - 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 + // Find orphans (tool_calls without matching tool_results) + const orphanIds = [...toolCallIds].filter((id) => !toolResultIds.has(id)) + + if (orphanIds.length === 0) { + return messages } - 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 [] + // 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(), } - // Filter for reasoning blocks and cast to ContentBlockParam (the type field is compatible) - return message.content.filter((block) => (block as any).type === "reasoning") as any[] -} -/** - * Result of getKeepMessagesWithToolBlocks - */ -export type KeepMessagesResult = { - keepMessages: ApiMessage[] - toolUseBlocksToPreserve: Anthropic.Messages.ToolUseBlock[] - // Reasoning blocks from the preceding assistant message, needed for DeepSeek/Z.ai - // when tool_use blocks are preserved. Task stores reasoning as {type: "reasoning", text: "..."} - // blocks, and convertToR1Format/convertToZAiFormat already extract these. - reasoningBlocksToPreserve: Anthropic.Messages.ContentBlockParam[] + return [...messages, syntheticMessage] } /** - * 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. + * Extracts blocks from a message's content. + * These blocks represent active workflows that must be preserved across condensings. * - * 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 - * - * @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 + * @param message - The message to extract command blocks from + * @returns A string containing all command blocks found, or empty string if none */ -export function getKeepMessagesWithToolBlocks(messages: ApiMessage[], keepCount: number): KeepMessagesResult { - if (messages.length <= keepCount) { - return { keepMessages: messages, toolUseBlocksToPreserve: [], reasoningBlocksToPreserve: [] } +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 "" } - const startIndex = messages.length - keepCount - const keepMessages = messages.slice(startIndex) - - const toolUseBlocksToPreserve: Anthropic.Messages.ToolUseBlock[] = [] - const reasoningBlocksToPreserve: Anthropic.Messages.ContentBlockParam[] = [] - const preservedToolUseIds = new Set() + // Match all blocks including their content + const commandRegex = /]*>[\s\S]*?<\/command>/g + const matches = text.match(commandRegex) - // Check ALL kept messages for tool_result blocks - 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) - } - } + if (!matches || matches.length === 0) { + return "" } - return { - keepMessages, - toolUseBlocksToPreserve, - reasoningBlocksToPreserve, - } + return matches.join("\n") } -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 - export type SummarizeResponse = { messages: ApiMessage[] // The messages after summarization summary: string // The summary text; empty string for no summary 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 +139,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 +150,7 @@ export async function summarizeConversation( prevContextTokens: number, isAutomaticTrigger?: boolean, customCondensingPrompt?: string, + metadata?: ApiHandlerCreateMessageMetadata, ): Promise { TelemetryService.instance.captureContextCondensed( taskId, @@ -194,51 +160,44 @@ 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 } } + // 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: "Summarize the conversation so far, as described in the prompt instructions.", + content: condenseInstructions, } - 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 }), ) // 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") { @@ -247,19 +206,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,95 +271,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.", - } - - 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] + // 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} +`, + }) } // 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, @@ -408,8 +385,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. * @@ -420,6 +400,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 @@ -436,7 +481,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)) { 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/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 } } 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 5109f96507f..2513040e6d4 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) @@ -1573,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, @@ -1590,9 +1642,10 @@ export class Task extends EventEmitter implements TaskLike { prevContextTokens, false, // manual trigger customCondensingPrompt, // User's custom prompt + metadata, // Pass metadata with tools ) if (error) { - this.say( + await this.say( "condense_context_error", error, undefined /* images */, @@ -3659,7 +3712,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 @@ -3684,61 +3737,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 }) + } } /** @@ -3855,71 +3948,112 @@ 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) { + 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, + } + 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 }) } } @@ -3928,7 +4062,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 (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[]) // 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..94b0159c484 --- /dev/null +++ b/src/core/task/__tests__/mergeConsecutiveApiMessages.spec.ts @@ -0,0 +1,46 @@ +// 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("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 new file mode 100644 index 00000000000..d46d681a94c --- /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) && + // Allow merging regular messages into a summary (API-only shaping), + // but never merge a summary into something else. + !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 +} 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) 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 51f4310fc2e..da14c4367fa 100644 --- a/src/shared/support-prompt.ts +++ b/src/shared/support-prompt.ts @@ -52,43 +52,109 @@ 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. -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] - - [...] + 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: + +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. + 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. + +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. + +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: + +## 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}