Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 16 additions & 16 deletions packages/skills/skills/argent-create-flow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,20 +161,20 @@ Record an `await-ui-element` step to **gate** the next step on a screen transiti
- `tool: gesture-pinch` → `pinch: { on: "<target>", scale: … }`, deriving `scale` as `endDistance / startDistance`. Set `on:` to the element under the pinch center when the pinch was aimed at one (the map or image being zoomed); omit it for a screen-center pinch. Don't carry the recorded distances/angle over — the directive re-derives the geometry (finger placement, system-edge avoidance, chaining of large scales) at run time, so the conversion swaps device-specific coordinates for a portable selector with auto-wait. Keep the raw `tool: gesture-pinch` step when the pinch is anchored at a specific point _inside_ a large element (zooming toward a particular map location, not the map's center) or deliberately pans via `endCenterX`/`endCenterY` — `on:` takes only a selector and re-centers the pinch on the element's frame center, so converting would silently move the zoom anchor.
- `tool: gesture-rotate` → `rotate: { on: "<target>", by: … }`, deriving `by` as `endAngle − startAngle` (the tool's `endAngle` > `startAngle` turns clockwise, matching the directive's positive `by`). Set `on:` to the element under the rotation center when the rotation was aimed at one (the map or image being rotated); omit it for a screen-center rotation. Don't carry the recorded `centerX`/`centerY`, radii (`radius` or `radiusX`/`radiusY`), `startAngle`, or `durationMs` over — the directive re-derives the geometry (finger placement, physical-circle radius, system-edge avoidance) and runs at a fixed pace (~90° per 300 ms), so the conversion swaps device-specific coordinates for a portable selector with auto-wait. Keep the raw `tool: gesture-rotate` step when the rotation is anchored at a specific point _inside_ a large element rather than its center (the directive re-centers on the element's frame center, so converting would silently move the pivot), when the gesture's speed itself matters (the directive's pace is fixed), or when the sweep exceeds the directive's ±3000° bound.

Every other recorded tool (a velocity-dependent `gesture-swipe`, a fixed-distance `gesture-scroll` not aimed at an element, `button`, `screenshot`, …) has no directive form — leave it as a `tool:` step. The recorder already handles the rest: coordinate `gesture-tap`s are captured as portable `tap:` selector steps, a `restart-app` is captured as a `launch:` step, a `flow-execute` of a sibling fragment is captured as a `run: <name>` composition directive, and device ids are stripped. Captured selectors are emitted in the strict map form (`tap: { text: General }`), never as a loose bare string — the recorder verified the exact element the tap hit, and a bare string would re-parse as loose and route through the identifier-first fallback it was never checked against. After editing, re-run with `flow-execute` to confirm the cleaned flow still passes.
Every other recorded tool (a velocity-dependent `gesture-swipe`, a fixed-distance `gesture-scroll` not aimed at an element, `button`, `screenshot`, …) has no directive form — leave it as a `tool:` step. The recorder already handles the rest: coordinate `gesture-tap`s are captured as portable `tap:` selector steps, a `restart-app` is captured as a `launch:` step, a `flow-execute` of a sibling fragment is captured as a `run: <name>` composition directive, and device ids are stripped. Captured selectors are emitted in the strict map form (`tap: { text: Settings }`), never as a loose bare string — the recorder verified the exact element the tap hit, and a bare string would re-parse as loose and route through the identifier-first fallback it was never checked against. After editing, re-run with `flow-execute` to confirm the cleaned flow still passes.

### Example session

