feat: implement environment details caching to reduce duplicate processing

- Add EnvironmentDetailsCache class with section-based hashing
- Implement change detection for VSCode files, tabs, terminals, cost, mode
- Add configurable TTL for different section types
- Modify getEnvironmentDetails to use caching for performance optimization
- Add comprehensive test suite for caching mechanism
- Maintain backward compatibility with existing API

Fixes #5844
This commit is contained in:
Roo Code 2025-07-17 21:44:55 +00:00
parent a7771a3c11
commit bcd29e385d
3 changed files with 1113 additions and 204 deletions

View file

@ -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<string, CachedSection>()
private readonly sectionConfigs: Record<string, SectionConfig> = {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
// 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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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 += `<slug>${currentMode}</slug>\n`
details += `<name>${modeDetails.name}</name>\n`
details += `<model>${modelId}</model>\n`
if (Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.POWER_STEERING)) {
details += `<role>${modeDetails.roleDefinition}</role>\n`
if (modeDetails.customInstructions) {
details += `<custom_instructions>${modeDetails.customInstructions}</custom_instructions>\n`
}
}
return details
}
/**
* Gets or generates the file details section
*/
async getFileDetailsSection(cline: Task, maxWorkspaceFiles: number): Promise<string> {
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<string> {
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())
}
}
}

View file

@ -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<Task>
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("<slug>")
expect(result).toContain("<name>Test Mode</name>")
expect(result).toContain("<model>test-model</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")
})
})
})

View file

@ -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<ExperimentId, boolean>,
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 += `<slug>${currentMode}</slug>\n`
details += `<name>${modeDetails.name}</name>\n`
details += `<model>${modelId}</model>\n`
if (Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.POWER_STEERING)) {
details += `<role>${modeDetails.roleDefinition}</role>\n`
if (modeDetails.customInstructions) {
details += `<custom_instructions>${modeDetails.customInstructions}</custom_instructions>\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 `<environment_details>\n${details.trim()}\n${reminderSection}\n</environment_details>`
}
/**
* 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()
}