Compare commits

...

2 Commits

Author SHA1 Message Date
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 169 additions and 11 deletions
@@ -535,6 +535,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 }) =>
@@ -26,6 +26,7 @@ import {
formatRunCommandQueryPreview,
getEditorSizeError,
getReadFileRangeError,
normalizeJsonLikeRunCommandsInput,
normalizeRunCommandsInput,
TimeoutError,
withTimeout,
@@ -118,10 +119,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 +142,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 +155,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"));
}
@@ -336,7 +352,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,48 @@ 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 normalizeJsonLikeRunCommandsInput(input: unknown): unknown {
const parsed = parseJsonLikeString(input);
if (!isRecord(parsed) || !("commands" in parsed)) {
return parsed;
}
const commands = parseJsonLikeString(parsed.commands);
if (isRecord(commands) && "commands" in commands) {
return { ...parsed, commands: parseJsonLikeString(commands.commands) };
}
return { ...parsed, commands };
}
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,
]);
/**