```
flow-start-recording { name: "open-about", project_root: "/Users/dev/MyApp" }
flow-add-echo { message: "Start Settings from scratch" }
flow-add-step { command: "restart-app", args: "{\"udid\": \"ABC\", \"bundleId\": \"com.apple.Preferences\"}" } # ⇒ captured as `- launch: com.apple.Preferences` — this is now an e2e flow
flow-add-echo { message: "On the Settings root list, tapping the 'General' row" }
flow-add-step { command: "gesture-tap", args: "{\"udid\": \"ABC\", \"x\": 0.5, \"y\": 0.35}" } # ⇒ captured as `- tap: { text: General }` (portable selector, no udid)
flow-add-step { command: "await-ui-element", args: "{\"udid\": \"ABC\", \"condition\": \"visible\", \"selector\": {\"text\": \"About\"}}" } # gate the transition
flow-add-echo { message: "On Settings > General, tapping 'About'" }
flow-add-echo { message: "Start the app from scratch" }
flow-add-step { command: "restart-app", args: "{\"udid\": \"ABC\", \"bundleId\": \"com.example.myapp\"}" } # ⇒ captured as `- launch: com.example.myapp` — this is now an e2e flow
flow-add-echo { message: "On the app's root list, tapping the 'Settings' row" }
flow-add-step { command: "gesture-tap", args: "{\"udid\": \"ABC\", \"x\": 0.5, \"y\": 0.35}" } # ⇒ captured as `- tap: { text: Settings }` (portable selector, no udid)
flow-add-step { command: "await-ui-element", args: "{\"udid\": \"ABC\", \"condition\": \"visible\", \"selector\": {\"text\": \"Account\"}}" } # gate the transition
flow-add-echo { message: "On Settings, tapping 'Account'" }
flow-add-step { command: "gesture-tap", args: "{\"udid\": \"ABC\", \"x\": 0.5, \"y\": 0.17}" }
flow-add-step { command: "await-ui-element", args: "{\"udid\": \"ABC\", \"condition\": \"visible\", \"selector\": {\"text\": \"Model Name\"}}" }
flow-add-step { command: "await-ui-element", args: "{\"udid\": \"ABC\", \"condition\": \"visible\", \"selector\": {\"text\": \"Email\"}}" }
flow-finish-recording {}
```

Expand All @@ -198,14 +198,14 @@ The polished result of the example session above:

```yaml
steps:
- echo: Start Settings from scratch
- launch: com.apple.Preferences
- echo: On the Settings root list, tapping the 'General' row
- tap: { text: General }
- await: { visible: About }
- echo: On Settings > General, tapping 'About'
- tap: { text: About }
- await: { visible: Model Name }
- echo: Start the app from scratch
- launch: com.example.myapp
- echo: On the app's root list, tapping the 'Settings' row
- tap: { text: Settings }
- await: { visible: Account }
- echo: On Settings, tapping 'Account'
- tap: { text: Account }
- await: { visible: Email }
```

Note there is **no device id** anywhere in the file — the recorder strips them and the runner injects the bound device.
Expand Down
33 changes: 33 additions & 0 deletions packages/tool-server/src/tools/flows/flow-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,22 @@ export interface ActionEnv {
ctx?: ToolContext;
device: DeviceInfo;
signal?: AbortSignal;
/**
* Bundle id of an app the run launched that can never carry the
* view-hierarchy instrumentation. Set means every tree read is doomed, so
* they fail immediately with a terminal reason instead of polling a source
* that will never appear — and, more importantly, instead of auto-targeting
* whichever other app happens to still be connected.
*/
nonInjectableApp?: string;
}

