Intelligent Context Condensation v2 (#10873)

This commit is contained in:
Hannes Rudolph 2026-01-23 12:33:35 -07:00 • committed by GitHub
parent d1e74cb3e3
commit cf5d42e1e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 2023 additions and 1632 deletions

View file

@ -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('<command name="setup">')
expect(result).toContain(commandContent)
expect(result).toContain("</command>")
expect(result).toContain("Please help me set up the project")
expect(result.slashCommandHelp).toContain('<command name="setup">')
expect(result.slashCommandHelp).toContain(commandContent)
expect(result.slashCommandHelp).toContain("</command>")
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('<command name="setup">')
expect(result).toContain("# Setup Environment")
expect(result).toContain('<command name="deploy">')
expect(result).toContain("# Deploy Environment")
expect(result.slashCommandHelp).toContain('<command name="setup">')
expect(result.slashCommandHelp).toContain("# Setup Environment")
expect(result.slashCommandHelp).toContain('<command name="deploy">')
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('<command name="nonexistent">')
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('<command name="error-command">')
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('<command name="error-command">')
expect(result).toContain("# Error command")
expect(result).toContain("</command>")
expect(result.slashCommandHelp).toContain('<command name="error-command">')
expect(result.slashCommandHelp).toContain("# Error command")
expect(result.slashCommandHelp).toContain("</command>")
})
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('<command name="setup-dev">')
expect(result).toContain("# Dev setup")
expect(result.slashCommandHelp).toContain('<command name="setup-dev">')
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('<command name="complex">')
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("</command>")
expect(result.slashCommandHelp).toContain('<command name="complex">')
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("</command>")
})
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('<command name="empty">')
expect(result).toContain("</command>")
expect(result.slashCommandHelp).toContain('<command name="empty">')
expect(result.slashCommandHelp).toContain("</command>")
// 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'")
})
})
})

View file

@ -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 <command name="prr">/prr #123</command> more text',
}
const result = extractCommandBlocks(message)
expect(result).toBe('<command name="prr">/prr #123</command>')
})
it("should extract multiple command blocks", () => {
const message: ApiMessage = {
role: "user",
content: '<command name="prr">/prr #123</command> text <command name="mode">/mode code</command>',
}
const result = extractCommandBlocks(message)
expect(result).toBe('<command name="prr">/prr #123</command>\n<command name="mode">/mode code</command>')
})
it("should extract command blocks from array content", () => {
const message: ApiMessage = {
role: "user",
content: [
{ type: "text", text: "Some user text" },
{ type: "text", text: '<command name="prr">Help content</command>' },
],
}
const result = extractCommandBlocks(message)
expect(result).toBe('<command name="prr">Help content</command>')
})
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: `<command name="prr">
Line 1
Line 2
</command>`,
}
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",
})
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)
// 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
// 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
// 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))
expect(effectiveHistory.length).toBe(1)
expect(effectiveHistory[0].isSummary).toBe(true)
expect(effectiveHistory[0].role).toBe("user")
})
it("should preserve slash command content in the first message", async () => {
const slashCommandContent = "/prr #123 - Fix authentication bug"
it("should tag ALL messages with condenseParent", async () => {
const messages: ApiMessage[] = [
{ role: "user", content: slashCommandContent },
{ role: "assistant", content: "I'll help you fix that authentication bug" },
{ role: "user", content: "The issue is with JWT tokens" },
{ role: "assistant", content: "Let me examine the JWT implementation" },
{ role: "user", content: "It's failing on refresh" },
{ role: "assistant", content: "I found the issue" },
{ role: "user", content: "Great, can you fix it?" },
{ role: "assistant", content: "Here's the fix" },
{ role: "user", content: "Thanks!" },
{ role: "user", content: "First message with /prr command content" },
{ role: "assistant", content: "Second message" },
{ role: "user", content: "Third message" },
{ role: "assistant", content: "Fourth message" },
{ role: "user", content: "Fifth message" },
]
const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false)
// The first message with slash command should be intact
expect(result.messages[0].content).toBe(slashCommandContent)
expect(result.messages[0]).toEqual(messages[0])
// 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 <command> blocks in the summary", async () => {
const messages: ApiMessage[] = [
{
role: "user",
content: [
{ type: "text", text: "Some user text" },
{ type: "text", text: '<command name="prr">Help content</command>' },
],
},
{ role: "assistant", content: "Second message" },
{ role: "user", content: "Third message" },
{ role: "assistant", content: "Fourth message" },
{ role: "user", content: "Fifth message" },
{ role: "assistant", content: "Sixth message" },
{ role: "user", content: "Seventh message" },
{ role: "assistant", content: "Eighth message" },
{ role: "user", content: "Ninth message" },
]
const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false)
const summaryMessage = result.messages.find((msg) => msg.isSummary)
expect(summaryMessage).toBeTruthy()
const contentArray = summaryMessage!.content as any[]
// Summary content is split into separate text blocks:
// - First block: "## Conversation Summary\n..."
// - Second block: "<system-reminder>..." with command blocks
expect(contentArray).toHaveLength(2)
expect(contentArray[0].text).toContain("## Conversation Summary")
expect(contentArray[1].text).toContain('<command name="prr">')
expect(contentArray[1].text).toContain("<system-reminder>")
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])
})
})
})

