mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
50c9580e30
> Stacked on #717. ## The problem An invalid tool call returned Zod's raw issue array. Someone who wrote `flow_name` instead of `name` got back, depending on the tool: ``` Invalid params for tool "flow-start-recording": [{"expected":"string","code":"invalid_type","path":["name"]}] Invalid params for tool "flow-execute": [{"code":"custom","path":["flow_path"],"message":"Pass exactly one flow source: name or flow_path."}] ``` The first names the parameter the tool **wanted** and never the one the caller **sent**. The second — flow-execute's, whose `name` is optional so `flow_path` can stand in — names neither: it is anchored on the one source field the caller had no reason to send. Either way the mistake is invisible, and working it out costs a whole turn. ## What changes **1. `describeParamIssues` — one sentence per bad parameter.** Missing fields read as `` `name` is required (string) and was not provided`` rather than as a type error about `undefined`. Unknown keys are named and path-qualified, so a key nested in `selector` is not reported as a bare name that contradicts the top-level list printed beside it. The message closes with the caller's own keys — `You sent: \`flow_name\`, \`project_root\`.` — which is what makes the mistake self-evident. Key names only, never values: this string reaches logs, telemetry and the agent transcript, and a params object can carry a secret. Wired into both entry points: the registry's `invokeTool` and the HTTP boundary. **2. `flow-execute` accepts `flow_name` as an alias for `name`.** The tool is called `flow-execute`, so "the flow's name" spells itself that way; this is a mistake worth absorbing rather than rejecting. The name is resolved in code rather than by the schema — a Zod `required` failure would once again name only the field it wanted — and the error text names the spellings that are silently discarded (`flowName`, `flow`, `flow_file`), because by then Zod has already stripped them and the caller has no other way to learn their key was dropped. **3. A flow DIRECTIVE passed as `command` points at the tool that records it.** `command: "echo"` used to come back as a bare "Tool not found". It now names `flow-add-echo` — and says to call it **directly**, since routing it through the recorder would write the echo *and* a `tool: flow-add-echo` step that fails on every replay. The hint distinguishes directives the recorder **rewrites** from those it stores **raw** for the polish pass, because promising a rewrite that does not happen sends the author looking for a directive that is not in the file. `wait` and `long-press` get their own answers: neither has a recording tool at all. Only a genuine not-found is rewritten — a tool that ran and failed still reports its own error. **4. A recorder tool as `command` is refused.** `flow-add-echo`, `flow-add-step`, `flow-start-recording`, `flow-finish-recording` each mutate the recording itself, and nesting one reported success either way. Each is refused for its own reason: `flow-add-echo` would write the echo **and** a replay-breaking `tool:` step, `flow-start-recording` would **erase** this flow at replay, `flow-finish-recording` would end the take it is a step in, and `flow-add-step` cannot record itself. Now nothing is executed and nothing is written. --------- Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
257 lines
8.7 KiB
TypeScript
257 lines
8.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ToolInvocationError } from "@argent/tools-client";
|
|
import { run } from "../src/run.js";
|
|
|
|
const toolsClientMock = vi.hoisted(() => ({
|
|
fetchTool: vi.fn(),
|
|
callTool: vi.fn(),
|
|
baseUrl: vi.fn(async () => ({ url: "http://127.0.0.1:3001", token: "t" })),
|
|
}));
|
|
|
|
const telemetryMock = vi.hoisted(() => ({
|
|
init: vi.fn(),
|
|
shutdown: vi.fn(async () => undefined),
|
|
track: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@argent/tools-client", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
return {
|
|
...actual,
|
|
createToolsClient: vi.fn(() => toolsClientMock),
|
|
materializeArtifacts: vi.fn(async (result: unknown) => ({ result, images: [] })),
|
|
};
|
|
});
|
|
|
|
vi.mock("@argent/telemetry", () => telemetryMock);
|
|
|
|
const gestureTap = {
|
|
name: "gesture-tap",
|
|
description: "Tap the screen",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
udid: { type: "string" },
|
|
x: { type: "number" },
|
|
y: { type: "number" },
|
|
clickCount: { type: "integer" },
|
|
},
|
|
required: ["udid", "x", "y"],
|
|
},
|
|
};
|
|
|
|
const runSequence = {
|
|
name: "run-sequence",
|
|
description: "Run steps",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
udid: { type: "string" },
|
|
steps: { type: "array", items: { type: "object" } },
|
|
},
|
|
required: ["udid", "steps"],
|
|
},
|
|
};
|
|
|
|
const invoke = (argv: string[]) => run(argv, { paths: {} as never });
|
|
|
|
function stderr(): string {
|
|
return (console.error as unknown as { mock: { calls: unknown[][] } }).mock.calls
|
|
.map((c) => String(c[0]))
|
|
.join("\n");
|
|
}
|
|
|
|
function stdout(): string {
|
|
return (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls
|
|
.map((c) => String(c[0]))
|
|
.join("\n");
|
|
}
|
|
|
|
describe("argent run input validation", () => {
|
|
let exitSpy: ReturnType<typeof vi.spyOn>;
|
|
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
toolsClientMock.fetchTool.mockResolvedValue(gestureTap);
|
|
toolsClientMock.callTool.mockResolvedValue({ data: { tapped: true } });
|
|
exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
throw new Error(`process.exit:${code}`);
|
|
}) as typeof process.exit);
|
|
errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
exitSpy.mockRestore();
|
|
errorSpy.mockRestore();
|
|
logSpy.mockRestore();
|
|
});
|
|
|
|
it("names the missing flags and shows the tool's help without calling the tool", async () => {
|
|
await expect(invoke(["gesture-tap"])).rejects.toThrow("process.exit:2");
|
|
|
|
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
|
|
expect(stderr()).toContain("Error: missing required flags --udid, --x, --y");
|
|
expect(stdout()).toContain("argent run gesture-tap [flags]");
|
|
expect(stdout()).toContain("--udid <value>");
|
|
expect(telemetryMock.track).toHaveBeenCalledWith(
|
|
"cli:run_fail",
|
|
expect.objectContaining({
|
|
tool: "gesture-tap",
|
|
error_code: "CLI_RUN_INPUT_VALIDATION_FAILED",
|
|
failure_stage: "cli_run_required_flags",
|
|
failure_area: "cli",
|
|
error_kind: "validation",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("never prints a raw issue list", async () => {
|
|
await expect(invoke(["gesture-tap"])).rejects.toThrow("process.exit:2");
|
|
expect(stderr()).not.toContain("invalid_type");
|
|
expect(stderr()).not.toContain('"path"');
|
|
});
|
|
|
|
it("accepts required fields supplied through --args", async () => {
|
|
await invoke(["gesture-tap", "--args", '{"udid":"X","x":0.5,"y":0.5}']);
|
|
|
|
expect(toolsClientMock.callTool).toHaveBeenCalledWith("gesture-tap", {
|
|
udid: "X",
|
|
x: 0.5,
|
|
y: 0.5,
|
|
});
|
|
});
|
|
|
|
it("accepts required fields split across --args and flags", async () => {
|
|
await invoke(["gesture-tap", "--udid", "X", "--args", '{"x":0.5,"y":0.5}']);
|
|
expect(toolsClientMock.callTool).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("reports only the fields still missing after --args is merged", async () => {
|
|
await expect(invoke(["gesture-tap", "--args", '{"x":0.5}'])).rejects.toThrow("process.exit:2");
|
|
expect(stderr()).toContain("missing required flags --udid, --y");
|
|
});
|
|
|
|
it("names a JSON-only field by its -json flag", async () => {
|
|
toolsClientMock.fetchTool.mockResolvedValue(runSequence);
|
|
|
|
await expect(invoke(["run-sequence", "--udid", "X"])).rejects.toThrow("process.exit:2");
|
|
expect(stderr()).toContain("Error: missing required flag --steps-json");
|
|
});
|
|
|
|
it("accepts a JSON-only field passed as --field-json", async () => {
|
|
toolsClientMock.fetchTool.mockResolvedValue(runSequence);
|
|
|
|
await invoke(["run-sequence", "--udid", "X", "--steps-json", '[{"tool":"button"}]']);
|
|
expect(toolsClientMock.callTool).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("still prints help for --help without demanding flags", async () => {
|
|
await invoke(["gesture-tap", "--help"]);
|
|
|
|
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
|
|
expect(stdout()).toContain("argent run gesture-tap [flags]");
|
|
expect(stderr()).not.toContain("missing required");
|
|
});
|
|
|
|
describe.each([
|
|
[
|
|
"a modern server (prose message + issues)",
|
|
() =>
|
|
new ToolInvocationError("`x`: Too big: expected <=1. You sent: `udid`, `x`, `y`.", {
|
|
errorCode: "HTTP_ZOD_VALIDATION_FAILED",
|
|
errorKind: "validation",
|
|
issues: [{ code: "too_big", path: ["x"], message: "Too big: expected <=1" }],
|
|
}),
|
|
],
|
|
[
|
|
"a pre-prose server (the issue list WAS the message)",
|
|
() =>
|
|
new Error(
|
|
JSON.stringify([{ code: "too_big", path: ["x"], message: "Too big: expected <=1" }])
|
|
),
|
|
],
|
|
])("a value the tool rejects, reported by %s", (_label, buildError) => {
|
|
beforeEach(() => {
|
|
toolsClientMock.callTool.mockRejectedValue(buildError());
|
|
});
|
|
|
|
it("is reported against its flag instead of as a raw issue list", async () => {
|
|
await expect(
|
|
invoke(["gesture-tap", "--udid", "X", "--x", "99", "--y", "0.5"])
|
|
).rejects.toThrow("process.exit:2");
|
|
|
|
expect(stderr()).toContain("Error: --x Too big: expected <=1");
|
|
expect(stderr()).not.toContain('"code"');
|
|
expect(stdout()).toContain("argent run gesture-tap [flags]");
|
|
expect(telemetryMock.track).toHaveBeenCalledWith(
|
|
"cli:run_fail",
|
|
expect.objectContaining({
|
|
error_code: "CLI_RUN_INPUT_VALIDATION_FAILED",
|
|
failure_stage: "cli_run_server_validation",
|
|
error_kind: "validation",
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
it("leaves an ordinary runtime failure exactly as it was", async () => {
|
|
toolsClientMock.callTool.mockRejectedValue(new Error("Simulator not booted"));
|
|
|
|
await expect(
|
|
invoke(["gesture-tap", "--udid", "X", "--x", "0.5", "--y", "0.5"])
|
|
).rejects.toThrow("process.exit:1");
|
|
|
|
expect(console.error).toHaveBeenCalledWith("Simulator not booted");
|
|
expect(stderr()).not.toContain("Error: ");
|
|
expect(stdout()).not.toContain("argent run gesture-tap [flags]");
|
|
expect(telemetryMock.track).toHaveBeenCalledWith(
|
|
"cli:run_fail",
|
|
expect.objectContaining({ error_code: "CLI_RUN_TOOL_CALL_FAILED", error_kind: "unknown" })
|
|
);
|
|
});
|
|
|
|
describe("--json", () => {
|
|
it("emits one machine-readable object on stderr and nothing on stdout", async () => {
|
|
await expect(invoke(["gesture-tap", "--json"])).rejects.toThrow("process.exit:2");
|
|
|
|
expect(stdout()).toBe("");
|
|
const envelope = JSON.parse(stderr());
|
|
expect(envelope).toEqual({
|
|
error: "missing required flags --udid, --x, --y",
|
|
missing: ["--udid", "--x", "--y"],
|
|
issues: [],
|
|
});
|
|
});
|
|
|
|
it("carries the tool's own issue list when the tool rejected a value", async () => {
|
|
const issues = [{ code: "too_big", path: ["x"], message: "Too big: expected <=1" }];
|
|
toolsClientMock.callTool.mockRejectedValue(
|
|
new ToolInvocationError("`x`: Too big: expected <=1. You sent: `udid`, `x`, `y`.", {
|
|
errorKind: "validation",
|
|
issues,
|
|
})
|
|
);
|
|
|
|
await expect(
|
|
invoke(["gesture-tap", "--json", "--udid", "X", "--x", "99", "--y", "0.5"])
|
|
).rejects.toThrow("process.exit:2");
|
|
|
|
const envelope = JSON.parse(stderr());
|
|
expect(envelope.issues).toEqual(issues);
|
|
expect(envelope.missing).toEqual([]);
|
|
expect(envelope.error).toBe("--x Too big: expected <=1");
|
|
expect(stdout()).toBe("");
|
|
});
|
|
});
|
|
|
|
it("reports a missing value for a global option instead of a raw stack", async () => {
|
|
await expect(invoke(["gesture-tap", "--out"])).rejects.toThrow("process.exit:2");
|
|
|
|
expect(stderr()).toContain("Error: --out requires a path");
|
|
expect(stderr()).toContain("argent run gesture-tap --help");
|
|
});
|
|
});
|