mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
e24b7cac20
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>
237 lines
8.9 KiB
TypeScript
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/);
|
|
});
|
|
});
|