Files
software-mansion__argent/packages/tool-server/test/flows/script/flow-script-verdict.test.ts
Hubert Gancarczyk 0f90d182b2 feat(flows): record a script step by running it (#940)
#864 (the executor) and #865 (the `script:` step) are merged. This
branch now
sits directly on `main` as a single commit, so the diff and the commit
list are
this change alone.

A `script:` step could only enter a flow by being typed into the YAML
after the
recording ended. The authoring skill said so in as many words, and
listed it
among the legal unrecorded insertions. That made it the one directive
exempt
from every rule the recorder exists to enforce, and it cost two things:

- **The script never ran while the flow was authored.** It first
executed during
the closing replay, where a failure reads as a flow that was never right
rather than a script that needs one more attempt. Worse, the device
steps
recorded after it were walked against backend state the author had
established
by hand — so the recording proved nothing about whether the script
establishes
  that same state.
- **The author never saw what the script returned.** A later release
reads
`{{output:user.id}}`; the only way to know that path exists is to have
seen the
  document.

This adds one MCP tool, `flow-add-script`, that runs a local `.mjs` and
records
the step it ran. **No new flow syntax** — the `script:` step is
unchanged, and
this is a second way for one to enter a flow file.

```text
flow-add-script { name: "checkout", project_root: "/Users/dev/shop",
                  path: "../../scripts/seed-order.mjs", timeout: 60000 }
```

## What it adds

- **One shared execution path.** Path resolution, the on-disk casing
guard, the
executor call and the verdict were private to `flow-run.ts`. They are
now
`runFlowScriptStep` in `flow-script-step.ts`, with the file-ref helpers
in
`flow-file-refs.ts`, and `runScriptStep` is that call plus its
`ExecState`
plumbing. This is the load-bearing change: "the recording runs the
script
exactly as replay will" is one function, not two code paths that agree
today.
- **A failure records nothing.** The opposite of `flow-add-step`,
deliberately.
A failed script did not establish the state the rest of the walkthrough
would
then be recorded against, so an appended red step costs every step after
it,
not just itself. The `message` also says whether a child actually *ran*
— a
path that resolved to no file forked nothing, while a script that threw
halfway did not roll back — because the author's next move is a retry or
a
  cleanup and they cannot pick without knowing.
- **The returned document, as JSON text.** Text rather than an object
because
this is the first tool result carrying bytes the server did not author:
every
  result is deep-walked for `__argentClientFile` and `__argentArtifact`
directives, which match on shape alone and write or fetch files on the
agent's
  machine. As a string it inherits none of that. Bounded at 64 KiB with
  `outputTruncated`, for the reason `log` is bounded.
- **`{{output:` refused by `parseFlow`.** Surfacing the document invites
an
author to write a reference that has no meaning yet — it would be
printed
literally by a step that passes, green and wrong. The scan runs over
exactly
the field list the output release declares supported, so it is a seam
that
release converts from reject to resolve rather than scaffolding it
deletes.
Patterns are excluded: a `{{` in a regular expression is a quantifier
the
  author meant.
- **Two boundaries.** A `client`-mode recording is refused — its `.mjs`
never
left the agent's machine, the same anchor reason `run:`, `snapshot` and
  uploaded flows are refused. And the tool is `longRunning`, so the MCP
adapter's 30s fetch abort cannot retry a call whose whole purpose is a
side
effect; the comment records what that flag does *not* close, and where
the
  rest belongs.
- **`flow-add-step` says when the call already ran.** The `{{output:`
refusal is
the one refusal this release adds on a path where the tool call has
already
fired, and its message asks for an edit-and-retry — which repeats the
device
  action. Verified: a `keyboard` step recorded twice left
`{{output:code}}{{output:code}}` in the field. That one refusal now
carries
  what already happened.

## Documentation

Rule 5 of `argent-create-flow` no longer lists `script:` as a legal
unrecorded
insertion — leaving it there would tell agents to keep doing the thing
this PR
removes. `live-authoring.md` gains the recorder contract, the return
fields that
are not self-evident, and the ordering rule: a setup script is recorded
*before*
the `restart-app` it prepares state for, because that is where it runs
at
replay. A leading script does not make the flow a fragment. `qa-flows`
and
`reliability-and-recovery` follow the count and the wording.

## Verification

`npm run build`, `npx eslint . --max-warnings 0`, `npx prettier --check`
over
tracked files, and `npm run typecheck:tests -w @argent/tool-server` are
green.
`npm test -w @argent/tool-server`: 358 files, 4657 passed, 1 skipped.

Exercised end to end against a live tool server built from this branch,
not
only in tests — recorded a flow, then replayed the saved YAML through
`argent flow run` with no hand editing:

```
flow-add-script → status "pass", durationMs 124
  log: "seeding order for the flow\nwarn: using the fake backend\n"
  outputJson: {"order":{"id":"ord_4417","total":39.5},
               "user":{"id":"usr_9","email":"buyer@example.com"}}
  recorded: 1. script: ../../scripts/seed-order.mjs (timeout 30000ms)
```

```yaml
steps:
  - script:
      path: ../../scripts/seed-order.mjs
      timeout: 30000
  - echo: order seeded; expect Home
```

```
Flow "checkout"
  ✓  1 script ../../scripts/seed-order.mjs
       │ seeding order for the flow
       │ warn: using the fake backend
  › order seeded; expect Home

PASS — 1 passed, 0 failed, 0 errored, 0 skipped
```

The three refusals, live on the same recording:

- A script that threw after creating records: `status "fail"`,
`stepCount`
unchanged at 1, and the message naming the side effects that were not
rolled
  back.
- A path resolving to no file: refused before any fork, no `durationMs`,
and
  "Nothing ran, so there is nothing to clean up".
- `flow-add-echo` holding `{{output:user.id}}`: refused with the
author-facing
  message naming the step, the field and the later release.

No device is involved at any point — a recording may open with a seeding
script
before it boots anything.



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added the `flow-add-script` tool to run local `.mjs` scripts and
record successful executions in flows.
* Added script status, output, duration, logs, and failure details to
recording results.
* Added clearer handling for script cancellation, timeouts, missing
files, and path casing.

* **Documentation**
* Updated flow guidance to clarify end-to-end classification and script
setup or cleanup usage.
* Documented script path requirements, failure conditions, and recording
workflows.

* **Bug Fixes**
* Flows now reject unsupported output-reference templates with more
precise diagnostics.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-07 12:59:29 +02:00

308 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { Registry, ToolContext } from "@argent/registry";
import { createRunFlowTool, type FlowRunResult } from "../../../src/tools/flows/flow-run";
import { flowStartRecordingTool } from "../../../src/tools/flows/flow-start-recording";
import { flowAddScriptTool } from "../../../src/tools/flows/flow-add-script";
import { scriptVerdict, type ScriptRan } from "../../../src/tools/flows/flow-script-step";
import { __resetRecordingsForTesting, parseFlow } from "../../../src/tools/flows/flow-utils";
import type {
FlowScriptFailureKind,
FlowScriptResult,
} from "../../../src/tools/flows/script/flow-script-executor";
const { executeMock } = vi.hoisted(() => ({ executeMock: vi.fn() }));
vi.mock("../../../src/tools/flows/script/flow-script-executor", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../src/tools/flows/script/flow-script-executor")>();
return { ...actual, flowScriptExecutor: () => ({ execute: executeMock }) };
});
let root: string;
function outcome(over: Partial<FlowScriptResult>): FlowScriptResult {
return {
ok: false,
log: "",
logTruncated: false,
durationMs: 1,
queuedMs: 0,
notes: [],
...over,
};
}
function mockRegistry(): Registry {
return {
invokeTool: vi.fn(async () => ({ devices: [] })),
getTool: vi.fn(() => ({ inputSchema: { properties: { udid: {} } } })),
resolveService: vi.fn(async () => ({})),
} as unknown as Registry;
}
async function runScript(): Promise<FlowRunResult["steps"][number]> {
const result = (await createRunFlowTool(mockRegistry()).execute({}, {
name: "verdict",
project_root: root,
} as never)) as FlowRunResult;
return result.steps[0]!;
}
async function recordScript(ctx?: ToolContext) {
await flowStartRecordingTool.execute({}, { name: "recorded", project_root: root });
return flowAddScriptTool.execute(
{},
{
name: "recorded",
project_root: root,
path: "../../scripts/seed.mjs",
} as never,
ctx
);
}
function executedRequest(): Record<string, unknown> {
return executeMock.mock.calls[0]![0] as Record<string, unknown>;
}
async function recordedSteps() {
return parseFlow(await fs.readFile(path.join(root, ".argent", "flows", "recorded.yaml"), "utf8"))
.steps;
}
beforeEach(async () => {
__resetRecordingsForTesting();
root = await fs.mkdtemp(path.join(os.tmpdir(), "flow-script-verdict-"));
await fs.mkdir(path.join(root, ".argent", "flows"), { recursive: true });
await fs.mkdir(path.join(root, "scripts"), { recursive: true });
await fs.writeFile(path.join(root, "scripts", "seed.mjs"), "");
await fs.writeFile(
path.join(root, ".argent", "flows", "verdict.yaml"),
"steps:\n - script: { path: ../../scripts/seed.mjs }\n",
"utf8"
);
executeMock.mockReset();
});
afterEach(async () => {
__resetRecordingsForTesting();
await fs.rm(root, { recursive: true, force: true });
});
const VERDICTS: Record<FlowScriptFailureKind, "fail" | "error"> = {
load: "fail",
runtime: "fail",
output: "fail",
exit: "fail",
protocol: "error",
timeout: "error",
cancelled: "error",
signal: "error",
heap: "error",
spawn: "error",
queue: "error",
invalid: "error",
};
/**
* Whether each failure leaves the author something to clean up, judged from the
* KIND alone — the answer for a failure the executor did not mark `beforeFork`.
* Total over the kinds, like {@link VERDICTS}: the dangerous mapping is a kind
* that DID fork being told "nothing ran", and a table listing only some kinds
* stays green while a kind moves in or out of the never-forked set.
*/
const RAN: Record<FlowScriptFailureKind, ScriptRan> = {
queue: "no",
spawn: "no",
invalid: "no",
protocol: "unknown",
load: "yes",
runtime: "yes",
output: "yes",
exit: "yes",
timeout: "yes",
cancelled: "yes",
signal: "yes",
heap: "yes",
};
/** The move each answer asks the author to make. */
const NEXT_MOVE: Record<ScriptRan, string> = {
yes: "Check or restore its changes before you retry",
no: "Fix the reason before you retry",
unknown: "Check its changes before you retry",
};
/** How the same answer opens, anchored so a later "failed" cannot match it. */
const LEAD: Record<ScriptRan, string> = {
yes: "failed",
no: "did not run",
unknown: "may have run",
};
function headline(ran: ScriptRan): string {
return `Script "../../scripts/seed.mjs" ${LEAD[ran]};`;
}
describe("which side of the fail/error line a script failure lands on", () => {
it.each(Object.entries(VERDICTS))("reports a %s failure as %s", async (kind, status) => {
executeMock.mockResolvedValue(
outcome({ failure: { kind: kind as FlowScriptFailureKind, message: `the ${kind} message` } })
);
expect(await runScript()).toMatchObject({
kind: "script",
status,
reason: `the ${kind} message`,
});
});
it("errors, rather than blaming the flow, for a result carrying no failure at all", async () => {
executeMock.mockResolvedValue(outcome({ ok: false }));
expect(await runScript()).toMatchObject({
status: "error",
reason: "Script failed without a reason.",
});
});
});
describe("an executor note on the step report", () => {
it("rides into the reason of a step that PASSED", async () => {
executeMock.mockResolvedValue(
outcome({
ok: true,
output: {},
notes: ["timeout clamped to 300000ms.", "project_root did not exist."],
})
);
expect(await runScript()).toMatchObject({
status: "pass",
reason: "timeout clamped to 300000ms. project_root did not exist.",
});
});
it("leaves a quiet pass with no reason at all", async () => {
executeMock.mockResolvedValue(outcome({ ok: true, output: {} }));
const step = await runScript();
expect(step).toMatchObject({ status: "pass" });
expect(step).not.toHaveProperty("reason");
});
it("follows the failure message rather than replacing it", async () => {
executeMock.mockResolvedValue(
outcome({
failure: { kind: "timeout", message: "The script ran past its 1000ms limit." },
notes: ["timeout clamped to 1000ms."],
})
);
expect(await runScript()).toMatchObject({
status: "error",
reason: "The script ran past its 1000ms limit. timeout clamped to 1000ms.",
});
});
});
describe("the recorder reports the verdict the runner will", () => {
it.each(Object.keys(VERDICTS) as FlowScriptFailureKind[])(
"agrees with the runner about a %s failure",
async (kind) => {
const result = outcome({ failure: { kind, message: `the ${kind} message` } });
executeMock.mockResolvedValue(result);
const replayed = await runScript();
const recorded = await recordScript();
expect(recorded.status).toBe(scriptVerdict(result).status);
expect(recorded.status).toBe(replayed.status);
expect(recorded.reason).toBe(replayed.reason);
expect(await recordedSteps()).toEqual([]);
}
);
it.each(Object.entries(RAN) as [FlowScriptFailureKind, ScriptRan][])(
"tells the author whether a %s failure left anything behind",
async (kind, ran) => {
// The executor answers three of its failures WITHOUT forking anything, so
// "there is a result" does not mean "something ran", and telling an
// author to clean up after a queue that was full sends them hunting for
// state that was never created. `cancelled` counts as ran only once the
// executor has NOT marked it `beforeFork` - the half of that kind which
// stopped a process that was already running. `protocol` is the runner
// failing around the script - almost always before the script began, but
// reachable from a script that has already done its work, so it claims
// neither.
executeMock.mockResolvedValue(outcome({ failure: { kind, message: `the ${kind} message` } }));
const recorded = await recordScript();
expect(recorded.status).not.toBe("pass");
expect(recorded.message).toContain(NEXT_MOVE[ran]);
expect(recorded.message).toContain(headline(ran));
// The headline is the clause an agent acts on first, so it may not answer
// "is there state to check?" differently from the move that follows it.
for (const other of Object.keys(NEXT_MOVE) as ScriptRan[]) {
if (other !== ran) expect(recorded.message).not.toContain(headline(other));
}
expect(await recordedSteps()).toEqual([]);
}
);
// The executor's own answer outranks the table above: `cancelled` is the one
// kind that reaches this tool from both sides of the fork, and the executor
// marks the failures it raised before there was a child to run anything.
it.each(Object.keys(RAN) as FlowScriptFailureKind[])(
"believes the executor over the kind when a %s failure never forked",
async (kind) => {
executeMock.mockResolvedValue(
outcome({ failure: { kind, message: `the ${kind} message`, beforeFork: true } })
);
const recorded = await recordScript();
expect(recorded.message).toContain(NEXT_MOVE.no);
expect(recorded.message).toContain(headline("no"));
expect(recorded.message).not.toContain(NEXT_MOVE.yes);
expect(recorded.message).not.toContain(NEXT_MOVE.unknown);
expect(await recordedSteps()).toEqual([]);
}
);
it("agrees on a pass, and only then records the step", async () => {
const result = outcome({ ok: true, output: { order: { id: 7 } }, notes: ["a note."] });
executeMock.mockResolvedValue(result);
const replayed = await runScript();
const recorded = await recordScript();
expect(recorded.status).toBe("pass");
expect(recorded.status).toBe(replayed.status);
expect(recorded.reason).toBe(replayed.reason);
expect(recorded.outputJson).toBe('{"order":{"id":7}}');
expect(await recordedSteps()).toEqual([{ kind: "script", path: "../../scripts/seed.mjs" }]);
});
it("hands the executor the caller's cancellation signal", async () => {
executeMock.mockResolvedValue(outcome({ ok: true, output: {} }));
const controller = new AbortController();
await recordScript({ signal: controller.signal } as unknown as ToolContext);
expect(executedRequest().signal).toBe(controller.signal);
});
it("passes no signal when the caller has none", async () => {
executeMock.mockResolvedValue(outcome({ ok: true, output: {} }));
await recordScript();
expect("signal" in executedRequest()).toBe(false);
});
});