mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat(condense v2.1): add smart code folding (#10942)
* feat(condense): add smart code folding with tree-sitter signatures At context condensation time, use tree-sitter to generate folded code signatures (function definitions, class declarations) for files read during the conversation. Each file is included as its own <system-reminder> block in the condensed summary, preserving structural awareness without consuming excessive tokens. - Add getFilesReadByRoo() method to FileContextTracker - Create generateFoldedFileContext() using tree-sitter parsing - Update summarizeConversation() to accept array of file sections - Each file gets its own content block in the summary message - Add comprehensive test coverage (12 tests) * fix: skip tree-sitter error strings in folded file context - Add isTreeSitterErrorString helper to detect error messages - Skip files that return error strings instead of embedding them - Add test for error string handling * refactor: move generateFoldedFileContext() inside summarizeConversation() - Update summarizeConversation() to accept filesReadByRoo, cwd, rooIgnoreController instead of pre-generated sections - Move folded file context generation inside summarizeConversation() (lines 319-339) - Update ContextManagementOptions type and manageContext() to pass new parameters - Remove generateFoldedFileContext from Task.ts imports - folding now handled internally - Update all tests to use new parameter signature - Reduces Task.ts complexity by moving folding logic to summarization module * fix: prioritize most recently read files in folded context Files are now sorted by roo_read_date descending before folded context generation, so if the character budget runs out, the most relevant (recently read) files are included and older files are skipped. * refactor: improve code quality in condense module - Convert summarizeConversation to use options object instead of 11 positional params - Extract duplicated getFilesReadByRoo error handling into helper method - Remove unnecessary re-export of generateFoldedFileContext - Update all test files to use new options object pattern * fix: address roomote feedback - batch error logging and early budget exit --------- Co-authored-by: Roo Code <roomote@roocode.com> Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>
This commit is contained in:
parent
7607c684e9
commit
d748de6fae
9 changed files with 1100 additions and 104 deletions
|
|
@ -136,7 +136,13 @@ Line 2
|
|||
{ role: "user", content: "Ninth message" },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, false)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: false,
|
||||
})
|
||||
|
||||
// Verify we have a summary message with role "user" (fresh start model)
|
||||
const summaryMessage = result.messages.find((msg) => msg.isSummary)
|
||||
|
|
@ -164,7 +170,13 @@ Line 2
|
|||
{ role: "user", content: "Fifth message" },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, false)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: false,
|
||||
})
|
||||
|
||||
// All original messages should be tagged with condenseParent
|
||||
const taggedMessages = result.messages.filter((msg) => !msg.isSummary)
|
||||
|
|
@ -193,7 +205,13 @@ Line 2
|
|||
{ role: "user", content: "Ninth message" },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, false)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: false,
|
||||
})
|
||||
|
||||
const summaryMessage = result.messages.find((msg) => msg.isSummary)
|
||||
expect(summaryMessage).toBeTruthy()
|
||||
|
|
@ -227,7 +245,13 @@ Line 2
|
|||
{ role: "user", content: "Perfect!" },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, false)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: false,
|
||||
})
|
||||
|
||||
// Effective history should contain only the summary (fresh start)
|
||||
const effectiveHistory = getEffectiveApiHistory(result.messages)
|
||||
|
|
@ -239,7 +263,13 @@ Line 2
|
|||
it("should return error when not enough messages to summarize", async () => {
|
||||
const messages: ApiMessage[] = [{ role: "user", content: "Only one message" }]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, false)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: false,
|
||||
})
|
||||
|
||||
// Should return an error since we have only 1 message
|
||||
expect(result.error).toBeDefined()
|
||||
|
|
@ -253,7 +283,13 @@ Line 2
|
|||
{ role: "user", content: "Previous summary", isSummary: true },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, false)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: false,
|
||||
})
|
||||
|
||||
// Should return an error due to recent summary with no substantial messages after
|
||||
expect(result.error).toBeDefined()
|
||||
|
|
@ -286,7 +322,13 @@ Line 2
|
|||
{ role: "user", content: "Seventh" },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, emptyHandler, "System prompt", taskId, false)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: emptyHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: false,
|
||||
})
|
||||
|
||||
expect(result.error).toBeDefined()
|
||||
expect(result.messages).toEqual(messages)
|
||||
|
|
|
|||
391
src/core/condense/__tests__/foldedFileContext.spec.ts
Normal file
391
src/core/condense/__tests__/foldedFileContext.spec.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
// npx vitest src/core/condense/__tests__/foldedFileContext.spec.ts
|
||||
|
||||
import * as path from "path"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import type { ModelInfo } from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
import { BaseProvider } from "../../../api/providers/base-provider"
|
||||
|
||||
// Mock the tree-sitter module
|
||||
vi.mock("../../../services/tree-sitter", () => ({
|
||||
parseSourceCodeDefinitionsForFile: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock generateFoldedFileContext for summarizeConversation tests
|
||||
vi.mock("../foldedFileContext", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../foldedFileContext")>()
|
||||
return {
|
||||
...actual,
|
||||
generateFoldedFileContext: vi.fn().mockImplementation(actual.generateFoldedFileContext),
|
||||
}
|
||||
})
|
||||
|
||||
import { generateFoldedFileContext } from "../foldedFileContext"
|
||||
import { parseSourceCodeDefinitionsForFile } from "../../../services/tree-sitter"
|
||||
|
||||
const mockedGenerateFoldedFileContext = vi.mocked(generateFoldedFileContext)
|
||||
|
||||
const mockedParseSourceCodeDefinitions = vi.mocked(parseSourceCodeDefinitionsForFile)
|
||||
|
||||
describe("foldedFileContext", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("generateFoldedFileContext", () => {
|
||||
it("should return empty content for empty file list", async () => {
|
||||
const result = await generateFoldedFileContext([], { cwd: "/test" })
|
||||
|
||||
expect(result.content).toBe("")
|
||||
expect(result.sections).toEqual([])
|
||||
expect(result.filesProcessed).toBe(0)
|
||||
expect(result.filesSkipped).toBe(0)
|
||||
expect(result.characterCount).toBe(0)
|
||||
})
|
||||
|
||||
it("should generate folded context for a TypeScript file with its own system-reminder block", async () => {
|
||||
const mockDefinitions = `1--5 | export interface User
|
||||
7--12 | export function createUser(name: string): User
|
||||
14--28 | export class UserService`
|
||||
|
||||
mockedParseSourceCodeDefinitions.mockResolvedValue(mockDefinitions)
|
||||
|
||||
const result = await generateFoldedFileContext(["/test/user.ts"], { cwd: "/test" })
|
||||
|
||||
// Each file should be wrapped in its own <system-reminder> block
|
||||
expect(result.content).toContain("<system-reminder>")
|
||||
expect(result.content).toContain("</system-reminder>")
|
||||
expect(result.content).toContain("## File Context: /test/user.ts")
|
||||
expect(result.content).toContain("interface User")
|
||||
expect(result.content).toContain("function createUser")
|
||||
expect(result.content).toContain("class UserService")
|
||||
expect(result.filesProcessed).toBe(1)
|
||||
expect(result.filesSkipped).toBe(0)
|
||||
})
|
||||
|
||||
it("should generate folded context for a JavaScript file with its own system-reminder block", async () => {
|
||||
const mockDefinitions = `1--3 | function greet(name)
|
||||
5--15 | class Calculator`
|
||||
|
||||
mockedParseSourceCodeDefinitions.mockResolvedValue(mockDefinitions)
|
||||
|
||||
const result = await generateFoldedFileContext(["/test/utils.js"], { cwd: "/test" })
|
||||
|
||||
expect(result.content).toContain("<system-reminder>")
|
||||
expect(result.content).toContain("## File Context: /test/utils.js")
|
||||
expect(result.content).toContain("function greet")
|
||||
expect(result.content).toContain("class Calculator")
|
||||
expect(result.filesProcessed).toBe(1)
|
||||
})
|
||||
|
||||
it("should skip files when parseSourceCodeDefinitions returns undefined", async () => {
|
||||
// First file succeeds, second returns undefined
|
||||
mockedParseSourceCodeDefinitions
|
||||
.mockResolvedValueOnce("1--3 | export const x = 1")
|
||||
.mockResolvedValueOnce(undefined)
|
||||
|
||||
const result = await generateFoldedFileContext(["/test/existing.ts", "/test/unsupported.txt"], {
|
||||
cwd: "/test",
|
||||
})
|
||||
|
||||
expect(result.filesProcessed).toBe(1)
|
||||
expect(result.filesSkipped).toBe(1)
|
||||
})
|
||||
|
||||
it("should skip files when parseSourceCodeDefinitions throws an error", async () => {
|
||||
mockedParseSourceCodeDefinitions
|
||||
.mockResolvedValueOnce("1--3 | export const x = 1")
|
||||
.mockRejectedValueOnce(new Error("File not found"))
|
||||
|
||||
const result = await generateFoldedFileContext(["/test/existing.ts", "/test/non-existent.ts"], {
|
||||
cwd: "/test",
|
||||
})
|
||||
|
||||
expect(result.filesProcessed).toBe(1)
|
||||
expect(result.filesSkipped).toBe(1)
|
||||
})
|
||||
|
||||
it("should skip files when parseSourceCodeDefinitions returns error strings", async () => {
|
||||
// Tree-sitter can return error strings for missing or denied files
|
||||
// These should be treated as skipped, not embedded in the output
|
||||
mockedParseSourceCodeDefinitions
|
||||
.mockResolvedValueOnce("1--3 | export const x = 1")
|
||||
.mockResolvedValueOnce("This file does not exist or you do not have permission to access it.")
|
||||
.mockResolvedValueOnce("Unsupported file type: /test/file.xyz")
|
||||
|
||||
const result = await generateFoldedFileContext(["/test/valid.ts", "/test/missing.ts", "/test/file.xyz"], {
|
||||
cwd: "/test",
|
||||
})
|
||||
|
||||
// Only the first file should be processed, the other two return error strings
|
||||
expect(result.filesProcessed).toBe(1)
|
||||
expect(result.filesSkipped).toBe(2)
|
||||
|
||||
// The content should NOT contain the error messages
|
||||
expect(result.content).not.toContain("does not exist")
|
||||
expect(result.content).not.toContain("do not have permission")
|
||||
expect(result.content).not.toContain("Unsupported file type")
|
||||
|
||||
// But it should contain the valid file's content
|
||||
expect(result.content).toContain("## File Context: /test/valid.ts")
|
||||
expect(result.content).toContain("export const x = 1")
|
||||
})
|
||||
|
||||
it("should respect character budget limit", async () => {
|
||||
// Create multiple files that would exceed a small budget
|
||||
const longDefinitions = `1--3 | export function longFunctionName1()
|
||||
5--7 | export function longFunctionName2()
|
||||
9--11 | export function longFunctionName3()`
|
||||
|
||||
mockedParseSourceCodeDefinitions.mockResolvedValue(longDefinitions)
|
||||
|
||||
const result = await generateFoldedFileContext(["/test/file1.ts", "/test/file2.ts", "/test/file3.ts"], {
|
||||
cwd: "/test",
|
||||
maxCharacters: 200, // Small budget
|
||||
})
|
||||
|
||||
expect(result.characterCount).toBeLessThanOrEqual(200)
|
||||
// Some files should be skipped due to budget limit
|
||||
expect(result.filesSkipped).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should handle Python files with its own system-reminder block", async () => {
|
||||
const mockDefinitions = `1--2 | def greet(name)
|
||||
4--12 | class Person`
|
||||
|
||||
mockedParseSourceCodeDefinitions.mockResolvedValue(mockDefinitions)
|
||||
|
||||
const result = await generateFoldedFileContext(["/test/person.py"], { cwd: "/test" })
|
||||
|
||||
expect(result.content).toContain("<system-reminder>")
|
||||
expect(result.content).toContain("## File Context: /test/person.py")
|
||||
expect(result.content).toContain("def greet")
|
||||
expect(result.content).toContain("class Person")
|
||||
expect(result.filesProcessed).toBe(1)
|
||||
})
|
||||
|
||||
it("should include file path in the File Context header", async () => {
|
||||
mockedParseSourceCodeDefinitions.mockResolvedValue("1--3 | export function helper()")
|
||||
|
||||
const result = await generateFoldedFileContext(["/test/src/utils/helpers.ts"], { cwd: "/test" })
|
||||
|
||||
// The path should appear in the File Context header
|
||||
expect(result.content).toContain("## File Context: /test/src/utils/helpers.ts")
|
||||
})
|
||||
|
||||
it("should generate separate system-reminder blocks for multiple files", async () => {
|
||||
mockedParseSourceCodeDefinitions
|
||||
.mockResolvedValueOnce("1--3 | export async function fetchData(url: string): Promise<any>")
|
||||
.mockResolvedValueOnce("1--4 | export interface DataModel")
|
||||
|
||||
const result = await generateFoldedFileContext(["/test/api.ts", "/test/models.ts"], { cwd: "/test" })
|
||||
|
||||
// Each file should have its own <system-reminder> block
|
||||
const systemReminderMatches = result.content.match(/<system-reminder>/g)
|
||||
expect(systemReminderMatches).toHaveLength(2)
|
||||
|
||||
// sections array should have separate entries for each file
|
||||
expect(result.sections).toHaveLength(2)
|
||||
expect(result.sections[0]).toContain("## File Context: /test/api.ts")
|
||||
expect(result.sections[1]).toContain("## File Context: /test/models.ts")
|
||||
|
||||
expect(result.content).toContain("## File Context: /test/api.ts")
|
||||
expect(result.content).toContain("## File Context: /test/models.ts")
|
||||
expect(result.content).toContain("fetchData")
|
||||
expect(result.content).toContain("interface DataModel")
|
||||
expect(result.filesProcessed).toBe(2)
|
||||
})
|
||||
|
||||
it("should truncate content when approaching character limit", async () => {
|
||||
// Create a definition that would fit but is close to the limit
|
||||
const longDefinitions = "1--3 | " + "x".repeat(300)
|
||||
|
||||
mockedParseSourceCodeDefinitions.mockResolvedValue(longDefinitions)
|
||||
|
||||
const result = await generateFoldedFileContext(["/test/file1.ts", "/test/file2.ts"], {
|
||||
cwd: "/test",
|
||||
maxCharacters: 350, // First file will fit, second will be truncated
|
||||
})
|
||||
|
||||
// Content should include truncation marker if truncation happened
|
||||
expect(result.filesProcessed + result.filesSkipped).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("summarizeConversation with foldedFileContext", () => {
|
||||
beforeEach(() => {
|
||||
if (!TelemetryService.hasInstance()) {
|
||||
TelemetryService.createInstance([])
|
||||
}
|
||||
})
|
||||
|
||||
// Mock API handler for testing
|
||||
class MockApiHandler extends BaseProvider {
|
||||
createMessage(): any {
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { type: "text", text: "Mock summary of the conversation" }
|
||||
yield { type: "usage", inputTokens: 100, outputTokens: 50, totalCost: 0.01 }
|
||||
},
|
||||
}
|
||||
return mockStream
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
return {
|
||||
id: "test-model",
|
||||
info: {
|
||||
contextWindow: 100000,
|
||||
maxTokens: 50000,
|
||||
supportsPromptCache: true,
|
||||
supportsImages: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Test model",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
override async countTokens(content: Array<Anthropic.Messages.ContentBlockParam>): Promise<number> {
|
||||
let tokens = 0
|
||||
for (const block of content) {
|
||||
if (block.type === "text") {
|
||||
tokens += Math.ceil(block.text.length / 4)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
}
|
||||
|
||||
it("should include folded file context with each file as a separate content block", async () => {
|
||||
const { summarizeConversation } = await import("../index")
|
||||
|
||||
const mockApiHandler = new MockApiHandler()
|
||||
const taskId = "test-task-id"
|
||||
|
||||
const messages: any[] = [
|
||||
{ role: "user", content: "First message" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
// Mock generateFoldedFileContext to return the expected folded sections
|
||||
const mockFoldedSections = [
|
||||
`<system-reminder>
|
||||
## File Context: src/user.ts
|
||||
1--5 | export interface User
|
||||
7--12 | export function createUser(name: string): User
|
||||
14--28 | export class UserService
|
||||
</system-reminder>`,
|
||||
`<system-reminder>
|
||||
## File Context: src/api.ts
|
||||
1--3 | export async function fetchData(url: string): Promise<any>
|
||||
</system-reminder>`,
|
||||
]
|
||||
|
||||
mockedGenerateFoldedFileContext.mockResolvedValue({
|
||||
content: mockFoldedSections.join("\n"),
|
||||
sections: mockFoldedSections,
|
||||
filesProcessed: 2,
|
||||
filesSkipped: 0,
|
||||
characterCount: mockFoldedSections.join("\n").length,
|
||||
})
|
||||
|
||||
const filesReadByRoo = ["src/user.ts", "src/api.ts"]
|
||||
const cwd = "/test/project"
|
||||
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: false,
|
||||
filesReadByRoo,
|
||||
cwd,
|
||||
})
|
||||
|
||||
// Verify generateFoldedFileContext was called with the right arguments
|
||||
expect(mockedGenerateFoldedFileContext).toHaveBeenCalledWith(filesReadByRoo, {
|
||||
cwd,
|
||||
rooIgnoreController: undefined,
|
||||
})
|
||||
|
||||
// Verify the summary was created
|
||||
expect(result.summary).toBeDefined()
|
||||
expect(result.messages.length).toBeGreaterThan(0)
|
||||
|
||||
// Find the summary message
|
||||
const summaryMessage = result.messages.find((msg: any) => msg.isSummary)
|
||||
expect(summaryMessage).toBeDefined()
|
||||
|
||||
// Each file should have its own content block
|
||||
const contentArray = summaryMessage!.content as any[]
|
||||
|
||||
// Find the content blocks containing file contexts
|
||||
const userFileBlock = contentArray.find(
|
||||
(block: any) => block.type === "text" && block.text?.includes("## File Context: src/user.ts"),
|
||||
)
|
||||
const apiFileBlock = contentArray.find(
|
||||
(block: any) => block.type === "text" && block.text?.includes("## File Context: src/api.ts"),
|
||||
)
|
||||
|
||||
expect(userFileBlock).toBeDefined()
|
||||
expect(apiFileBlock).toBeDefined()
|
||||
|
||||
// Each file block should have its own <system-reminder> tags
|
||||
expect(userFileBlock.text).toContain("<system-reminder>")
|
||||
expect(userFileBlock.text).toContain("export interface User")
|
||||
|
||||
expect(apiFileBlock.text).toContain("<system-reminder>")
|
||||
expect(apiFileBlock.text).toContain("fetchData")
|
||||
})
|
||||
|
||||
it("should not include file context section when filesReadByRoo is empty", async () => {
|
||||
const { summarizeConversation } = await import("../index")
|
||||
|
||||
const mockApiHandler = new MockApiHandler()
|
||||
const taskId = "test-task-id-2"
|
||||
|
||||
const messages: any[] = [
|
||||
{ role: "user", content: "First message" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
// Reset the mock to ensure clean state
|
||||
mockedGenerateFoldedFileContext.mockClear()
|
||||
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: false,
|
||||
filesReadByRoo: [],
|
||||
cwd: "/test/project",
|
||||
})
|
||||
|
||||
// generateFoldedFileContext should NOT be called when filesReadByRoo is empty
|
||||
expect(mockedGenerateFoldedFileContext).not.toHaveBeenCalled()
|
||||
|
||||
// Find the summary message
|
||||
const summaryMessage = result.messages.find((msg: any) => msg.isSummary)
|
||||
expect(summaryMessage).toBeDefined()
|
||||
|
||||
// The summary content should NOT contain any file context blocks
|
||||
const contentArray = summaryMessage!.content as any[]
|
||||
const fileContextBlock = contentArray.find(
|
||||
(block: any) => block.type === "text" && block.text?.includes("## File Context"),
|
||||
)
|
||||
expect(fileContextBlock).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -710,7 +710,12 @@ describe("summarizeConversation", () => {
|
|||
it("should not summarize when there are not enough messages", async () => {
|
||||
const messages: ApiMessage[] = [{ role: "user", content: "Hello", ts: 1 }]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
expect(result.messages).toEqual(messages)
|
||||
expect(result.cost).toBe(0)
|
||||
expect(result.summary).toBe("")
|
||||
|
|
@ -730,7 +735,12 @@ describe("summarizeConversation", () => {
|
|||
{ role: "user", content: "Tell me more", ts: 7 },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
// Check that the API was called correctly
|
||||
expect(mockApiHandler.createMessage).toHaveBeenCalled()
|
||||
|
|
@ -784,7 +794,12 @@ describe("summarizeConversation", () => {
|
|||
{ role: "user", content: "What's new?", ts: 5 },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
const summaryMessage = result.messages.find((m) => m.isSummary)
|
||||
expect(summaryMessage).toBeDefined()
|
||||
|
|
@ -807,7 +822,12 @@ describe("summarizeConversation", () => {
|
|||
{ role: "user", content: "What's new?", ts: 5 },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
const summaryMessage = result.messages.find((m) => m.isSummary)
|
||||
expect(summaryMessage).toBeDefined()
|
||||
|
|
@ -844,7 +864,12 @@ describe("summarizeConversation", () => {
|
|||
return messages.map(({ role, content }: { role: string; content: any }) => ({ role, content }))
|
||||
})
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
// Should return original messages when summary is empty
|
||||
expect(result.messages).toEqual(messages)
|
||||
|
|
@ -865,7 +890,12 @@ describe("summarizeConversation", () => {
|
|||
{ role: "user", content: "Tell me more", ts: 7 },
|
||||
]
|
||||
|
||||
await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
// Verify that createMessage was called with the SUMMARY_PROMPT (which contains CRITICAL instructions), messages array, and optional metadata
|
||||
expect(mockApiHandler.createMessage).toHaveBeenCalledWith(
|
||||
|
|
@ -897,7 +927,12 @@ describe("summarizeConversation", () => {
|
|||
{ role: "user", content: "Newest", ts: 7 },
|
||||
]
|
||||
|
||||
await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
const mockCallArgs = (maybeRemoveImageBlocks as Mock).mock.calls[0][0] as any[]
|
||||
|
||||
|
|
@ -935,7 +970,12 @@ describe("summarizeConversation", () => {
|
|||
// Override the mock for this test
|
||||
mockApiHandler.createMessage = vi.fn().mockReturnValue(streamWithUsage) as any
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, systemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
// Verify that countTokens was called with system prompt + summary message
|
||||
expect(mockApiHandler.countTokens).toHaveBeenCalled()
|
||||
|
|
@ -970,7 +1010,12 @@ describe("summarizeConversation", () => {
|
|||
// Mock countTokens to return a small value
|
||||
mockApiHandler.countTokens = vi.fn().mockImplementation(() => Promise.resolve(30)) as any
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
// Result contains all messages plus summary
|
||||
expect(result.messages.length).toBe(messages.length + 1)
|
||||
|
|
@ -1010,7 +1055,12 @@ describe("summarizeConversation", () => {
|
|||
const mockError = vi.fn()
|
||||
console.error = mockError
|
||||
|
||||
const result = await summarizeConversation(messages, invalidHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: invalidHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
// Should return original messages when handler is invalid
|
||||
expect(result.messages).toEqual(messages)
|
||||
|
|
@ -1035,7 +1085,12 @@ describe("summarizeConversation", () => {
|
|||
{ role: "user", content: "Thanks", ts: 5 },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
const summaryMessage = result.messages.find((m) => m.isSummary)
|
||||
expect(summaryMessage).toBeDefined()
|
||||
|
|
@ -1056,7 +1111,12 @@ describe("summarizeConversation", () => {
|
|||
{ role: "user", content: "Thanks", ts: 5 },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
// Summary should be the last message
|
||||
const lastMessage = result.messages[result.messages.length - 1]
|
||||
|
|
@ -1120,14 +1180,14 @@ describe("summarizeConversation with custom settings", () => {
|
|||
it("should use custom prompt when provided", async () => {
|
||||
const customPrompt = "Custom summarization prompt"
|
||||
|
||||
await summarizeConversation(
|
||||
sampleMessages,
|
||||
mockMainApiHandler,
|
||||
defaultSystemPrompt,
|
||||
localTaskId,
|
||||
false,
|
||||
customPrompt,
|
||||
)
|
||||
await summarizeConversation({
|
||||
messages: sampleMessages,
|
||||
apiHandler: mockMainApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId: localTaskId,
|
||||
isAutomaticTrigger: false,
|
||||
customCondensingPrompt: customPrompt,
|
||||
})
|
||||
|
||||
// Verify the custom prompt was used in the user message content
|
||||
const createMessageCalls = (mockMainApiHandler.createMessage as Mock).mock.calls
|
||||
|
|
@ -1144,7 +1204,14 @@ describe("summarizeConversation with custom settings", () => {
|
|||
*/
|
||||
it("should use default systemPrompt when custom prompt is empty or not provided", async () => {
|
||||
// Test with empty string
|
||||
await summarizeConversation(sampleMessages, mockMainApiHandler, defaultSystemPrompt, localTaskId, false, " ")
|
||||
await summarizeConversation({
|
||||
messages: sampleMessages,
|
||||
apiHandler: mockMainApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId: localTaskId,
|
||||
isAutomaticTrigger: false,
|
||||
customCondensingPrompt: " ",
|
||||
})
|
||||
|
||||
// Verify the default SUMMARY_PROMPT was used (contains CRITICAL instructions)
|
||||
let createMessageCalls = (mockMainApiHandler.createMessage as Mock).mock.calls
|
||||
|
|
@ -1156,14 +1223,13 @@ describe("summarizeConversation with custom settings", () => {
|
|||
|
||||
// Reset mock and test with undefined
|
||||
vi.clearAllMocks()
|
||||
await summarizeConversation(
|
||||
sampleMessages,
|
||||
mockMainApiHandler,
|
||||
defaultSystemPrompt,
|
||||
localTaskId,
|
||||
false,
|
||||
undefined,
|
||||
)
|
||||
await summarizeConversation({
|
||||
messages: sampleMessages,
|
||||
apiHandler: mockMainApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId: localTaskId,
|
||||
isAutomaticTrigger: false,
|
||||
})
|
||||
|
||||
// Verify the default SUMMARY_PROMPT was used again (contains CRITICAL instructions)
|
||||
createMessageCalls = (mockMainApiHandler.createMessage as Mock).mock.calls
|
||||
|
|
@ -1178,14 +1244,14 @@ describe("summarizeConversation with custom settings", () => {
|
|||
* Test that telemetry is called for custom prompt usage
|
||||
*/
|
||||
it("should capture telemetry when using custom prompt", async () => {
|
||||
await summarizeConversation(
|
||||
sampleMessages,
|
||||
mockMainApiHandler,
|
||||
defaultSystemPrompt,
|
||||
localTaskId,
|
||||
false,
|
||||
"Custom prompt",
|
||||
)
|
||||
await summarizeConversation({
|
||||
messages: sampleMessages,
|
||||
apiHandler: mockMainApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId: localTaskId,
|
||||
isAutomaticTrigger: false,
|
||||
customCondensingPrompt: "Custom prompt",
|
||||
})
|
||||
|
||||
// Verify telemetry was called with custom prompt flag
|
||||
expect(TelemetryService.instance.captureContextCondensed).toHaveBeenCalledWith(
|
||||
|
|
@ -1199,14 +1265,14 @@ describe("summarizeConversation with custom settings", () => {
|
|||
* Test that telemetry is called with isAutomaticTrigger flag
|
||||
*/
|
||||
it("should capture telemetry with isAutomaticTrigger flag", async () => {
|
||||
await summarizeConversation(
|
||||
sampleMessages,
|
||||
mockMainApiHandler,
|
||||
defaultSystemPrompt,
|
||||
localTaskId,
|
||||
true, // isAutomaticTrigger
|
||||
"Custom prompt",
|
||||
)
|
||||
await summarizeConversation({
|
||||
messages: sampleMessages,
|
||||
apiHandler: mockMainApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId: localTaskId,
|
||||
isAutomaticTrigger: true,
|
||||
customCondensingPrompt: "Custom prompt",
|
||||
})
|
||||
|
||||
// Verify telemetry was called with isAutomaticTrigger flag
|
||||
expect(TelemetryService.instance.captureContextCondensed).toHaveBeenCalledWith(
|
||||
|
|
|
|||
168
src/core/condense/foldedFileContext.ts
Normal file
168
src/core/condense/foldedFileContext.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import * as path from "path"
|
||||
import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter"
|
||||
import { RooIgnoreController } from "../ignore/RooIgnoreController"
|
||||
|
||||
/**
|
||||
* Checks if a definitions string is actually an error message from tree-sitter
|
||||
* rather than valid code definitions. These error strings should not be embedded
|
||||
* in the folded file context - instead, the file should be skipped.
|
||||
*/
|
||||
function isTreeSitterErrorString(definitions: string): boolean {
|
||||
// These are known error messages from parseSourceCodeDefinitionsForFile
|
||||
const errorPatterns = ["This file does not exist", "do not have permission", "Unsupported file type:"]
|
||||
return errorPatterns.some((pattern) => definitions.includes(pattern))
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of generating folded file context.
|
||||
*/
|
||||
export interface FoldedFileContextResult {
|
||||
/** The formatted string containing all folded file definitions (joined) */
|
||||
content: string
|
||||
/** Individual file sections, each in its own <system-reminder> block */
|
||||
sections: string[]
|
||||
/** Number of files successfully processed */
|
||||
filesProcessed: number
|
||||
/** Number of files that failed or were skipped */
|
||||
filesSkipped: number
|
||||
/** Total character count of the folded content */
|
||||
characterCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for generating folded file context.
|
||||
*/
|
||||
export interface FoldedFileContextOptions {
|
||||
/** Maximum total characters for the folded content (default: 50000) */
|
||||
maxCharacters?: number
|
||||
/** The current working directory for resolving relative paths */
|
||||
cwd: string
|
||||
/** Optional RooIgnoreController for file access validation */
|
||||
rooIgnoreController?: RooIgnoreController
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates folded (signatures-only) file context for a list of files using tree-sitter.
|
||||
*
|
||||
* This function takes file paths that were read during a conversation and produces
|
||||
* a condensed representation showing only function signatures, class declarations,
|
||||
* and other important structural definitions - hiding implementation bodies.
|
||||
*
|
||||
* Each file is wrapped in its own `<system-reminder>` block during context condensation,
|
||||
* allowing the model to retain awareness of file structure without consuming excessive tokens.
|
||||
*
|
||||
* @param filePaths - Array of file paths to process (relative to cwd)
|
||||
* @param options - Configuration options including cwd and max characters
|
||||
* @returns FoldedFileContextResult with the formatted content and statistics
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await generateFoldedFileContext(
|
||||
* ['src/utils/helpers.ts', 'src/api/client.ts'],
|
||||
* { cwd: '/project', maxCharacters: 30000 }
|
||||
* )
|
||||
* // result.content contains individual <system-reminder> blocks for each file:
|
||||
* // <system-reminder>
|
||||
* // ## File Context: src/utils/helpers.ts
|
||||
* // 1--15 | export function formatDate(...)
|
||||
* // 17--45 | export class DateHelper {...}
|
||||
* // </system-reminder>
|
||||
* // <system-reminder>
|
||||
* // ## File Context: src/api/client.ts
|
||||
* // ...
|
||||
* // </system-reminder>
|
||||
* ```
|
||||
*/
|
||||
export async function generateFoldedFileContext(
|
||||
filePaths: string[],
|
||||
options: FoldedFileContextOptions,
|
||||
): Promise<FoldedFileContextResult> {
|
||||
const { maxCharacters = 50000, cwd, rooIgnoreController } = options
|
||||
|
||||
const result: FoldedFileContextResult = {
|
||||
content: "",
|
||||
sections: [],
|
||||
filesProcessed: 0,
|
||||
filesSkipped: 0,
|
||||
characterCount: 0,
|
||||
}
|
||||
|
||||
if (filePaths.length === 0) {
|
||||
return result
|
||||
}
|
||||
|
||||
const foldedSections: string[] = []
|
||||
let currentCharCount = 0
|
||||
const failedFiles: string[] = []
|
||||
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const filePath = filePaths[i]
|
||||
// Resolve to absolute path for tree-sitter
|
||||
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath)
|
||||
|
||||
try {
|
||||
// Get the folded definitions using tree-sitter
|
||||
const definitions = await parseSourceCodeDefinitionsForFile(absolutePath, rooIgnoreController)
|
||||
|
||||
if (!definitions || isTreeSitterErrorString(definitions)) {
|
||||
// File type not supported, no definitions found, or error accessing file
|
||||
result.filesSkipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Wrap each file in its own <system-reminder> block
|
||||
const sectionContent = `<system-reminder>
|
||||
## File Context: ${filePath}
|
||||
${definitions}
|
||||
</system-reminder>`
|
||||
|
||||
// Check if adding this file would exceed the character limit
|
||||
if (currentCharCount + sectionContent.length > maxCharacters) {
|
||||
// Would exceed limit - check if we can fit at least a truncated version
|
||||
const remainingChars = maxCharacters - currentCharCount
|
||||
if (remainingChars < 200) {
|
||||
// Not enough room for meaningful content, stop processing all remaining files
|
||||
result.filesSkipped += filePaths.length - i
|
||||
break
|
||||
}
|
||||
|
||||
// Truncate the definitions to fit within the system-reminder block
|
||||
const truncatedDefinitions = definitions.substring(0, remainingChars - 100) + "\n... (truncated)"
|
||||
const truncatedContent = `<system-reminder>
|
||||
## File Context: ${filePath}
|
||||
${truncatedDefinitions}
|
||||
</system-reminder>`
|
||||
foldedSections.push(truncatedContent)
|
||||
currentCharCount += truncatedContent.length
|
||||
result.filesProcessed++
|
||||
|
||||
// Stop processing more files since we've hit the limit
|
||||
result.filesSkipped += filePaths.length - result.filesProcessed - result.filesSkipped
|
||||
break
|
||||
}
|
||||
|
||||
foldedSections.push(sectionContent)
|
||||
currentCharCount += sectionContent.length
|
||||
result.filesProcessed++
|
||||
} catch (error) {
|
||||
// Collect failed files for batch logging to reduce noise
|
||||
failedFiles.push(filePath)
|
||||
result.filesSkipped++
|
||||
}
|
||||
}
|
||||
|
||||
// Log failed files as a single batch summary instead of per-file errors
|
||||
if (failedFiles.length > 0) {
|
||||
console.warn(
|
||||
`Folded context generation: skipped ${failedFiles.length} file(s) due to errors: ${failedFiles.slice(0, 5).join(", ")}${failedFiles.length > 5 ? ` and ${failedFiles.length - 5} more` : ""}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (foldedSections.length > 0) {
|
||||
result.sections = foldedSections
|
||||
result.content = foldedSections.join("\n")
|
||||
result.characterCount = result.content.length
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -9,6 +9,10 @@ import { ApiMessage } from "../task-persistence/apiMessages"
|
|||
import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
|
||||
import { findLast } from "../../shared/array"
|
||||
import { supportPrompt } from "../../shared/support-prompt"
|
||||
import { RooIgnoreController } from "../ignore/RooIgnoreController"
|
||||
import { generateFoldedFileContext } from "./foldedFileContext"
|
||||
|
||||
export type { FoldedFileContextResult, FoldedFileContextOptions } from "./foldedFileContext"
|
||||
|
||||
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
|
||||
|
|
@ -123,6 +127,20 @@ export type SummarizeResponse = {
|
|||
condenseId?: string // The unique ID of the created Summary message, for linking to condense_context clineMessage
|
||||
}
|
||||
|
||||
export type SummarizeConversationOptions = {
|
||||
messages: ApiMessage[]
|
||||
apiHandler: ApiHandler
|
||||
systemPrompt: string
|
||||
taskId: string
|
||||
isAutomaticTrigger?: boolean
|
||||
customCondensingPrompt?: string
|
||||
metadata?: ApiHandlerCreateMessageMetadata
|
||||
environmentDetails?: string
|
||||
filesReadByRoo?: string[]
|
||||
cwd?: string
|
||||
rooIgnoreController?: RooIgnoreController
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarizes the conversation messages using an LLM call.
|
||||
*
|
||||
|
|
@ -131,6 +149,7 @@ export type SummarizeResponse = {
|
|||
* - 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
|
||||
* - File context (folded code definitions) can be preserved for continuity
|
||||
*
|
||||
* Environment details handling:
|
||||
* - For AUTOMATIC condensing (isAutomaticTrigger=true): Environment details are included
|
||||
|
|
@ -139,27 +158,21 @@ export type SummarizeResponse = {
|
|||
* - For MANUAL condensing (isAutomaticTrigger=false): Environment details are NOT included
|
||||
* because fresh environment details will be injected on the very next turn via
|
||||
* getEnvironmentDetails() in recursivelyMakeClineRequests().
|
||||
*
|
||||
* @param {ApiMessage[]} messages - The conversation messages
|
||||
* @param {ApiHandler} apiHandler - The API handler to use for summarization and token counting
|
||||
* @param {string} systemPrompt - The system prompt for API requests (fallback if customCondensingPrompt not provided)
|
||||
* @param {string} taskId - The task ID for the conversation, used for telemetry
|
||||
* @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.)
|
||||
* @param {string} environmentDetails - Optional environment details string to include in the summary (only used when isAutomaticTrigger=true)
|
||||
* @returns {SummarizeResponse} - The result of the summarization operation (see above)
|
||||
*/
|
||||
export async function summarizeConversation(
|
||||
messages: ApiMessage[],
|
||||
apiHandler: ApiHandler,
|
||||
systemPrompt: string,
|
||||
taskId: string,
|
||||
isAutomaticTrigger?: boolean,
|
||||
customCondensingPrompt?: string,
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
environmentDetails?: string,
|
||||
): Promise<SummarizeResponse> {
|
||||
export async function summarizeConversation(options: SummarizeConversationOptions): Promise<SummarizeResponse> {
|
||||
const {
|
||||
messages,
|
||||
apiHandler,
|
||||
systemPrompt,
|
||||
taskId,
|
||||
isAutomaticTrigger,
|
||||
customCondensingPrompt,
|
||||
metadata,
|
||||
environmentDetails,
|
||||
filesReadByRoo,
|
||||
cwd,
|
||||
rooIgnoreController,
|
||||
} = options
|
||||
TelemetryService.instance.captureContextCondensed(
|
||||
taskId,
|
||||
isAutomaticTrigger ?? false,
|
||||
|
|
@ -289,7 +302,7 @@ export async function summarizeConversation(
|
|||
{ type: "text", text: `## Conversation Summary\n${summary}` },
|
||||
]
|
||||
|
||||
// Add command blocks as a separate text block if present
|
||||
// Add command blocks (active workflows) in their own system-reminder block if present
|
||||
if (commandBlocks) {
|
||||
summaryContent.push({
|
||||
type: "text",
|
||||
|
|
@ -301,6 +314,30 @@ ${commandBlocks}
|
|||
})
|
||||
}
|
||||
|
||||
// Generate and add folded file context (smart code folding) if file paths are provided
|
||||
// Each file gets its own <system-reminder> block as a separate content block
|
||||
if (filesReadByRoo && filesReadByRoo.length > 0 && cwd) {
|
||||
try {
|
||||
const foldedResult = await generateFoldedFileContext(filesReadByRoo, {
|
||||
cwd,
|
||||
rooIgnoreController,
|
||||
})
|
||||
if (foldedResult.sections.length > 0) {
|
||||
for (const section of foldedResult.sections) {
|
||||
if (section.trim()) {
|
||||
summaryContent.push({
|
||||
type: "text",
|
||||
text: section,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[summarizeConversation] Failed to generate folded file context:", error)
|
||||
// Continue without folded context - non-critical failure
|
||||
}
|
||||
}
|
||||
|
||||
// Add environment details as a separate text block if provided AND this is an automatic trigger.
|
||||
// For manual condensing, fresh environment details will be injected on the next turn.
|
||||
// For automatic condensing, the API request is already in progress so we need them in the summary.
|
||||
|
|
|
|||
|
|
@ -612,16 +612,13 @@ describe("Context Management", () => {
|
|||
})
|
||||
|
||||
// Verify summarizeConversation was called with the right parameters
|
||||
expect(summarizeSpy).toHaveBeenCalledWith(
|
||||
messagesWithSmallContent,
|
||||
mockApiHandler,
|
||||
"System prompt",
|
||||
expect(summarizeSpy).toHaveBeenCalledWith({
|
||||
messages: messagesWithSmallContent,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
true, // isAutomaticTrigger
|
||||
undefined, // customCondensingPrompt
|
||||
undefined, // metadata
|
||||
undefined, // environmentDetails
|
||||
)
|
||||
isAutomaticTrigger: true,
|
||||
})
|
||||
|
||||
// Verify the result contains the summary information
|
||||
expect(result).toMatchObject({
|
||||
|
|
@ -787,16 +784,13 @@ describe("Context Management", () => {
|
|||
})
|
||||
|
||||
// Verify summarizeConversation was called with the right parameters
|
||||
expect(summarizeSpy).toHaveBeenCalledWith(
|
||||
messagesWithSmallContent,
|
||||
mockApiHandler,
|
||||
"System prompt",
|
||||
expect(summarizeSpy).toHaveBeenCalledWith({
|
||||
messages: messagesWithSmallContent,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
true, // isAutomaticTrigger
|
||||
undefined, // customCondensingPrompt
|
||||
undefined, // metadata
|
||||
undefined, // environmentDetails
|
||||
)
|
||||
isAutomaticTrigger: true,
|
||||
})
|
||||
|
||||
// Verify the result contains the summary information
|
||||
expect(result).toMatchObject({
|
||||
|
|
@ -854,6 +848,215 @@ describe("Context Management", () => {
|
|||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests for filesReadByRoo being passed to summarizeConversation
|
||||
*/
|
||||
describe("filesReadByRoo parameters", () => {
|
||||
const createModelInfo = (contextWindow: number, maxTokens?: number): ModelInfo => ({
|
||||
contextWindow,
|
||||
supportsPromptCache: true,
|
||||
maxTokens,
|
||||
})
|
||||
|
||||
const messages: ApiMessage[] = [
|
||||
{ role: "user", content: "First message" },
|
||||
{ role: "assistant", content: "Second message" },
|
||||
{ role: "user", content: "Third message" },
|
||||
{ role: "assistant", content: "Fourth message" },
|
||||
{ role: "user", content: "Fifth message" },
|
||||
]
|
||||
|
||||
it("should pass filesReadByRoo, cwd, and rooIgnoreController to summarizeConversation when provided", async () => {
|
||||
// Mock the summarizeConversation function
|
||||
const mockSummary = "Summary with folded context"
|
||||
const mockCost = 0.05
|
||||
const mockSummarizeResponse: condenseModule.SummarizeResponse = {
|
||||
messages: [
|
||||
{ role: "user", content: "First message" },
|
||||
{ role: "assistant", content: mockSummary, isSummary: true },
|
||||
{ role: "user", content: "Last message" },
|
||||
],
|
||||
summary: mockSummary,
|
||||
cost: mockCost,
|
||||
newContextTokens: 100,
|
||||
}
|
||||
|
||||
const summarizeSpy = vi
|
||||
.spyOn(condenseModule, "summarizeConversation")
|
||||
.mockResolvedValue(mockSummarizeResponse)
|
||||
|
||||
const modelInfo = createModelInfo(100000, 30000)
|
||||
const totalTokens = 70001 // Above threshold
|
||||
const messagesWithSmallContent = [
|
||||
...messages.slice(0, -1),
|
||||
{ ...messages[messages.length - 1], content: "" },
|
||||
]
|
||||
|
||||
const filesReadByRoo = ["src/test.ts", "src/utils.ts"]
|
||||
const cwd = "/test/project"
|
||||
const mockRooIgnoreController = {
|
||||
filterPaths: vi.fn(),
|
||||
} as unknown as import("../../ignore/RooIgnoreController").RooIgnoreController
|
||||
|
||||
const result = await manageContext({
|
||||
messages: messagesWithSmallContent,
|
||||
totalTokens,
|
||||
contextWindow: modelInfo.contextWindow,
|
||||
maxTokens: modelInfo.maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
autoCondenseContext: true,
|
||||
autoCondenseContextPercent: 100,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
profileThresholds: {},
|
||||
currentProfileId: "default",
|
||||
filesReadByRoo,
|
||||
cwd,
|
||||
rooIgnoreController: mockRooIgnoreController,
|
||||
})
|
||||
|
||||
// Verify summarizeConversation was called with filesReadByRoo, cwd, and rooIgnoreController
|
||||
expect(summarizeSpy).toHaveBeenCalledWith({
|
||||
messages: messagesWithSmallContent,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: true,
|
||||
filesReadByRoo,
|
||||
cwd,
|
||||
rooIgnoreController: mockRooIgnoreController,
|
||||
})
|
||||
|
||||
// Verify the result contains the summary information
|
||||
expect(result).toMatchObject({
|
||||
messages: mockSummarizeResponse.messages,
|
||||
summary: mockSummary,
|
||||
cost: mockCost,
|
||||
prevContextTokens: totalTokens,
|
||||
})
|
||||
|
||||
// Clean up
|
||||
summarizeSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should pass undefined filesReadByRoo parameters when not provided", async () => {
|
||||
// Mock the summarizeConversation function
|
||||
const mockSummary = "Summary without folded context"
|
||||
const mockCost = 0.03
|
||||
const mockSummarizeResponse: condenseModule.SummarizeResponse = {
|
||||
messages: [
|
||||
{ role: "user", content: "First message" },
|
||||
{ role: "assistant", content: mockSummary, isSummary: true },
|
||||
{ role: "user", content: "Last message" },
|
||||
],
|
||||
summary: mockSummary,
|
||||
cost: mockCost,
|
||||
newContextTokens: 80,
|
||||
}
|
||||
|
||||
const summarizeSpy = vi
|
||||
.spyOn(condenseModule, "summarizeConversation")
|
||||
.mockResolvedValue(mockSummarizeResponse)
|
||||
|
||||
const modelInfo = createModelInfo(100000, 30000)
|
||||
const totalTokens = 70001 // Above threshold
|
||||
const messagesWithSmallContent = [
|
||||
...messages.slice(0, -1),
|
||||
{ ...messages[messages.length - 1], content: "" },
|
||||
]
|
||||
|
||||
const result = await manageContext({
|
||||
messages: messagesWithSmallContent,
|
||||
totalTokens,
|
||||
contextWindow: modelInfo.contextWindow,
|
||||
maxTokens: modelInfo.maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
autoCondenseContext: true,
|
||||
autoCondenseContextPercent: 100,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
profileThresholds: {},
|
||||
currentProfileId: "default",
|
||||
// filesReadByRoo, cwd, rooIgnoreController are NOT provided
|
||||
})
|
||||
|
||||
// Verify summarizeConversation was called with undefined parameters
|
||||
expect(summarizeSpy).toHaveBeenCalledWith({
|
||||
messages: messagesWithSmallContent,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: true,
|
||||
})
|
||||
|
||||
// Verify the result
|
||||
expect(result).toMatchObject({
|
||||
summary: mockSummary,
|
||||
cost: mockCost,
|
||||
})
|
||||
|
||||
// Clean up
|
||||
summarizeSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should pass empty array filesReadByRoo when provided as empty", async () => {
|
||||
// Mock the summarizeConversation function
|
||||
const mockSummary = "Summary with empty file list"
|
||||
const mockCost = 0.04
|
||||
const mockSummarizeResponse: condenseModule.SummarizeResponse = {
|
||||
messages: [
|
||||
{ role: "user", content: "First message" },
|
||||
{ role: "assistant", content: mockSummary, isSummary: true },
|
||||
{ role: "user", content: "Last message" },
|
||||
],
|
||||
summary: mockSummary,
|
||||
cost: mockCost,
|
||||
newContextTokens: 90,
|
||||
}
|
||||
|
||||
const summarizeSpy = vi
|
||||
.spyOn(condenseModule, "summarizeConversation")
|
||||
.mockResolvedValue(mockSummarizeResponse)
|
||||
|
||||
const modelInfo = createModelInfo(100000, 30000)
|
||||
const totalTokens = 70001 // Above threshold
|
||||
const messagesWithSmallContent = [
|
||||
...messages.slice(0, -1),
|
||||
{ ...messages[messages.length - 1], content: "" },
|
||||
]
|
||||
|
||||
const result = await manageContext({
|
||||
messages: messagesWithSmallContent,
|
||||
totalTokens,
|
||||
contextWindow: modelInfo.contextWindow,
|
||||
maxTokens: modelInfo.maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
autoCondenseContext: true,
|
||||
autoCondenseContextPercent: 100,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
profileThresholds: {},
|
||||
currentProfileId: "default",
|
||||
filesReadByRoo: [], // Empty array
|
||||
cwd: "/test/project",
|
||||
})
|
||||
|
||||
// Verify summarizeConversation was called with empty array
|
||||
expect(summarizeSpy).toHaveBeenCalledWith({
|
||||
messages: messagesWithSmallContent,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: true,
|
||||
filesReadByRoo: [],
|
||||
cwd: "/test/project",
|
||||
})
|
||||
|
||||
// Clean up
|
||||
summarizeSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests for profile-specific thresholds functionality
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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"
|
||||
import { RooIgnoreController } from "../ignore/RooIgnoreController"
|
||||
|
||||
/**
|
||||
* Context Management
|
||||
|
|
@ -222,6 +223,12 @@ export type ContextManagementOptions = {
|
|||
metadata?: ApiHandlerCreateMessageMetadata
|
||||
/** Optional environment details string to include in the condensed summary */
|
||||
environmentDetails?: string
|
||||
/** Optional array of file paths read by Roo during the task (will be folded via tree-sitter) */
|
||||
filesReadByRoo?: string[]
|
||||
/** Optional current working directory for resolving file paths (required if filesReadByRoo is provided) */
|
||||
cwd?: string
|
||||
/** Optional controller for file access validation */
|
||||
rooIgnoreController?: RooIgnoreController
|
||||
}
|
||||
|
||||
export type ContextManagementResult = SummarizeResponse & {
|
||||
|
|
@ -252,6 +259,9 @@ export async function manageContext({
|
|||
currentProfileId,
|
||||
metadata,
|
||||
environmentDetails,
|
||||
filesReadByRoo,
|
||||
cwd,
|
||||
rooIgnoreController,
|
||||
}: ContextManagementOptions): Promise<ContextManagementResult> {
|
||||
let error: string | undefined
|
||||
let errorDetails: string | undefined
|
||||
|
|
@ -297,16 +307,19 @@ export async function manageContext({
|
|||
const contextPercent = (100 * prevContextTokens) / contextWindow
|
||||
if (contextPercent >= effectiveThreshold || prevContextTokens > allowedTokens) {
|
||||
// Attempt to intelligently condense the context
|
||||
const result = await summarizeConversation(
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler,
|
||||
systemPrompt,
|
||||
taskId,
|
||||
true, // automatic trigger
|
||||
isAutomaticTrigger: true,
|
||||
customCondensingPrompt,
|
||||
metadata,
|
||||
environmentDetails,
|
||||
)
|
||||
filesReadByRoo,
|
||||
cwd,
|
||||
rooIgnoreController,
|
||||
})
|
||||
if (result.error) {
|
||||
error = result.error
|
||||
errorDetails = result.errorDetails
|
||||
|
|
|
|||
|
|
@ -206,6 +206,59 @@ export class FileContextTracker {
|
|||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of unique file paths that Roo has read during this task.
|
||||
* Files are sorted by most recently read first, so if there's a character
|
||||
* budget during folded context generation, the most relevant (recent) files
|
||||
* are prioritized.
|
||||
*
|
||||
* @param sinceTimestamp - Optional timestamp to filter files read after this time
|
||||
* @returns Array of unique file paths that have been read, most recent first
|
||||
*/
|
||||
async getFilesReadByRoo(sinceTimestamp?: number): Promise<string[]> {
|
||||
try {
|
||||
const metadata = await this.getTaskMetadata(this.taskId)
|
||||
|
||||
const readEntries = metadata.files_in_context.filter((entry) => {
|
||||
// Only include files that were read by Roo (not user edits)
|
||||
const isReadByRoo = entry.record_source === "read_tool" || entry.record_source === "file_mentioned"
|
||||
if (!isReadByRoo) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If sinceTimestamp is provided, only include files read after that time
|
||||
if (sinceTimestamp && entry.roo_read_date) {
|
||||
return entry.roo_read_date >= sinceTimestamp
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Sort by roo_read_date descending (most recent first)
|
||||
// Entries without a date go to the end
|
||||
readEntries.sort((a, b) => {
|
||||
const dateA = a.roo_read_date ?? 0
|
||||
const dateB = b.roo_read_date ?? 0
|
||||
return dateB - dateA
|
||||
})
|
||||
|
||||
// Deduplicate while preserving order (first occurrence = most recent read)
|
||||
const seen = new Set<string>()
|
||||
const uniquePaths: string[] = []
|
||||
for (const entry of readEntries) {
|
||||
if (!seen.has(entry.path)) {
|
||||
seen.add(entry.path)
|
||||
uniquePaths.push(entry.path)
|
||||
}
|
||||
}
|
||||
|
||||
return uniquePaths
|
||||
} catch (error) {
|
||||
console.error("Failed to get files read by Roo:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
getAndClearCheckpointPossibleFile(): string[] {
|
||||
const files = Array.from(this.checkpointPossibleFiles)
|
||||
this.checkpointPossibleFiles.clear()
|
||||
|
|
|
|||
|
|
@ -1573,6 +1573,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
}
|
||||
|
||||
private async getFilesReadByRooSafely(context: string): Promise<string[] | undefined> {
|
||||
try {
|
||||
return await this.fileContextTracker.getFilesReadByRoo()
|
||||
} catch (error) {
|
||||
console.error(`[Task#${context}] Failed to get files read by Roo:`, error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
public async condenseContext(): Promise<void> {
|
||||
// CRITICAL: Flush any pending tool results before condensing
|
||||
// to ensure tool_use/tool_result pairs are complete in history
|
||||
|
|
@ -1623,6 +1632,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
// Generate environment details to include in the condensed summary
|
||||
const environmentDetails = await getEnvironmentDetails(this, true)
|
||||
|
||||
const filesReadByRoo = await this.getFilesReadByRooSafely("condenseContext")
|
||||
|
||||
const {
|
||||
messages,
|
||||
summary,
|
||||
|
|
@ -1631,16 +1642,19 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
error,
|
||||
errorDetails,
|
||||
condenseId,
|
||||
} = await summarizeConversation(
|
||||
this.apiConversationHistory,
|
||||
this.api, // Main API handler (fallback)
|
||||
systemPrompt, // Default summarization prompt (fallback)
|
||||
this.taskId,
|
||||
false, // manual trigger
|
||||
customCondensingPrompt, // User's custom prompt
|
||||
metadata, // Pass metadata with tools
|
||||
environmentDetails, // Include environment details in summary
|
||||
)
|
||||
} = await summarizeConversation({
|
||||
messages: this.apiConversationHistory,
|
||||
apiHandler: this.api,
|
||||
systemPrompt,
|
||||
taskId: this.taskId,
|
||||
isAutomaticTrigger: false,
|
||||
customCondensingPrompt,
|
||||
metadata,
|
||||
environmentDetails,
|
||||
filesReadByRoo,
|
||||
cwd: this.cwd,
|
||||
rooIgnoreController: this.rooIgnoreController,
|
||||
})
|
||||
if (error) {
|
||||
await this.say(
|
||||
"condense_context_error",
|
||||
|
|
@ -4039,6 +4053,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
? await getEnvironmentDetails(this, true)
|
||||
: undefined
|
||||
|
||||
// Get files read by Roo for code folding - only when context management will run
|
||||
const contextMgmtFilesReadByRoo =
|
||||
contextManagementWillRun && autoCondenseContext
|
||||
? await this.getFilesReadByRooSafely("attemptApiRequest")
|
||||
: undefined
|
||||
|
||||
try {
|
||||
const truncateResult = await manageContext({
|
||||
messages: this.apiConversationHistory,
|
||||
|
|
@ -4055,6 +4075,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
currentProfileId,
|
||||
metadata: contextMgmtMetadata,
|
||||
environmentDetails: contextMgmtEnvironmentDetails,
|
||||
filesReadByRoo: contextMgmtFilesReadByRoo,
|
||||
cwd: this.cwd,
|
||||
rooIgnoreController: this.rooIgnoreController,
|
||||
})
|
||||
if (truncateResult.messages !== this.apiConversationHistory) {
|
||||
await this.overwriteApiConversationHistory(truncateResult.messages)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue