Move environment details to a separate module, add tests (#3078)

This commit is contained in:
Chris Estreich 2025-05-05 12:07:20 -07:00 committed by GitHub
parent 305185cd88
commit 78beb718c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 606 additions and 358 deletions

View file

@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Split Cline.getEnvironmentDetails out into a standalone function

View file

@ -33,14 +33,11 @@ import {
import { getApiMetrics } from "../shared/getApiMetrics"
import { HistoryItem } from "../shared/HistoryItem"
import { ClineAskResponse } from "../shared/WebviewMessage"
import { defaultModeSlug, getModeBySlug, getFullModeDetails, isToolAllowedForMode } from "../shared/modes"
import { EXPERIMENT_IDS, experiments as Experiments, ExperimentId } from "../shared/experiments"
import { formatLanguage } from "../shared/language"
import { defaultModeSlug, getModeBySlug } from "../shared/modes"
import { ToolParamName, ToolResponse, DiffStrategy } from "../shared/tools"
// services
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
import { listFiles } from "../services/glob/list-files"
import { BrowserSession } from "../services/browser/BrowserSession"
import { McpHub } from "../services/mcp/McpHub"
import { McpServerManager } from "../services/mcp/McpServerManager"
@ -51,12 +48,11 @@ import { CheckpointServiceOptions, RepoPerTaskCheckpointService } from "../servi
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
import { RooTerminalProcess } from "../integrations/terminal/types"
import { Terminal } from "../integrations/terminal/Terminal"
import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry"
// utils
import { calculateApiCostAnthropic } from "../utils/cost"
import { arePathsEqual, getWorkspacePath } from "../utils/path"
import { getWorkspacePath } from "../utils/path"
// tools
import { fetchInstructionsTool } from "./tools/fetchInstructionsTool"
@ -91,6 +87,7 @@ import { ClineProvider } from "./webview/ClineProvider"
import { validateToolUse } from "./mode-validator"
import { MultiSearchReplaceDiffStrategy } from "./diff/strategies/multi-search-replace"
import { readApiMessages, saveApiMessages, readTaskMessages, saveTaskMessages, taskMetadata } from "./task-persistence"
import { getEnvironmentDetails } from "./environment/getEnvironmentDetails"
type UserContent = Array<Anthropic.Messages.ContentBlockParam>
@ -145,7 +142,7 @@ export class Cline extends EventEmitter<ClineEvents> {
private promptCacheKey: string
rooIgnoreController?: RooIgnoreController
private fileContextTracker: FileContextTracker
fileContextTracker: FileContextTracker
private urlContentFetcher: UrlContentFetcher
browserSession: BrowserSession
didEditFile: boolean = false
@ -1021,7 +1018,7 @@ export class Cline extends EventEmitter<ClineEvents> {
)
const parsedUserContent = await this.parseUserContent(userContent)
const environmentDetails = await this.getEnvironmentDetails(includeFileDetails)
const environmentDetails = await getEnvironmentDetails(this, includeFileDetails)
// Add environment details as its own text block, separate from tool
// results.
@ -2077,258 +2074,6 @@ export class Cline extends EventEmitter<ClineEvents> {
)
}
// Environment
public async getEnvironmentDetails(includeFileDetails: boolean = false) {
let details = ""
const { terminalOutputLineLimit = 500, maxWorkspaceFiles = 200 } =
(await this.providerRef.deref()?.getState()) ?? {}
// 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(this.cwd, absolutePath))
.slice(0, maxWorkspaceFiles)
// Filter paths through rooIgnoreController
const allowedVisibleFiles = this.rooIgnoreController
? this.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 } = (await this.providerRef.deref()?.getState()) ?? {}
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(this.cwd, absolutePath).toPosix())
.slice(0, maxTabs)
// Filter paths through rooIgnoreController
const allowedOpenTabs = this.rooIgnoreController
? this.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.
const busyTerminals = [
...TerminalRegistry.getTerminals(true, this.taskId),
...TerminalRegistry.getBackgroundTerminals(true),
]
const inactiveTerminals = [
...TerminalRegistry.getTerminals(false, this.taskId),
...TerminalRegistry.getBackgroundTerminals(false),
]
if (busyTerminals.length > 0) {
if (this.didEditFile) {
await delay(300) // Delay after saving file to let terminals catch up.
}
// Wait for terminals to cool down.
await pWaitFor(() => busyTerminals.every((t) => !TerminalRegistry.isProcessHot(t.id)), {
interval: 100,
timeout: 5_000,
}).catch(() => {})
}
// Reset, this lets us know when to wait for saved files to update terminals.
this.didEditFile = false
// Waiting for updated diagnostics lets terminal output be the most
// up-to-date possible.
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) {
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) {
terminalDetails += `\n## Terminal ${inactiveTerminal.id}`
terminalOutputs.forEach((output) => {
terminalDetails += `\n### New Output\n${output}`
})
}
}
}
// console.log(`[Cline#getEnvironmentDetails] terminalDetails: ${terminalDetails}`)
// Add recently modified files section.
const recentlyModifiedFiles = this.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 formatter = new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: true,
})
const timeZone = formatter.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\n${formatter.format(now)} (${timeZone}, UTC${timeZoneOffsetStr})`
// Add context tokens information.
const { contextTokens, totalCost } = getApiMetrics(this.clineMessages)
const modelInfo = this.api.getModel().info
const contextWindow = modelInfo.contextWindow
const contextPercentage =
contextTokens && contextWindow ? Math.round((contextTokens / contextWindow) * 100) : undefined
details += `\n\n# Current Context Size (Tokens)\n${contextTokens ? `${contextTokens.toLocaleString()} (${contextPercentage}%)` : "(Not available)"}`
details += `\n\n# Current Cost\n${totalCost !== null ? `$${totalCost.toFixed(2)}` : "(Not available)"}`
// Add current mode and any mode-specific warnings.
const {
mode,
customModes,
apiModelId,
customModePrompts,
experiments = {} as Record<ExperimentId, boolean>,
customInstructions: globalCustomInstructions,
language,
} = (await this.providerRef.deref()?.getState()) ?? {}
const currentMode = mode ?? defaultModeSlug
const modeDetails = await getFullModeDetails(currentMode, customModes, customModePrompts, {
cwd: this.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>${apiModelId}</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`
}
}
// Add warning if not in code mode.
if (
!isToolAllowedForMode("write_to_file", currentMode, customModes ?? [], { apply_diff: this.diffEnabled }) &&
!isToolAllowedForMode("apply_diff", currentMode, customModes ?? [], { apply_diff: this.diffEnabled })
) {
const currentModeName = getModeBySlug(currentMode, customModes)?.name ?? currentMode
const defaultModeName = getModeBySlug(defaultModeSlug, customModes)?.name ?? defaultModeSlug
details += `\n\nNOTE: You are currently in '${currentModeName}' mode, which does not allow write operations. To write files, the user will need to switch to a mode that supports file writing, such as '${defaultModeName}' mode.`
}
if (includeFileDetails) {
details += `\n\n# Current Workspace Directory (${this.cwd.toPosix()}) Files\n`
const isDesktop = arePathsEqual(this.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
const [files, didHitLimit] = await listFiles(this.cwd, true, maxFiles)
const { showRooIgnoredFiles = true } = (await this.providerRef.deref()?.getState()) ?? {}
const result = formatResponse.formatFilesList(
this.cwd,
files,
didHitLimit,
this.rooIgnoreController,
showRooIgnoredFiles,
)
details += result
}
}
return `<environment_details>\n${details.trim()}\n</environment_details>`
}
// Checkpoints
private getCheckpointService() {

View file

@ -13,6 +13,10 @@ import { ApiConfiguration, ModelInfo } from "../../shared/api"
import { ApiStreamChunk } from "../../api/transform/stream"
import { ContextProxy } from "../config/ContextProxy"
jest.mock("../environment/getEnvironmentDetails", () => ({
getEnvironmentDetails: jest.fn().mockResolvedValue(""),
}))
jest.mock("execa", () => ({
execa: jest.fn(),
}))
@ -316,90 +320,7 @@ describe("Cline", () => {
})
describe("getEnvironmentDetails", () => {
let originalDate: DateConstructor
let mockDate: Date
beforeEach(() => {
originalDate = global.Date
const fixedTime = new Date("2024-01-01T12:00:00Z")
mockDate = new Date(fixedTime)
mockDate.getTimezoneOffset = jest.fn().mockReturnValue(420) // UTC-7
class MockDate extends Date {
constructor() {
super()
return mockDate
}
static override now() {
return mockDate.getTime()
}
}
global.Date = MockDate as DateConstructor
// Create a proper mock of Intl.DateTimeFormat
const mockDateTimeFormat = {
resolvedOptions: () => ({
timeZone: "America/Los_Angeles",
}),
format: () => "1/1/2024, 5:00:00 AM",
}
const MockDateTimeFormat = function (this: any) {
return mockDateTimeFormat
} as any
MockDateTimeFormat.prototype = mockDateTimeFormat
MockDateTimeFormat.supportedLocalesOf = jest.fn().mockReturnValue(["en-US"])
global.Intl.DateTimeFormat = MockDateTimeFormat
})
afterEach(() => {
global.Date = originalDate
})
it("should include timezone information in environment details", async () => {
const cline = new Cline({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})
const details = await cline["getEnvironmentDetails"](false)
// Verify timezone information is present and formatted correctly.
expect(details).toContain("America/Los_Angeles")
expect(details).toMatch(/UTC-7:00/) // Fixed offset for America/Los_Angeles.
expect(details).toContain("# Current Time")
expect(details).toMatch(/1\/1\/2024.*5:00:00 AM.*\(America\/Los_Angeles, UTC-7:00\)/) // Full time string format.
})
describe("API conversation handling", () => {
/**
* Mock environment details retrieval to avoid filesystem access in tests
*
* This setup:
* 1. Prevents file listing operations that might cause test instability
* 2. Preserves test-specific mocks when they exist (via _mockGetEnvironmentDetails)
* 3. Provides a stable, empty environment by default
*/
beforeEach(() => {
// Mock the method with a stable implementation
jest.spyOn(Cline.prototype, "getEnvironmentDetails").mockImplementation(
// Use 'any' type to allow for dynamic test properties
async function (this: any, _verbose: boolean = false): Promise<string> {
// Use test-specific mock if available
if (this._mockGetEnvironmentDetails) {
return this._mockGetEnvironmentDetails()
}
// Default to empty environment details for stability
return ""
},
)
})
it("should clean conversation history before sending to API", async () => {
// Cline.create will now use our mocked getEnvironmentDetails
const [cline, task] = Cline.create({
@ -420,12 +341,6 @@ describe("Cline", () => {
const cleanMessageSpy = jest.fn().mockReturnValue(mockStreamForClean)
jest.spyOn(cline.api, "createMessage").mockImplementation(cleanMessageSpy)
// Mock getEnvironmentDetails to return empty details.
jest.spyOn(cline as any, "getEnvironmentDetails").mockResolvedValue("")
// Mock loadContext to return unmodified content.
jest.spyOn(cline as any, "parseUserContent").mockImplementation(async (content) => [content, ""])
// Add test message to conversation history.
cline.apiConversationHistory = [
{
@ -574,12 +489,6 @@ describe("Cline", () => {
configurable: true,
})
// Mock environment details and context loading
jest.spyOn(clineWithImages as any, "getEnvironmentDetails").mockResolvedValue("")
jest.spyOn(clineWithoutImages as any, "getEnvironmentDetails").mockResolvedValue("")
jest.spyOn(clineWithImages as any, "parseUserContent").mockImplementation(async (content) => content)
jest.spyOn(clineWithoutImages as any, "parseUserContent").mockImplementation(async (content) => content)
// Set up mock streams
const mockStreamWithImages = (async function* () {
yield { type: "text", text: "test response" }

View file

@ -0,0 +1,316 @@
// npx jest src/core/environment/__tests__/getEnvironmentDetails.test.ts
import pWaitFor from "p-wait-for"
import delay from "delay"
import { getEnvironmentDetails } from "../getEnvironmentDetails"
import { EXPERIMENT_IDS, experiments } from "../../../shared/experiments"
import { defaultModeSlug, getFullModeDetails, getModeBySlug, isToolAllowedForMode } from "../../../shared/modes"
import { getApiMetrics } from "../../../shared/getApiMetrics"
import { listFiles } from "../../../services/glob/list-files"
import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry"
import { Terminal } from "../../../integrations/terminal/Terminal"
import { arePathsEqual } from "../../../utils/path"
import { FileContextTracker } from "../../context-tracking/FileContextTracker"
import { ApiHandler } from "../../../api/index"
import { ClineProvider } from "../../webview/ClineProvider"
import { RooIgnoreController } from "../../ignore/RooIgnoreController"
import { formatResponse } from "../../prompts/responses"
import { Cline } from "../../Cline"
jest.mock("vscode", () => ({
window: {
tabGroups: { all: [], onDidChangeTabs: jest.fn() },
visibleTextEditors: [],
},
env: {
language: "en-US",
},
}))
jest.mock("p-wait-for")
jest.mock("delay")
jest.mock("execa", () => ({
execa: jest.fn(),
}))
jest.mock("../../../shared/experiments")
jest.mock("../../../shared/modes")
jest.mock("../../../shared/getApiMetrics")
jest.mock("../../../services/glob/list-files")
jest.mock("../../../integrations/terminal/TerminalRegistry")
jest.mock("../../../integrations/terminal/Terminal")
jest.mock("../../../utils/path")
jest.mock("../../prompts/responses")
describe("getEnvironmentDetails", () => {
const mockCwd = "/test/path"
const mockTaskId = "test-task-id"
type MockTerminal = {
id: string
getLastCommand: jest.Mock
getProcessesWithOutput: jest.Mock
cleanCompletedProcessQueue?: jest.Mock
}
let mockCline: Partial<Cline>
let mockProvider: any
let mockState: any
beforeEach(() => {
jest.clearAllMocks()
mockState = {
terminalOutputLineLimit: 100,
maxWorkspaceFiles: 50,
maxOpenTabsContext: 10,
mode: "code",
customModes: [],
apiModelId: "test-model",
experiments: {},
customInstructions: "test instructions",
language: "en",
showRooIgnoredFiles: true,
}
mockProvider = {
getState: jest.fn().mockResolvedValue(mockState),
}
mockCline = {
cwd: mockCwd,
taskId: mockTaskId,
didEditFile: false,
fileContextTracker: {
getAndClearRecentlyModifiedFiles: jest.fn().mockReturnValue([]),
} as unknown as FileContextTracker,
rooIgnoreController: {
filterPaths: jest.fn((paths: string[]) => paths.join("\n")),
cwd: mockCwd,
ignoreInstance: {},
disposables: [],
rooIgnoreContent: "",
isPathIgnored: jest.fn(),
getIgnoreContent: jest.fn(),
updateIgnoreContent: jest.fn(),
addToIgnore: jest.fn(),
removeFromIgnore: jest.fn(),
dispose: jest.fn(),
} as unknown as RooIgnoreController,
clineMessages: [],
api: {
getModel: jest.fn().mockReturnValue({ info: { contextWindow: 100000 } }),
createMessage: jest.fn(),
countTokens: jest.fn(),
} as unknown as ApiHandler,
diffEnabled: true,
providerRef: {
deref: jest.fn().mockReturnValue(mockProvider),
[Symbol.toStringTag]: "WeakRef",
} as unknown as WeakRef<ClineProvider>,
}
// Mock other dependencies.
;(getApiMetrics as jest.Mock).mockReturnValue({ contextTokens: 50000, totalCost: 0.25 })
;(getFullModeDetails as jest.Mock).mockResolvedValue({
name: "💻 Code",
roleDefinition: "You are a code assistant",
customInstructions: "Custom instructions",
})
;(isToolAllowedForMode as jest.Mock).mockReturnValue(true)
;(listFiles as jest.Mock).mockResolvedValue([["file1.ts", "file2.ts"], false])
;(formatResponse.formatFilesList as jest.Mock).mockReturnValue("file1.ts\nfile2.ts")
;(arePathsEqual as jest.Mock).mockReturnValue(false)
;(Terminal.compressTerminalOutput as jest.Mock).mockImplementation((output: string) => output)
;(TerminalRegistry.getTerminals as jest.Mock).mockReturnValue([])
;(TerminalRegistry.getBackgroundTerminals as jest.Mock).mockReturnValue([])
;(TerminalRegistry.isProcessHot as jest.Mock).mockReturnValue(false)
;(TerminalRegistry.getUnretrievedOutput as jest.Mock).mockReturnValue("")
;(pWaitFor as unknown as jest.Mock).mockResolvedValue(undefined)
;(delay as jest.Mock).mockResolvedValue(undefined)
})
it("should return basic environment details", async () => {
const result = await getEnvironmentDetails(mockCline as Cline)
expect(result).toContain("<environment_details>")
expect(result).toContain("</environment_details>")
expect(result).toContain("# VSCode Visible Files")
expect(result).toContain("# VSCode Open Tabs")
expect(result).toContain("# Current Time")
expect(result).toContain("# Current Context Size (Tokens)")
expect(result).toContain("# Current Cost")
expect(result).toContain("# Current Mode")
expect(mockProvider.getState).toHaveBeenCalled()
expect(getFullModeDetails).toHaveBeenCalledWith("code", [], undefined, {
cwd: mockCwd,
globalCustomInstructions: "test instructions",
language: "en",
})
expect(getApiMetrics).toHaveBeenCalledWith(mockCline.clineMessages)
})
it("should include file details when includeFileDetails is true", async () => {
const result = await getEnvironmentDetails(mockCline as Cline, true)
expect(result).toContain("# Current Workspace Directory")
expect(result).toContain("Files")
expect(listFiles).toHaveBeenCalledWith(mockCwd, true, 50)
expect(formatResponse.formatFilesList).toHaveBeenCalledWith(
mockCwd,
["file1.ts", "file2.ts"],
false,
mockCline.rooIgnoreController,
true,
)
})
it("should not include file details when includeFileDetails is false", async () => {
await getEnvironmentDetails(mockCline as Cline, false)
expect(listFiles).not.toHaveBeenCalled()
expect(formatResponse.formatFilesList).not.toHaveBeenCalled()
})
it("should handle desktop directory specially", async () => {
;(arePathsEqual as jest.Mock).mockReturnValue(true)
const result = await getEnvironmentDetails(mockCline as Cline, true)
expect(result).toContain("Desktop files not shown automatically")
expect(listFiles).not.toHaveBeenCalled()
})
it("should include recently modified files if any", async () => {
;(mockCline.fileContextTracker!.getAndClearRecentlyModifiedFiles as jest.Mock).mockReturnValue([
"modified1.ts",
"modified2.ts",
])
const result = await getEnvironmentDetails(mockCline as Cline)
expect(result).toContain("# Recently Modified Files")
expect(result).toContain("modified1.ts")
expect(result).toContain("modified2.ts")
})
it("should include active terminal information", async () => {
const mockActiveTerminal = {
id: "terminal-1",
getLastCommand: jest.fn().mockReturnValue("npm test"),
getProcessesWithOutput: jest.fn().mockReturnValue([]),
} as MockTerminal
;(TerminalRegistry.getTerminals as jest.Mock).mockReturnValue([mockActiveTerminal])
;(TerminalRegistry.getUnretrievedOutput as jest.Mock).mockReturnValue("Test output")
const result = await getEnvironmentDetails(mockCline as Cline)
expect(result).toContain("# Actively Running Terminals")
expect(result).toContain("Original command: `npm test`")
expect(result).toContain("Test output")
mockCline.didEditFile = true
await getEnvironmentDetails(mockCline as Cline)
expect(delay).toHaveBeenCalledWith(300)
expect(pWaitFor).toHaveBeenCalled()
})
it("should include inactive terminals with output", async () => {
const mockProcess = {
command: "npm build",
getUnretrievedOutput: jest.fn().mockReturnValue("Build output"),
}
const mockInactiveTerminal = {
id: "terminal-2",
getProcessesWithOutput: jest.fn().mockReturnValue([mockProcess]),
cleanCompletedProcessQueue: jest.fn(),
} as MockTerminal
;(TerminalRegistry.getTerminals as jest.Mock).mockImplementation((active: boolean) =>
active ? [] : [mockInactiveTerminal],
)
const result = await getEnvironmentDetails(mockCline as Cline)
expect(result).toContain("# Inactive Terminals with Completed Process Output")
expect(result).toContain("Terminal terminal-2")
expect(result).toContain("Command: `npm build`")
expect(result).toContain("Build output")
expect(mockInactiveTerminal.cleanCompletedProcessQueue).toHaveBeenCalled()
})
it("should include warning when file writing is not allowed", async () => {
;(isToolAllowedForMode as jest.Mock).mockReturnValue(false)
;(getModeBySlug as jest.Mock).mockImplementation((slug: string) => {
if (slug === "code") {
return { name: "💻 Code" }
}
if (slug === defaultModeSlug) {
return { name: "Default Mode" }
}
return null
})
const result = await getEnvironmentDetails(mockCline as Cline)
expect(result).toContain("NOTE: You are currently in '💻 Code' mode, which does not allow write operations")
})
it("should include experiment-specific details when Power Steering is enabled", async () => {
mockState.experiments = { [EXPERIMENT_IDS.POWER_STEERING]: true }
;(experiments.isEnabled as jest.Mock).mockReturnValue(true)
const result = await getEnvironmentDetails(mockCline as Cline)
expect(result).toContain("<role>You are a code assistant</role>")
expect(result).toContain("<custom_instructions>Custom instructions</custom_instructions>")
})
it("should handle missing provider or state", async () => {
// Mock provider to return null.
mockCline.providerRef!.deref = jest.fn().mockReturnValue(null)
const result = await getEnvironmentDetails(mockCline as Cline)
// Verify the function still returns a result.
expect(result).toContain("<environment_details>")
expect(result).toContain("</environment_details>")
// Mock provider to return null state.
mockCline.providerRef!.deref = jest.fn().mockReturnValue({
getState: jest.fn().mockResolvedValue(null),
})
const result2 = await getEnvironmentDetails(mockCline as Cline)
// Verify the function still returns a result.
expect(result2).toContain("<environment_details>")
expect(result2).toContain("</environment_details>")
})
it("should handle errors gracefully", async () => {
;(pWaitFor as unknown as jest.Mock).mockRejectedValue(new Error("Test error"))
const mockErrorTerminal = {
id: "terminal-1",
getLastCommand: jest.fn().mockReturnValue("npm test"),
getProcessesWithOutput: jest.fn().mockReturnValue([]),
} as MockTerminal
;(TerminalRegistry.getTerminals as jest.Mock).mockReturnValue([mockErrorTerminal])
;(TerminalRegistry.getBackgroundTerminals as jest.Mock).mockReturnValue([])
;(mockCline.fileContextTracker!.getAndClearRecentlyModifiedFiles as jest.Mock).mockReturnValue([])
await expect(getEnvironmentDetails(mockCline as Cline)).resolves.not.toThrow()
})
})

View file

@ -0,0 +1,270 @@
import path from "path"
import os from "os"
import * as vscode from "vscode"
import pWaitFor from "p-wait-for"
import delay from "delay"
import { EXPERIMENT_IDS, experiments as Experiments, ExperimentId } from "../../shared/experiments"
import { formatLanguage } from "../../shared/language"
import { defaultModeSlug, getFullModeDetails, getModeBySlug, isToolAllowedForMode } from "../../shared/modes"
import { getApiMetrics } from "../../shared/getApiMetrics"
import { listFiles } from "../../services/glob/list-files"
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
import { Terminal } from "../../integrations/terminal/Terminal"
import { arePathsEqual } from "../../utils/path"
import { formatResponse } from "../prompts/responses"
import { Cline } from "../Cline"
export async function getEnvironmentDetails(cline: Cline, 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 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.
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.
}
// Wait for terminals to cool down.
await pWaitFor(() => busyTerminals.every((t) => !TerminalRegistry.isProcessHot(t.id)), {
interval: 100,
timeout: 5_000,
}).catch(() => {})
}
// 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 = ""
if (busyTerminals.length > 0) {
// Terminals are cool, let's retrieve their output.
terminalDetails += "\n\n# Actively Running Terminals"
for (const busyTerminal of busyTerminals) {
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) {
terminalDetails += `\n## Terminal ${inactiveTerminal.id}`
terminalOutputs.forEach((output) => {
terminalDetails += `\n### New Output\n${output}`
})
}
}
}
// console.log(`[Cline#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 formatter = new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: true,
})
const timeZone = formatter.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\n${formatter.format(now)} (${timeZone}, UTC${timeZoneOffsetStr})`
// Add context tokens information.
const { contextTokens, totalCost } = getApiMetrics(cline.clineMessages)
const modelInfo = cline.api.getModel().info
const contextWindow = modelInfo.contextWindow
const contextPercentage =
contextTokens && contextWindow ? Math.round((contextTokens / contextWindow) * 100) : undefined
details += `\n\n# Current Context Size (Tokens)\n${contextTokens ? `${contextTokens.toLocaleString()} (${contextPercentage}%)` : "(Not available)"}`
details += `\n\n# Current Cost\n${totalCost !== null ? `$${totalCost.toFixed(2)}` : "(Not available)"}`
// Add current mode and any mode-specific warnings.
const {
mode,
customModes,
apiModelId,
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>${apiModelId}</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`
}
}
// Add warning if not in code mode.
if (
!isToolAllowedForMode("write_to_file", currentMode, customModes ?? [], { apply_diff: cline.diffEnabled }) &&
!isToolAllowedForMode("apply_diff", currentMode, customModes ?? [], { apply_diff: cline.diffEnabled })
) {
const currentModeName = getModeBySlug(currentMode, customModes)?.name ?? currentMode
const defaultModeName = getModeBySlug(defaultModeSlug, customModes)?.name ?? defaultModeSlug
details += `\n\nNOTE: You are currently in '${currentModeName}' mode, which does not allow write operations. To write files, the user will need to switch to a mode that supports file writing, such as '${defaultModeName}' mode.`
}
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
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
}
}
return `<environment_details>\n${details.trim()}\n</environment_details>`
}

View file

@ -40,6 +40,7 @@ const DIRS_TO_IGNORE = [
export async function listFiles(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> {
// Handle special directories
const specialResult = await handleSpecialDirectories(dirPath)
if (specialResult) {
return specialResult
}

View file

@ -85,11 +85,13 @@ const ApiOptions = ({
return Object.entries(headers)
})
// Effect to synchronize internal customHeaders state with prop changes
useEffect(() => {
const propHeaders = apiConfiguration?.openAiHeaders || {}
if (JSON.stringify(customHeaders) !== JSON.stringify(Object.entries(propHeaders))) setCustomHeaders(Object.entries(propHeaders))
}, [apiConfiguration?.openAiHeaders])
if (JSON.stringify(customHeaders) !== JSON.stringify(Object.entries(propHeaders))) {
setCustomHeaders(Object.entries(propHeaders))
}
}, [apiConfiguration?.openAiHeaders, customHeaders])
const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
const [openAiNativeBaseUrlSelected, setOpenAiNativeBaseUrlSelected] = useState(