Просмотр исходного кода

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 <[email protected]>
Co-authored-by: daniel-lxs <[email protected]>
Hannes Rudolph 2 недель назад
Родитель
Сommit
d748de6fae

+ 49 - 7
src/core/condense/__tests__/condense.spec.ts

@@ -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 - 0
src/core/condense/__tests__/foldedFileContext.spec.ts

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

+ 111 - 45
src/core/condense/__tests__/index.spec.ts

@@ -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 - 0
src/core/condense/foldedFileContext.ts

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

+ 58 - 21
src/core/condense/index.ts

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

+ 221 - 18
src/core/context-management/__tests__/context-management.spec.ts

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

+ 16 - 3
src/core/context-management/index.ts

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

+ 53 - 0
src/core/context-tracking/FileContextTracker.ts

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

+ 33 - 10
src/core/task/Task.ts

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