From 2f01eee89ec223780c29e5f7f5438755daa98493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:39:29 +0200 Subject: [PATCH] fix(tools): coerce stringified array params in tool calls Native tool calls deliver batch params (read_file/list_files `paths`, execute_command `commands`, get_function/get_file_skeleton/search_files/ diagnostics_scan/rename_symbol/find_symbol_references `paths`/`symbols`) as real arrays. Intermittently an array argument arrives as its JSON-stringified form, e.g. the string '["README.md"]'. Handlers then fell into the "not an array -> wrap as [value]" branch and treated the literal bracketed text as a single path/command, causing ENOENT on a bracketed path and no-op or failing shell commands, which spun the agent into a retry loop. Add coerceToStringArray() / coerceFirstStringArray() and route every string-array batch param through them: JSON-stringified arrays are parsed back into real arrays, plain strings are wrapped, nullish/empty values yield []. coerceFirstStringArray preserves the singular fallback (path/symbol) where handlers support it. Object-array params (replace_symbol `replacements`, edit_file `files`) are intentionally left untouched. Covers 9 handlers. 12 unit tests for the helpers; verified end-to-end via isaac (list_files + read_file now receive real arrays, no retry loop). --- .../handlers/DiagnosticsScanToolHandler.ts | 7 +- .../handlers/ExecuteCommandToolHandler.ts | 37 ++++------ .../FindSymbolReferencesToolHandler.ts | 13 ++-- .../handlers/GetFileSkeletonToolHandler.ts | 7 +- .../tools/handlers/GetFunctionToolHandler.ts | 13 ++-- .../tools/handlers/ListFilesToolHandler.ts | 19 ++---- .../tools/handlers/ReadFileToolHandler.ts | 11 ++- .../tools/handlers/RenameSymbolToolHandler.ts | 19 ++---- .../tools/handlers/SearchFilesToolHandler.ts | 11 +-- .../tools/utils/__tests__/coerceArray.test.ts | 67 +++++++++++++++++++ src/core/task/tools/utils/coerceArray.ts | 51 ++++++++++++++ 11 files changed, 168 insertions(+), 87 deletions(-) create mode 100644 src/core/task/tools/utils/__tests__/coerceArray.test.ts create mode 100644 src/core/task/tools/utils/coerceArray.ts diff --git a/src/core/task/tools/handlers/DiagnosticsScanToolHandler.ts b/src/core/task/tools/handlers/DiagnosticsScanToolHandler.ts index f09d0009..d56b5115 100644 --- a/src/core/task/tools/handlers/DiagnosticsScanToolHandler.ts +++ b/src/core/task/tools/handlers/DiagnosticsScanToolHandler.ts @@ -11,6 +11,7 @@ import { telemetryService } from "@/services/telemetry" import { ToolResponse } from "../../index" import { IFullyManagedTool } from "../ToolExecutorCoordinator" import { ToolValidator } from "../ToolValidator" +import { coerceToStringArray } from "../utils/coerceArray" import { TaskConfig } from "../types/TaskConfig" import { StronglyTypedUIHelpers } from "../types/UIHelpers" @@ -23,13 +24,13 @@ export class DiagnosticsScanToolHandler implements IFullyManagedTool { constructor(private validator: ToolValidator) {} getDescription(block: ToolUse): string { - const relPaths = Array.isArray(block.params.paths) ? block.params.paths : (block.params.paths ? [block.params.paths as string] : []) + const relPaths = coerceToStringArray(block.params.paths) const pathsText = relPaths.length > 0 ? ` for ${relPaths.map((p) => `'${p}'`).join(", ")}` : "" return `[${block.name}${pathsText}]` } async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise { - const relPaths = Array.isArray(block.params.paths) ? block.params.paths : (block.params.paths ? [block.params.paths as string] : []) + const relPaths = coerceToStringArray(block.params.paths) const config = uiHelpers.getConfig() const message = JSON.stringify({ @@ -55,7 +56,7 @@ export class DiagnosticsScanToolHandler implements IFullyManagedTool { return await config.callbacks.sayAndCreateMissingParamError(this.name, "paths") } - const relPaths = Array.isArray(block.params.paths) ? block.params.paths : (block.params.paths ? [block.params.paths as string] : []) + const relPaths = coerceToStringArray(block.params.paths) if (relPaths.length === 0) { config.taskState.consecutiveMistakeCount++ return await config.callbacks.sayAndCreateMissingParamError(this.name, "paths") diff --git a/src/core/task/tools/handlers/ExecuteCommandToolHandler.ts b/src/core/task/tools/handlers/ExecuteCommandToolHandler.ts index 9fa90e6c..59ad5bd7 100644 --- a/src/core/task/tools/handlers/ExecuteCommandToolHandler.ts +++ b/src/core/task/tools/handlers/ExecuteCommandToolHandler.ts @@ -17,6 +17,7 @@ import type { IFullyManagedTool } from "../ToolExecutorCoordinator" import type { ToolValidator } from "../ToolValidator" import type { TaskConfig } from "../types/TaskConfig" import type { StronglyTypedUIHelpers } from "../types/UIHelpers" +import { coerceToStringArray } from "../utils/coerceArray" import { isSafeCommand } from "../utils/CommandSafetyChecker" import { applyModelContentFixes } from "../utils/ModelContentProcessor" import { ToolResultUtils } from "../utils/ToolResultUtils" @@ -73,11 +74,7 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool { constructor(private validator: ToolValidator) {} getDescription(block: ToolUse): string { - const commands = Array.isArray(block.params.commands) - ? block.params.commands - : block.params.commands - ? [block.params.commands as string] - : [] + const commands = coerceToStringArray(block.params.commands) const script = block.params.script as string | undefined const language = block.params.language as string | undefined @@ -99,23 +96,17 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool { return } - const rawCommands = (block.params.commands as any) || [] + const rawCommands = coerceToStringArray(block.params.commands) const script = block.params.script as string | undefined const language = (block.params.language as string | undefined) || "bash" const commandsToProcess: { command: string; displayName?: string }[] = [] - if (Array.isArray(rawCommands)) { - rawCommands.forEach((cmd: string) => { - if (cmd) { - commandsToProcess.push({ - command: uiHelpers.removeClosingTag(block, "commands", cmd), - }) - } - }) - } else if (typeof rawCommands === "string" && rawCommands.trim() !== "") { - commandsToProcess.push({ - command: uiHelpers.removeClosingTag(block, "commands", rawCommands), - }) + for (const cmd of rawCommands) { + if (cmd) { + commandsToProcess.push({ + command: uiHelpers.removeClosingTag(block, "commands", cmd), + }) + } } if (script) { @@ -158,7 +149,7 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool { } async execute(config: TaskConfig, block: ToolUse): Promise { - const rawCommands = (block.params.commands as any) || [] + const rawCommands = coerceToStringArray(block.params.commands) const script = block.params.script as string | undefined const language = (block.params.language as string | undefined) || "bash" @@ -187,10 +178,10 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool { // Normalize to a list of commands const commandsToProcess: { command: string; displayName?: string }[] = [] - if (Array.isArray(rawCommands) && rawCommands.length > 0) { - rawCommands.forEach((cmd: string) => commandsToProcess.push({ command: cmd })) - } else if (typeof rawCommands === "string" && rawCommands.trim() !== "") { - commandsToProcess.push({ command: rawCommands }) + for (const cmd of rawCommands) { + if (cmd) { + commandsToProcess.push({ command: cmd }) + } } if (script) { diff --git a/src/core/task/tools/handlers/FindSymbolReferencesToolHandler.ts b/src/core/task/tools/handlers/FindSymbolReferencesToolHandler.ts index 8fa4049b..6f876f4e 100644 --- a/src/core/task/tools/handlers/FindSymbolReferencesToolHandler.ts +++ b/src/core/task/tools/handlers/FindSymbolReferencesToolHandler.ts @@ -16,6 +16,7 @@ import type { ToolValidator } from "../ToolValidator" import type { TaskConfig } from "../types/TaskConfig" import type { StronglyTypedUIHelpers } from "../types/UIHelpers" import { ToolResultUtils } from "../utils/ToolResultUtils" +import { coerceFirstStringArray } from "../utils/coerceArray" export class FindSymbolReferencesToolHandler implements IFullyManagedTool { readonly name = DiracDefaultTool.FIND_SYMBOL_REFERENCES @@ -23,15 +24,15 @@ export class FindSymbolReferencesToolHandler implements IFullyManagedTool { constructor(private validator: ToolValidator) {} getDescription(block: ToolUse): string { - const relPaths = Array.isArray(block.params.paths) ? block.params.paths : (block.params.paths ? [block.params.paths as string] : (block.params.path ? [block.params.path as string] : [])) - const symbols = Array.isArray(block.params.symbols) ? block.params.symbols : (block.params.symbols ? [block.params.symbols as string] : (block.params.symbol ? [block.params.symbol as string] : [])) + const relPaths = coerceFirstStringArray(block.params.paths, block.params.path) + const symbols = coerceFirstStringArray(block.params.symbols, block.params.symbol) const findType = (block.params.find_type as string) || "both" return `[${block.name} for ${symbols.map((s) => `'${s}'`).join(", ")} in ${relPaths.map((p) => `'${p}'`).join(", ")}${findType !== "both" ? ` (type: ${findType})` : ""}]` } async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise { - const relPaths = Array.isArray(block.params.paths) ? block.params.paths : (block.params.paths ? [block.params.paths as string] : (block.params.path ? [block.params.path as string] : [])) - const symbols = Array.isArray(block.params.symbols) ? block.params.symbols : (block.params.symbols ? [block.params.symbols as string] : (block.params.symbol ? [block.params.symbol as string] : [])) + const relPaths = coerceFirstStringArray(block.params.paths, block.params.path) + const symbols = coerceFirstStringArray(block.params.symbols, block.params.symbol) const config = uiHelpers.getConfig() if (config.isSubagentExecution) { @@ -60,8 +61,8 @@ export class FindSymbolReferencesToolHandler implements IFullyManagedTool { } async execute(config: TaskConfig, block: ToolUse): Promise { - const relPaths = Array.isArray(block.params.paths) ? block.params.paths : (block.params.paths ? [block.params.paths as string] : (block.params.path ? [block.params.path as string] : [])) - const symbols = Array.isArray(block.params.symbols) ? block.params.symbols : (block.params.symbols ? [block.params.symbols as string] : (block.params.symbol ? [block.params.symbol as string] : [])) + const relPaths = coerceFirstStringArray(block.params.paths, block.params.path) + const symbols = coerceFirstStringArray(block.params.symbols, block.params.symbol) const findType = (block.params.find_type as "definition" | "reference" | "both") || "both" const apiConfig = config.services.stateManager.getApiConfiguration() diff --git a/src/core/task/tools/handlers/GetFileSkeletonToolHandler.ts b/src/core/task/tools/handlers/GetFileSkeletonToolHandler.ts index 64446f60..9d360268 100644 --- a/src/core/task/tools/handlers/GetFileSkeletonToolHandler.ts +++ b/src/core/task/tools/handlers/GetFileSkeletonToolHandler.ts @@ -13,6 +13,7 @@ import type { ToolValidator } from "../ToolValidator" import type { TaskConfig } from "../types/TaskConfig" import type { StronglyTypedUIHelpers } from "../types/UIHelpers" import { ToolResultUtils } from "../utils/ToolResultUtils" +import { coerceFirstStringArray } from "../utils/coerceArray" export class GetFileSkeletonToolHandler implements IFullyManagedTool { readonly name = DiracDefaultTool.GET_FILE_SKELETON @@ -20,12 +21,12 @@ export class GetFileSkeletonToolHandler implements IFullyManagedTool { constructor(private validator: ToolValidator) {} getDescription(block: ToolUse): string { - const relPaths = Array.isArray(block.params.paths) ? block.params.paths : (block.params.paths ? [block.params.paths as string] : (block.params.path ? [block.params.path as string] : [])) + const relPaths = coerceFirstStringArray(block.params.paths, block.params.path) return `[${block.name} for ${relPaths.map((p) => `'${p}'`).join(", ")}]` } async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise { - const relPaths = Array.isArray(block.params.paths) ? block.params.paths : (block.params.paths ? [block.params.paths as string] : (block.params.path ? [block.params.path as string] : [])) + const relPaths = coerceFirstStringArray(block.params.paths, block.params.path) const config = uiHelpers.getConfig() if (config.isSubagentExecution) { return @@ -50,7 +51,7 @@ export class GetFileSkeletonToolHandler implements IFullyManagedTool { } async execute(config: TaskConfig, block: ToolUse): Promise { - const relPaths = Array.isArray(block.params.paths) ? block.params.paths : (block.params.paths ? [block.params.paths as string] : (block.params.path ? [block.params.path as string] : [])) + const relPaths = coerceFirstStringArray(block.params.paths, block.params.path) const apiConfig = config.services.stateManager.getApiConfiguration() const currentMode = config.services.stateManager.getGlobalSettingsKey("mode") const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string diff --git a/src/core/task/tools/handlers/GetFunctionToolHandler.ts b/src/core/task/tools/handlers/GetFunctionToolHandler.ts index 6ec6a304..8ad69679 100644 --- a/src/core/task/tools/handlers/GetFunctionToolHandler.ts +++ b/src/core/task/tools/handlers/GetFunctionToolHandler.ts @@ -13,6 +13,7 @@ import type { ToolValidator } from "../ToolValidator" import type { TaskConfig } from "../types/TaskConfig" import type { StronglyTypedUIHelpers } from "../types/UIHelpers" import { ToolResultUtils } from "../utils/ToolResultUtils" +import { coerceToStringArray, coerceFirstStringArray } from "../utils/coerceArray" export class GetFunctionToolHandler implements IFullyManagedTool { readonly name = DiracDefaultTool.GET_FUNCTION @@ -20,13 +21,13 @@ export class GetFunctionToolHandler implements IFullyManagedTool { constructor(private validator: ToolValidator) {} getDescription(block: ToolUse): string { - const functionNames = Array.isArray(block.params.function_names) ? block.params.function_names : (block.params.function_names ? [block.params.function_names as string] : []) - const relPaths = Array.isArray(block.params.paths) ? block.params.paths : (block.params.paths ? [block.params.paths as string] : (block.params.path ? [block.params.path as string] : [])) + const functionNames = coerceToStringArray(block.params.function_names) + const relPaths = coerceFirstStringArray(block.params.paths, block.params.path) return `[${block.name} for '${functionNames.join(", ")}' in ${relPaths.map((p) => `'${p}'`).join(", ")}]` } async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise { - const relPaths = Array.isArray(block.params.paths) ? block.params.paths : (block.params.paths ? [block.params.paths as string] : (block.params.path ? [block.params.path as string] : [])) - const functionNames = Array.isArray(block.params.function_names) ? block.params.function_names : (block.params.function_names ? [block.params.function_names as string] : []) + const relPaths = coerceFirstStringArray(block.params.paths, block.params.path) + const functionNames = coerceToStringArray(block.params.function_names) const config = uiHelpers.getConfig() if (config.isSubagentExecution) { @@ -119,8 +120,8 @@ export class GetFunctionToolHandler implements IFullyManagedTool { async execute(config: TaskConfig, block: ToolUse): Promise { - const relPaths = Array.isArray(block.params.paths) ? block.params.paths : (block.params.paths ? [block.params.paths as string] : (block.params.path ? [block.params.path as string] : [])) - const functionNames = Array.isArray(block.params.function_names) ? block.params.function_names : (block.params.function_names ? [block.params.function_names as string] : []) + const relPaths = coerceFirstStringArray(block.params.paths, block.params.path) + const functionNames = coerceToStringArray(block.params.function_names) const apiConfig = config.services.stateManager.getApiConfiguration() const currentMode = config.services.stateManager.getGlobalSettingsKey("mode") diff --git a/src/core/task/tools/handlers/ListFilesToolHandler.ts b/src/core/task/tools/handlers/ListFilesToolHandler.ts index 5aa23e27..aeb579a3 100644 --- a/src/core/task/tools/handlers/ListFilesToolHandler.ts +++ b/src/core/task/tools/handlers/ListFilesToolHandler.ts @@ -15,6 +15,7 @@ import type { IFullyManagedTool } from "../ToolExecutorCoordinator" import type { ToolValidator } from "../ToolValidator" import type { TaskConfig } from "../types/TaskConfig" import type { StronglyTypedUIHelpers } from "../types/UIHelpers" +import { coerceToStringArray } from "../utils/coerceArray" import { ToolResultUtils } from "../utils/ToolResultUtils" // Sprint 2 — task E: async-by-default list_files when recursive=true. @@ -39,20 +40,12 @@ export class ListFilesToolHandler implements IFullyManagedTool { constructor(private validator: ToolValidator) {} getDescription(block: ToolUse): string { - const relPaths = Array.isArray(block.params.paths) - ? block.params.paths - : block.params.paths - ? [block.params.paths as string] - : [] + const relPaths = coerceToStringArray(block.params.paths) return `[${block.name} for ${relPaths.map((p) => `'${p}'`).join(", ")}]` } async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise { - const relPaths = Array.isArray(block.params.paths) - ? block.params.paths - : block.params.paths - ? [block.params.paths as string] - : [] + const relPaths = coerceToStringArray(block.params.paths) // Get config access for services const config = uiHelpers.getConfig() @@ -177,11 +170,7 @@ export class ListFilesToolHandler implements IFullyManagedTool { } async execute(config: TaskConfig, block: ToolUse): Promise { - const relPaths = Array.isArray(block.params.paths) - ? block.params.paths - : block.params.paths - ? [block.params.paths as string] - : [] + const relPaths = coerceToStringArray(block.params.paths) const recursiveRaw = block.params.recursive const recursive = String(recursiveRaw ?? "").toLowerCase() === "true" diff --git a/src/core/task/tools/handlers/ReadFileToolHandler.ts b/src/core/task/tools/handlers/ReadFileToolHandler.ts index 108956ad..7b807a82 100644 --- a/src/core/task/tools/handlers/ReadFileToolHandler.ts +++ b/src/core/task/tools/handlers/ReadFileToolHandler.ts @@ -16,6 +16,7 @@ import type { IFullyManagedTool } from "../ToolExecutorCoordinator" import type { ToolValidator } from "../ToolValidator" import type { TaskConfig } from "../types/TaskConfig" import type { StronglyTypedUIHelpers } from "../types/UIHelpers" +import { coerceToStringArray } from "../utils/coerceArray" import { extractLastKnownHashFromHistory } from "../utils/extractLastKnownHash" import { ToolResultUtils } from "../utils/ToolResultUtils" import { sliceContentLines } from "./readFilePagination" @@ -43,7 +44,7 @@ export class ReadFileToolHandler implements IFullyManagedTool { constructor(private validator: ToolValidator) {} getDescription(block: ToolUse): string { - const relPaths = Array.isArray(block.params.paths) ? block.params.paths : (block.params.paths ? [block.params.paths as string] : []) + const relPaths = coerceToStringArray(block.params.paths) const range = block.params.start_line || block.params.end_line ? ` lines ${block.params.start_line || 1}-${block.params.end_line || "?"}` @@ -52,7 +53,7 @@ export class ReadFileToolHandler implements IFullyManagedTool { } async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise { - const relPaths = Array.isArray(block.params.paths) ? block.params.paths : (block.params.paths ? [block.params.paths as string] : []) + const relPaths = coerceToStringArray(block.params.paths) const config = uiHelpers.getConfig() if (config.isSubagentExecution) { return @@ -90,11 +91,7 @@ export class ReadFileToolHandler implements IFullyManagedTool { } async execute(config: TaskConfig, block: ToolUse): Promise { - const relPaths = Array.isArray(block.params.paths) - ? block.params.paths - : block.params.paths - ? [block.params.paths as string] - : [] + const relPaths = coerceToStringArray(block.params.paths) const startLineNum = block.params.start_line ? Number.parseInt(String(block.params.start_line)) : undefined const endLineNum = block.params.end_line ? Number.parseInt(String(block.params.end_line)) : undefined const rawOffset = (block.params as any).offset diff --git a/src/core/task/tools/handlers/RenameSymbolToolHandler.ts b/src/core/task/tools/handlers/RenameSymbolToolHandler.ts index 1edfc641..220d6d2c 100644 --- a/src/core/task/tools/handlers/RenameSymbolToolHandler.ts +++ b/src/core/task/tools/handlers/RenameSymbolToolHandler.ts @@ -21,6 +21,7 @@ import type { ToolValidator } from "../ToolValidator" import type { TaskConfig } from "../types/TaskConfig" import type { StronglyTypedUIHelpers } from "../types/UIHelpers" import { ToolResultUtils } from "../utils/ToolResultUtils" +import { coerceToStringArray } from "../utils/coerceArray" export class RenameSymbolToolHandler implements IFullyManagedTool { readonly name = DiracDefaultTool.RENAME_SYMBOL @@ -35,11 +36,7 @@ export class RenameSymbolToolHandler implements IFullyManagedTool { getDescription(block: ToolUse): string { const existingSymbol = block.params.existing_symbol as string const newSymbol = block.params.new_symbol as string - const paths = Array.isArray(block.params.paths) - ? block.params.paths - : block.params.paths - ? [block.params.paths as string] - : [] + const paths = coerceToStringArray(block.params.paths) return `[${block.name} for '${existingSymbol}' to '${newSymbol}' in ${paths.map((p) => `'${p}'`).join(", ")}]` } @@ -50,11 +47,7 @@ export class RenameSymbolToolHandler implements IFullyManagedTool { (block.params.existing_symbol as string) || "", ) const newSymbol = uiHelpers.removeClosingTag(block, "new_symbol", (block.params.new_symbol as string) || "") - const paths = Array.isArray(block.params.paths) - ? block.params.paths - : block.params.paths - ? [block.params.paths as string] - : [] + const paths = coerceToStringArray(block.params.paths) const config = uiHelpers.getConfig() if (config.isSubagentExecution) { @@ -83,11 +76,7 @@ export class RenameSymbolToolHandler implements IFullyManagedTool { async execute(config: TaskConfig, block: ToolUse): Promise { const existingSymbol = block.params.existing_symbol as string const newSymbol = block.params.new_symbol as string - const relPaths = Array.isArray(block.params.paths) - ? block.params.paths - : block.params.paths - ? [block.params.paths as string] - : [] + const relPaths = coerceToStringArray(block.params.paths) if (!existingSymbol || !newSymbol || relPaths.length === 0) { config.taskState.consecutiveMistakeCount++ diff --git a/src/core/task/tools/handlers/SearchFilesToolHandler.ts b/src/core/task/tools/handlers/SearchFilesToolHandler.ts index f6a8be19..e3430525 100644 --- a/src/core/task/tools/handlers/SearchFilesToolHandler.ts +++ b/src/core/task/tools/handlers/SearchFilesToolHandler.ts @@ -21,6 +21,7 @@ import type { ToolValidator } from "../ToolValidator" import type { TaskConfig } from "../types/TaskConfig" import type { StronglyTypedUIHelpers } from "../types/UIHelpers" import { ToolResultUtils } from "../utils/ToolResultUtils" +import { coerceFirstStringArray } from "../utils/coerceArray" // Sprint 2 — task D: async-by-default search_files. Mirrors S2-C // (ExecuteCommandToolHandler): if ripgrep finishes within ASYNC_FAST_PATH_MS @@ -35,15 +36,7 @@ export class SearchFilesToolHandler implements IFullyManagedTool { constructor(private validator: ToolValidator) {} private getRelPaths(params: any): string[] { - return Array.isArray(params.paths) - ? params.paths - : params.paths - ? [params.paths as string] - : Array.isArray(params.path) - ? params.path - : params.path - ? [params.path as string] - : [] + return coerceFirstStringArray(params.paths, params.path) } getDescription(block: ToolUse): string { diff --git a/src/core/task/tools/utils/__tests__/coerceArray.test.ts b/src/core/task/tools/utils/__tests__/coerceArray.test.ts new file mode 100644 index 00000000..b167155e --- /dev/null +++ b/src/core/task/tools/utils/__tests__/coerceArray.test.ts @@ -0,0 +1,67 @@ +import { describe, it } from "mocha" +import "should" +import { coerceFirstStringArray, coerceToStringArray } from "../coerceArray" + +describe("coerceToStringArray", () => { + it("passes through real arrays, stringifying members", () => { + coerceToStringArray(["README.md", "src/index.ts"]).should.deepEqual(["README.md", "src/index.ts"]) + coerceToStringArray([1, 2]).should.deepEqual(["1", "2"]) + }) + + it("parses a JSON-stringified array back into a real array", () => { + coerceToStringArray('["README.md"]').should.deepEqual(["README.md"]) + coerceToStringArray('["ls -la"]').should.deepEqual(["ls -la"]) + coerceToStringArray('["."]').should.deepEqual(["."]) + coerceToStringArray('["a", "b", "c"]').should.deepEqual(["a", "b", "c"]) + }) + + it("tolerates surrounding whitespace around a stringified array", () => { + coerceToStringArray(' ["README.md"] ').should.deepEqual(["README.md"]) + }) + + it("wraps a plain (non-bracketed) string as a single-element array", () => { + coerceToStringArray("README.md").should.deepEqual(["README.md"]) + coerceToStringArray("ls -la").should.deepEqual(["ls -la"]) + }) + + it("treats a bracketed-but-invalid-JSON string as a single value", () => { + coerceToStringArray("[not json]").should.deepEqual(["[not json]"]) + }) + + it("returns an empty array for empty / whitespace strings and nullish values", () => { + coerceToStringArray("").should.deepEqual([]) + coerceToStringArray(" ").should.deepEqual([]) + coerceToStringArray(undefined).should.deepEqual([]) + coerceToStringArray(null).should.deepEqual([]) + }) + + it("returns an empty array for a stringified empty array", () => { + coerceToStringArray("[]").should.deepEqual([]) + }) +}) + +describe("coerceFirstStringArray", () => { + it("returns the first non-empty candidate", () => { + coerceFirstStringArray(["a", "b"], "ignored").should.deepEqual(["a", "b"]) + coerceFirstStringArray(undefined, "fallback").should.deepEqual(["fallback"]) + coerceFirstStringArray(null, ["x"]).should.deepEqual(["x"]) + }) + + it("preserves a singular fallback when the plural form is absent", () => { + // plural `paths` undefined, singular `path` provided + coerceFirstStringArray(undefined, "src/index.ts").should.deepEqual(["src/index.ts"]) + }) + + it("parses a stringified array in any candidate position", () => { + coerceFirstStringArray('["a"]', "b").should.deepEqual(["a"]) + coerceFirstStringArray("", '["b"]').should.deepEqual(["b"]) + }) + + it("skips empty candidates", () => { + coerceFirstStringArray("", " ", [], "real").should.deepEqual(["real"]) + }) + + it("returns an empty array when all candidates are empty", () => { + coerceFirstStringArray(undefined, null, "", []).should.deepEqual([]) + }) +}) diff --git a/src/core/task/tools/utils/coerceArray.ts b/src/core/task/tools/utils/coerceArray.ts new file mode 100644 index 00000000..c6123fce --- /dev/null +++ b/src/core/task/tools/utils/coerceArray.ts @@ -0,0 +1,51 @@ +/** + * Coerce a tool parameter into a string array. + * + * Native tool calls deliver array params (read_file `paths`, list_files + * `paths`, execute_command `commands`, …) as real arrays. But an array + * argument can occasionally arrive as its JSON-stringified form — e.g. the + * string `'["README.md"]'` — depending on the upstream worker / relay. Without + * this, handlers fall into the "not an array → wrap as `[value]`" branch and + * treat the literal `["README.md"]` text as a single path/command (ENOENT on + * a bracketed path, a no-op shell command, etc.). Parse such strings back into + * a real array so the handler operates on the intended values. + */ +export function coerceToStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value.map((v) => String(v)) + } + if (typeof value === "string") { + const trimmed = value.trim() + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + try { + const parsed = JSON.parse(trimmed) + if (Array.isArray(parsed)) { + return parsed.map((v) => String(v)) + } + } catch { + // not valid JSON — fall through and treat as a single value + } + } + return trimmed ? [trimmed] : [] + } + return [] +} + +/** + * Coerce the first non-empty candidate into a string array. + * + * Several handlers accept a plural batch param (`paths`, `symbols`) with a + * singular fallback (`path`, `symbol`). Try each candidate in order via + * {@link coerceToStringArray} and return the first that yields a non-empty + * array, so the singular fallback is preserved while still parsing any + * JSON-stringified array form. + */ +export function coerceFirstStringArray(...values: unknown[]): string[] { + for (const value of values) { + const arr = coerceToStringArray(value) + if (arr.length > 0) { + return arr + } + } + return [] +}