Skip to content

Commit f6e4e35

Browse files
authored
Move executeCommand out of Cline and add telemetry for shell integration errors (#2771)
1 parent 0bb5ec1 commit f6e4e35

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+393
-476
lines changed

.changeset/four-trainers-move.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"roo-cline": patch
3+
---
4+
5+
Move executeCommand out of Cline and add telemetry for shell integration errors

src/core/Cline.ts

Lines changed: 15 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import { GlobalFileNames } from "../shared/globalFileNames"
3939
import { defaultModeSlug, getModeBySlug, getFullModeDetails, isToolAllowedForMode } from "../shared/modes"
4040
import { EXPERIMENT_IDS, experiments as Experiments, ExperimentId } from "../shared/experiments"
4141
import { formatLanguage } from "../shared/language"
42-
import { ToolParamName, ToolResponse } from "../shared/tools"
42+
import { ToolParamName, ToolResponse, DiffStrategy } from "../shared/tools"
4343

4444
// services
4545
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
@@ -52,7 +52,6 @@ import { CheckpointServiceOptions, RepoPerTaskCheckpointService } from "../servi
5252
// integrations
5353
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
5454
import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
55-
import { ExitCodeDetails, TerminalProcess } from "../integrations/terminal/TerminalProcess"
5655
import { Terminal } from "../integrations/terminal/Terminal"
5756
import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry"
5857

@@ -92,8 +91,8 @@ import { RooIgnoreController } from "./ignore/RooIgnoreController"
9291
import { type AssistantMessageContent, parseAssistantMessage } from "./assistant-message"
9392
import { truncateConversationIfNeeded } from "./sliding-window"
9493
import { ClineProvider } from "./webview/ClineProvider"
95-
import { DiffStrategy, getDiffStrategy } from "./diff/DiffStrategy"
9694
import { validateToolUse } from "./mode-validator"
95+
import { MultiSearchReplaceDiffStrategy } from "./diff/strategies/multi-search-replace"
9796

9897
type UserContent = Array<Anthropic.Messages.ContentBlockParam>
9998

@@ -247,8 +246,7 @@ export class Cline extends EventEmitter<ClineEvents> {
247246
telemetryService.captureTaskCreated(this.taskId)
248247
}
249248

250-
// Initialize diffStrategy based on current state.
251-
this.updateDiffStrategy(experiments ?? {})
249+
this.diffStrategy = new MultiSearchReplaceDiffStrategy(this.fuzzyMatchThreshold)
252250

253251
onCreated?.(this)
254252

@@ -283,15 +281,6 @@ export class Cline extends EventEmitter<ClineEvents> {
283281
return getWorkspacePath(path.join(os.homedir(), "Desktop"))
284282
}
285283

286-
// Add method to update diffStrategy.
287-
async updateDiffStrategy(experiments: Partial<Record<ExperimentId, boolean>>) {
288-
this.diffStrategy = getDiffStrategy({
289-
model: this.api.getModel().id,
290-
experiments,
291-
fuzzyMatchThreshold: this.fuzzyMatchThreshold,
292-
})
293-
}
294-
295284
// Storing task to disk for history
296285

297286
private async ensureTaskDirectoryExists(): Promise<string> {
@@ -308,9 +297,11 @@ export class Cline extends EventEmitter<ClineEvents> {
308297
private async getSavedApiConversationHistory(): Promise<Anthropic.MessageParam[]> {
309298
const filePath = path.join(await this.ensureTaskDirectoryExists(), GlobalFileNames.apiConversationHistory)
310299
const fileExists = await fileExistsAtPath(filePath)
300+
311301
if (fileExists) {
312302
return JSON.parse(await fs.readFile(filePath, "utf8"))
313303
}
304+
314305
return []
315306
}
316307

@@ -378,7 +369,8 @@ export class Cline extends EventEmitter<ClineEvents> {
378369
const tokenUsage = this.getTokenUsage()
379370
this.emit("taskTokenUsageUpdated", this.taskId, tokenUsage)
380371

381-
const taskMessage = this.clineMessages[0] // first message is always the task say
372+
const taskMessage = this.clineMessages[0] // First message is always the task say
373+
382374
const lastRelevantMessage =
383375
this.clineMessages[
384376
findLastIndex(
@@ -913,11 +905,6 @@ export class Cline extends EventEmitter<ClineEvents> {
913905
}
914906

915907
async abortTask(isAbandoned = false) {
916-
// if (this.abort) {
917-
// console.log(`[subtasks] already aborted task ${this.taskId}.${this.instanceId}`)
918-
// return
919-
// }
920-
921908
console.log(`[subtasks] aborting task ${this.taskId}.${this.instanceId}`)
922909

923910
// Will stop any autonomously running promises.
@@ -951,159 +938,6 @@ export class Cline extends EventEmitter<ClineEvents> {
951938

952939
// Tools
953940

954-
async executeCommandTool(command: string, customCwd?: string): Promise<[boolean, ToolResponse]> {
955-
let workingDir: string
956-
if (!customCwd) {
957-
workingDir = this.cwd
958-
} else if (path.isAbsolute(customCwd)) {
959-
workingDir = customCwd
960-
} else {
961-
workingDir = path.resolve(this.cwd, customCwd)
962-
}
963-
964-
// Check if directory exists
965-
try {
966-
await fs.access(workingDir)
967-
} catch (error) {
968-
return [false, `Working directory '${workingDir}' does not exist.`]
969-
}
970-
971-
const terminalInfo = await TerminalRegistry.getOrCreateTerminal(workingDir, !!customCwd, this.taskId)
972-
973-
// Update the working directory in case the terminal we asked for has
974-
// a different working directory so that the model will know where the
975-
// command actually executed:
976-
workingDir = terminalInfo.getCurrentWorkingDirectory()
977-
978-
const workingDirInfo = workingDir ? ` from '${workingDir.toPosix()}'` : ""
979-
terminalInfo.terminal.show() // weird visual bug when creating new terminals (even manually) where there's an empty space at the top.
980-
let userFeedback: { text?: string; images?: string[] } | undefined
981-
let didContinue = false
982-
let completed = false
983-
let result: string = ""
984-
let exitDetails: ExitCodeDetails | undefined
985-
const { terminalOutputLineLimit = 500 } = (await this.providerRef.deref()?.getState()) ?? {}
986-
987-
const sendCommandOutput = async (line: string, terminalProcess: TerminalProcess): Promise<void> => {
988-
try {
989-
const { response, text, images } = await this.ask("command_output", line)
990-
if (response === "yesButtonClicked") {
991-
// proceed while running
992-
} else {
993-
userFeedback = { text, images }
994-
}
995-
didContinue = true
996-
terminalProcess.continue() // continue past the await
997-
} catch {
998-
// This can only happen if this ask promise was ignored, so ignore this error
999-
}
1000-
}
1001-
1002-
const process = terminalInfo.runCommand(command, {
1003-
onLine: (line, process) => {
1004-
if (!didContinue) {
1005-
sendCommandOutput(Terminal.compressTerminalOutput(line, terminalOutputLineLimit), process)
1006-
} else {
1007-
this.say("command_output", Terminal.compressTerminalOutput(line, terminalOutputLineLimit))
1008-
}
1009-
},
1010-
onCompleted: (output) => {
1011-
result = output ?? ""
1012-
completed = true
1013-
},
1014-
onShellExecutionComplete: (details) => {
1015-
exitDetails = details
1016-
},
1017-
onNoShellIntegration: async (message) => {
1018-
await this.say("shell_integration_warning", message)
1019-
},
1020-
})
1021-
1022-
await process
1023-
1024-
// Wait for a short delay to ensure all messages are sent to the webview
1025-
// This delay allows time for non-awaited promises to be created and
1026-
// for their associated messages to be sent to the webview, maintaining
1027-
// the correct order of messages (although the webview is smart about
1028-
// grouping command_output messages despite any gaps anyways)
1029-
await delay(50)
1030-
1031-
result = Terminal.compressTerminalOutput(result, terminalOutputLineLimit)
1032-
1033-
// keep in case we need it to troubleshoot user issues, but this should be removed in the future
1034-
// if everything looks good:
1035-
console.debug(
1036-
"[execute_command status]",
1037-
JSON.stringify(
1038-
{
1039-
completed,
1040-
userFeedback,
1041-
hasResult: result.length > 0,
1042-
exitDetails,
1043-
terminalId: terminalInfo.id,
1044-
workingDir: workingDirInfo,
1045-
isTerminalBusy: terminalInfo.busy,
1046-
},
1047-
null,
1048-
2,
1049-
),
1050-
)
1051-
1052-
if (userFeedback) {
1053-
await this.say("user_feedback", userFeedback.text, userFeedback.images)
1054-
return [
1055-
true,
1056-
formatResponse.toolResult(
1057-
`Command is still running in terminal ${terminalInfo.id}${workingDirInfo}.${
1058-
result.length > 0 ? `\nHere's the output so far:\n${result}` : ""
1059-
}\n\nThe user provided the following feedback:\n<feedback>\n${userFeedback.text}\n</feedback>`,
1060-
userFeedback.images,
1061-
),
1062-
]
1063-
} else if (completed) {
1064-
let exitStatus: string = ""
1065-
if (exitDetails !== undefined) {
1066-
if (exitDetails.signal) {
1067-
exitStatus = `Process terminated by signal ${exitDetails.signal} (${exitDetails.signalName})`
1068-
if (exitDetails.coreDumpPossible) {
1069-
exitStatus += " - core dump possible"
1070-
}
1071-
} else if (exitDetails.exitCode === undefined) {
1072-
result += "<VSCE exit code is undefined: terminal output and command execution status is unknown.>"
1073-
exitStatus = `Exit code: <undefined, notify user>`
1074-
} else {
1075-
if (exitDetails.exitCode !== 0) {
1076-
exitStatus += "Command execution was not successful, inspect the cause and adjust as needed.\n"
1077-
}
1078-
exitStatus += `Exit code: ${exitDetails.exitCode}`
1079-
}
1080-
} else {
1081-
result += "<VSCE exitDetails == undefined: terminal output and command execution status is unknown.>"
1082-
exitStatus = `Exit code: <undefined, notify user>`
1083-
}
1084-
1085-
let workingDirInfo: string = workingDir ? ` within working directory '${workingDir.toPosix()}'` : ""
1086-
const newWorkingDir = terminalInfo.getCurrentWorkingDirectory()
1087-
1088-
if (newWorkingDir !== workingDir) {
1089-
workingDirInfo += `\nNOTICE: Your command changed the working directory for this terminal to '${newWorkingDir.toPosix()}' so you MUST adjust future commands accordingly because they will be executed in this directory`
1090-
}
1091-
1092-
const outputInfo = `\nOutput:\n${result}`
1093-
return [
1094-
false,
1095-
`Command executed in terminal ${terminalInfo.id}${workingDirInfo}. ${exitStatus}${outputInfo}`,
1096-
]
1097-
} else {
1098-
return [
1099-
false,
1100-
`Command is still running in terminal ${terminalInfo.id}${workingDirInfo}.${
1101-
result.length > 0 ? `\nHere's the output so far:\n${result}` : ""
1102-
}\n\nYou will be updated on the terminal status and new output in the future.`,
1103-
]
1104-
}
1105-
}
1106-
1107941
async *attemptApiRequest(previousApiReqIndex: number, retryAttempt: number = 0): ApiStream {
1108942
let mcpHub: McpHub | undefined
1109943

@@ -1566,6 +1400,7 @@ export class Cline extends EventEmitter<ClineEvents> {
15661400
}
15671401

15681402
if (!block.partial) {
1403+
this.recordToolUsage(block.name)
15691404
telemetryService.captureToolUsage(this.taskId, block.name)
15701405
}
15711406

@@ -2699,16 +2534,19 @@ export class Cline extends EventEmitter<ClineEvents> {
26992534
return getApiMetrics(combineApiRequests(combineCommandSequences(this.clineMessages.slice(1))))
27002535
}
27012536

2702-
public recordToolUsage({ toolName, success = true }: { toolName: ToolName; success?: boolean }) {
2537+
public recordToolUsage(toolName: ToolName) {
27032538
if (!this.toolUsage[toolName]) {
27042539
this.toolUsage[toolName] = { attempts: 0, failures: 0 }
27052540
}
27062541

27072542
this.toolUsage[toolName].attempts++
2708-
2709-
if (!success) {
2710-
this.toolUsage[toolName].failures++
2543+
}
2544+
public recordToolError(toolName: ToolName) {
2545+
if (!this.toolUsage[toolName]) {
2546+
this.toolUsage[toolName] = { attempts: 0, failures: 0 }
27112547
}
2548+
2549+
this.toolUsage[toolName].failures++
27122550
}
27132551

27142552
public getToolUsage() {

src/core/__tests__/Cline.test.ts

Lines changed: 6 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ jest.mock("../ignore/RooIgnoreController")
1717

1818
// Mock storagePathManager to prevent dynamic import issues
1919
jest.mock("../../shared/storagePathManager", () => ({
20-
getTaskDirectoryPath: jest.fn().mockImplementation((globalStoragePath, taskId) => {
21-
return Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)
22-
}),
23-
getSettingsDirectoryPath: jest.fn().mockImplementation((globalStoragePath) => {
24-
return Promise.resolve(`${globalStoragePath}/settings`)
25-
}),
20+
getTaskDirectoryPath: jest
21+
.fn()
22+
.mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)),
23+
getSettingsDirectoryPath: jest
24+
.fn()
25+
.mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)),
2626
}))
2727

2828
// Mock fileExistsAtPath
@@ -298,50 +298,6 @@ describe("Cline", () => {
298298
expect(cline.diffStrategy).toBeDefined()
299299
})
300300

301-
it("should use provided fuzzy match threshold", async () => {
302-
const getDiffStrategySpy = jest.spyOn(require("../diff/DiffStrategy"), "getDiffStrategy")
303-
304-
const cline = new Cline({
305-
provider: mockProvider,
306-
apiConfiguration: mockApiConfig,
307-
customInstructions: "custom instructions",
308-
enableDiff: true,
309-
fuzzyMatchThreshold: 0.9,
310-
task: "test task",
311-
startTask: false,
312-
})
313-
314-
expect(cline.diffEnabled).toBe(true)
315-
expect(cline.diffStrategy).toBeDefined()
316-
317-
expect(getDiffStrategySpy).toHaveBeenCalledWith({
318-
model: "claude-3-5-sonnet-20241022",
319-
experiments: {},
320-
fuzzyMatchThreshold: 0.9,
321-
})
322-
})
323-
324-
it("should pass default threshold to diff strategy when not provided", async () => {
325-
const getDiffStrategySpy = jest.spyOn(require("../diff/DiffStrategy"), "getDiffStrategy")
326-
327-
const cline = new Cline({
328-
provider: mockProvider,
329-
apiConfiguration: mockApiConfig,
330-
customInstructions: "custom instructions",
331-
enableDiff: true,
332-
task: "test task",
333-
startTask: false,
334-
})
335-
336-
expect(cline.diffEnabled).toBe(true)
337-
expect(cline.diffStrategy).toBeDefined()
338-
expect(getDiffStrategySpy).toHaveBeenCalledWith({
339-
model: "claude-3-5-sonnet-20241022",
340-
experiments: {},
341-
fuzzyMatchThreshold: 1.0,
342-
})
343-
})
344-
345301
it("should require either task or historyItem", () => {
346302
expect(() => {
347303
new Cline({ provider: mockProvider, apiConfiguration: mockApiConfig })

src/core/__tests__/read-file-maxReadFileLine.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ describe("read_file tool with maxReadFileLine setting", () => {
127127
mockCline.getFileContextTracker = jest.fn().mockReturnValue({
128128
trackFileContext: jest.fn().mockResolvedValue(undefined),
129129
})
130-
mockCline.recordToolUsage = jest.fn().mockReturnValue({} as ToolUsage)
131-
130+
mockCline.recordToolUsage = jest.fn().mockReturnValue(undefined)
131+
mockCline.recordToolError = jest.fn().mockReturnValue(undefined)
132132
// Reset tool result
133133
toolResult = undefined
134134
})

src/core/__tests__/read-file-xml.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ describe("read_file tool XML output structure", () => {
121121
mockCline.getFileContextTracker = jest.fn().mockReturnValue({
122122
trackFileContext: jest.fn().mockResolvedValue(undefined),
123123
})
124-
mockCline.recordToolUsage = jest.fn().mockReturnValue({} as ToolUsage)
124+
mockCline.recordToolUsage = jest.fn().mockReturnValue(undefined)
125+
mockCline.recordToolError = jest.fn().mockReturnValue(undefined)
125126

126127
// Reset tool result
127128
toolResult = undefined

src/core/diff/DiffStrategy.ts

Lines changed: 0 additions & 22 deletions
This file was deleted.

0 commit comments

Comments
 (0)