Compare commits

...

4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 5b7179ac20 Initial plan 2026-03-09 09:15:28 +00:00
Dominic Cooney 7f1017e228 address review: move clineignore check before IO in ListFilesToolHandler
Move the .clineignore access validation before resolveWorkspacePath and
listFiles so blocked paths are rejected without incurring IO cost.
Also ensures consecutiveMistakeCount is only reset after all
validations and the core operation succeed.
2026-03-09 18:14:02 +09: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 531 additions and 34 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,23 +71,7 @@ export class ListFilesToolHandler implements IFullyManagedTool {
return await config.callbacks.sayAndCreateMissingParamError(this.name, "path")
}
config.taskState.consecutiveMistakeCount = 0
// 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
// 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
resolvedToNonPrimary: !arePathsEqual(absolutePath, fallbackAbsolutePath),
resolutionMethod: (typeof pathResult !== "string" ? "hint" : "primary_fallback") as "hint" | "primary_fallback",
}
// Check clineignore access
// Check clineignore access before performing any IO
const accessValidation = this.validator.checkClineIgnorePath(relDirPath!)
if (!accessValidation.ok) {
if (!config.isSubagentExecution) {
@@ -96,8 +80,39 @@ 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)
// 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}`)
}
// Only reset after all validations and the core operation succeed 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,
resolvedToNonPrimary: !arePathsEqual(absolutePath, fallbackAbsolutePath),
resolutionMethod: (usedWorkspaceHint ? "hint" : "primary_fallback") as "hint" | "primary_fallback",
}
const result = formatResponse.formatFilesList(absolutePath, files, didHitLimit, config.services.clineIgnoreController)
@@ -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)
})
})