Files
software-mansion__argent/packages/tool-server/test/flows/flow-nested-outcome.test.ts
filip131311 e24b7cac20 fix(flows): let a nested run's verdict reach the run that contains it (#697)
Fixes #606.

## The bug

A flow step that runs another orchestrator reported `pass` whatever the
nested run did. `case "tool"` treats any non-throwing result as a pass,
and both `flow-execute` and `run-sequence` report failure *in their
result* rather than by throwing.

Reproduced live — the **same flow**, run two ways:

```
run DIRECTLY                            ->  ok=False  passed=0  failed=1
run NESTED via raw `tool: flow-execute`  ->  OUTER ok=True  passed=1  failed=0
                                             step status = pass
                                             sub-report right there: ok=False failed=1
```

The failing verdict was sitting inside the very object being reported as
a pass. A second shape lost the verdict entirely: a sub-flow whose
`executionPrerequisite` was never acknowledged returns a notice, runs
**zero** steps, and also reported a green pass.

## The fix

Two shapes, mapped to statuses the runner already has:

| nested result | status | why |
|---|---|---|
| `ok: false` | **fail** | the composed flow ran and its assertions
failed — what an inline `run:` composition already produces |
| `notice`, zero steps | **error** | never runnable as written; the
class the runner already uses for an unreadable fragment or a cyclic
reference |
| `aborted: true` | **skip** | matches the runner's own rule that a
cancelled step is a skip, never a failure |

Both fail and error hard-stop — in this runner *every* fail and error
does (`state.stopped`), and there is no continue-on-failure concept: a
per-step `optional:` is rejected at parse time because `when:` already
expresses it.

### `run-sequence` had the same hole

Found while reviewing the plan. `run-sequence` has **no verdict field at
all** — every failure path (disallowed tool, unsupported operation,
unmet `await-ui-element`, a tool that threw) pushes an `error` entry,
`break`s, and returns normally. So a flow step whose sequence stopped at
step 1 of 8 also reported a pass. Fixed here rather than left as a known
identical bug on the same line.

### Why not a blanket `ok === false` rule

There is no `ok` contract in this codebase to generalise. The only other
soft-verdict tool spells it `success` (`await-ui-element`),
`run-sequence` spells it neither way, and `case "tool"` dispatches tools
whose results are typed `unknown` or `Record<string, unknown>` — several
carrying app-derived payloads. A blanket rule would silently bind all of
those, and every tool added later, to "a key called `ok` decides my
flow's verdict". `isUnmetUiWaitResult` set the precedent for naming the
tool instead. **There is a test pinning this**, so a future blanket
refactor trips.

Verified exhaustively: exactly one registered tool returns a top-level
`ok` in its result — `flow-execute`. In particular
`settings-permissions` does *not*; its `{ok: false}` is a private
per-`pm`-invocation type that either throws (already an error) or
returns `applied`/`skipped` (a legitimate pass).

## Verified live

```
raw-b-fail   direct  -> ok=False failed=1
             nested  -> OUTER ok=False failed=1, step fail
                        reason: flow "b-fail" failed: 0 passed, 1 failed, 0 errored (await: …)
                        sub-report attached
raw-b-prereq nested  -> OUTER ok=False errored=1, step error
                        reason: flow "b-prereq" did not run — its execution prerequisite was not
                        acknowledged: Settings must be open. Add prerequisiteAcknowledged: true to
                        the step's args, or compose with run: instead.
```

The direct and nested verdicts now agree, which is the exact discrepancy
in the issue.

11 new tests in a new file; the 5 behavioural ones each confirmed to
fail before and pass after. Full suite 3097 passing; lint, prettier,
`typecheck:tests` and the tool-description gate clean.

## Reporting shape

One failing step carrying a summarising `reason` **plus the whole
sub-report in `result`** — the pass path already attached `result`, so
this is the same shape with a non-pass status. The nested steps are
deliberately *not* spliced into the outer `steps[]`: `run:` can expand
inline only because it shares one `ExecState` (one index sequence, one
depth base, one device, one baseline dir), whereas a raw `tool:
flow-execute` is a separate runner invocation. Splicing would mean
renumbering indices and re-homing artifacts — a wire-format change for a
bug fix. Nothing is lost: MCP renders `result` for any step that has
one, and the CLI (which renders only `reason`) gets the sub-flow's own
first failure inside the reason string.

No new `StepReport` fields, no wire-format change; older clients render
the new line unchanged.

## Merge ordering and conflicts

- **Merge #696 (#607) first.** Before it, a nested `flow-execute` could
run against a stale baked-in device and legitimately report `ok: false`;
with this landed that becomes a parent failure and would read like a
regression *caused by* this PR. No code-level conflict — #696 touches
`flow-device.ts` and three other test files.
- **#578 rewrites this exact block** (`case "tool"`, adding an
`evidence` code to each return) and adds
`StepReport.failure`/`durationMs`. Whichever lands second should give
the new branches an evidence code — likely `nested-flow-failed` /
`nested-flow-prerequisite-unacknowledged` / `nested-flow-aborted` — so
its CI diagnostics classify the composition case. **#677** also edits
`execLeafStep` and adds `StepReport.warning`.

