Files
software-mansion__argent/packages/tool-server/test/flows/flow-param-errors-http.test.ts
Hubert Gancarczyk 50c9580e30 fix(tools): make a schema failure name the parameter that was sent (#718)
> 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>
2026-08-25 10:48:49 +02:00

168 lines
5.9 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import request from "supertest";
import { z } from "zod";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { Registry, FILE_INPUT_MARKER } from "@argent/registry";
import { createHttpApp } from "../../src/http";
import { createRunFlowTool } from "../../src/tools/flows/flow-run";
describe("flow param errors over HTTP", () => {
let tmpDir: string;
let flowFile: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-http-params-"));
flowFile = path.join(tmpDir, ".argent", "flows", "demo.yaml");
await fs.mkdir(path.dirname(flowFile), { recursive: true });
await fs.writeFile(flowFile, "steps:\n - echo: hi\n", "utf8");
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it("returns 400 for a source-less flow-execute, with the guidance in the body", async () => {
const registry = new Registry();
registry.registerTool(createRunFlowTool(registry) as never);
const { app } = createHttpApp(registry);
const res = await request(app)
.post("/tools/flow-execute")
.send({ project_root: "/tmp/does-not-matter", prerequisiteAcknowledged: true });
expect(res.status).toBe(400);
expect(res.body.message).toContain("needs the flow's name in `name`");
expect(res.body.message).toContain(".argent/flows/<name>.yaml");
expect(res.body.error).toContain("needs the flow's name in `name`");
});
it("renders the 400 body as prose that names the caller's own keys, not raw Zod JSON", async () => {
const registry = new Registry();
registry.registerTool({
id: "validated-thing",
zodSchema: z.object({ count: z.number() }),
services: () => ({}),
async execute() {
throw new Error("execute should have been skipped");
},
} as never);
const { app } = createHttpApp(registry);
const res = await request(app).post("/tools/validated-thing").send({ countt: 5 });
expect(res.status).toBe(400);
expect(res.body.message).toContain("`count` is required");
expect(res.body.message).toContain("You sent: `countt`");
expect(res.body.message).not.toContain('"code"');
});
it("keeps `error` parseable for a CLI released before `issues`", async () => {
const registry = new Registry();
registry.registerTool({
id: "validated-thing",
zodSchema: z.object({ count: z.number() }),
services: () => ({}),
async execute() {
throw new Error("execute should have been skipped");
},
} as never);
const { app } = createHttpApp(registry);
const res = await request(app).post("/tools/validated-thing").send({ countt: 5 });
expect(res.status).toBe(400);
expect(() => JSON.parse(res.body.error)).not.toThrow();
expect(JSON.parse(res.body.error)).toMatchObject([{ code: "invalid_type", path: ["count"] }]);
});
it("carries the machine-readable issue list beside the prose", async () => {
const registry = new Registry();
registry.registerTool({
id: "validated-thing",
zodSchema: z.object({ count: z.number() }),
services: () => ({}),
async execute() {
throw new Error("execute should have been skipped");
},
} as never);
const { app } = createHttpApp(registry);
const res = await request(app).post("/tools/validated-thing").send({ count: "x" });
expect(res.status).toBe(400);
expect(Array.isArray(res.body.issues)).toBe(true);
expect(res.body.issues[0]).toMatchObject({ code: "invalid_type", path: ["count"] });
expect(typeof res.body.issues[0].message).toBe("string");
});
it("leaves the client-DERIVED flow_file out of the keys it reads back", async () => {
const registry = new Registry();
registry.registerTool(createRunFlowTool(registry) as never);
const { app } = createHttpApp(registry);
const res = await request(app)
.post("/tools/flow-execute")
.send({
name: "demo",
project_root: tmpDir,
platform: "iOS",
flow_file: { [FILE_INPUT_MARKER]: true, path: flowFile },
});
expect(res.status).toBe(400);
expect(res.body.message).toContain("`platform`");
expect(res.body.message).toContain("You sent: `name`, `project_root`, `platform`.");
expect(res.body.message).not.toContain("`flow_file`");
});
it("still names a file-input the CALLER authored", async () => {
const registry = new Registry();
registry.registerTool(createRunFlowTool(registry) as never);
const { app } = createHttpApp(registry);
const res = await request(app)
.post("/tools/flow-execute")
.send({
project_root: tmpDir,
device: 5,
flow_path: { [FILE_INPUT_MARKER]: true, path: flowFile },
});
expect(res.status).toBe(400);
expect(res.body.message).toContain("`device`");
expect(res.body.message).toContain("`flow_path`");
});
it("answers a NESTED tool's schema miss with 400, matching the direct call", async () => {
const registry = new Registry();
registry.registerTool({
id: "inner",
zodSchema: z.object({ count: z.number() }),
services: () => ({}),
async execute() {
return { ok: true };
},
} as never);
registry.registerTool({
id: "outer",
zodSchema: z.object({ pass: z.unknown() }),
services: () => ({}),
async execute(_s: unknown, params: { pass: unknown }) {
return registry.invokeTool("inner", params.pass);
},
} as never);
const { app } = createHttpApp(registry);
const res = await request(app)
.post("/tools/outer")
.send({ pass: { countt: 5 } });
expect(res.status).toBe(400);
expect(res.body.error_kind).toBe("validation");
expect(res.body.error).toContain("`count` is required");
expect(res.body.error).toContain("You sent: `countt`");
});
});