File diff suppressed because it is too large Load diff

View file

@ -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)
// Should be: summary only
expect(effectiveBefore.length).toBe(1)
// Simulate rewind: user truncates back to message ts=4 (keeping 0-3)
const truncatedHistory = fullHistory.slice(0, 4) // Keep first, condensed1, condensed2, summary
// 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()
}
// 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)
// 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
// 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[1].content).toBe("Writing unit tests now")
expect(effective[2].role).toBe("assistant")
expect(effective[2].content).toBe("Writing unit tests now")
expect(effective[2].role).toBe("user")
expect(effective[2].content).toBe("Include edge cases")
expect(effective[3].role).toBe("user")
expect(effective[3].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[1].content).toBe("Writing integration tests")
expect(effective[2].role).toBe("assistant")
expect(effective[2].content).toBe("Writing integration tests")
expect(effective[2].role).toBe("user")
expect(effective[2].content).toBe("Test the auth flow")
expect(effective[3].role).toBe("user")
expect(effective[3].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 },

View file

@ -4,157 +4,114 @@ 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")
}
/**
* Gets the tool_use blocks from a message.
*/
function getToolUseBlocks(message: ApiMessage): Anthropic.Messages.ToolUseBlock[] {
if (message.role !== "assistant" || typeof message.content === "string") {
return []
}
return message.content.filter((block) => block.type === "tool_use") as Anthropic.Messages.ToolUseBlock[]
}
/**
* Gets the tool_result blocks from a message.
*/
function getToolResultBlocks(message: ApiMessage): Anthropic.ToolResultBlockParam[] {
if (message.role !== "user" || typeof message.content === "string") {
return []
}
return message.content.filter((block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result")
}
/**
* Finds a tool_use block by ID in a message.
*/
function findToolUseBlockById(message: ApiMessage, toolUseId: string): Anthropic.Messages.ToolUseBlock | undefined {
if (message.role !== "assistant" || typeof message.content === "string") {
return undefined
}
return message.content.find(
(block): block is Anthropic.Messages.ToolUseBlock => block.type === "tool_use" && block.id === toolUseId,
)
}
/**
* Gets reasoning blocks from a message's content array.
* Task stores reasoning as {type: "reasoning", text: "..."} blocks,
* which convertToR1Format and convertToZAiFormat already know how to extract.
*/
function getReasoningBlocks(message: ApiMessage): Anthropic.Messages.ContentBlockParam[] {
if (message.role !== "assistant" || typeof message.content === "string") {
return []
}
// Filter for reasoning blocks and cast to ContentBlockParam (the type field is compatible)
return message.content.filter((block) => (block as any).type === "reasoning") as any[]
}
/**
* Result of getKeepMessagesWithToolBlocks
*/
export type KeepMessagesResult = {
keepMessages: ApiMessage[]
toolUseBlocksToPreserve: Anthropic.Messages.ToolUseBlock[]
// Reasoning blocks from the preceding assistant message, needed for DeepSeek/Z.ai
// when tool_use blocks are preserved. Task stores reasoning as {type: "reasoning", text: "..."}
// blocks, and convertToR1Format/convertToZAiFormat already extract these.
reasoningBlocksToPreserve: Anthropic.Messages.ContentBlockParam[]
}
/**
* Extracts tool_use blocks that need to be preserved to match tool_result blocks in keepMessages.
* Checks ALL kept messages for tool_result blocks and searches backwards through the condensed
* region (bounded by N_MESSAGES_TO_KEEP) to find the matching tool_use blocks by ID.
* These tool_use blocks will be appended to the summary message to maintain proper pairing.
*
* Also extracts reasoning blocks from messages containing preserved tool_uses, which are required
* by DeepSeek and Z.ai for interleaved thinking mode. Without these, the API returns a 400 error
* "Missing reasoning_content field in the assistant message".
* See: https://api-docs.deepseek.com/guides/thinking_mode#tool-calls
*
* @param messages - The full conversation messages
* @param keepCount - The number of messages to keep from the end
* @returns Object containing keepMessages, tool_use blocks, and reasoning blocks to preserve
*/
export function getKeepMessagesWithToolBlocks(messages: ApiMessage[], keepCount: number): KeepMessagesResult {
if (messages.length <= keepCount) {
return { keepMessages: messages, toolUseBlocksToPreserve: [], reasoningBlocksToPreserve: [] }
}
const startIndex = messages.length - keepCount
const keepMessages = messages.slice(startIndex)
const toolUseBlocksToPreserve: Anthropic.Messages.ToolUseBlock[] = []
const reasoningBlocksToPreserve: Anthropic.Messages.ContentBlockParam[] = []
const preservedToolUseIds = new Set<string>()
// 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)
}
}
}
return {
keepMessages,
toolUseBlocksToPreserve,
reasoningBlocksToPreserve,
}
}
export const N_MESSAGES_TO_KEEP = 3
export const MIN_CONDENSE_THRESHOLD = 5 // Minimum percentage of context window to trigger condensing
export const MAX_CONDENSE_THRESHOLD = 100 // Maximum percentage of context window to trigger condensing
const SUMMARY_PROMPT = supportPrompt.default.CONDENSE
const SUMMARY_PROMPT = `You are a helpful AI assistant tasked with summarizing conversations.
CRITICAL: This is a summarization-only request. DO NOT call any tools or functions.
Your ONLY task is to analyze the conversation and produce a text summary.
Respond with text only - no tool calls will be processed.
CRITICAL: This summarization request is a SYSTEM OPERATION, not a user message.
When analyzing "user requests" and "user intent", completely EXCLUDE this summarization message.
The "most recent user request" and "next step" must be based on what the user was doing BEFORE this system message appeared.
The goal is for work to continue seamlessly after condensation - as if it never happened.`
/**
* Injects synthetic tool_results for orphan tool_calls that don't have matching results.
* This is necessary because OpenAI's Responses API rejects conversations with orphan tool_calls.
* This can happen when the user triggers condense after receiving a tool_call (like attempt_completion)
* but before responding to it.
*
* @param messages - The conversation messages to process
* @returns The messages with synthetic tool_results appended if needed
*/
export function injectSyntheticToolResults(messages: ApiMessage[]): ApiMessage[] {
// Find all tool_call IDs in assistant messages
const toolCallIds = new Set<string>()
// Find all tool_result IDs in user messages
const toolResultIds = new Set<string>()
for (const msg of messages) {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use") {
toolCallIds.add(block.id)
}
}
}
if (msg.role === "user" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_result") {
toolResultIds.add(block.tool_use_id)
}
}
}
}
// Find orphans (tool_calls without matching tool_results)
const orphanIds = [...toolCallIds].filter((id) => !toolResultIds.has(id))
if (orphanIds.length === 0) {
return messages
}
// Inject synthetic tool_results as a new user message
const syntheticResults: Anthropic.Messages.ToolResultBlockParam[] = orphanIds.map((id) => ({
type: "tool_result" as const,
tool_use_id: id,
content: "Context condensation triggered. Tool execution deferred.",
}))
const syntheticMessage: ApiMessage = {
role: "user",
content: syntheticResults,
ts: Date.now(),
}
return [...messages, syntheticMessage]
}
/**
* Extracts <command> blocks from a message's content.
* These blocks represent active workflows that must be preserved across condensings.
*
* @param message - The message to extract command blocks from
* @returns A string containing all command blocks found, or empty string if none
*/
export function extractCommandBlocks(message: ApiMessage): string {
const content = message.content
let text: string
if (typeof content === "string") {
text = content
} else if (Array.isArray(content)) {
// Concatenate all text blocks
text = content
.filter((block): block is Anthropic.Messages.TextBlockParam => block.type === "text")
.map((block) => block.text)
.join("\n")
} else {
return ""
}
// Match all <command> blocks including their content
const commandRegex = /<command[^>]*>[\s\S]*?<\/command>/g
const matches = text.match(commandRegex)
if (!matches || matches.length === 0) {
return ""
}
return matches.join("\n")
}
export type SummarizeResponse = {
messages: ApiMessage[] // The messages after summarization
@ -162,11 +119,18 @@ export type SummarizeResponse = {
cost: number // The cost of the summarization operation
newContextTokens?: number // The number of tokens in the context for the next API request
error?: string // Populated iff the operation fails: error message shown to the user on failure (see Task.ts)
errorDetails?: string // Detailed error information including stack trace and API error info
condenseId?: string // The unique ID of the created Summary message, for linking to condense_context clineMessage
}
/**
* Summarizes the conversation messages using an LLM call
* Summarizes the conversation messages using an LLM call.
*
* This implements the "fresh start" model where:
* - The summary becomes a user message (not assistant)
* - Post-condense, the model sees only the summary (true fresh start)
* - All messages are still stored but tagged with condenseParent
* - <command> 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<SummarizeResponse> {
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<string, unknown>
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)
// 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) : ""
// 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.",
}
// Build the summary content as separate text blocks
const summaryContent: Anthropic.Messages.ContentBlockParam[] = [
{ type: "text", text: `## Conversation Summary\n${summary}` },
]
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]
// Add command blocks as a separate text block if present
if (commandBlocks) {
summaryContent.push({
type: "text",
text: `<system-reminder>
## Active Workflows
The following directives must be maintained across all future condensings:
${commandBlocks}
</system-reminder>`,
})
}
// 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<string>()
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<string>()
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<string>()
// 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)) {

View file

@ -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

View file

@ -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<string, number>
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<ContextManagementResult> {
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 }
}

View file

@ -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: "<user_message>Run command</user_message>",
},
]
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: "<user_message>Tool output</user_message>",
},
]
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: "<user_message>Array item</user_message>",
},
],
},
]
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",
},
],
})
})
})
})

View file

@ -73,6 +73,7 @@ export async function openMention(cwd: string, mention?: string): Promise<void>
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<command name="${commandName}">\n${commandOutput}\n</command>`
slashCommandHelp += `\n\n<command name="${commandName}">\n${commandOutput}\n</command>`
} catch (error) {
parsedText += `\n\n<command name="${commandName}">\nError loading command '${commandName}': ${error.message}\n</command>`
slashCommandHelp += `\n\n<command name="${commandName}">\nError loading command '${commandName}': ${error.message}\n</command>`
}
}
@ -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(

View file

@ -42,39 +42,15 @@ export async function processUserContentMentions({
// 2. ToolResultBlockParam's content/context text arrays if it contains
// "<user_message>" - 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("<user_message>")
const content = (
await Promise.all(
userContent.map(async (block) => {
const shouldProcessMentions = (text: string) => text.includes("<user_message>")
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 }
}

View file

@ -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,

View file

@ -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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> implements TaskLike {
private async handleContextWindowExceededError(): Promise<void> {
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<TaskEvents> 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(),
// 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
}
// Build metadata with tools and taskId for the condensing API call
const metadata: ApiHandlerCreateMessageMetadata = {
mode,
taskId: this.taskId,
profileThresholds,
currentProfileId,
})
if (truncateResult.messages !== this.apiConversationHistory) {
await this.overwriteApiConversationHistory(truncateResult.messages)
...(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<TaskEvents> 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,
taskId: this.taskId,
customCondensingPrompt,
profileThresholds,
currentProfileId,
})
if (truncateResult.messages !== this.apiConversationHistory) {
await this.overwriteApiConversationHistory(truncateResult.messages)
}
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,
// 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
}
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,
)
}
// 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 })
// Build metadata with tools and taskId for the condensing API call
const contextMgmtMetadata: ApiHandlerCreateMessageMetadata = {
mode,
taskId: this.taskId,
...(contextMgmtTools.length > 0
? {
tools: contextMgmtTools,
tool_choice: "auto",
parallelToolCalls: false,
}
: {}),
}
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)
}
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 })
}
}
}
@ -3928,7 +4062,10 @@ export class Task extends EventEmitter<TaskEvents> 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

View file

@ -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)
})
})

View file

@ -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<Role>(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
}

View file

@ -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)

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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": "इंटरनेट कनेक्शन नहीं है। कृपया अपना नेटवर्क कनेक्शन जांचें और फिर से कोशिश करें।",

View file

@ -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.",

View file

@ -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.",

View file

@ -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": "インターネット接続がありません。ネットワーク接続を確認してもう一度試してください。",

View file

@ -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": "인터넷 연결이 없습니다. 네트워크 연결을 확인하고 다시 시도해 주세요.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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": "Нет подключения к интернету. Проверь сетевое подключение и попробуй снова.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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": "无网络连接。请检查网络连接并重试。",

View file

@ -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": "無網路連線。請檢查網路連線並重試。",

View file

@ -52,43 +52,109 @@ const supportPromptConfigs: Record<SupportPromptType, SupportPromptConfig> = {
\${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.
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 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.
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 <analysis> 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:
<example>
<analysis>
[Your thought process, ensuring all points are covered thoroughly and accurately]
</analysis>
<summary>
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]
- [...]
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]
- [...]
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]
Output only the summary of the conversation so far, without any additional commentary or explanation.`,
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]
</summary>
</example>
Please provide your summary based on the conversation so far, following this structure and ensuring precision and thoroughness in your response.
Note: Any <command> blocks from the original task will be automatically appended to your summary wrapped in <system-reminder> 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:
<example>
## Compact Instructions
When summarizing the conversation focus on typescript code changes and also remember the mistakes you made and how you fixed them.
</example>
<example>
# Summary instructions
When you are using compact - please focus on test output and code changes. Include file reads verbatim.
</example>`,
},
EXPLAIN: {
template: `Explain the following code from file path \${filePath}:\${startLine}-\${endLine}