## Behaviour change worth knowing

Previously-green flows containing a nested composition that was silently
failing will now go red. That is the fix, but it surfaces pre-existing
breakage on upgrade. The likeliest one is a recorded raw step missing
`prerequisiteAcknowledged`, which becomes a hard error instead of a
silent no-op — the reason names both remedies.

## Follow-up, deliberately not bundled

Raw `tool: flow-execute` nesting has **no cycle or depth guard**:
`MAX_RUN_DEPTH` and the run-stack cycle check cover only `run:`, so a
flow whose raw step names itself recurses through fresh runner
invocations. Independent of this issue; filing separately.

---

> **Stacked on #649** (`filip/flow-deviceless-and-counts`). Both edit
the import block of `packages/tool-server/src/tools/flows/flow-run.ts` —
#649 widens the `./flow-device` import for its device-optional run, this
one adds `./flow-nested-outcome`. Rebased on #649, so review only the
top commit. GitHub retargets it to `main` when #649 merges.

Co-authored-by: Filip131311 <f.kaminski2000@gmail.com>
2026-08-04 10:32:57 +02:00

237 lines
8.9 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { Registry } from "@argent/registry";
import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run";
import { nestedOrchestratorOutcome } from "../../src/tools/flows/flow-nested-outcome";
/**
* Issue #606: a step that runs a nested orchestrator reported `pass` whatever
* the nested run actually did. The generic `tool` step treats any non-throwing
* result as a pass, and both `flow-execute` and `run-sequence` report failure in
* their result rather than by throwing.
*
* Measured before the fix: the same flow reported `ok=false, failed=1` when run
* directly and `ok=true, passed=1` when nested — with the failing sub-report
* sitting inside the result object being called a pass.
*/
const PROJECT_ROOT = path.join(os.tmpdir(), `flow-nested-tests-${process.pid}`);
function makeRegistry(invoke: (id: string, args: unknown) => Promise<unknown>) {
return {
invokeTool: vi.fn(invoke),
getTool: vi.fn(() => undefined),
} as unknown as Registry;
}
async function writeFlow(yaml: string): Promise<string> {
const flowsDir = path.join(PROJECT_ROOT, ".argent", "flows");
const file = path.join(flowsDir, "outer.yaml");
await fs.mkdir(flowsDir, { recursive: true });
await fs.writeFile(file, yaml, "utf8");
return file;
}
afterEach(async () => {
await fs.rm(PROJECT_ROOT, { recursive: true, force: true });
});
function asRun(r: FlowRunResult | { notice: string }): FlowRunResult {
if (!("steps" in r)) throw new Error(`expected a FlowRunResult, got a notice: ${r.notice}`);
return r;
}
/** A nested orchestrator step, followed by a step that must not run if it fails. */
const OUTER = (tool: string) => `executionPrerequisite: ""
steps:
- tool: ${tool}
args:
name: sub
- tool: gesture-tap
args:
udid: X
x: 0.5
y: 0.5
`;
async function run(tool: string, nestedResult: unknown) {
const flowFile = await writeFlow(OUTER(tool));
const registry = makeRegistry(async (id) => (id === tool ? nestedResult : { ok: true }));
const result = asRun(
await createRunFlowTool(registry).execute(
{},
{ name: "outer", project_root: PROJECT_ROOT, flow_file: flowFile, device: "DEV" }
)
);
return { result, registry };
}
/** A sub-flow that ran and failed. */
const FAILED_SUBFLOW = {
flow: "sub",
device: "DEV",
executionPrerequisite: "",
ok: false,
passed: 0,
failed: 1,
skipped: 0,
errored: 0,
steps: [
{
index: 0,
kind: "await",
status: "fail",
tool: "await-ui-element",
reason: "no element matched the selector before timeout",
},
],
};
describe("a nested flow-execute reports its own verdict", () => {
it("fails the step when the composed flow failed", async () => {
const { result, registry } = await run("flow-execute", FAILED_SUBFLOW);
expect(result.steps[0].status).toBe("fail");
expect(result.steps[0].reason).toMatch(/flow "sub" failed/);
expect(result.steps[0].reason).toMatch(/1 failed/);
// The sub-flow's own reason is surfaced, so the CLI — which renders only
// `reason` — says what actually went wrong rather than just "it failed".
expect(result.steps[0].reason).toMatch(/no element matched/);
// The whole sub-report still rides along for clients that render results.
expect(result.steps[0].result).toEqual(FAILED_SUBFLOW);
// …and the run hard-stops, exactly as an inline `run:` composition would.
expect(result.steps[1].status).toBe("skip");
expect(registry.invokeTool).not.toHaveBeenCalledWith("gesture-tap", expect.anything());
expect(result.ok).toBe(false);
expect(result.failed).toBe(1);
});
it("errors the step when the composed flow ran nothing at all", async () => {
// An unmet executionPrerequisite returns a notice and zero steps. Nothing
// was asserted, so this is not a failure of the app — it is a step that was
// never runnable as written.
const { result } = await run("flow-execute", {
flow: "sub",
notice: "This flow has an execution prerequisite that must be fulfilled before it can run.",
executionPrerequisite: "Settings is open on the root page",
});
expect(result.steps[0].status).toBe("error");
expect(result.steps[0].reason).toMatch(/did not run/);
expect(result.steps[0].reason).toMatch(/Settings is open on the root page/);
// The remedy has to be in the reason: it is all the CLI shows.
expect(result.steps[0].reason).toMatch(/prerequisiteAcknowledged/);
expect(result.errored).toBe(1);
expect(result.ok).toBe(false);
});
it("treats a cancelled nested run as a skip, not a failure", async () => {
const { result } = await run("flow-execute", { ...FAILED_SUBFLOW, aborted: true });
expect(result.steps[0].status).toBe("skip");
expect(result.steps[0].reason).toMatch(/aborted/);
expect(result.failed).toBe(0);
});
it("still passes a composed flow that succeeded", async () => {
const passing = { ...FAILED_SUBFLOW, ok: true, passed: 1, failed: 0, steps: [] };
const { result, registry } = await run("flow-execute", passing);
expect(result.steps[0].status).toBe("pass");
expect(result.steps[0].result).toEqual(passing);
expect(result.steps[1].status).toBe("pass");
expect(registry.invokeTool).toHaveBeenCalledWith("gesture-tap", expect.anything());
expect(result.ok).toBe(true);
});
});
describe("a nested run-sequence reports its own verdict", () => {
// run-sequence has no verdict field at all: every failure path pushes an
// `error` entry, breaks the loop and returns normally, so a sequence that
// stopped on its first step looked like an ordinary result.
it("fails the step when a step in the sequence failed", async () => {
const { result } = await run("run-sequence", {
completed: 1,
total: 3,
steps: [
{ tool: "gesture-tap", result: { tapped: true } },
{ tool: "keyboard", error: "keyboard failed: device not found" },
],
});
expect(result.steps[0].status).toBe("fail");
expect(result.steps[0].reason).toMatch(/run-sequence stopped at keyboard/);
expect(result.steps[0].reason).toMatch(/1 of 3/);
expect(result.steps[0].reason).toMatch(/device not found/);
expect(result.steps[1].status).toBe("skip");
expect(result.ok).toBe(false);
});
it("skips when the sequence was cut short by cancellation", async () => {
// No error entry, but fewer step results than steps requested — the only
// other way run-sequence leaves its loop.
const { result } = await run("run-sequence", {
completed: 1,
total: 4,
steps: [{ tool: "gesture-tap", result: { tapped: true } }],
});
expect(result.steps[0].status).toBe("skip");
expect(result.steps[0].reason).toMatch(/aborted/);
expect(result.failed).toBe(0);
});
it("still passes a sequence that ran every step", async () => {
const { result } = await run("run-sequence", {
completed: 2,
total: 2,
steps: [
{ tool: "gesture-tap", result: { tapped: true } },
{ tool: "keyboard", result: { typed: true } },
],
});
expect(result.steps[0].status).toBe("pass");
expect(result.ok).toBe(true);
});
});
describe("the check is deliberately scoped to the two orchestrator tools", () => {
// There is no `ok` contract in this codebase to generalise: await-ui-element
// spells it `success`, run-sequence spells it neither way, and the generic
// `tool` step dispatches tools whose results are typed `unknown` — some
// carrying app-derived payloads. A blanket "ok: false fails the step" rule
// would bind all of those, and everything added later, to a key name.
it("leaves an ordinary tool's `ok` field alone", async () => {
const { result } = await run("gesture-tap", { ok: false });
expect(result.steps[0].status).toBe("pass");
});
it("ignores a result shape it does not recognise", async () => {
for (const shape of [null, "text", 42, {}, { steps: [] }, { ok: "no" }]) {
expect(nestedOrchestratorOutcome("flow-execute", shape)).toBeUndefined();
}
});
it("never throws on a malformed nested report", () => {
expect(() =>
nestedOrchestratorOutcome("flow-execute", { ok: false, steps: [null, 7, { status: "fail" }] })
).not.toThrow();
expect(() => nestedOrchestratorOutcome("run-sequence", { steps: "nope" })).not.toThrow();
});
it("says which step failed even when the sub-report is partly malformed", () => {
const out = nestedOrchestratorOutcome("flow-execute", {
ok: false,
steps: [null, { status: "fail", kind: "assert" }],
});
expect(out?.status).toBe("fail");
// No tool and no reason on that entry — it still names the kind rather than
// rendering "undefined".
expect(out?.reason).toMatch(/assert: no reason given/);
});
});