Compare commits

...

3 Commits

Author SHA1 Message Date
abeatrix e40a9d6072 feat: add support for JSON-encoded string arrays for tools
- Added normalizeJsonLikeSearchCodebaseInput and normalizeJsonLikeReadFilesInput helpers
- Updated createSearchTool and createReadFilesTool to accept queries/files as JSON-encoded string arrays
- Refactored normalizeJsonLikeRunCommandsInput into a generic normalizeJsonLikeToolInput utility
- Added test coverage for new input format acceptance
2026-06-24 14:29:14 -07:00
abeatrix d270941bcd keeps union validation 2026-06-24 13:45:16 -07:00
abeatrix 86e8b61206 fix(tools): parse JSON-encoded command arrays in shell tools
Handle cases where the `commands` field is passed as a JSON-encoded
string array instead of a native array. Refactor input handling in
`createBashTool` and `createWindowsShellTool` to support both string
and structured command inputs, and update `coalesceSplitHeredocCommands`
to skip non-string entries. Removes the `RunCommandsInputUnionSchema`
in favor of direct validation.
2026-06-24 12:53:30 -07:00
4 changed files with 283 additions and 13 deletions
@@ -399,6 +399,47 @@ describe("default search_codebase tool", () => {
},
]);
});
it("accepts object input with queries as a JSON-encoded string array", async () => {
const execute = vi.fn(async (query: string) => `results:${query}`);
const tool = createSearchTool(execute);
const result = await tool.execute(
{
queries: JSON.stringify(["createSearchTool", "run_commands"]),
} as never,
{
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
},
);
expect(result).toEqual([
{
query: "createSearchTool",
result: "results:createSearchTool",
success: true,
},
{
query: "run_commands",
result: "results:run_commands",
success: true,
},
]);
expect(execute).toHaveBeenNthCalledWith(
1,
"createSearchTool",
process.cwd(),
expect.objectContaining({ iteration: 1 }),
);
expect(execute).toHaveBeenNthCalledWith(
2,
"run_commands",
process.cwd(),
expect.objectContaining({ iteration: 1 }),
);
});
});
describe("default apply_patch tool", () => {
@@ -535,6 +576,111 @@ describe("default run_commands tool", () => {
);
});
it("accepts object input with commands as a JSON-encoded string array", async () => {
const execute = vi.fn(async (command: string | { command: string }) =>
typeof command === "string" ? `ran:${command}` : `ran:${command.command}`,
);
const tool = createBashTool(execute);
const command = "cd /repo && bunx tsc --noEmit --pretty 2>&1 | head -40";
const result = await tool.execute(
{ commands: JSON.stringify([command]) } as never,
{
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
},
);
expect(result).toEqual([
{
query: command,
result: `ran:${command}`,
success: true,
},
]);
expect(execute).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledWith(
command,
process.cwd(),
expect.objectContaining({
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
}),
);
});
it("accepts JSON-encoded command arrays in the Windows shell tool", async () => {
const execute = vi.fn(async (command: string | { command: string }) =>
typeof command === "string" ? `ran:${command}` : `ran:${command.command}`,
);
const tool = createWindowsShellTool(execute);
const result = await tool.execute(
{ commands: JSON.stringify(["git status --short"]) } as never,
{
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
},
);
expect(result).toEqual([
{
query: "git status --short",
result: "ran:git status --short",
success: true,
},
]);
expect(execute).toHaveBeenCalledWith(
"git status --short",
process.cwd(),
expect.objectContaining({
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
}),
);
});
it("accepts nested JSON-encoded commands payloads", async () => {
const execute = vi.fn(async (command: string | { command: string }) =>
typeof command === "string" ? `ran:${command}` : `ran:${command.command}`,
);
const tool = createBashTool(execute);
const result = await tool.execute(
{
commands: JSON.stringify({
commands: JSON.stringify(["git status --short"]),
}),
} as never,
{
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
},
);
expect(result).toEqual([
{
query: "git status --short",
result: "ran:git status --short",
success: true,
},
]);
expect(execute).toHaveBeenCalledWith(
"git status --short",
process.cwd(),
expect.objectContaining({
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
}),
);
});
it("accepts common single-command aliases", async () => {
const execute = vi.fn(
async (command: string | { command: string }) =>
@@ -1360,6 +1506,45 @@ describe("default read_files tool", () => {
);
});
it("accepts object input with files as a JSON-encoded string array", async () => {
const execute = vi.fn(
async (request: { path: string }) => `content:${request.path}`,
);
const tool = createReadFilesTool(execute);
const result = await tool.execute(
{ files: JSON.stringify(["/tmp/a.ts", "/tmp/b.ts"]) } as never,
{
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
},
);
expect(result).toEqual([
{
query: "/tmp/a.ts",
result: "content:/tmp/a.ts",
success: true,
},
{
query: "/tmp/b.ts",
result: "content:/tmp/b.ts",
success: true,
},
]);
expect(execute).toHaveBeenNthCalledWith(
1,
{ path: "/tmp/a.ts" },
expect.objectContaining({ iteration: 1 }),
);
expect(execute).toHaveBeenNthCalledWith(
2,
{ path: "/tmp/b.ts" },
expect.objectContaining({ iteration: 1 }),
);
});
it("rejects invalid union inputs before calling the executor", async () => {
const execute = vi.fn(async () => "should not run");
const tool = createReadFilesTool(execute);
@@ -26,6 +26,9 @@ import {
formatRunCommandQueryPreview,
getEditorSizeError,
getReadFileRangeError,
normalizeJsonLikeReadFilesInput,
normalizeJsonLikeRunCommandsInput,
normalizeJsonLikeSearchCodebaseInput,
normalizeRunCommandsInput,
TimeoutError,
withTimeout,
@@ -118,10 +121,21 @@ function getHeredocDelimiter(command: string): string | undefined {
return match?.[1] ?? match?.[2] ?? match?.[3];
}
function coalesceSplitHeredocCommands(commands: string[]): string[] {
const coalesced: string[] = [];
function coalesceSplitHeredocCommands(commands: string[]): string[];
function coalesceSplitHeredocCommands(
commands: Array<string | StructuredCommandInput>,
): Array<string | StructuredCommandInput>;
function coalesceSplitHeredocCommands(
commands: Array<string | StructuredCommandInput>,
): Array<string | StructuredCommandInput> {
const coalesced: Array<string | StructuredCommandInput> = [];
for (let index = 0; index < commands.length; index += 1) {
const command = commands[index];
if (typeof command !== "string") {
coalesced.push(command);
continue;
}
const delimiter = getHeredocDelimiter(command);
if (!delimiter) {
coalesced.push(command);
@@ -130,7 +144,9 @@ function coalesceSplitHeredocCommands(commands: string[]): string[] {
const endIndex = commands.findIndex(
(nextCommand, nextIndex) =>
nextIndex > index && nextCommand.trim() === delimiter,
nextIndex > index &&
typeof nextCommand === "string" &&
nextCommand.trim() === delimiter,
);
if (endIndex === -1) {
coalesced.push(command);
@@ -141,7 +157,9 @@ function coalesceSplitHeredocCommands(commands: string[]): string[] {
while (index < endIndex) {
index += 1;
const nextCommand = commands[index];
parts.push(nextCommand);
if (typeof nextCommand === "string") {
parts.push(nextCommand);
}
}
coalesced.push(parts.join("\n"));
}
@@ -176,7 +194,10 @@ export function createReadFilesTool(
retryable: true,
maxRetries: 1,
execute: async (input, context) => {
const validate = validateWithZod(ReadFilesInputUnionSchema, input);
const validate = validateWithZod(
ReadFilesInputUnionSchema,
normalizeJsonLikeReadFilesInput(input),
);
let requests: ReadFileRequest[];
if (typeof validate === "string") {
requests = [{ path: validate }];
@@ -270,7 +291,10 @@ export function createSearchTool(
maxRetries: 1,
execute: async (input, context) => {
// Validate input with Zod schema
const validate = validateWithZod(SearchCodebaseUnionInputSchema, input);
const validate = validateWithZod(
SearchCodebaseUnionInputSchema,
normalizeJsonLikeSearchCodebaseInput(input),
);
const queries = Array.isArray(validate)
? validate
: typeof validate === "object"
@@ -336,7 +360,10 @@ export function createBashTool(
retryable: false, // Shell commands often have side effects
maxRetries: 0,
execute: async (input, context) => {
const validate = validateWithZod(RunCommandsInputUnionSchema, input);
const validate = validateWithZod(
RunCommandsInputUnionSchema,
normalizeJsonLikeRunCommandsInput(input),
);
let commands: string[];
if (typeof validate === "string") {
commands = [validate];
@@ -77,10 +77,72 @@ export function getReadFileRangeError(request: ReadFileRequest): string | null {
return `start_line must be less than or equal to end_line (received start_line: ${start_line}, end_line: ${end_line})`;
}
function parseJsonLikeString(input: unknown): unknown {
if (typeof input !== "string") {
return input;
}
const trimmed = input.trim();
if (!trimmed.startsWith("[") && !trimmed.startsWith("{")) {
return input;
}
try {
return JSON.parse(trimmed) as unknown;
} catch {
return input;
}
}
function isRecord(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input != null && !Array.isArray(input);
}
export function normalizeJsonLikeToolInput(
input: unknown,
keys: string[],
): unknown {
const parsed = parseJsonLikeString(input);
if (!isRecord(parsed)) {
return parsed;
}
const normalized = { ...parsed };
for (const key of keys) {
if (!(key in normalized)) {
continue;
}
const value = parseJsonLikeString(normalized[key]);
if (isRecord(value) && key in value) {
normalized[key] = parseJsonLikeString(value[key]);
} else {
normalized[key] = value;
}
}
return normalized;
}
export function normalizeJsonLikeRunCommandsInput(input: unknown): unknown {
return normalizeJsonLikeToolInput(input, ["commands"]);
}
export function normalizeJsonLikeReadFilesInput(input: unknown): unknown {
return normalizeJsonLikeToolInput(input, ["files", "file_paths", "paths"]);
}
export function normalizeJsonLikeSearchCodebaseInput(input: unknown): unknown {
return normalizeJsonLikeToolInput(input, ["queries"]);
}
export function normalizeRunCommandsInput(
input: unknown,
): Array<string | StructuredCommandInput> {
const validate = validateWithZod(StructuredCommandsInputUnionSchema, input);
const validate = validateWithZod(
StructuredCommandsInputUnionSchema,
normalizeJsonLikeRunCommandsInput(input),
);
if (typeof validate === "string") {
return [validate];
@@ -155,15 +155,11 @@ export const StructuredCommandsInputSchema = z.object({
* Union schema for run_commands tool input. More flexible.
*/
export const StructuredCommandsInputUnionSchema = z.union([
RunCommandsInputSchema,
StructuredCommandsInputSchema,
z.object({ commands: StructuredCommandEntrySchema }),
z.array(StructuredCommandInputSchema),
StructuredCommandInputSchema,
z.object({ command: CommandInputSchema }),
z.object({ cmd: CommandInputSchema }),
z.array(z.string()),
z.string(),
RunCommandsInputUnionSchema,
]);
/**