/** Outcome of a selector directive: ok, or a machine-readable reason it failed. */
export interface DirectiveOutcome {
ok: boolean;
reason?: string;
/** Caveat carried by a step that still succeeded. */
warning?: string;
/** The run was cancelled mid-step — reported as a skip, not a step failure. */
aborted?: boolean;
/**
Expand Down Expand Up @@ -314,7 +324,24 @@ function flowSelectorToFrame(tree: DescribeNode, sel: FlowSelector): DescribeFra
* convert the outage into a misleading "element not found" downstream. The
* throw lands in the step's structured report via `execLeafStep`'s catch.
*/
/**
* Why no selector can resolve against an app that cannot be instrumented.
* Deliberately not the native-devtools recovery text, which tells the caller to
* use `describe`/`screenshot` — the answer for a flow is coordinate steps.
*/
export function nonInjectableTreeReason(bundleId: string): string {
return (
`\`${bundleId}\` is an Apple system app, so argent's view-hierarchy instrumentation can never ` +
`be injected into it and selector-based steps cannot resolve. This is terminal — relaunching or ` +
`restarting the argent server will not change it. Target this screen by coordinate ` +
`(\`tap: { x, y }\`) instead.`
);
}

export async function settleTree(env: ActionEnv): Promise<DescribeNode | undefined> {
if (env.nonInjectableApp) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: This derives a terminal verdict about the view hierarchy from bundleId.startsWith("com.apple.") rather than from whether the hierarchy read succeeds, and where those two disagree it turns a passing flow into a failing one.

With com.apple.Preferences in listConnectedBundleIds(), isConnected() returning true, applicationState: "active" and a getFullHierarchy payload containing Settings, the step assert: { visible: "Settings" } after launch: com.apple.Preferences:

main @3fff176a  ->  launch:pass, assert:pass   ok=true
this branch     ->  launch:pass, assert:fail   ok=false

with the reason "... can never be injected into it and selector-based steps cannot resolve. This is terminal — relaunching or restarting the argent server will not change it." — emitted while the read that message describes as impossible had already returned a tree containing Settings. Same for tap: { text: Search }.

That state is not hypothetical: it is what was measured on an iOS 18.5 simulator during the review of #560lsof showing both libArgentInjectionBootstrap.dylib and libNativeDevtoolsIos.dylib mapped into the running process, the process holding a live unix peer of the tool-server's own /tmp/argent-nd-<udid>.sock, native-devtools-status returning "connected": true and "injectable": false in the same response, and a selector fragment passing 4/4. #453 recorded connected: false on iOS 26.5 and #623 was filed from a 26.5 matrix, so the evidence splits by runtime; the message states one runtime's reading as a universal.

The clearest form of it is a pair that differs by one line of YAML. Same app, same connection, same three steps, same tree:

no `launch` step   ->  3/3 pass on BOTH branches   (the guard never arms)
`launch` prefixed  ->  assert:fail, tap:skip, assert:skip   on this branch only

A second consequence: for a system app that genuinely never connects, this branch emits byte-identical output to the connected case above, so the two are no longer distinguishable from the report. On the guards-removed build the unreadable case still fails, but says "could not read the UI tree: No native-devtools-connected apps are available for auto-targeting."

On isolating it — deleting only the two env.nonInjectableApp guards, keeping the flow-run.ts:298 gate skip and the launch warning, turns the whole file green at 16/16. The gate skip is not implicated.

On the 8 s the description cites as the cost being removed: waitForNativeDevtools tests api.isConnected(bundleId) at the top of its loop, before the first sleep, and isConnected is a synchronous Map.has. A connected app therefore already returns in ~0 ms on main. Measured, launching a connected system app takes 1507 ms on this branch and 1508 ms on main — the wait being removed never fires for the case that regresses. The full 8 s elapses only when the app never connects (9522 ms), and there main errored the launch, so no selector step ran.

throw new Error(nonInjectableTreeReason(env.nonInjectableApp));
}
const deadline = Date.now() + SETTLE_TIMEOUT_MS;
let prevFp: string | undefined;
let prevTree: DescribeNode | undefined;
Expand Down Expand Up @@ -992,6 +1019,12 @@ async function waitForCondition(
},
timeoutMs: number
): Promise<DirectiveOutcome> {
// Not routed through settleTree, so it needs its own guard — and a `hidden`
// wait is the one condition that would otherwise resolve TRUE off an
// unreadable screen.
if (env.nonInjectableApp) {
return { ok: false, reason: nonInjectableTreeReason(env.nonInjectableApp) };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: This early return omits indeterminate, so a when: guard reads an unreadable tree as a plainly-false condition and green-skips the block.

This one does not depend on the injectability question at all — it fires when the premise is entirely correct, on an app that genuinely never connects.

probeWhenCondition's own docstring states the contract: "indeterminate distinguishes an unreadable tree (the caller errors — unknown is not false) from a plainly unmet condition (the caller skips)." execWhenStep branches on exactly that flag, and its docstring adds that "silently skipping would let a broken tree source turn every guarded dismissal into a green no-op." Every other unreadable-tree exit in this same function sets it (the lastTrustedReadAt === undefined arm, and both !lastReadTrusted arms).

Driving launch: com.apple.Preferences then when: { hidden: "Onboarding" } wrapping an echo, against a registry whose resolveService throws:

this branch  ->  launch:pass, when:skip, echo:skip    ok=TRUE
                 when reason: 'condition not met (hidden text="Onboarding") — block skipped (1 step)'
main         ->  launch:error, when:skip              ok=false

The run reports green and the report asserts the element was not hidden, which nothing observed. The sibling await: { hidden: ... } on the identical state correctly reports fail, so the same condition yields two different verdicts depending only on which directive asked. That is the shape of #519, reached through the when: door.

}
const deadline = Date.now() + timeoutMs;

let lastMatches: ReturnType<typeof findAll> = [];
Expand Down
37 changes: 35 additions & 2 deletions packages/tool-server/src/tools/flows/flow-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ import {
type ActionEnv,
type DirectiveOutcome,
} from "./flow-actions";
import { nativeDevtoolsRef, type NativeDevtoolsApi } from "../../blueprints/native-devtools";
import {
nativeDevtoolsRef,
isInjectableBundleId,
type NativeDevtoolsApi,
} from "../../blueprints/native-devtools";
import { androidDevtoolsRef, type AndroidDevtoolsApi } from "../../blueprints/android-devtools";
import {
chromiumCdpRef,
Expand Down Expand Up @@ -111,6 +115,12 @@ export interface StepReport {
* percentage, baseline written/updated).
*/
reason?: string;
/**
* A caveat about a step that still passed — currently, launching an app that
* can never carry the view-hierarchy instrumentation. Renderers show it as a
* `⚠` in place of the pass glyph and count it in the summary.
*/
warning?: string;
/** Underlying tool id for `tool` steps. */
tool?: string;
/** Tool result for `tool` steps. */
Expand Down Expand Up @@ -282,6 +292,10 @@ async function treeSourceGate(
signal?: AbortSignal
): Promise<string | null> {
if (device.platform === "ios" && !signal?.aborted) {
// An app that can never be injected will never connect, so waiting the full
// timeout only delays advice that cannot work. The flow can still run every
// step that does not read the view hierarchy.
if (!isInjectableBundleId(bundleId)) return null;
const connected = await waitForNativeDevtools(registry, device, bundleId, signal);
if (!connected && !signal?.aborted) {
return (
Expand Down Expand Up @@ -396,11 +410,25 @@ async function runLaunch(state: ExecState, app: Launch): Promise<DirectiveOutcom
return { ok: false, reason: `restart-app failed: ${errMsg(err)}` };
}
if (!(await sleepOrAbort(POST_LAUNCH_SETTLE_MS, signal))) return ABORTED_OUTCOME;
// Recorded on every launch, so a later injectable launch clears it.
state.nonInjectableApp =
device.platform === "ios" && !isInjectableBundleId(bundleId) ? bundleId : undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: The guard is keyed on the launched bundle id, but the read it guards resolves a different quantity: flow-ios-tree.ts:287 calls resolveNativeTargetApp(nativeApi, undefined), which auto-targets from the connected list and never consults state.nonInjectableApp. Two consequences fall out of the mismatch.

It is bypassable. tool: { name: launch-app, args: { bundleId: com.apple.Preferences } } followed by the same assert never sets the flag:

tool:pass, assert:pass    on BOTH branches

Same app, same launch, same tree — the guard is silent because the launch did not go through the launch: directive. So the terminal claim holds or not depending on which spelling of "launch this app" the flow used.

It fires for an app that was never the read target. Launching com.apple.Preferences while a connected, active, injectable com.example.myapp is what auto-target would resolve: this branch blocks that read with "com.apple.Preferences ... can never be injected", naming an app that is not the one the read would have used.


const gate = await treeSourceGate(registry, device, bundleId, signal);
// The gate returns null (ready) on abort — check the signal before trusting
// it, or a cancelled gate would read as a launch that verified readiness.
if (signal?.aborted) return ABORTED_OUTCOME;
if (gate) return { ok: false, reason: gate };
if (state.nonInjectableApp) {
return {
ok: true,
warning:
`${bundleId} is an Apple system app: it is a platform binary with library validation, so ` +
`argent's view-hierarchy instrumentation can never be injected into it. The app launched — ` +
`coordinate steps (\`tap: { x, y }\`), \`wait\` and \`snapshot\` work; selector-based steps ` +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: This warning tells the flow author that snapshot works for such an app, and the guard added in this PR contradicts it.

snapshot: { cropOn } routes through waitForFrame into settleTree, which is the flow-actions.ts:342 throw. On launch: com.apple.Preferences + snapshot: { name: row, cropOn: { text: Settings } }:

"kind": "snapshot", "status": "error",
"reason": "`com.apple.Preferences` is an Apple system app, ... This is terminal ..."

On main the same step gets past the tree read. A plain snapshot with no cropOn does work, because flow-visual.ts swallows that throw — so the sentence is true for one form of the directive and false for the other. The PR description's design-choices paragraph lists snapshot as covered by the guard, which is the opposite of what this line tells the author.

`cannot resolve for this app.`,
};
}
return { ok: true };
}

Expand Down Expand Up @@ -1052,7 +1080,12 @@ async function execLeafStep(
// A run cancelled mid-launch is a skip (matching the pre-step guard and
// the directives), never a step failure — the app did nothing wrong.
if (r.aborted) return { ...base, status: "skip", reason: r.reason };
return { ...base, status: r.ok ? "pass" : "error", reason: r.reason };
return {
...base,
status: r.ok ? "pass" : "error",
reason: r.reason,
...(r.warning ? { warning: r.warning } : {}),
};
}

case "tap":
Expand Down
72 changes: 72 additions & 0 deletions packages/tool-server/test/flows/flow-composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,4 +321,76 @@ describe("flow validation", () => {
// …while a non-map, non-string body still gets the shape error.
expect(() => parseFlow("steps:\n - launch: 42\n")).toThrow(/launch needs an app id/i);
});
it("launches a non-injectable system app and keeps running coordinate steps", async () => {
// An Apple system app is a platform binary with library validation, so the
// instrumentation can never load. Waiting for it only delayed advice that
// could never work, and took the rest of the flow down with it — even when
// nothing in the flow needed the view hierarchy.
await writeFlow("main", {
executionPrerequisite: "",
steps: [
{ kind: "launch", app: "com.apple.Preferences" },
{ kind: "echo", message: "coordinate work still runs" },
],
});
const registry = {
invokeTool: vi.fn(async (id: string) =>
id === "list-devices" ? { devices: [] } : { ok: true }
),
getTool: vi.fn(() => undefined),
// Would throw if the gate consulted it — it must not for this bundle.
resolveService: vi.fn(async () => {
throw new Error("native-devtools unavailable");
}),
} as unknown as Registry;

const result = asRun(
await createRunFlowTool(registry).execute(
{},
{ name: "main", project_root: tmpDir, device: DEVICE }
)
);

expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["launch:pass", "echo:pass"]);
expect(result.steps[0].warning).toMatch(/system app/i);
// The advice that can never work must be gone.
expect(result.steps[0].warning).not.toMatch(/restart the argent server/i);
expect(result.ok).toBe(true);
});

it("fails a selector step against a non-injectable app terminally, not by timing out", async () => {
// Without this the step reaches auto-targeting and reports "Launch or
// restart the app first" — worse advice than the gate gave — and, if any
// other app is still connected, could resolve against ITS tree instead.
await writeFlow("main", {
executionPrerequisite: "",
steps: [
{ kind: "launch", app: "com.apple.Preferences" },
{ kind: "await", condition: "visible", selector: { text: "General" } },
],
});
const registry = {
invokeTool: vi.fn(async (id: string) =>
id === "list-devices" ? { devices: [] } : { ok: true }
),
getTool: vi.fn(() => undefined),
resolveService: vi.fn(async () => {
throw new Error("native-devtools unavailable");
}),
} as unknown as Registry;

const started = Date.now();
const result = asRun(
await createRunFlowTool(registry).execute(
{},
{ name: "main", project_root: tmpDir, device: DEVICE }
)
);

expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["launch:pass", "await:fail"]);
expect(result.steps[1].reason).toMatch(/can never be injected/i);
expect(result.steps[1].reason).toMatch(/terminal/i);
// Immediate, not after the selector timeout.
expect(Date.now() - started).toBeLessThan(8000);
});
});