fix(core): reject an empty run_commands list instead of reporting success (#15089)

* fix(core): reject an empty run_commands list instead of reporting success

`{"commands":[]}` validated, ran nothing, and came back as a successful
call with no results. Fail it with a clear tool error so the model sees
that no command was given, whether it emitted the empty list itself or
a repair produced it.

* fix(core): advertise the run_commands list as non-empty

The lenient input union still accepts an empty list so the normalizer
can reject it with one clear message; the advertised schema now also
tells the model up front, as minItems: 1.
This commit is contained in:
Mikołaj Kondratek
2026-10-21 07:17:24 +02:00
committed by GitHub
parent 92c1f4e031
commit e9dafbb764
3 changed files with 60 additions and 1 deletions
@@ -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);
});
});
@@ -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<string | StructuredCommandInput> {
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<typeof RunCommandsInputUnionSchema>,
): Array<string | StructuredCommandInput> {
if (typeof validate === "string") {
return [validate];
}
@@ -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."),
});