Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot] 265061122f Initial plan 2026-03-09 09:08:50 +00:00
Dominic Cooney b9cbc41c57 address review: expand try/catch scope, add stub-based tests
- Include resolveWorkspacePath inside try/catch in
  ListCodeDefinitionNamesToolHandler and ListFilesToolHandler (matching
  SearchFilesToolHandler's pattern) so path resolution failures are
  also caught gracefully.
- Fix trivially-true assertion in file-not-a-directory test.
- Add 6 new stub-based tests that force core operations to throw:
  parseSourceCodeForDefinitionsTopLevel, listFiles, and
  determineSearchPaths — verifying the catch paths return
  formatResponse.toolError() and increment consecutiveMistakeCount.
- Total: 19 passing tests (up from 13).
2026-03-09 18:05:15 +09:00
Dominic Cooney 8228a75f14 fix: catch errors in path-based tool handlers instead of crashing
ListCodeDefinitionNamesToolHandler, ListFilesToolHandler, and
SearchFilesToolHandler let exceptions from their core operations
propagate through ToolExecutor's re-throw path, crashing the CLI
process. This is the same class of bug fixed for ReadFileToolHandler
in #9730.

Changes per handler:
- Wrap the core operation in try/catch, returning formatResponse.toolError()
  on failure so the model can see the error and recover gracefully.
- Move consecutiveMistakeCount reset to after a successful operation so
  repeated failures accumulate toward the yolo-mode mistake limit.
- Increment consecutiveMistakeCount on caught errors.

Add end-to-end tests exercising each handler with a mock TaskConfig,
covering: non-existent paths, missing parameters, failure accumulation,
and success-based counter reset.
2026-03-09 17:55:47 +09:00
4 changed files with 522 additions and 25 deletions
@@ -65,16 +65,28 @@ export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
return await config.callbacks.sayAndCreateMissingParamError(this.name, "path")
}
// Resolve the path and execute the parse operation inside a single
// try/catch so that failures in either step (e.g. bad workspace hint,
// non-existent directory) return a graceful tool error instead of
// crashing the task.
let absolutePath: string
let displayPath: string
let result: string
try {
const pathResult = resolveWorkspacePath(config, relDirPath!, "ListCodeDefinitionNamesToolHandler.execute")
;({ absolutePath, displayPath } =
typeof pathResult === "string" ? { absolutePath: pathResult, displayPath: relDirPath! } : pathResult)
result = await parseSourceCodeForDefinitionsTopLevel(absolutePath, config.services.clineIgnoreController)
} catch (error) {
config.taskState.consecutiveMistakeCount++
const errorMessage = error instanceof Error ? error.message : String(error)
return formatResponse.toolError(`Error listing code definitions: ${errorMessage}`)
}
// Only reset after a successful operation so repeated failures
// accumulate toward the yolo-mode mistake limit.
config.taskState.consecutiveMistakeCount = 0
// Resolve the absolute path based on multi-workspace configuration
const pathResult = resolveWorkspacePath(config, relDirPath!, "ListCodeDefinitionNamesToolHandler.execute")
const { absolutePath, displayPath } =
typeof pathResult === "string" ? { absolutePath: pathResult, displayPath: relDirPath! } : pathResult
// Execute the actual parse source code operation
const result = await parseSourceCodeForDefinitionsTopLevel(absolutePath, config.services.clineIgnoreController)
// Handle approval flow
const sharedMessageProps = {
tool: "listCodeDefinitionNames",
@@ -71,20 +71,38 @@ export class ListFilesToolHandler implements IFullyManagedTool {
return await config.callbacks.sayAndCreateMissingParamError(this.name, "path")
}
config.taskState.consecutiveMistakeCount = 0
// Resolve the path and execute the list operation inside a single
// try/catch so that failures in either step (e.g. bad workspace hint,
// non-existent directory) return a graceful tool error instead of
// crashing the task.
let absolutePath: string
let displayPath: string
let files: string[]
let didHitLimit: boolean
let usedWorkspaceHint: boolean
try {
const pathResult = resolveWorkspacePath(config, relDirPath!, "ListFilesToolHandler.execute")
;({ absolutePath, displayPath } =
typeof pathResult === "string" ? { absolutePath: pathResult, displayPath: relDirPath! } : pathResult)
usedWorkspaceHint = typeof pathResult !== "string"
;[files, didHitLimit] = await listFiles(absolutePath, recursive, 200)
} catch (error) {
config.taskState.consecutiveMistakeCount++
const errorMessage = error instanceof Error ? error.message : String(error)
return formatResponse.toolError(`Error listing files: ${errorMessage}`)
}
// Resolve the absolute path based on multi-workspace configuration
const pathResult = resolveWorkspacePath(config, relDirPath!, "ListFilesToolHandler.execute")
const { absolutePath, displayPath } =
typeof pathResult === "string" ? { absolutePath: pathResult, displayPath: relDirPath! } : pathResult
// Only reset after a successful operation so repeated failures
// accumulate toward the yolo-mode mistake limit.
config.taskState.consecutiveMistakeCount = 0
// Determine workspace context for telemetry
const fallbackAbsolutePath = path.resolve(config.cwd, relDirPath ?? "")
const workspaceContext = {
isMultiRootEnabled: config.isMultiRootEnabled || false,
usedWorkspaceHint: typeof pathResult !== "string", // multi-root path result indicates hint usage
usedWorkspaceHint,
resolvedToNonPrimary: !arePathsEqual(absolutePath, fallbackAbsolutePath),
resolutionMethod: (typeof pathResult !== "string" ? "hint" : "primary_fallback") as "hint" | "primary_fallback",
resolutionMethod: (usedWorkspaceHint ? "hint" : "primary_fallback") as "hint" | "primary_fallback",
}
// Check clineignore access
@@ -96,9 +114,6 @@ export class ListFilesToolHandler implements IFullyManagedTool {
return formatResponse.toolError(formatResponse.clineIgnoreError(relDirPath!))
}
// Execute the actual list files operation
const [files, didHitLimit] = await listFiles(absolutePath, recursive, 200)
const result = formatResponse.formatFilesList(absolutePath, files, didHitLimit, config.services.clineIgnoreController)
// Handle approval flow
@@ -224,13 +224,22 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
return await config.callbacks.sayAndCreateMissingParamError(this.name, "regex")
}
config.taskState.consecutiveMistakeCount = 0
// Parse workspace hint from the path
const { workspaceHint, relPath: parsedPath } = parseWorkspaceInlinePath(relDirPath!)
// Determine which paths to search
const searchPaths = this.determineSearchPaths(config, parsedPath, workspaceHint, relDirPath!)
// Parse workspace hint from the path and determine search targets.
// These can throw if the workspace configuration is invalid or the
// path cannot be resolved, so catch and return a graceful tool error.
let parsedPath: string
let workspaceHint: string | undefined
let searchPaths: ReturnType<SearchFilesToolHandler["determineSearchPaths"]>
try {
const parsed = parseWorkspaceInlinePath(relDirPath!)
parsedPath = parsed.relPath
workspaceHint = parsed.workspaceHint
searchPaths = this.determineSearchPaths(config, parsedPath, workspaceHint, relDirPath!)
} catch (error) {
config.taskState.consecutiveMistakeCount++
const errorMessage = error instanceof Error ? error.message : String(error)
return formatResponse.toolError(`Error resolving search path: ${errorMessage}`)
}
// Determine workspace context for telemetry
const primaryWorkspaceRoot = searchPaths[0]?.workspaceRoot
@@ -279,6 +288,10 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
// Format and combine results
const results = this.formatSearchResults(config, searchResults, searchPaths)
// Only reset after a successful operation so repeated failures
// accumulate toward the yolo-mode mistake limit.
config.taskState.consecutiveMistakeCount = 0
// Capture workspace search pattern telemetry
if (config.isMultiRootEnabled && config.workspaceManager) {
const searchType = workspaceHint ? "targeted" : searchPaths.length > 1 ? "cross_workspace" : "primary_only"
@@ -0,0 +1,457 @@
import { strict as assert } from "node:assert"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { ClineDefaultTool } from "@shared/tools"
import * as pathUtils from "@utils/path"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import { TaskState } from "../../../TaskState"
import { ToolValidator } from "../../ToolValidator"
import type { TaskConfig } from "../../types/TaskConfig"
import { ListCodeDefinitionNamesToolHandler } from "../ListCodeDefinitionNamesToolHandler"
import { ListFilesToolHandler } from "../ListFilesToolHandler"
import { SearchFilesToolHandler } from "../SearchFilesToolHandler"
/**
* End-to-end tests for path-based tool handlers' error recovery.
*
* These exercise the actual handlers with a mock TaskConfig (following the
* SubagentToolHandler.test.ts pattern), verifying that:
*
* 1. Non-existent paths return a tool error (not a thrown exception)
* 2. consecutiveMistakeCount increments on failure
* 3. Repeated failures accumulate across calls
* 4. A successful operation resets consecutiveMistakeCount to 0
* 5. Missing parameters increment the counter
* 6. Forced exceptions from core operations are caught gracefully
*/
let tmpDir: string
function createConfig() {
const taskState = new TaskState()
const callbacks = {
say: sinon.stub().resolves(undefined),
ask: sinon.stub().resolves({ response: "yesButtonClicked" }),
saveCheckpoint: sinon.stub().resolves(),
sayAndCreateMissingParamError: sinon.stub().resolves("missing"),
removeLastPartialMessageIfExistsWithType: sinon.stub().resolves(),
shouldAutoApproveToolWithPath: sinon.stub().resolves(true),
postStateToWebview: sinon.stub().resolves(),
cancelTask: sinon.stub().resolves(),
updateTaskHistory: sinon.stub().resolves([]),
switchToActMode: sinon.stub().resolves(false),
setActiveHookExecution: sinon.stub().resolves(),
clearActiveHookExecution: sinon.stub().resolves(),
getActiveHookExecution: sinon.stub().resolves(undefined),
runUserPromptSubmitHook: sinon.stub().resolves({}),
executeCommandTool: sinon.stub().resolves([false, "ok"]),
cancelRunningCommandTool: sinon.stub().resolves(false),
doesLatestTaskCompletionHaveNewChanges: sinon.stub().resolves(false),
updateFCListFromToolResponse: sinon.stub().resolves(),
shouldAutoApproveTool: sinon.stub().returns([true, true]),
reinitExistingTaskFromId: sinon.stub().resolves(),
applyLatestBrowserSettings: sinon.stub().resolves(undefined),
}
const config = {
taskId: "task-1",
ulid: "ulid-1",
cwd: tmpDir,
mode: "act",
strictPlanModeEnabled: false,
yoloModeToggled: true,
doubleCheckCompletionEnabled: false,
vscodeTerminalExecutionMode: "backgroundExec",
enableParallelToolCalling: true,
isSubagentExecution: true, // skip UI calls and approval flow
taskState,
messageState: {},
api: {
getModel: () => ({ id: "test-model", info: { supportsImages: false } }),
},
autoApprovalSettings: {
enableNotifications: false,
actions: { executeSafeCommands: false, executeAllCommands: false },
},
autoApprover: {
shouldAutoApproveTool: sinon.stub().returns([true, true]),
},
browserSettings: {},
focusChainSettings: {},
services: {
stateManager: {
getGlobalStateKey: () => undefined,
getGlobalSettingsKey: (key: string) => {
if (key === "mode") return "act"
if (key === "hooksEnabled") return false
return undefined
},
getApiConfiguration: () => ({
planModeApiProvider: "openai",
actModeApiProvider: "openai",
}),
},
fileContextTracker: {
trackFileContext: sinon.stub().resolves(),
},
mcpHub: {},
browserSession: {},
urlContentFetcher: {},
diffViewProvider: {},
clineIgnoreController: { validateAccess: () => true, filterPaths: (paths: string[]) => paths },
commandPermissionController: {},
contextManager: {},
},
callbacks,
coordinator: { getHandler: sinon.stub() },
} as unknown as TaskConfig
const validator = new ToolValidator({ validateAccess: () => true } as any)
return { config, callbacks, taskState, validator }
}
// ─── ListCodeDefinitionNamesToolHandler ─────────────────────────────────────
describe("ListCodeDefinitionNamesToolHandler.execute error recovery", () => {
let sandbox: sinon.SinonSandbox
beforeEach(async () => {
sandbox = sinon.createSandbox()
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cline-listdef-test-"))
sandbox.stub(pathUtils, "isLocatedInWorkspace").resolves(true)
})
afterEach(async () => {
sandbox.restore()
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
})
function makeBlock(relPath?: string) {
return {
type: "tool_use" as const,
name: ClineDefaultTool.LIST_CODE_DEF,
params: relPath !== undefined ? { path: relPath } : {},
partial: false,
}
}
it("returns a tool result (not a thrown exception) for a non-existent directory", async () => {
const { config, taskState, validator } = createConfig()
const handler = new ListCodeDefinitionNamesToolHandler(validator)
const result = await handler.execute(config, makeBlock("no-such-dir"))
// parseSourceCodeForDefinitionsTopLevel returns a descriptive string for
// non-existent directories rather than throwing.
assert.equal(typeof result, "string")
assert.equal(taskState.consecutiveMistakeCount, 0)
})
it("returns a graceful message for a file path (not a directory)", async () => {
const { config, validator } = createConfig()
const handler = new ListCodeDefinitionNamesToolHandler(validator)
const filePath = "not-a-dir.txt"
await fs.writeFile(path.join(tmpDir, filePath), "content")
const result = await handler.execute(config, makeBlock(filePath))
assert.equal(typeof result, "string")
assert.ok((result as string).includes("file, not a directory"))
})
it("increments consecutiveMistakeCount when path parameter is missing", async () => {
const { config, taskState, validator } = createConfig()
const handler = new ListCodeDefinitionNamesToolHandler(validator)
const result = await handler.execute(config, makeBlock())
assert.equal(result, "missing")
assert.equal(taskState.consecutiveMistakeCount, 1)
})
it("resets consecutiveMistakeCount to 0 after a successful operation", async () => {
const { config, taskState, validator } = createConfig()
const handler = new ListCodeDefinitionNamesToolHandler(validator)
// Accumulate failures via missing params
await handler.execute(config, makeBlock())
await handler.execute(config, makeBlock())
assert.equal(taskState.consecutiveMistakeCount, 2)
// Create a real directory and list definitions (will find none, but succeeds)
const dirName = "real-dir"
await fs.mkdir(path.join(tmpDir, dirName))
await handler.execute(config, makeBlock(dirName))
assert.equal(taskState.consecutiveMistakeCount, 0)
})
it("catches a thrown exception from the core operation and returns a tool error", async () => {
const { config, taskState, validator } = createConfig()
const handler = new ListCodeDefinitionNamesToolHandler(validator)
// Stub parseSourceCodeForDefinitionsTopLevel to throw
const treeSitter = await import("@services/tree-sitter")
sandbox.stub(treeSitter, "parseSourceCodeForDefinitionsTopLevel").rejects(new Error("tree-sitter crashed"))
const result = await handler.execute(config, makeBlock("some-dir"))
assert.equal(typeof result, "string")
assert.ok((result as string).includes("Error"))
assert.ok((result as string).includes("tree-sitter crashed"))
assert.equal(taskState.consecutiveMistakeCount, 1)
})
it("accumulates failures when the core operation throws repeatedly", async () => {
const { config, taskState, validator } = createConfig()
const handler = new ListCodeDefinitionNamesToolHandler(validator)
const treeSitter = await import("@services/tree-sitter")
sandbox.stub(treeSitter, "parseSourceCodeForDefinitionsTopLevel").rejects(new Error("boom"))
await handler.execute(config, makeBlock("dir-1"))
assert.equal(taskState.consecutiveMistakeCount, 1)
await handler.execute(config, makeBlock("dir-2"))
assert.equal(taskState.consecutiveMistakeCount, 2)
await handler.execute(config, makeBlock("dir-3"))
assert.equal(taskState.consecutiveMistakeCount, 3)
})
})
// ─── ListFilesToolHandler ───────────────────────────────────────────────────
describe("ListFilesToolHandler.execute error recovery", () => {
let sandbox: sinon.SinonSandbox
beforeEach(async () => {
sandbox = sinon.createSandbox()
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cline-listfiles-test-"))
sandbox.stub(pathUtils, "isLocatedInWorkspace").resolves(true)
})
afterEach(async () => {
sandbox.restore()
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
})
function makeBlock(relPath?: string, recursive?: string) {
const params: Record<string, string> = {}
if (relPath !== undefined) params.path = relPath
if (recursive !== undefined) params.recursive = recursive
return {
type: "tool_use" as const,
name: ClineDefaultTool.LIST_FILES,
params,
partial: false,
}
}
it("returns a tool result (not a thrown exception) for a non-existent directory", async () => {
const { config, taskState, validator } = createConfig()
const handler = new ListFilesToolHandler(validator)
const result = await handler.execute(config, makeBlock("no-such-dir"))
// listFiles returns empty for non-existent directories, so the handler
// should succeed rather than throw.
assert.equal(typeof result, "string")
assert.equal(taskState.consecutiveMistakeCount, 0)
})
it("increments consecutiveMistakeCount when path parameter is missing", async () => {
const { config, taskState, validator } = createConfig()
const handler = new ListFilesToolHandler(validator)
const result = await handler.execute(config, makeBlock())
assert.equal(result, "missing")
assert.equal(taskState.consecutiveMistakeCount, 1)
})
it("repeated missing-param failures accumulate", async () => {
const { config, taskState, validator } = createConfig()
const handler = new ListFilesToolHandler(validator)
await handler.execute(config, makeBlock())
await handler.execute(config, makeBlock())
await handler.execute(config, makeBlock())
assert.equal(taskState.consecutiveMistakeCount, 3)
})
it("resets consecutiveMistakeCount to 0 after a successful list", async () => {
const { config, taskState, validator } = createConfig()
const handler = new ListFilesToolHandler(validator)
// Accumulate failures
await handler.execute(config, makeBlock())
await handler.execute(config, makeBlock())
assert.equal(taskState.consecutiveMistakeCount, 2)
// Create a real directory with a file
const dirName = "real-dir"
await fs.mkdir(path.join(tmpDir, dirName))
await fs.writeFile(path.join(tmpDir, dirName, "file.txt"), "content")
const result = await handler.execute(config, makeBlock(dirName))
assert.equal(typeof result, "string")
assert.ok((result as string).includes("file.txt"))
assert.equal(taskState.consecutiveMistakeCount, 0)
})
it("catches a thrown exception from listFiles and returns a tool error", async () => {
const { config, taskState, validator } = createConfig()
const handler = new ListFilesToolHandler(validator)
// Stub listFiles to throw
const listFilesModule = await import("@services/glob/list-files")
sandbox.stub(listFilesModule, "listFiles").rejects(new Error("cwd must be a directory"))
const result = await handler.execute(config, makeBlock("some-dir"))
assert.equal(typeof result, "string")
assert.ok((result as string).includes("Error"))
assert.ok((result as string).includes("cwd must be a directory"))
assert.equal(taskState.consecutiveMistakeCount, 1)
})
it("accumulates failures when listFiles throws repeatedly", async () => {
const { config, taskState, validator } = createConfig()
const handler = new ListFilesToolHandler(validator)
const listFilesModule = await import("@services/glob/list-files")
sandbox.stub(listFilesModule, "listFiles").rejects(new Error("boom"))
await handler.execute(config, makeBlock("dir-1"))
assert.equal(taskState.consecutiveMistakeCount, 1)
await handler.execute(config, makeBlock("dir-2"))
assert.equal(taskState.consecutiveMistakeCount, 2)
await handler.execute(config, makeBlock("dir-3"))
assert.equal(taskState.consecutiveMistakeCount, 3)
})
})
// ─── SearchFilesToolHandler ─────────────────────────────────────────────────
describe("SearchFilesToolHandler.execute error recovery", () => {
let sandbox: sinon.SinonSandbox
beforeEach(async () => {
sandbox = sinon.createSandbox()
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cline-search-test-"))
sandbox.stub(pathUtils, "isLocatedInWorkspace").resolves(true)
})
afterEach(async () => {
sandbox.restore()
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
})
function makeBlock(relPath?: string, regex?: string, filePattern?: string) {
const params: Record<string, string> = {}
if (relPath !== undefined) params.path = relPath
if (regex !== undefined) params.regex = regex
if (filePattern !== undefined) params.file_pattern = filePattern
return {
type: "tool_use" as const,
name: ClineDefaultTool.SEARCH,
params,
partial: false,
}
}
it("returns a tool result (not a thrown exception) for a non-existent directory", async () => {
const { config, taskState, validator } = createConfig()
const handler = new SearchFilesToolHandler(validator)
const result = await handler.execute(config, makeBlock("no-such-dir", "pattern"))
// regexSearchFiles is resilient to non-existent directories.
assert.equal(typeof result, "string")
assert.equal(taskState.consecutiveMistakeCount, 0)
})
it("increments consecutiveMistakeCount when path parameter is missing", async () => {
const { config, taskState, validator } = createConfig()
const handler = new SearchFilesToolHandler(validator)
const result = await handler.execute(config, makeBlock(undefined, "pattern"))
assert.equal(result, "missing")
assert.equal(taskState.consecutiveMistakeCount, 1)
})
it("increments consecutiveMistakeCount when regex parameter is missing", async () => {
const { config, taskState, validator } = createConfig()
const handler = new SearchFilesToolHandler(validator)
const result = await handler.execute(config, makeBlock("some-dir"))
assert.equal(result, "missing")
assert.equal(taskState.consecutiveMistakeCount, 1)
})
it("repeated missing-param failures accumulate", async () => {
const { config, taskState, validator } = createConfig()
const handler = new SearchFilesToolHandler(validator)
await handler.execute(config, makeBlock()) // missing path
await handler.execute(config, makeBlock("dir")) // missing regex
await handler.execute(config, makeBlock()) // missing path again
assert.equal(taskState.consecutiveMistakeCount, 3)
})
it("resets consecutiveMistakeCount to 0 after a successful search", async () => {
const { config, taskState, validator } = createConfig()
const handler = new SearchFilesToolHandler(validator)
// Accumulate failures
await handler.execute(config, makeBlock())
await handler.execute(config, makeBlock("dir"))
assert.equal(taskState.consecutiveMistakeCount, 2)
// Search in tmpDir (exists, will find 0 results but should succeed)
const result = await handler.execute(config, makeBlock(".", "nonexistent-pattern-xyz"))
assert.equal(typeof result, "string")
assert.equal(taskState.consecutiveMistakeCount, 0)
})
it("catches a thrown exception from path resolution and returns a tool error", async () => {
const { config, taskState, validator } = createConfig()
const handler = new SearchFilesToolHandler(validator)
// Stub determineSearchPaths to throw (simulating a bad workspace config)
sandbox.stub(handler as any, "determineSearchPaths").throws(new Error("invalid workspace hint"))
const result = await handler.execute(config, makeBlock("some-dir", "pattern"))
assert.equal(typeof result, "string")
assert.ok((result as string).includes("Error"))
assert.ok((result as string).includes("invalid workspace hint"))
assert.equal(taskState.consecutiveMistakeCount, 1)
})
it("accumulates failures when path resolution throws repeatedly", async () => {
const { config, taskState, validator } = createConfig()
const handler = new SearchFilesToolHandler(validator)
sandbox.stub(handler as any, "determineSearchPaths").throws(new Error("boom"))
await handler.execute(config, makeBlock("dir-1", "pat"))
assert.equal(taskState.consecutiveMistakeCount, 1)
await handler.execute(config, makeBlock("dir-2", "pat"))
assert.equal(taskState.consecutiveMistakeCount, 2)
await handler.execute(config, makeBlock("dir-3", "pat"))
assert.equal(taskState.consecutiveMistakeCount, 3)
})
})