diff --git a/src/core/environment/EnvironmentDetailsCache.ts b/src/core/environment/EnvironmentDetailsCache.ts new file mode 100644 index 0000000000..939160ac7e --- /dev/null +++ b/src/core/environment/EnvironmentDetailsCache.ts @@ -0,0 +1,621 @@ +import crypto from "crypto" +import * as vscode from "vscode" +import path from "path" +import os from "os" + +import { Task } from "../task/Task" +import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" +import { Terminal } from "../../integrations/terminal/Terminal" +import { getApiMetrics } from "../../shared/getApiMetrics" +import { defaultModeSlug, getFullModeDetails } from "../../shared/modes" +import { formatLanguage } from "../../shared/language" +import { EXPERIMENT_IDS, experiments as Experiments } from "../../shared/experiments" +import { listFiles } from "../../services/glob/list-files" +import { formatResponse } from "../prompts/responses" +import { arePathsEqual } from "../../utils/path" + +/** + * Represents a cached section of environment details + */ +interface CachedSection { + hash: string + content: string + timestamp: number +} + +/** + * Configuration for environment details sections + */ +interface SectionConfig { + /** Whether this section should be cached */ + cacheable: boolean + /** TTL in milliseconds (0 = no expiration) */ + ttl: number +} + +/** + * Environment details cache manager that implements change detection + * to reduce duplicate processing and token usage + */ +export class EnvironmentDetailsCache { + private cache = new Map() + private readonly sectionConfigs: Record = { + visibleFiles: { cacheable: true, ttl: 5000 }, // 5 seconds + openTabs: { cacheable: true, ttl: 5000 }, + terminals: { cacheable: true, ttl: 1000 }, // Shorter TTL for dynamic content + recentlyModified: { cacheable: true, ttl: 2000 }, + time: { cacheable: false, ttl: 0 }, // Always fresh + cost: { cacheable: true, ttl: 1000 }, + mode: { cacheable: true, ttl: 10000 }, // 10 seconds + fileDetails: { cacheable: true, ttl: 30000 }, // 30 seconds for file listings + } + + /** + * Creates a hash for the given content + */ + private createHash(content: string): string { + return crypto.createHash("sha256").update(content).digest("hex") + } + + /** + * Checks if a cached section is still valid + */ + private isCacheValid(section: CachedSection, config: SectionConfig): boolean { + if (config.ttl === 0) return true // No expiration + return Date.now() - section.timestamp < config.ttl + } + + /** + * Gets or generates the visible files section + */ + async getVisibleFilesSection(cline: Task, maxWorkspaceFiles: number): Promise { + const sectionKey = "visibleFiles" + const config = this.sectionConfigs[sectionKey] + + if (!config.cacheable) { + return this.generateVisibleFilesSection(cline, maxWorkspaceFiles) + } + + // Create a hash of the inputs that affect this section + const visibleFilePaths = vscode.window.visibleTextEditors + ?.map((editor: vscode.TextEditor) => editor.document?.uri?.fsPath) + .filter(Boolean) + .map((absolutePath: string) => path.relative(cline.cwd, absolutePath)) + .slice(0, maxWorkspaceFiles) + + const inputHash = this.createHash(JSON.stringify({ + visibleFilePaths: visibleFilePaths || [], + cwd: cline.cwd, + maxWorkspaceFiles + })) + + const cached = this.cache.get(sectionKey) + if (cached && cached.hash === inputHash && this.isCacheValid(cached, config)) { + return cached.content + } + + // Generate fresh content + const content = await this.generateVisibleFilesSection(cline, maxWorkspaceFiles) + this.cache.set(sectionKey, { + hash: inputHash, + content, + timestamp: Date.now() + }) + + return content + } + + /** + * Generates the visible files section content + */ + private async generateVisibleFilesSection(cline: Task, maxWorkspaceFiles: number): Promise { + let details = "\n\n# VSCode Visible Files" + + const visibleFilePaths = vscode.window.visibleTextEditors + ?.map((editor: vscode.TextEditor) => editor.document?.uri?.fsPath) + .filter(Boolean) + .map((absolutePath: string) => path.relative(cline.cwd, absolutePath)) + .slice(0, maxWorkspaceFiles) + + // Filter paths through rooIgnoreController + const allowedVisibleFiles = cline.rooIgnoreController + ? cline.rooIgnoreController.filterPaths(visibleFilePaths || []) + : (visibleFilePaths || []).map((p: string) => p.toPosix()).join("\n") + + if (allowedVisibleFiles) { + details += `\n${allowedVisibleFiles}` + } else { + details += "\n(No visible files)" + } + + return details + } + + /** + * Gets or generates the open tabs section + */ + async getOpenTabsSection(cline: Task, maxTabs: number): Promise { + const sectionKey = "openTabs" + const config = this.sectionConfigs[sectionKey] + + if (!config.cacheable) { + return this.generateOpenTabsSection(cline, maxTabs) + } + + // Create a hash of the inputs that affect this section + const openTabPaths = vscode.window.tabGroups.all + .flatMap((group: vscode.TabGroup) => group.tabs) + .map((tab: vscode.Tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath) + .filter(Boolean) + .map((absolutePath: string) => path.relative(cline.cwd, absolutePath).toPosix()) + .slice(0, maxTabs) + + const inputHash = this.createHash(JSON.stringify({ + openTabPaths, + cwd: cline.cwd, + maxTabs + })) + + const cached = this.cache.get(sectionKey) + if (cached && cached.hash === inputHash && this.isCacheValid(cached, config)) { + return cached.content + } + + // Generate fresh content + const content = await this.generateOpenTabsSection(cline, maxTabs) + this.cache.set(sectionKey, { + hash: inputHash, + content, + timestamp: Date.now() + }) + + return content + } + + /** + * Generates the open tabs section content + */ + private async generateOpenTabsSection(cline: Task, maxTabs: number): Promise { + let details = "\n\n# VSCode Open Tabs" + + const openTabPaths = vscode.window.tabGroups.all + .flatMap((group: vscode.TabGroup) => group.tabs) + .map((tab: vscode.Tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath) + .filter(Boolean) + .map((absolutePath: string) => path.relative(cline.cwd, absolutePath).toPosix()) + .slice(0, maxTabs) + + // Filter paths through rooIgnoreController + const allowedOpenTabs = cline.rooIgnoreController + ? cline.rooIgnoreController.filterPaths(openTabPaths) + : openTabPaths.map((p: string) => p.toPosix()).join("\n") + + if (allowedOpenTabs) { + details += `\n${allowedOpenTabs}` + } else { + details += "\n(No open tabs)" + } + + return details + } + + /** + * Gets or generates the terminals section + */ + async getTerminalsSection(cline: Task, terminalOutputLineLimit: number): Promise { + const sectionKey = "terminals" + const config = this.sectionConfigs[sectionKey] + + if (!config.cacheable) { + return this.generateTerminalsSection(cline, terminalOutputLineLimit) + } + + // Get terminal state for hashing + const busyTerminals = [ + ...TerminalRegistry.getTerminals(true, cline.taskId), + ...TerminalRegistry.getBackgroundTerminals(true), + ] + + const inactiveTerminals = [ + ...TerminalRegistry.getTerminals(false, cline.taskId), + ...TerminalRegistry.getBackgroundTerminals(false), + ] + + // Create a simplified hash of terminal state + const terminalState = { + busyCount: busyTerminals.length, + inactiveCount: inactiveTerminals.length, + busyIds: busyTerminals.map(t => t.id).sort(), + inactiveIds: inactiveTerminals.map(t => t.id).sort(), + // Include a timestamp component since terminal output changes frequently + timeWindow: Math.floor(Date.now() / 1000) // 1-second granularity + } + + const inputHash = this.createHash(JSON.stringify(terminalState)) + + const cached = this.cache.get(sectionKey) + if (cached && cached.hash === inputHash && this.isCacheValid(cached, config)) { + return cached.content + } + + // Generate fresh content + const content = await this.generateTerminalsSection(cline, terminalOutputLineLimit) + this.cache.set(sectionKey, { + hash: inputHash, + content, + timestamp: Date.now() + }) + + return content + } + + /** + * Generates the terminals section content + */ + private async generateTerminalsSection(cline: Task, terminalOutputLineLimit: number): Promise { + // Get task-specific and background terminals. + const busyTerminals = [ + ...TerminalRegistry.getTerminals(true, cline.taskId), + ...TerminalRegistry.getBackgroundTerminals(true), + ] + + const inactiveTerminals = [ + ...TerminalRegistry.getTerminals(false, cline.taskId), + ...TerminalRegistry.getBackgroundTerminals(false), + ] + + let terminalDetails = "" + + if (busyTerminals.length > 0) { + // Terminals are cool, let's retrieve their output. + terminalDetails += "\n\n# Actively Running Terminals" + + for (const busyTerminal of busyTerminals) { + const cwd = busyTerminal.getCurrentWorkingDirectory() + terminalDetails += `\n## Terminal ${busyTerminal.id} (Active)` + terminalDetails += `\n### Working Directory: \`${cwd}\`` + terminalDetails += `\n### Original command: \`${busyTerminal.getLastCommand()}\`` + let newOutput = TerminalRegistry.getUnretrievedOutput(busyTerminal.id) + + if (newOutput) { + newOutput = Terminal.compressTerminalOutput(newOutput, terminalOutputLineLimit) + terminalDetails += `\n### New Output\n${newOutput}` + } + } + } + + // First check if any inactive terminals in this task have completed + // processes with output. + const terminalsWithOutput = inactiveTerminals.filter((terminal) => { + const completedProcesses = terminal.getProcessesWithOutput() + return completedProcesses.length > 0 + }) + + // Only add the header if there are terminals with output. + if (terminalsWithOutput.length > 0) { + terminalDetails += "\n\n# Inactive Terminals with Completed Process Output" + + // Process each terminal with output. + for (const inactiveTerminal of terminalsWithOutput) { + let terminalOutputs: string[] = [] + + // Get output from completed processes queue. + const completedProcesses = inactiveTerminal.getProcessesWithOutput() + + for (const process of completedProcesses) { + let output = process.getUnretrievedOutput() + + if (output) { + output = Terminal.compressTerminalOutput(output, terminalOutputLineLimit) + terminalOutputs.push(`Command: \`${process.command}\`\n${output}`) + } + } + + // Clean the queue after retrieving output. + inactiveTerminal.cleanCompletedProcessQueue() + + // Add this terminal's outputs to the details. + if (terminalOutputs.length > 0) { + const cwd = inactiveTerminal.getCurrentWorkingDirectory() + terminalDetails += `\n## Terminal ${inactiveTerminal.id} (Inactive)` + terminalDetails += `\n### Working Directory: \`${cwd}\`` + terminalOutputs.forEach((output) => { + terminalDetails += `\n### New Output\n${output}` + }) + } + } + } + + return terminalDetails + } + + /** + * Gets or generates the recently modified files section + */ + async getRecentlyModifiedSection(cline: Task): Promise { + const sectionKey = "recentlyModified" + const config = this.sectionConfigs[sectionKey] + + if (!config.cacheable) { + return this.generateRecentlyModifiedSection(cline) + } + + // Get recently modified files for hashing + const recentlyModifiedFiles = cline.fileContextTracker.getAndClearRecentlyModifiedFiles() + const inputHash = this.createHash(JSON.stringify(recentlyModifiedFiles.sort())) + + const cached = this.cache.get(sectionKey) + if (cached && cached.hash === inputHash && this.isCacheValid(cached, config)) { + // Note: We still need to clear the files even if using cache + cline.fileContextTracker.getAndClearRecentlyModifiedFiles() + return cached.content + } + + // Generate fresh content + const content = await this.generateRecentlyModifiedSection(cline) + this.cache.set(sectionKey, { + hash: inputHash, + content, + timestamp: Date.now() + }) + + return content + } + + /** + * Generates the recently modified files section content + */ + private async generateRecentlyModifiedSection(cline: Task): Promise { + const recentlyModifiedFiles = cline.fileContextTracker.getAndClearRecentlyModifiedFiles() + + if (recentlyModifiedFiles.length > 0) { + let details = "\n\n# Recently Modified Files\nThese files have been modified since you last accessed them (file was just edited so you may need to re-read it before editing):" + for (const filePath of recentlyModifiedFiles) { + details += `\n${filePath}` + } + return details + } + + return "" + } + + /** + * Gets the current time section (always fresh) + */ + getTimeSection(): string { + const now = new Date() + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone + const timeZoneOffset = -now.getTimezoneOffset() / 60 + const timeZoneOffsetHours = Math.floor(Math.abs(timeZoneOffset)) + const timeZoneOffsetMinutes = Math.abs(Math.round((Math.abs(timeZoneOffset) - timeZoneOffsetHours) * 60)) + const timeZoneOffsetStr = `${timeZoneOffset >= 0 ? "+" : "-"}${timeZoneOffsetHours}:${timeZoneOffsetMinutes.toString().padStart(2, "0")}` + + return `\n\n# Current Time\n${now.toISOString()} (UTC, UTC${timeZoneOffsetStr})` + } + + /** + * Gets or generates the cost section + */ + async getCostSection(cline: Task): Promise { + const sectionKey = "cost" + const config = this.sectionConfigs[sectionKey] + + if (!config.cacheable) { + return this.generateCostSection(cline) + } + + // Get cost metrics for hashing + const { totalCost } = getApiMetrics(cline.clineMessages) + const inputHash = this.createHash(JSON.stringify({ totalCost })) + + const cached = this.cache.get(sectionKey) + if (cached && cached.hash === inputHash && this.isCacheValid(cached, config)) { + return cached.content + } + + // Generate fresh content + const content = await this.generateCostSection(cline) + this.cache.set(sectionKey, { + hash: inputHash, + content, + timestamp: Date.now() + }) + + return content + } + + /** + * Generates the cost section content + */ + private async generateCostSection(cline: Task): Promise { + const { totalCost } = getApiMetrics(cline.clineMessages) + return `\n\n# Current Cost\n${totalCost !== null ? `$${totalCost.toFixed(2)}` : "(Not available)"}` + } + + /** + * Gets or generates the mode section + */ + async getModeSection(cline: Task): Promise { + const sectionKey = "mode" + const config = this.sectionConfigs[sectionKey] + + if (!config.cacheable) { + return this.generateModeSection(cline) + } + + // Get state for hashing + const clineProvider = cline.providerRef.deref() + const state = await clineProvider?.getState() + const { + mode, + customModes, + customModePrompts, + experiments = {}, + customInstructions: globalCustomInstructions, + language, + } = state ?? {} + + const currentMode = mode ?? defaultModeSlug + const { id: modelId } = cline.api.getModel() + + const inputHash = this.createHash(JSON.stringify({ + currentMode, + modelId, + customModes, + customModePrompts, + experiments, + globalCustomInstructions, + language + })) + + const cached = this.cache.get(sectionKey) + if (cached && cached.hash === inputHash && this.isCacheValid(cached, config)) { + return cached.content + } + + // Generate fresh content + const content = await this.generateModeSection(cline) + this.cache.set(sectionKey, { + hash: inputHash, + content, + timestamp: Date.now() + }) + + return content + } + + /** + * Generates the mode section content + */ + private async generateModeSection(cline: Task): Promise { + const clineProvider = cline.providerRef.deref() + const state = await clineProvider?.getState() + const { + mode, + customModes, + customModePrompts, + experiments = {}, + customInstructions: globalCustomInstructions, + language, + } = state ?? {} + + const currentMode = mode ?? defaultModeSlug + const { id: modelId } = cline.api.getModel() + + const modeDetails = await getFullModeDetails(currentMode, customModes, customModePrompts, { + cwd: cline.cwd, + globalCustomInstructions, + language: language ?? formatLanguage(vscode.env.language), + }) + + let details = `\n\n# Current Mode\n` + details += `${currentMode}\n` + details += `${modeDetails.name}\n` + details += `${modelId}\n` + + if (Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.POWER_STEERING)) { + details += `${modeDetails.roleDefinition}\n` + + if (modeDetails.customInstructions) { + details += `${modeDetails.customInstructions}\n` + } + } + + return details + } + + /** + * Gets or generates the file details section + */ + async getFileDetailsSection(cline: Task, maxWorkspaceFiles: number): Promise { + const sectionKey = "fileDetails" + const config = this.sectionConfigs[sectionKey] + + if (!config.cacheable) { + return this.generateFileDetailsSection(cline, maxWorkspaceFiles) + } + + // Create hash based on workspace directory and settings + const clineProvider = cline.providerRef.deref() + const state = await clineProvider?.getState() + const { showRooIgnoredFiles = true } = state ?? {} + + const inputHash = this.createHash(JSON.stringify({ + cwd: cline.cwd, + maxWorkspaceFiles, + showRooIgnoredFiles, + // Add a time component to periodically refresh file listings + timeWindow: Math.floor(Date.now() / 30000) // 30-second windows + })) + + const cached = this.cache.get(sectionKey) + if (cached && cached.hash === inputHash && this.isCacheValid(cached, config)) { + return cached.content + } + + // Generate fresh content + const content = await this.generateFileDetailsSection(cline, maxWorkspaceFiles) + this.cache.set(sectionKey, { + hash: inputHash, + content, + timestamp: Date.now() + }) + + return content + } + + /** + * Generates the file details section content + */ + private async generateFileDetailsSection(cline: Task, maxWorkspaceFiles: number): Promise { + let details = `\n\n# Current Workspace Directory (${cline.cwd.toPosix()}) Files\n` + const isDesktop = arePathsEqual(cline.cwd, path.join(os.homedir(), "Desktop")) + + if (isDesktop) { + // Don't want to immediately access desktop since it would show + // permission popup. + details += "(Desktop files not shown automatically. Use list_files to explore if needed.)" + } else { + const maxFiles = maxWorkspaceFiles ?? 200 + + // Early return for limit of 0 + if (maxFiles === 0) { + details += "(Workspace files context disabled. Use list_files to explore if needed.)" + } else { + const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles) + const clineProvider = cline.providerRef.deref() + const state = await clineProvider?.getState() + const { showRooIgnoredFiles = true } = state ?? {} + + const result = formatResponse.formatFilesList( + cline.cwd, + files, + didHitLimit, + cline.rooIgnoreController, + showRooIgnoredFiles, + ) + + details += result + } + } + + return details + } + + /** + * Clears the cache (useful for testing or manual refresh) + */ + clearCache(): void { + this.cache.clear() + } + + /** + * Gets cache statistics for debugging + */ + getCacheStats(): { size: number; sections: string[] } { + return { + size: this.cache.size, + sections: Array.from(this.cache.keys()) + } + } +} \ No newline at end of file diff --git a/src/core/environment/__tests__/EnvironmentDetailsCache.spec.ts b/src/core/environment/__tests__/EnvironmentDetailsCache.spec.ts new file mode 100644 index 0000000000..2c958e8b69 --- /dev/null +++ b/src/core/environment/__tests__/EnvironmentDetailsCache.spec.ts @@ -0,0 +1,452 @@ +// npx vitest core/environment/__tests__/EnvironmentDetailsCache.spec.ts + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import * as vscode from "vscode" + +import { EnvironmentDetailsCache } from "../EnvironmentDetailsCache" +import { Task } from "../../task/Task" +import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" +import { Terminal } from "../../../integrations/terminal/Terminal" +import { getApiMetrics } from "../../../shared/getApiMetrics" +import { getFullModeDetails } from "../../../shared/modes" +import { listFiles } from "../../../services/glob/list-files" +import { formatResponse } from "../../prompts/responses" + +// Mock all external dependencies +vi.mock("vscode", () => ({ + window: { + visibleTextEditors: [], + tabGroups: { all: [] }, + }, + env: { + language: "en-US", + }, +})) + +vi.mock("../../../integrations/terminal/TerminalRegistry") +vi.mock("../../../integrations/terminal/Terminal") +vi.mock("../../../shared/getApiMetrics") +vi.mock("../../../shared/modes") +vi.mock("../../../services/glob/list-files") +vi.mock("../../prompts/responses") + +describe("EnvironmentDetailsCache", () => { + let cache: EnvironmentDetailsCache + let mockTask: Partial + + beforeEach(() => { + cache = new EnvironmentDetailsCache() + + // Mock task + mockTask = { + cwd: "/test/workspace", + taskId: "test-task-id", + rooIgnoreController: { + filterPaths: vi.fn((paths: string[]) => paths.join("\n")), + }, + fileContextTracker: { + getAndClearRecentlyModifiedFiles: vi.fn().mockReturnValue([]), + }, + clineMessages: [], + api: { + getModel: vi.fn().mockReturnValue({ id: "test-model" }), + }, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + maxWorkspaceFiles: 200, + maxOpenTabsContext: 20, + showRooIgnoredFiles: true, + }), + }), + }, + } + + // Mock external functions + vi.mocked(TerminalRegistry.getTerminals).mockReturnValue([]) + vi.mocked(TerminalRegistry.getBackgroundTerminals).mockReturnValue([]) + vi.mocked(getApiMetrics).mockReturnValue({ contextTokens: 1000, totalCost: 0.05 }) + vi.mocked(getFullModeDetails).mockResolvedValue({ + name: "Test Mode", + roleDefinition: "Test role", + customInstructions: "Test instructions", + }) + vi.mocked(listFiles).mockResolvedValue([["file1.ts", "file2.ts"], false]) + vi.mocked(formatResponse.formatFilesList).mockReturnValue("file1.ts\nfile2.ts") + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("getVisibleFilesSection", () => { + it("should generate visible files section", async () => { + // Mock VSCode visible editors + vi.mocked(vscode.window.visibleTextEditors).mockReturnValue([ + { + document: { + uri: { fsPath: "/test/workspace/file1.ts" }, + }, + }, + { + document: { + uri: { fsPath: "/test/workspace/file2.ts" }, + }, + }, + ] as any) + + const result = await cache.getVisibleFilesSection(mockTask as Task, 200) + + expect(result).toContain("# VSCode Visible Files") + expect(result).toContain("file1.ts") + expect(result).toContain("file2.ts") + }) + + it("should cache visible files section when inputs are the same", async () => { + // Mock VSCode visible editors + vi.mocked(vscode.window.visibleTextEditors).mockReturnValue([ + { + document: { + uri: { fsPath: "/test/workspace/file1.ts" }, + }, + }, + ] as any) + + // First call + const result1 = await cache.getVisibleFilesSection(mockTask as Task, 200) + + // Second call with same inputs + const result2 = await cache.getVisibleFilesSection(mockTask as Task, 200) + + expect(result1).toBe(result2) // Should return exact same string (cached) + expect(mockTask.rooIgnoreController?.filterPaths).toHaveBeenCalledTimes(1) // Only called once + }) + + it("should regenerate when visible files change", async () => { + // First call with one file + vi.mocked(vscode.window.visibleTextEditors).mockReturnValue([ + { + document: { + uri: { fsPath: "/test/workspace/file1.ts" }, + }, + }, + ] as any) + + const result1 = await cache.getVisibleFilesSection(mockTask as Task, 200) + + // Second call with different files + vi.mocked(vscode.window.visibleTextEditors).mockReturnValue([ + { + document: { + uri: { fsPath: "/test/workspace/file2.ts" }, + }, + }, + ] as any) + + const result2 = await cache.getVisibleFilesSection(mockTask as Task, 200) + + expect(result1).not.toBe(result2) // Should be different + expect(result2).toContain("file2.ts") + expect(result2).not.toContain("file1.ts") + }) + }) + + describe("getOpenTabsSection", () => { + it("should generate open tabs section", async () => { + // Mock VSCode tab groups + vi.mocked(vscode.window.tabGroups).all = [ + { + tabs: [ + { + input: { + uri: { fsPath: "/test/workspace/tab1.ts" }, + }, + }, + { + input: { + uri: { fsPath: "/test/workspace/tab2.ts" }, + }, + }, + ], + }, + ] as any + + const result = await cache.getOpenTabsSection(mockTask as Task, 20) + + expect(result).toContain("# VSCode Open Tabs") + expect(result).toContain("tab1.ts") + expect(result).toContain("tab2.ts") + }) + + it("should cache open tabs section when inputs are the same", async () => { + // Mock VSCode tab groups + vi.mocked(vscode.window.tabGroups).all = [ + { + tabs: [ + { + input: { + uri: { fsPath: "/test/workspace/tab1.ts" }, + }, + }, + ], + }, + ] as any + + // First call + const result1 = await cache.getOpenTabsSection(mockTask as Task, 20) + + // Second call with same inputs + const result2 = await cache.getOpenTabsSection(mockTask as Task, 20) + + expect(result1).toBe(result2) // Should return exact same string (cached) + }) + }) + + describe("getTerminalsSection", () => { + it("should generate terminals section with active terminals", async () => { + const mockTerminal = { + id: "terminal-1", + getCurrentWorkingDirectory: vi.fn().mockReturnValue("/test/workspace"), + getLastCommand: vi.fn().mockReturnValue("npm test"), + } + + vi.mocked(TerminalRegistry.getTerminals).mockReturnValue([mockTerminal] as any) + vi.mocked(TerminalRegistry.getUnretrievedOutput).mockReturnValue("Test output") + vi.mocked(Terminal.compressTerminalOutput).mockReturnValue("Compressed output") + + const result = await cache.getTerminalsSection(mockTask as Task, 500) + + expect(result).toContain("# Actively Running Terminals") + expect(result).toContain("Terminal terminal-1 (Active)") + expect(result).toContain("npm test") + expect(result).toContain("Compressed output") + }) + + it("should handle inactive terminals with completed processes", async () => { + const mockProcess = { + command: "npm build", + getUnretrievedOutput: vi.fn().mockReturnValue("Build output"), + } + + const mockInactiveTerminal = { + id: "terminal-2", + getCurrentWorkingDirectory: vi.fn().mockReturnValue("/test/workspace"), + getProcessesWithOutput: vi.fn().mockReturnValue([mockProcess]), + cleanCompletedProcessQueue: vi.fn(), + } + + vi.mocked(TerminalRegistry.getTerminals).mockImplementation((active: boolean) => + active ? [] : [mockInactiveTerminal] as any + ) + vi.mocked(Terminal.compressTerminalOutput).mockReturnValue("Compressed build output") + + const result = await cache.getTerminalsSection(mockTask as Task, 500) + + expect(result).toContain("# Inactive Terminals with Completed Process Output") + expect(result).toContain("Terminal terminal-2 (Inactive)") + expect(result).toContain("npm build") + expect(result).toContain("Compressed build output") + expect(mockInactiveTerminal.cleanCompletedProcessQueue).toHaveBeenCalled() + }) + + it("should return empty string when no terminals", async () => { + vi.mocked(TerminalRegistry.getTerminals).mockReturnValue([]) + vi.mocked(TerminalRegistry.getBackgroundTerminals).mockReturnValue([]) + + const result = await cache.getTerminalsSection(mockTask as Task, 500) + + expect(result).toBe("") + }) + }) + + describe("getRecentlyModifiedSection", () => { + it("should generate recently modified files section", async () => { + vi.mocked(mockTask.fileContextTracker!.getAndClearRecentlyModifiedFiles).mockReturnValue([ + "modified1.ts", + "modified2.ts", + ]) + + const result = await cache.getRecentlyModifiedSection(mockTask as Task) + + expect(result).toContain("# Recently Modified Files") + expect(result).toContain("modified1.ts") + expect(result).toContain("modified2.ts") + }) + + it("should return empty string when no recently modified files", async () => { + vi.mocked(mockTask.fileContextTracker!.getAndClearRecentlyModifiedFiles).mockReturnValue([]) + + const result = await cache.getRecentlyModifiedSection(mockTask as Task) + + expect(result).toBe("") + }) + + it("should cache based on file list", async () => { + // Mock the same file list for both calls + vi.mocked(mockTask.fileContextTracker!.getAndClearRecentlyModifiedFiles) + .mockReturnValueOnce(["file1.ts"]) + .mockReturnValueOnce(["file1.ts"]) + + const result1 = await cache.getRecentlyModifiedSection(mockTask as Task) + const result2 = await cache.getRecentlyModifiedSection(mockTask as Task) + + // Note: Even with caching, the method still needs to clear the files + expect(mockTask.fileContextTracker!.getAndClearRecentlyModifiedFiles).toHaveBeenCalledTimes(2) + }) + }) + + describe("getTimeSection", () => { + it("should generate current time section", () => { + const result = cache.getTimeSection() + + expect(result).toContain("# Current Time") + expect(result).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/) // ISO format + expect(result).toContain("UTC") + }) + + it("should always return fresh time (not cached)", () => { + const result1 = cache.getTimeSection() + + // Small delay to ensure different timestamps + const start = Date.now() + while (Date.now() - start < 2) { + // Wait for at least 2ms + } + + const result2 = cache.getTimeSection() + + // Times should be different (not cached) + expect(result1).not.toBe(result2) + }) + }) + + describe("getCostSection", () => { + it("should generate cost section", async () => { + vi.mocked(getApiMetrics).mockReturnValue({ contextTokens: 1000, totalCost: 0.05 }) + + const result = await cache.getCostSection(mockTask as Task) + + expect(result).toContain("# Current Cost") + expect(result).toContain("$0.05") + }) + + it("should handle null cost", async () => { + vi.mocked(getApiMetrics).mockReturnValue({ contextTokens: 1000, totalCost: null }) + + const result = await cache.getCostSection(mockTask as Task) + + expect(result).toContain("# Current Cost") + expect(result).toContain("(Not available)") + }) + + it("should cache cost section when cost is the same", async () => { + vi.mocked(getApiMetrics).mockReturnValue({ contextTokens: 1000, totalCost: 0.05 }) + + const result1 = await cache.getCostSection(mockTask as Task) + const result2 = await cache.getCostSection(mockTask as Task) + + expect(result1).toBe(result2) // Should be cached + expect(getApiMetrics).toHaveBeenCalledTimes(1) // Only called once for hashing + }) + }) + + describe("getModeSection", () => { + it("should generate mode section", async () => { + const result = await cache.getModeSection(mockTask as Task) + + expect(result).toContain("# Current Mode") + expect(result).toContain("") + expect(result).toContain("Test Mode") + expect(result).toContain("test-model") + }) + + it("should cache mode section when inputs are the same", async () => { + const result1 = await cache.getModeSection(mockTask as Task) + const result2 = await cache.getModeSection(mockTask as Task) + + expect(result1).toBe(result2) // Should be cached + expect(getFullModeDetails).toHaveBeenCalledTimes(1) // Only called once + }) + }) + + describe("getFileDetailsSection", () => { + it("should generate file details section", async () => { + const result = await cache.getFileDetailsSection(mockTask as Task, 200) + + expect(result).toContain("# Current Workspace Directory") + expect(result).toContain("Files") + expect(listFiles).toHaveBeenCalledWith("/test/workspace", true, 200) + expect(formatResponse.formatFilesList).toHaveBeenCalled() + }) + + it("should handle desktop directory specially", async () => { + const desktopTask = { + ...mockTask, + cwd: "/home/user/Desktop", // Mock desktop path + } + + const result = await cache.getFileDetailsSection(desktopTask as Task, 200) + + expect(result).toContain("Desktop files not shown automatically") + expect(listFiles).not.toHaveBeenCalled() + }) + + it("should handle maxWorkspaceFiles = 0", async () => { + const result = await cache.getFileDetailsSection(mockTask as Task, 0) + + expect(result).toContain("Workspace files context disabled") + expect(listFiles).not.toHaveBeenCalled() + }) + }) + + describe("cache management", () => { + it("should clear cache", () => { + // Add some cached data first + cache.getTimeSection() // This doesn't cache, but let's add a cacheable section + + const statsBefore = cache.getCacheStats() + cache.clearCache() + const statsAfter = cache.getCacheStats() + + expect(statsAfter.size).toBe(0) + expect(statsAfter.sections).toEqual([]) + }) + + it("should provide cache statistics", async () => { + // Generate some cached sections + await cache.getVisibleFilesSection(mockTask as Task, 200) + await cache.getCostSection(mockTask as Task) + + const stats = cache.getCacheStats() + + expect(stats.size).toBeGreaterThan(0) + expect(stats.sections).toContain("visibleFiles") + expect(stats.sections).toContain("cost") + }) + }) + + describe("TTL (Time To Live) behavior", () => { + it("should respect TTL for cached sections", async () => { + // Create a cache with very short TTL for testing + const shortTtlCache = new EnvironmentDetailsCache() + + // Mock a section that would normally be cached + vi.mocked(getApiMetrics).mockReturnValue({ contextTokens: 1000, totalCost: 0.05 }) + + const result1 = await shortTtlCache.getCostSection(mockTask as Task) + + // Immediately get again - should be cached + const result2 = await shortTtlCache.getCostSection(mockTask as Task) + expect(result1).toBe(result2) + + // Change the cost and verify it eventually updates + vi.mocked(getApiMetrics).mockReturnValue({ contextTokens: 1000, totalCost: 0.10 }) + + // Wait for TTL to expire (we can't easily test this without mocking time) + // For now, just verify the cache can be cleared manually + shortTtlCache.clearCache() + const result3 = await shortTtlCache.getCostSection(mockTask as Task) + expect(result3).toContain("$0.10") + }) + }) +}) \ No newline at end of file diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index 6f0c9fe2bf..89aac9be44 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -19,67 +19,23 @@ import { formatResponse } from "../prompts/responses" import { Task } from "../task/Task" import { formatReminderSection } from "./reminder" +import { EnvironmentDetailsCache } from "./EnvironmentDetailsCache" + +// Global cache instance - shared across all tasks for efficiency +const environmentCache = new EnvironmentDetailsCache() export async function getEnvironmentDetails(cline: Task, includeFileDetails: boolean = false) { - let details = "" - const clineProvider = cline.providerRef.deref() const state = await clineProvider?.getState() - const { terminalOutputLineLimit = 500, maxWorkspaceFiles = 200 } = state ?? {} - - // It could be useful for cline to know if the user went from one or no - // file to another between messages, so we always include this context. - details += "\n\n# VSCode Visible Files" - - const visibleFilePaths = vscode.window.visibleTextEditors - ?.map((editor) => editor.document?.uri?.fsPath) - .filter(Boolean) - .map((absolutePath) => path.relative(cline.cwd, absolutePath)) - .slice(0, maxWorkspaceFiles) - - // Filter paths through rooIgnoreController - const allowedVisibleFiles = cline.rooIgnoreController - ? cline.rooIgnoreController.filterPaths(visibleFilePaths) - : visibleFilePaths.map((p) => p.toPosix()).join("\n") - - if (allowedVisibleFiles) { - details += `\n${allowedVisibleFiles}` - } else { - details += "\n(No visible files)" - } - - details += "\n\n# VSCode Open Tabs" - const { maxOpenTabsContext } = state ?? {} + const { terminalOutputLineLimit = 500, maxWorkspaceFiles = 200, maxOpenTabsContext } = state ?? {} const maxTabs = maxOpenTabsContext ?? 20 - const openTabPaths = vscode.window.tabGroups.all - .flatMap((group) => group.tabs) - .map((tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath) - .filter(Boolean) - .map((absolutePath) => path.relative(cline.cwd, absolutePath).toPosix()) - .slice(0, maxTabs) - // Filter paths through rooIgnoreController - const allowedOpenTabs = cline.rooIgnoreController - ? cline.rooIgnoreController.filterPaths(openTabPaths) - : openTabPaths.map((p) => p.toPosix()).join("\n") - - if (allowedOpenTabs) { - details += `\n${allowedOpenTabs}` - } else { - details += "\n(No open tabs)" - } - - // Get task-specific and background terminals. + // Handle terminal waiting logic (this needs to happen before caching) const busyTerminals = [ ...TerminalRegistry.getTerminals(true, cline.taskId), ...TerminalRegistry.getBackgroundTerminals(true), ] - const inactiveTerminals = [ - ...TerminalRegistry.getTerminals(false, cline.taskId), - ...TerminalRegistry.getBackgroundTerminals(false), - ] - if (busyTerminals.length > 0) { if (cline.didEditFile) { await delay(300) // Delay after saving file to let terminals catch up. @@ -95,165 +51,45 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo // Reset, this lets us know when to wait for saved files to update terminals. cline.didEditFile = false - // Waiting for updated diagnostics lets terminal output be the most - // up-to-date possible. - let terminalDetails = "" + // Use cached sections where possible + const visibleFilesSection = await environmentCache.getVisibleFilesSection(cline, maxWorkspaceFiles) + const openTabsSection = await environmentCache.getOpenTabsSection(cline, maxTabs) + const terminalsSection = await environmentCache.getTerminalsSection(cline, terminalOutputLineLimit) + const recentlyModifiedSection = await environmentCache.getRecentlyModifiedSection(cline) + const timeSection = environmentCache.getTimeSection() // Always fresh + const costSection = await environmentCache.getCostSection(cline) + const modeSection = await environmentCache.getModeSection(cline) - if (busyTerminals.length > 0) { - // Terminals are cool, let's retrieve their output. - terminalDetails += "\n\n# Actively Running Terminals" - - for (const busyTerminal of busyTerminals) { - const cwd = busyTerminal.getCurrentWorkingDirectory() - terminalDetails += `\n## Terminal ${busyTerminal.id} (Active)` - terminalDetails += `\n### Working Directory: \`${cwd}\`` - terminalDetails += `\n### Original command: \`${busyTerminal.getLastCommand()}\`` - let newOutput = TerminalRegistry.getUnretrievedOutput(busyTerminal.id) - - if (newOutput) { - newOutput = Terminal.compressTerminalOutput(newOutput, terminalOutputLineLimit) - terminalDetails += `\n### New Output\n${newOutput}` - } - } - } - - // First check if any inactive terminals in this task have completed - // processes with output. - const terminalsWithOutput = inactiveTerminals.filter((terminal) => { - const completedProcesses = terminal.getProcessesWithOutput() - return completedProcesses.length > 0 - }) - - // Only add the header if there are terminals with output. - if (terminalsWithOutput.length > 0) { - terminalDetails += "\n\n# Inactive Terminals with Completed Process Output" - - // Process each terminal with output. - for (const inactiveTerminal of terminalsWithOutput) { - let terminalOutputs: string[] = [] - - // Get output from completed processes queue. - const completedProcesses = inactiveTerminal.getProcessesWithOutput() - - for (const process of completedProcesses) { - let output = process.getUnretrievedOutput() - - if (output) { - output = Terminal.compressTerminalOutput(output, terminalOutputLineLimit) - terminalOutputs.push(`Command: \`${process.command}\`\n${output}`) - } - } - - // Clean the queue after retrieving output. - inactiveTerminal.cleanCompletedProcessQueue() - - // Add this terminal's outputs to the details. - if (terminalOutputs.length > 0) { - const cwd = inactiveTerminal.getCurrentWorkingDirectory() - terminalDetails += `\n## Terminal ${inactiveTerminal.id} (Inactive)` - terminalDetails += `\n### Working Directory: \`${cwd}\`` - terminalOutputs.forEach((output) => { - terminalDetails += `\n### New Output\n${output}` - }) - } - } - } - - // console.log(`[Task#getEnvironmentDetails] terminalDetails: ${terminalDetails}`) - - // Add recently modified files section. - const recentlyModifiedFiles = cline.fileContextTracker.getAndClearRecentlyModifiedFiles() - - if (recentlyModifiedFiles.length > 0) { - details += - "\n\n# Recently Modified Files\nThese files have been modified since you last accessed them (file was just edited so you may need to re-read it before editing):" - for (const filePath of recentlyModifiedFiles) { - details += `\n${filePath}` - } - } - - if (terminalDetails) { - details += terminalDetails - } - - // Add current time information with timezone. - const now = new Date() - - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone - const timeZoneOffset = -now.getTimezoneOffset() / 60 // Convert to hours and invert sign to match conventional notation - const timeZoneOffsetHours = Math.floor(Math.abs(timeZoneOffset)) - const timeZoneOffsetMinutes = Math.abs(Math.round((Math.abs(timeZoneOffset) - timeZoneOffsetHours) * 60)) - const timeZoneOffsetStr = `${timeZoneOffset >= 0 ? "+" : "-"}${timeZoneOffsetHours}:${timeZoneOffsetMinutes.toString().padStart(2, "0")}` - details += `\n\n# Current Time\nCurrent time in ISO 8601 UTC format: ${now.toISOString()}\nUser time zone: ${timeZone}, UTC${timeZoneOffsetStr}` - - // Add context tokens information. - const { contextTokens, totalCost } = getApiMetrics(cline.clineMessages) - const { id: modelId } = cline.api.getModel() - - details += `\n\n# Current Cost\n${totalCost !== null ? `$${totalCost.toFixed(2)}` : "(Not available)"}` - - // Add current mode and any mode-specific warnings. - const { - mode, - customModes, - customModePrompts, - experiments = {} as Record, - customInstructions: globalCustomInstructions, - language, - } = state ?? {} - - const currentMode = mode ?? defaultModeSlug - - const modeDetails = await getFullModeDetails(currentMode, customModes, customModePrompts, { - cwd: cline.cwd, - globalCustomInstructions, - language: language ?? formatLanguage(vscode.env.language), - }) - - details += `\n\n# Current Mode\n` - details += `${currentMode}\n` - details += `${modeDetails.name}\n` - details += `${modelId}\n` - - if (Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.POWER_STEERING)) { - details += `${modeDetails.roleDefinition}\n` - - if (modeDetails.customInstructions) { - details += `${modeDetails.customInstructions}\n` - } - } + // Combine all sections + let details = "" + details += visibleFilesSection + details += openTabsSection + details += terminalsSection + details += recentlyModifiedSection + details += timeSection + details += costSection + details += modeSection + // Handle file details section if requested if (includeFileDetails) { - details += `\n\n# Current Workspace Directory (${cline.cwd.toPosix()}) Files\n` - const isDesktop = arePathsEqual(cline.cwd, path.join(os.homedir(), "Desktop")) - - if (isDesktop) { - // Don't want to immediately access desktop since it would show - // permission popup. - details += "(Desktop files not shown automatically. Use list_files to explore if needed.)" - } else { - const maxFiles = maxWorkspaceFiles ?? 200 - - // Early return for limit of 0 - if (maxFiles === 0) { - details += "(Workspace files context disabled. Use list_files to explore if needed.)" - } else { - const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles) - const { showRooIgnoredFiles = true } = state ?? {} - - const result = formatResponse.formatFilesList( - cline.cwd, - files, - didHitLimit, - cline.rooIgnoreController, - showRooIgnoredFiles, - ) - - details += result - } - } + const fileDetailsSection = await environmentCache.getFileDetailsSection(cline, maxWorkspaceFiles) + details += fileDetailsSection } const reminderSection = formatReminderSection(cline.todoList) return `\n${details.trim()}\n${reminderSection}\n` } + +/** + * Clears the environment details cache (useful for testing or manual refresh) + */ +export function clearEnvironmentDetailsCache(): void { + environmentCache.clearCache() +} + +/** + * Gets cache statistics for debugging + */ +export function getEnvironmentDetailsCacheStats(): { size: number; sections: string[] } { + return environmentCache.getCacheStats() +}