Files
software-mansion__argent/packages/tool-server/test/flows/flow-abort.test.ts
Jakub Piasecki d4a712c401 refactor(gesture): rename the momentum-free flag to momentum (#732)
`settle: true` collided with the runner's other `settle` — waiting for
the tree to quiesce — which is the very thing a swipe step does after
every dispatch, whichever way the flag is set. One sentence of the flow
skill used both senses at once.

This names the physics instead: `momentum: false` is the momentum-free
gesture, and the natural fling is the default the prose already
described.

### Renamed

| surface | before | after |
| --- | --- | --- |
| `swipe` flow directive | `settle: true` | `momentum: false` |
| `gesture-swipe` param | `settle` | `momentum` |
| `gesture-drag` param | `settle` | `momentum` |

The directive passes the flag straight through to the tools, so all
three move together. `momentum: true` is the default and normalizes to
absent, mirroring what `settle: false` did — parse/serialize stay exact
inverses.

### Also

- Recording summary label `(settle)` → `(momentum-free)`
- Internal constants → `MOMENTUM_FREE_EASE_EXPONENT`,
`MOMENTUM_FREE_MIN_STEPS`
- `flow-run` tool description and `argent-create-flow` skill updated

### Testing

`tsc -b`, eslint (`--max-warnings 0`) and prettier clean; `test/flows` +
`test/tools` pass 671/671, including a new case covering the
legacy-spelling rejection.
2026-08-28 09:55:47 +02:00

562 lines
21 KiB
TypeScript

import { afterEach, beforeEach, 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 type { DescribeNode, DescribeTreeData } from "../../src/tools/describe/contract";
// Cancel the run mid-directive by tripping an AbortController from inside the
// tree fetch itself: the mock counts reads and aborts on a scripted one, which
// lands the abort deterministically inside a directive's auto-wait / focus-wait
// poll (no timer races).
let currentFetch: () => DescribeTreeData;
vi.mock("../../src/tools/flows/flow-tree", async (importOriginal) => ({
...(await importOriginal<typeof import("../../src/tools/flows/flow-tree")>()),
fetchFlowTree: vi.fn(async (): Promise<DescribeTreeData> => currentFetch()),
}));
import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run";
import { serializeFlow } from "../../src/tools/flows/flow-utils";
const DEVICE = "00000000-0000-0000-0000-0000000000ab"; // iOS UDID shape
let tmpDir: string;
function n(partial: Partial<DescribeNode> & { frame: DescribeNode["frame"] }): DescribeNode {
return { role: "AXOther", children: [], ...partial };
}
function screen(children: DescribeNode[]): DescribeNode {
return n({ role: "AXWindow", frame: { x: 0, y: 0, width: 1, height: 1 }, children });
}
function mockRegistry(calls: string[]): Registry {
return {
invokeTool: vi.fn(async (id: string) => {
calls.push(id);
if (id === "list-devices") return { devices: [] };
return { ok: true };
}),
getTool: vi.fn(() => ({ inputSchema: { properties: { udid: {} } } })),
} as unknown as Registry;
}
async function writeFlow(name: string, yaml: Parameters<typeof serializeFlow>[0]): Promise<void> {
const dir = path.join(tmpDir, ".argent", "flows");
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, `${name}.yaml`), serializeFlow(yaml), "utf8");
}
function asRun(r: FlowRunResult | { notice: string }): FlowRunResult {
if (!("steps" in r)) throw new Error(`expected a run result, got notice: ${r.notice}`);
return r;
}
async function run(
name: string,
registry: Registry,
signal: AbortSignal,
device = DEVICE
): Promise<FlowRunResult> {
return asRun(
await createRunFlowTool(registry).execute({}, { name, project_root: tmpDir, device }, {
signal,
} as never)
);
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-abort-"));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe("run cancellation mid-directive", () => {
it("fails the verdict when an already-aborted run only contains echo narration", async () => {
const controller = new AbortController();
controller.abort();
currentFetch = () => ({ tree: screen([]), source: "native-devtools" });
await writeFlow("cancelled-echo", {
executionPrerequisite: "",
steps: [{ kind: "echo", message: "never narrated" }],
});
const result = await run("cancelled-echo", mockRegistry([]), controller.signal);
expect(result.steps).toMatchObject([{ kind: "echo", status: "skip", reason: "run aborted" }]);
// Echo remains excluded from displayed counters, but cancellation still
// makes the run incomplete rather than a zero-step pass.
expect(result.skipped).toBe(0);
expect(result.ok).toBe(false);
});
it("reports a tap cancelled during its auto-wait as a skip, not an offscreen failure", async () => {
const controller = new AbortController();
// The target never appears; the run is cancelled on the third tree read
// (i.e. while the tap's auto-wait is still polling).
let reads = 0;
currentFetch = () => {
reads++;
if (reads >= 3) controller.abort();
return {
tree: screen([n({ label: "Other", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 } })]),
source: "native-devtools",
};
};
const calls: string[] = [];
await writeFlow("cancelled-tap", {
executionPrerequisite: "",
steps: [{ kind: "tap", selector: { text: "Checkout", loose: true } }],
});
const result = await run("cancelled-tap", mockRegistry(calls), controller.signal);
// A skip with the uniform abort reason — NOT a fail with the misleading
// "no visible element matched … add a scroll-to step" hint.
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["tap:skip"]);
expect(result.steps[0].reason).toBe("run aborted");
expect(result.ok).toBe(false);
expect(calls).not.toContain("gesture-tap");
});
it("dispatches no tap when the run is cancelled during the settle-completing tree read", async () => {
const controller = new AbortController();
// The target is visible and the tree is stable, so read 2 is the read that
// completes the settle (two identical fingerprints). The abort lands inside
// that read — settleTree must NOT hand the tree back to waitForFrame, or the
// tap would be dispatched post-cancellation and recorded as a pass.
let reads = 0;
currentFetch = () => {
reads++;
if (reads >= 2) controller.abort();
return {
tree: screen([
n({ label: "Checkout", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 } }),
]),
source: "native-devtools",
};
};
const calls: string[] = [];
await writeFlow("cancelled-tap-settle", {
executionPrerequisite: "",
steps: [{ kind: "tap", selector: { text: "Checkout", loose: true } }],
});
const result = await run("cancelled-tap-settle", mockRegistry(calls), controller.signal);
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["tap:skip"]);
expect(result.steps[0].reason).toBe("run aborted");
expect(result.ok).toBe(false);
expect(calls).not.toContain("gesture-tap");
});
it("dispatches no tap when the run is cancelled one read before the settle completes", async () => {
// Control for the settle-completing case above: aborting during the FIRST
// read (not yet a fingerprint match) already skipped correctly — keep it so.
const controller = new AbortController();
let reads = 0;
currentFetch = () => {
reads++;
if (reads >= 1) controller.abort();
return {
tree: screen([
n({ label: "Checkout", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 } }),
]),
source: "native-devtools",
};
};
const calls: string[] = [];
await writeFlow("cancelled-tap-first-read", {
executionPrerequisite: "",
steps: [{ kind: "tap", selector: { text: "Checkout", loose: true } }],
});
const result = await run("cancelled-tap-first-read", mockRegistry(calls), controller.signal);
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["tap:skip"]);
expect(result.steps[0].reason).toBe("run aborted");
expect(calls).not.toContain("gesture-tap");
});
it("dispatches no scroll increment when the run is cancelled during a mid-scroll settle read", async () => {
const controller = new AbortController();
// The target never appears and the tree is stable — after read 2 settles,
// scroll-to would dispatch its first swipe increment. The abort lands inside
// that settle-completing read, so no gesture may follow it.
let reads = 0;
currentFetch = () => {
reads++;
if (reads >= 2) controller.abort();
return {
tree: screen([n({ label: "Row 1", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 } })]),
source: "native-devtools",
};
};
const calls: string[] = [];
await writeFlow("cancelled-scroll-settle", {
executionPrerequisite: "",
steps: [{ kind: "scroll-to", target: { text: "Order #1234" }, direction: "down" }],
});
const result = await run("cancelled-scroll-settle", mockRegistry(calls), controller.signal);
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["scroll-to:skip"]);
expect(result.steps[0].reason).toBe("run aborted");
expect(calls).not.toContain("gesture-swipe");
expect(calls).not.toContain("gesture-scroll");
});
// gesture-swipe consults the abort signal per 16ms frame and rejects instead of
// holding a finger down for the rest of the duration. That rejection reaches the
// flow through invokeOnDevice and invokeSubTool, so every dispatch site has to
// map it back onto the uniform abort skip - otherwise a cancelled run reads as
// broken rather than cancelled.
function abortingGestureRegistry(
calls: string[],
controller: AbortController,
gestureTool = "gesture-swipe"
): Registry {
return {
invokeTool: vi.fn(async (id: string) => {
calls.push(id);
if (id === "list-devices") return { devices: [] };
if (id === gestureTool) {
controller.abort();
const err = new Error(
`${gestureTool} aborted - cancelled mid-gesture after 3 of 301 frames`
);
err.name = "AbortError";
throw err;
}
return { ok: true };
}),
getTool: vi.fn(() => ({ inputSchema: { properties: { udid: {} } } })),
} as unknown as Registry;
}
it("reports a swipe cancelled inside the gesture as a skip, not the tool's error", async () => {
const controller = new AbortController();
currentFetch = () => ({
tree: screen([n({ label: "Row 1", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 } })]),
source: "native-devtools",
});
const calls: string[] = [];
await writeFlow("cancelled-mid-swipe", {
executionPrerequisite: "",
steps: [{ kind: "swipe", direction: "left" }],
});
const result = await run(
"cancelled-mid-swipe",
abortingGestureRegistry(calls, controller),
controller.signal
);
expect(calls).toContain("gesture-swipe");
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["swipe:skip"]);
expect(result.steps[0].reason).toBe("run aborted");
expect(result.aborted).toBe(true);
});
it("reports a scroll-to cancelled inside its increment as a skip, not the tool's error", async () => {
const controller = new AbortController();
// The target never appears and the tree is stable, so the settle completes
// and scroll-to dispatches its first swipe increment - which is where the
// cancellation lands here, one layer deeper than the settle-read case above.
currentFetch = () => ({
tree: screen([n({ label: "Row 1", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 } })]),
source: "native-devtools",
});
const calls: string[] = [];
await writeFlow("cancelled-mid-increment", {
executionPrerequisite: "",
steps: [{ kind: "scroll-to", target: { text: "Order #1234" }, direction: "down" }],
});
const result = await run(
"cancelled-mid-increment",
abortingGestureRegistry(calls, controller),
controller.signal
);
expect(calls).toContain("gesture-swipe");
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["scroll-to:skip"]);
expect(result.steps[0].reason).toBe("run aborted");
expect(result.aborted).toBe(true);
});
it("reports a raw tool step cancelled inside the gesture as a skip, not the tool's error", async () => {
const controller = new AbortController();
// The third dispatch site, and the one flow-yaml.md sends authors to for a
// flick faster than the swipe directive's floor: a raw `tool: gesture-swipe`
// step, whose signal invokeSubTool forwards to the tool itself.
currentFetch = () => ({ tree: screen([]), source: "native-devtools" });
const calls: string[] = [];
await writeFlow("cancelled-raw-swipe", {
executionPrerequisite: "",
steps: [
{
kind: "tool",
name: "gesture-swipe",
args: { fromX: 0.5, fromY: 0.8, toX: 0.5, toY: 0.2, durationMs: 16 },
},
],
});
const result = await run(
"cancelled-raw-swipe",
abortingGestureRegistry(calls, controller),
controller.signal
);
expect(calls).toContain("gesture-swipe");
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["tool:skip"]);
expect(result.steps[0].reason).toBe("run aborted");
// Not the tool's own "aborted - cancelled mid-gesture after N of M frames".
expect(result.steps[0].reason).not.toMatch(/frames/);
expect(result.aborted).toBe(true);
});
it("reports a chromium long-press cancelled inside the hold as a skip, not the tool's error", async () => {
const controller = new AbortController();
// On chromium a long-press IS a gesture-drag with from == to, so the tool's
// per-frame abort reaches this dispatch site as well.
currentFetch = () => ({
tree: screen([n({ label: "Row 3", frame: { x: 0.1, y: 0.4, width: 0.8, height: 0.1 } })]),
source: "native-devtools",
});
const calls: string[] = [];
await writeFlow("cancelled-mid-press", {
executionPrerequisite: "",
steps: [{ kind: "long-press", selector: { text: "Row 3", loose: true } }],
});
const result = await run(
"cancelled-mid-press",
abortingGestureRegistry(calls, controller, "gesture-drag"),
controller.signal,
"chromium-cdp-9222"
);
expect(calls).toContain("gesture-drag");
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["long-press:skip"]);
expect(result.steps[0].reason).toBe("run aborted");
expect(result.aborted).toBe(true);
});
it("dispatches no focus tap when a type step is cancelled during the settle-completing read", async () => {
const controller = new AbortController();
// Same timing as the tap case, but for `type`: the leak would be the focus
// tap (the keyboard dispatches are separately guarded already).
let reads = 0;
currentFetch = () => {
reads++;
if (reads >= 2) controller.abort();
return {
tree: screen([
n({ identifier: "email", frame: { x: 0.1, y: 0.2, width: 0.8, height: 0.06 } }),
]),
source: "native-devtools",
};
};
const calls: string[] = [];
await writeFlow("cancelled-type-settle", {
executionPrerequisite: "",
steps: [{ kind: "type", into: { identifier: "email" }, text: "a@b.com" }],
});
const result = await run("cancelled-type-settle", mockRegistry(calls), controller.signal);
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["type:skip"]);
expect(result.steps[0].reason).toBe("run aborted");
expect(calls).not.toContain("gesture-tap");
expect(calls).not.toContain("keyboard");
});
it("injects no keyboard input when the run is cancelled during the focus wait", async () => {
const controller = new AbortController();
// Reads 1-2 are the pre-tap settle (field resolves immediately); read 3 is
// the focus poll's first look — the field never reports focus, and the run
// is cancelled there.
let reads = 0;
currentFetch = () => {
reads++;
if (reads >= 3) controller.abort();
return {
tree: screen([
n({ identifier: "email", frame: { x: 0.1, y: 0.2, width: 0.8, height: 0.06 } }),
]),
source: "native-devtools",
};
};
const calls: string[] = [];
await writeFlow("cancelled-type", {
executionPrerequisite: "",
steps: [{ kind: "type", into: { identifier: "email" }, text: "a@b.com" }],
});
const result = await run("cancelled-type", mockRegistry(calls), controller.signal);
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["type:skip"]);
expect(result.steps[0].reason).toBe("run aborted");
// The focus tap fired before the cancel, but neither the text nor the
// submitting Enter may reach the app afterwards.
expect(calls).toContain("gesture-tap");
expect(calls).not.toContain("keyboard");
});
it("attributes abort skips inside a fragment to the fragment, not the root", async () => {
const controller = new AbortController();
// The fragment's tap polls for a target that never appears; the run is
// cancelled on the third tree read, mid-auto-wait. The steps after the tap
// then hit execSteps' abort-skip branch (the run: line) and the hard-stop
// branch (the trailing echo) — with the fragment still on the run stack.
// Every skip line must carry the fragment's attribution, and the run: line
// its own target stem, identical to the executed and hard-stop paths.
let reads = 0;
currentFetch = () => {
reads++;
if (reads >= 3) controller.abort();
return {
tree: screen([n({ label: "Other", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 } })]),
source: "native-devtools",
};
};
await writeFlow("other", {
executionPrerequisite: "",
steps: [{ kind: "echo", message: "never loaded" }],
});
await writeFlow("frag", {
executionPrerequisite: "",
steps: [
{ kind: "tap", selector: { text: "Checkout", loose: true } },
{ kind: "run", flow: "other.yaml" },
{ kind: "echo", message: "fragment tail" },
],
});
await writeFlow("main", {
executionPrerequisite: "",
steps: [{ kind: "run", flow: "frag.yaml" }],
});
const result = await run("main", mockRegistry([]), controller.signal);
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"run:pass",
"tap:skip",
"run:skip",
"echo:skip",
]);
// The cancelled tap and the abort-skipped run: line report the uniform
// abort reason; the echo after them is a plain hard-stop skip.
expect(result.steps[1]).toMatchObject({ flow: "frag", reason: "run aborted" });
expect(result.steps[2]).toMatchObject({
flow: "other",
target: "other.yaml",
reason: "run aborted",
});
expect(result.steps[3]).toMatchObject({ flow: "frag", message: "fragment tail" });
expect(result.ok).toBe(false);
});
it("reports an await cancelled mid-poll as a skip with the uniform abort reason", async () => {
const controller = new AbortController();
let reads = 0;
currentFetch = () => {
reads++;
if (reads >= 3) controller.abort();
return {
tree: screen([n({ label: "Other", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 } })]),
source: "native-devtools",
};
};
const calls: string[] = [];
await writeFlow("cancelled-await", {
executionPrerequisite: "",
steps: [{ kind: "await", condition: "visible", selector: { identifier: "spinner" } }],
});
const result = await run("cancelled-await", mockRegistry(calls), controller.signal);
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["await:skip"]);
expect(result.steps[0].reason).toBe("run aborted");
});
});
describe("run cancellation mid-launch", () => {
// Like mockRegistry, but the restart-app call runs a scripted hook first —
// tripping the abort deterministically inside the launch step.
function launchRegistry(calls: string[], onRestartApp: () => unknown): Registry {
return {
invokeTool: vi.fn(async (id: string) => {
calls.push(id);
if (id === "list-devices") return { devices: [] };
if (id === "restart-app") return onRestartApp();
return { ok: true };
}),
getTool: vi.fn(() => ({ inputSchema: { properties: { udid: {} } } })),
} as unknown as Registry;
}
it("reports a launch cancelled during the post-launch settle as a skip", async () => {
const controller = new AbortController();
const calls: string[] = [];
// restart-app succeeds, but the run is cancelled right after — the abort
// lands in the post-launch settle / tree-source gate.
const registry = launchRegistry(calls, () => {
controller.abort();
return { ok: true };
});
await writeFlow("cancelled-launch-settle", {
executionPrerequisite: "",
steps: [{ kind: "launch", app: "com.acme.app" }],
});
const result = await run("cancelled-launch-settle", registry, controller.signal);
// A skip with the uniform abort reason — NOT a pass: the settle and the
// tree-source gate were cut short, so the launch verified nothing.
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["launch:skip"]);
expect(result.steps[0].reason).toBe("run aborted");
expect(result.ok).toBe(false);
expect(calls).toContain("restart-app");
});
it("reports a launch cancelled during restart-app as a skip, not an error", async () => {
const controller = new AbortController();
const calls: string[] = [];
// The cancellation makes the restart-app sub-tool itself reject: that
// rejection is the abort, not an app failure.
const registry = launchRegistry(calls, () => {
controller.abort();
throw new Error("This operation was aborted");
});
await writeFlow("cancelled-launch-restart", {
executionPrerequisite: "",
steps: [{ kind: "launch", app: "com.acme.app" }],
});
const result = await run("cancelled-launch-restart", registry, controller.signal);
// A skip with the uniform abort reason — NOT an error blaming restart-app.
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["launch:skip"]);
expect(result.steps[0].reason).toBe("run aborted");
expect(calls).toContain("restart-app");
});
});