diff --git a/sdk/packages/core/src/extensions/tools/helpers.run-commands.test.ts b/sdk/packages/core/src/extensions/tools/helpers.run-commands.test.ts new file mode 100644 index 0000000000..321a47d787 --- /dev/null +++ b/sdk/packages/core/src/extensions/tools/helpers.run-commands.test.ts @@ -0,0 +1,38 @@ +import { zodToJsonSchema } from "@cline/shared"; +import { describe, expect, it } from "vitest"; +import { normalizeRunCommandsInput } from "./helpers"; +import { RunCommandsInputSchema } from "./schemas"; + +describe("normalizeRunCommandsInput", () => { + it("accepts the documented shapes", () => { + expect(normalizeRunCommandsInput({ commands: ["ls", "pwd"] })).toEqual([ + "ls", + "pwd", + ]); + expect(normalizeRunCommandsInput({ commands: "ls" })).toEqual(["ls"]); + expect(normalizeRunCommandsInput(["ls"])).toEqual(["ls"]); + expect(normalizeRunCommandsInput("ls")).toEqual(["ls"]); + expect(normalizeRunCommandsInput({ command: "ls" })).toEqual(["ls"]); + expect( + normalizeRunCommandsInput({ command: "git", args: ["status"] }), + ).toEqual([{ command: "git", args: ["status"] }]); + }); + + it.each([ + { label: "an object with an empty commands list", input: { commands: [] } }, + { label: "a bare empty list", input: [] }, + ])("rejects $label instead of reporting an empty success", ({ input }) => { + expect(() => normalizeRunCommandsInput(input)).toThrow( + "run_commands requires at least one command", + ); + }); +}); + +describe("RunCommandsInputSchema", () => { + it("advertises the command list as non-empty", () => { + const schema = zodToJsonSchema(RunCommandsInputSchema) as { + properties: { commands: { minItems?: number } }; + }; + expect(schema.properties.commands.minItems).toBe(1); + }); +}); diff --git a/sdk/packages/core/src/extensions/tools/helpers.ts b/sdk/packages/core/src/extensions/tools/helpers.ts index a9a3373e4b..dc6c6cd956 100644 --- a/sdk/packages/core/src/extensions/tools/helpers.ts +++ b/sdk/packages/core/src/extensions/tools/helpers.ts @@ -1,4 +1,5 @@ import { validateWithZod } from "@cline/shared"; +import type { z } from "zod"; import { type EditFileInput, INPUT_ARG_CHAR_LIMIT, @@ -137,8 +138,24 @@ export function coalesceOrphanReadRanges(input: unknown): unknown { export function normalizeRunCommandsInput( input: unknown, ): Array { - const validate = validateWithZod(RunCommandsInputUnionSchema, input); + const commands = collectRunCommands( + validateWithZod(RunCommandsInputUnionSchema, input), + ); + // An empty list is well-formed but never a real request: nothing would + // run and the call would still report success. Fail it so the model sees + // that no command was given instead of an empty result it has to read + // into. + if (commands.length === 0) { + throw new Error( + "run_commands requires at least one command; the commands list was empty.", + ); + } + return commands; +} +function collectRunCommands( + validate: z.infer, +): Array { if (typeof validate === "string") { return [validate]; } diff --git a/sdk/packages/core/src/extensions/tools/schemas.ts b/sdk/packages/core/src/extensions/tools/schemas.ts index b067cb61a3..8ebffd1160 100644 --- a/sdk/packages/core/src/extensions/tools/schemas.ts +++ b/sdk/packages/core/src/extensions/tools/schemas.ts @@ -149,6 +149,10 @@ export const StructuredCommandEntrySchema = z.union([ export const RunCommandsInputSchema = z.object({ commands: z .array(CommandInputSchema) + // Advertised to the model as minItems: 1. The lenient union below still + // accepts an empty list so the normalizer can reject it with one clear + // message instead of a union error. + .min(1) .describe("Array of complete shell command strings to execute."), });