Skip to content

fix(flow): raise the flow tree's depth cap and say why targeting failed - #721

Open
hubgan wants to merge 25 commits into
fix/ui-text-invisible-charactersfrom
fix/ios-flow-tree-depth-and-targeting
Open

fix(flow): raise the flow tree's depth cap and say why targeting failed#721
hubgan wants to merge 25 commits into
fix/ui-text-invisible-charactersfrom
fix/ios-flow-tree-depth-and-targeting

Conversation

@hubgan

@hubgan hubgan commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #720.

Depth cap: 40 → 100

The flow selector tree capped raw UIView nesting at 40. That does not clear the invisible wrapper layers React Native stacks on every screen — nested navigators (RNSScreenStackViewRNSScreenView, often two deep) plus a drawer/root wrapper routinely bury on-screen content 40–60 levels down before the first tappable view.

In a deeply nested production app, plainly visible interactive elements sat at depths 41–62. So id: and text: selectors silently failed to resolve, and only coordinate taps worked — which is the origin of coordinate-heavy flows on those apps.

The overflow is silent: nothing reports "truncated", selectors just stop matching. So the new cap carries generous headroom rather than hugging the deepest observed measurement.

What it costs. The tree itself is internal to selector resolution — consumed by selectorToFrame/evaluateCondition, never returned — so no tool result carries it. Text derived from it does reach the agent, though: assertReason's text arm quotes the matched node's hoisted subtreeText verbatim into a failing assert/await reason, and nothing truncates it downstream. Descendants a depth-40 read dropped now hoist, so that quoted string grows with the cap — and on one arm compatibilityMissNote quotes the same hoisted string a second time, doubling the rate. That is the trade — under the old cap those selectors did not resolve at all. Otherwise the cap only grows the getFullHierarchy payload over the native-devtools socket, which is already field-limited: ~11KB at depth 40, ~15KB at 48, and nothing at all past the tree's real depth.

Targeting errors that could not be acted on

Provide bundleId explicitlyresolveNativeTargetApp's own errors close with that advice. A flow selector step cannot follow it: the call hardcodes auto-targeting. Each failure now carries the remedy that actually exists:

  • ambiguous connected set — foreground the intended app with launch-app (which does not terminate, so the instrumentation it already has survives), and clear the others with xcrun simctl terminate (argent exposes no terminate tool, and restart-app would just bring that app back to the front). The command is addressed to the device's own CoreSimulator set, so it resolves for a simulator from a configured ios.additionalDeviceSets.
  • a lone connected app that is not foreground — same foreground remedy; it is already instrumented, so a relaunch would only discard state.
  • the state probe failed while connections are live — do not relaunch; the apps are instrumented and a suspended one simply stopped answering.
  • no connected app — this is the one state a relaunch fixes, through restart-app or a flow launch step.

launch-app, or a flow launch step — this was wrong in a way that wastes a cycle. launch-app does not terminate first, so against an app already running from Metro/Expo, Xcode, or its home-screen icon it only foregrounds that same uninstrumented process, and the next read fails identically. Only restart-app (terminate + relaunch) guarantees an instrumented launch whatever was already running.

These reasons are repeated per step — the recorder embeds one in the warning for every captured tap, and a failing await: repeats it per poll — so the two that enumerate connected apps are capped at two entries plus a count of what was left out, and the whole set is held under a documented ceiling by a test that iterates every branch.

FailureError data is preserved when these messages wrap an underlying error, so the failure code still reaches callers that switch on it.

Launch gate: same wait for every bundle, different fallback

Argent treats an Apple system app as non-injectable and guarantees it nothing — but it does not follow that one never connects. On iOS 18.3 and 26.5 simulators, Preferences/Maps/Contacts/Health/Photos and Safari all connect after the restart-app a launch step runs, and a flow assert against the view hierarchy passes: the simulator applies no library validation, so the injected dylib does load.

So the gate waits for every bundle. That wait is also what ties the launched bundle to the app a later selector step auto-targets — resolveNativeTargetApp(api, undefined) never compares the two — so skipping it per bundle could hand the next step a different app's hierarchy and still report the run green.

Only the fallback advice differs. For an injectable bundle the suspect is a stale or duplicate argent server holding the connection. For a com.apple.* one that keeps failing, the launch directive itself is what stops the run, so the remedy names the edit that gets past it: replace it with a raw tool: restart-app step (same terminate + relaunch, dispatched through the registry without the gate) and drive the app with point taps and tool: await-ui-element steps, which read the AX tree instead of selectors.

@hubgan
hubgan force-pushed the fix/ios-flow-tree-depth-and-targeting branch from ed821f3 to 21f2505 Compare August 5, 2026 10:15
@hubgan
hubgan force-pushed the fix/ios-flow-tree-depth-and-targeting branch from 21f2505 to 3fe5857 Compare August 5, 2026 10:38
@hubgan
hubgan force-pushed the fix/ios-flow-tree-depth-and-targeting branch from 3fe5857 to fff6208 Compare August 6, 2026 09:53
@hubgan
hubgan requested review from j-piasecki and latekvo August 7, 2026 07:51
@hubgan
hubgan marked this pull request as ready for review August 7, 2026 07:51

@j-piasecki j-piasecki left a comment

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.

Adversarial pass over the three changes (depth cap, targeting messages, launch gate skip). The substance holds up under the checks I could actually run:

  • restart-app / launch-app take the 2-arg precheckNativeDevtools, so a com.apple.* launch step really does execute rather than throwing at the precheck — the gate skip is reachable.
  • describeIos reads the ax-service first and only falls back to native-devtools on an empty tree, so the "raw point taps + tool: await-ui-element" story for a system app is real, not aspirational.
  • launch-app is simctl launch with no terminate, and the recorder only rewrites restart-app into a launch: step (flow-add-step.ts:1067) — the SKILL.md wording and the error-message advice both match the code.
  • withoutExplicitBundleIdAdvice matches both wordings resolveNativeTargetApp emits, firstClause leaves a dotted identifier intact, and getFailureSignal's BFS keeps the code reaching callers either way.
  • Typecheck and prettier are clean; test/flows/ is green.

Two findings survive, both about the new tests' coverage rather than the shipped behaviour.

Comment thread packages/tool-server/test/flows/flow-composition.test.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-ios-tree.ts Outdated

@latekvo latekvo left a comment

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]: Reviewed on 588c68bb. I built the branch and drove the real flow-execute, flow-start-recording and flow-add-step tools through a real Registry against the built dist, faking only the device/service seam with a NativeDevtools blueprint that truncates its subtree the way the on-device serializer does. vitest run in packages/tool-server is green apart from test/ios-instruments/malloc-stack-logging.test.ts, which timed out under parallel load and is unrelated to this diff.

The depth raise itself checks out end to end: with the device serving a view at raw depth 45, tap: { id: deep-button } resolves and dispatches gesture-tap at the button's centre (0.5, 0.525); the same fixture read at a device cap of 40 fails to resolve. I also checked the recorder claim that the raise weakens captured selectors and could not sustain it - a shallow app with the same testID-host-plus-<Text>-child shape already records {text: "Submit"} at cap 40, so the {identifier} capture in a deep tree was the truncation artifact, not a baseline the raise regresses.

Comment thread packages/tool-server/src/tools/flows/flow-run.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-run.ts Outdated
* when ready (or the platform needs no gate / the run was aborted), else the
* reason to report.
* when ready — and also when there is nothing to gate: a platform with no
* full-hierarchy source of its own, an iOS bundle Argent treats as

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]: "Returns null when ready" no longer holds for the skipped bundle: nothing on this path establishes that the app was launched at all.

restart-app's iOS handler returns - rather than throws - {status: "init_failed", …} when precheckNativeDevtools(nativeDevtools, udid) blocks, and that return happens before any simctl terminate / simctl launch (restart-app/platforms/ios.ts:36-44; RestartAppResult in restart-app/types.ts includes NativeDevtoolsInitFailedResult). runLaunch discards the value. The block is reachable from a single ensureEnvReady() rejection - the tool's own remedy text ("re-boot the simulator, or restart CoreSimulatorService") concedes it happens on working machines. A repo-wide grep finds init_failed handled only inside the native-devtools/* tools, nowhere on the flow path.

Reproduced through the real flow-execute, with restart-app returning the blocked result its real handler returns:

A) com.apple.* + restart-app BLOCKED   tools called: restart-app, gesture-tap
   steps: launch:pass, tap:pass | ok: true          <- never terminated or launched
B) injectable  + restart-app BLOCKED   tools called: restart-app
   steps: launch:error, tap:skip | ok: false

This lands on the flow shape the skip exists to enable: raw point taps plus tool: steps have no selector step that would fail later, so the run reports ok: true against whatever screen the simulator happened to be showing. The discarded-result gap itself predates the PR, but on iOS the gate masked it for every bundle until now.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not fixed here, and not introduced here. runLaunch discarding restart-app's init_failed return predates this branch - the same discard is on the base commit - and with 77c112c the gate runs for every bundle again, so this PR no longer changes what is exposed. Worth its own issue rather than a fix folded into this one.

Comment thread packages/tool-server/src/tools/flows/flow-ios-tree.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-ios-tree.ts Outdated
Comment thread packages/skills/skills/argent-create-flow/SKILL.md Outdated
Comment thread packages/tool-server/src/tools/flows/flow-ios-tree.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-ios-tree.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-run.ts Outdated
@hubgan
hubgan force-pushed the fix/ios-flow-tree-depth-and-targeting branch from 588c68b to 02f50bb Compare August 7, 2026 09:20
@hubgan
hubgan requested review from j-piasecki and latekvo August 7, 2026 13:23
Comment thread packages/tool-server/src/tools/flows/flow-run.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-ios-tree.ts
Comment thread packages/tool-server/src/tools/flows/flow-add-step.ts
@hubgan
hubgan force-pushed the fix/ios-flow-tree-depth-and-targeting branch from 6edf745 to cac4fc5 Compare August 9, 2026 20:23
hubgan and others added 11 commits August 10, 2026 10:55
The flow selector tree capped raw UIView nesting at depth 40. That does not
clear the invisible wrapper layers React Native stacks on every screen — nested
navigators plus a drawer/root wrapper routinely bury on-screen content 40-60
levels down before the first tappable view. In a deeply nested production app,
plainly visible interactive elements sat at depths 41-62, so `id:` and `text:`
selectors silently failed to resolve and only coordinate taps worked.

The overflow is SILENT — nothing reports "truncated", selectors just stop
matching — so the new cap of 100 carries generous headroom rather than hugging
the deepest observed measurement. It costs nothing downstream: this tree is
internal to selector resolution and is never returned to the caller, and the
payload it grows is already field-limited (~11KB at depth 40, ~15KB at 48, and
nothing at all past the tree's real depth).

Two targeting failures also stopped giving advice that cannot be followed:

- `resolveNativeTargetApp`'s own errors end with "Provide bundleId explicitly",
  which a flow selector step cannot do — it hardcodes auto-targeting. Each
  failure now carries the remedy that actually exists for it: disambiguate the
  foreground app, or relaunch through Argent.
- Both that path and the requires-restart path said "launch-app, or a flow
  launch step". `launch-app` does not terminate first, so against an app already
  running from Metro, Xcode or its icon it only foregrounds the SAME
  uninstrumented process — the advice could not work. They now name
  `restart-app`, and say why.

`FailureError` data is preserved when these messages wrap an underlying error,
so the failure code still reaches callers that switch on it.

Separately, an Apple system app (`com.apple.*`) can never load the injected
dylib, so the replay launch gate no longer waits for a connection that cannot
happen and then reports it as a retryable failure. An injection-free flow drives
such an app fine.
… a relaunch

The iOS flow selector's target-resolution catch only tailored the ambiguous
case. A single connected-but-backgrounded app (NATIVE_TARGET_SINGLE_APP_NOT_FOREGROUND)
fell through to the generic branch, whose message advises restart-app "to
guarantee instrumentation" and, via firstClause, drops the per-app
applicationState diagnostic. But that app is already instrumented and merely not
frontmost, so the real remedy is to foreground it. Give that case its own message
that says so and preserves the diagnostic line.

Also in the same targeting path:
- firstClause: drop the unreachable "?? native target resolution failed" fallback
  (String.split always yields a first element; noUncheckedIndexedAccess is off).
- withoutExplicitBundleIdAdvice: match the whole trailing line so it also strips
  the single-app wording ("...if you still want to target this app."), not only
  the ambiguous one, and couples less tightly to the exact sentence.
- Add tests for the single-app-not-foreground branch and the requires-restart
  message (the latter previously shipped unexercised).
The clause keyed on the first bare period, and every diagnostic that
reaches the generic targeting branch carries a dotted identifier: an RPC
timeout rendered as "ViewInspector RPC timed out: Application." and a
connection miss as "...bundleId: com.". Require the period to end a
sentence (whitespace or end of line) so the identifier survives, and a
line with no sentence break is returned whole.
resolveNativeTargetApp reaches its verdict by probing every connected
app's applicationState in one Promise.all, and iOS suspends a
backgrounded app within about a second: its socket stays open but the
probe times out, rejecting the whole read with a code neither verdict
branch matches. It landed in the generic branch, which told the agent
the process was not instrumented and that restart-app was the fix -
which discards the flow's state, and cannot help at all when the
unresponsive connection belongs to a second app while the flow's own
target is frontmost and healthy.

Re-read the connection list on that path: when apps are still connected
the read failed, not the instrumentation, so name them and give the
remedy that works - foreground the driven app with launch-app, terminate
the others.
…others

Backgrounding a connected app leaves it in the connections map and, once
iOS suspends it, stops it answering the state probe - which is precisely
what makes the read fail. Only terminating clears it, so say that and
say why backgrounding does not.
…nating

Both foreground remedies said "bring the app to the foreground" with no
mechanism, while the third message argued against launch-app - the only
tool that foregrounds an already-instrumented app without throwing its
state away. Name it where it is the fix.
…e app

The no-connected-app branch fires for an ordinary third-party app that
was merely launched outside Argent, and then appended the shared
non-injectable warning - but the injection precheck throws that error
only for com.apple.*; an injectable app gets restart_required, and the
native-* tools work after the restart-app the same message recommends.
Scope the dead-end claim to the Apple case.
The recorder embeds a failing read's reason in the warning for every
captured tap, and a failing await repeats it per poll, so a ~900
character reason turned one stuck screen into the session's largest
context consumer. Say the no-connection case once instead of twice and
drop the restatement, and pin every branch under 600 characters.
…e silently

Raising the iOS flow tree's depth cap surfaces views that used to be
truncated away, so an unlabeled icon inside a testID container is now
the smallest frame under a recorded tap: nodeAtPoint elects it and
deriveSelector falls through to its role, replacing a stable id
selector with one that holds only while it stays first-ranked for that
role. Nothing said so. Warn on the capture - what gets recorded and
where the tap lands are unchanged.
- Build a real 45-deep hierarchy and prove the buried view survives the
  query, survives adaptation, and resolves through an id selector, with
  the same fixture under the old cap losing it. The suite previously
  only checked that maxDepth 100 was requested.
- Pin that the strip drops only the trailing advice line, so a per-line
  anchor could not start eating the diagnostic's middle.
- Cover the resolveService failure path and cause preservation on both
  the plain-Error and FailureError branches.
- Drive a selector directive against a com.apple.* app end to end: the
  launch passes on the skipped gate and the read is what fails.
- Assert the gate DOES run for an injectable bundle on the same
  platform, so the skip is pinned as bundle-scoped, not platform-wide.
- Note that the requiresAppRestart fixture forces a state the live
  service cannot reach for an auto-resolved target.
- The lone-not-foreground branch named three triggers that cannot reach
  it: a home press or a deep-link jump suspends the app, which rejects
  the state probe and lands elsewhere, and a permission dialog leaves a
  foreground-inactive scene that resolves normally. Name the states that
  do reach it - answered the probe, but not foreground-like.
- The depth cost note claimed nothing tree-derived reaches the caller;
  compatibilityMissNote's output is concatenated into a step's reason.
- flow-execute's description promised a launch waits until the app is
  ready, which no longer holds for an Apple system app.
- treeSourceGate's contract described the skip as platform-scoped when
  it is bundle-scoped.
- The create-flow skill's "prefer launch-app" tip argued for the tool
  that does not guarantee instrumentation; restart-app is what the
  recorder should capture.
hubgan added 14 commits August 10, 2026 10:55
Verified on an iPhone 17 Pro simulator: com.apple.Preferences reports
connected=true (injectable=false) within 0.5s of a restart-app, and a
flow assert against its view hierarchy passes - the simulator applies no
library validation, so the injected dylib does load. Say what Argent
actually guarantees (it treats the bundle as non-injectable, and the
native-* tools refuse it) rather than a physical impossibility that does
not hold on the only iOS target flows run against.
…r bundle

The skip rested on an Apple system app never loading the injected dylib. It
does: on iPhone 17 Pro (iOS 26.5) and iPhone 16 Pro (iOS 18.3) simulators,
Preferences/Maps/Contacts/Health/Photos and Safari all report connected=true
after the restart-app a launch step runs, all three argent dylibs show up in
the process image, and a flow `assert exists { text: "Settings" }` resolves
against com.apple.Preferences' own full view hierarchy. So the gate was not
waiting for something that cannot happen - it was waiting for something that
does.

Skipping it also broke the one coupling flows have between the bundle a launch
step named and the app a later selector step reads: queryFullHierarchyTree
calls resolveNativeTargetApp(api, undefined), which auto-targets whatever is
connected and frontmost-like and never compares that against the launched
bundle. A launch that returns without the wait can hand the next selector step
a different app's hierarchy and report the run green. It also re-exposed
runLaunch discarding restart-app's init_failed result, which the gate had
masked on iOS for every bundle.

Keep the part of the change that was right: when the connection never comes up,
a non-injectable bundle gets the injection-free route (raw point taps plus
tool: await-ui-element against the AX tree) instead of "restart the argent
server", so a genuinely terminal state is not dressed up as a server fault. The
tool description drops the com.apple.* exception it can no longer claim.
…d apps

Both targeting reasons told the agent to "terminate the other connected apps".
Enumerating the tool ids off a registry built on this branch: 75 tools, whose
app-lifecycle members are launch-app, restart-app and reinstall-app - nothing
matching term/kill/quit/close beyond stop-metro, the simulator-server stops and
the profiler/recording stops. So the half of the remedy that is not launch-app
named no tool, and the nearest one makes it worse: restart-app on the other app
terminates AND relaunches it, leaving it frontmost, which is what the read was
failing on.

Name `xcrun simctl terminate <udid> <bundleId>` instead, and say why restart-app
is not the substitute. The suspended-probe branch only says it when there is
more than one connection - it was addressing "the others" to a lone app.
…pp count

`keeps every targeting reason short enough to repeat per step` looped over the
two SHORTEST of the failures it claims to cover, so it passed while the
ambiguous branch ran well past the ceiling - and that branch had no ceiling at
all. Measured on this branch before the change: 778 chars at 2 connected apps,
1000 at 4, 1444 at 8, because it keeps a ~110-char applicationState line per
app. The indeterminate branch grew the same way through its `Connected:` list
(548 chars at 7 apps on a real simulator).

Growth driven by the device is the wrong thing to leave uncapped here: the
ambiguous state is the most persistent one - it holds until somebody
foregrounds or terminates something - and captureTapSelector embeds the whole
reason per recorded tap while a failing `await:` repeats it per poll.

So cap the enumerated apps at 2, reporting the count dropped rather than
truncating silently, and drop the header that just restated the embedded
diagnostic's own first line. Both list branches now plateau: 703 and 548 chars
regardless of how many apps are connected.

The guard now iterates a table of every branch queryFullHierarchyTree can
produce (eight, not two) against a shared MAX_TARGETING_REASON_CHARS, so the
budget lives next to the messages it constrains. It is 760 rather than 600
because that is what the branches actually meet once the ambiguous one keeps
two full per-app diagnostics - the number the test asserted before was not one
the code held. Its docblock records that callers prefix the reason further
("could not read the UI tree: ", the when-guard label), so it is not the length
the agent finally reads.
The cost note claimed a deeper cap "does NOT enlarge any tool result or agent
context", and named compatibilityMissNote as the one path text could leak
through. Both halves are backwards.

compatibilityMissNote short-circuits on its first hit and quotes exactly one
candidate, so it does not grow with the tree at all. The path that does is
assertReason's `text` arm: it interpolates assertText(first) - the matched
node's hoisted subtreeText, every on-screen descendant's text up to the next
identified node - verbatim into a failing assert/await reason, and nothing
truncates it between there and the MCP text block.

So the cap does buy context: descendants a depth-40 read dropped now hoist. A
new test pins it directly - same fixture, labels at depths 20/30/41/55, read at
a device cap of 40 and of 100 - and the deeper read's hoisted string contains
the two the shallow one lost. That is a trade worth making, since under the old
cap those selectors did not resolve at all, but the note should say so rather
than deny the cost.
"the native-* tools refuse it too" is not true of native-devtools-status, which
is the one native-* tool that would confirm the state the sentence describes:
for a com.apple.* bundle it short-circuits before the precheck and REPORTS
{injectable: false, requiresRestart: false, nextLaunchWillBeInjected: false}.
Driven against a real simulator it answers connected=true, injectable=false for
com.apple.Preferences. The three native-profiler-* tools do not precheck at all.

Grepping every precheckNativeDevtools call site, only six use the throwing
3-arg overload - which is exactly the set NON_INJECTABLE_NATIVE_WARNING already
enumerates as "the native-devtools feature tools". Say that instead, so the
reason does not steer an agent away from the tool that can answer its question.
The message grew from one line to five, on a branch that for an auto-resolved
target can only be reached by a disconnect landing between the resolve and the
read - and it stated the opposite of that case.

resolveNativeTargetApp(api, undefined) only ever returns ids from
listConnectedBundleIds(), and requiresAppRestart answers false whenever
connections.has(bundleId). Confirmed against a real simulator:
com.hubertgancarczyk.demo (connected) reports requiresRestart=false, while an
uninstrumented app reports true but could never be auto-resolved in the first
place. So reaching this throw means the app WAS instrumented and then dropped
its socket - not that it "was launched before argent's instrumentation loaded",
and not that "only restart-app guarantees an instrumented launch" is the lesson.

Say what happened, name the retry first (waitForCondition's loop can ride a
transient drop out) and keep restart-app as the fallback. The test's note is
rewritten too: it was flagging the mock as unreachable-in-practice, when the
race it sets up is precisely the reachable path.
… contradicts

The bullet closed by calling launch-app "the right tool only for bringing an
already-instrumented app back to the front", on the premise that it foregrounds
"that uninstrumented process". Argent sets DYLD_INSERT_LIBRARIES device-wide in
the simulator's launchd environment (utils/ios-host.ts, via
`simctl spawn … launchctl setenv`), not per launch, so any launch of a
not-running app is injected.

Measured on an iPhone 17 Pro simulator: terminate xyz.blueskyweb.app ->
{appRunning: false, connected: false, nextLaunchWillBeInjected: true}; launch it
with launch-app (not restart-app) -> {appRunning: true, connected: true,
requiresRestart: false}. native-devtools-status's own description says as much
("If appRunning is false and nextLaunchWillBeInjected is true: use launch-app
normally"), as do rules/argent.md and argent-device-interact.

The operative advice - record restart-app, not launch-app - is unaffected and
stands on its own reason: it is the only one that guarantees a fresh
instrumented process whatever was already running, and the only one the
recorder rewrites into a `launch:` step.
Left as one over-long line by the previous commit; prettier does not reflow
template-literal contents, so it has to be done by hand to match the rest of
the description block. No wording change.
…pth cap

The depth cap's cost note carved compatibilityMissNote out entirely, on the
grounds that it short-circuits on its first hit and quotes exactly one
candidate. That is true of the arm reached by an `exists`/`visible` miss, which
walks the tree comparing label and value only - never subtreeText - and so
genuinely does not move with the cap.

It is not true of the other arm. When a `text` condition's locator MATCHED, the
note's candidate list starts with assertText(first), the located node's hoisted
subtreeText - the very string assertReason has already quoted - and picks it
whenever the container carries no own label or value. So a typographic near-miss
against an identified container carries the hoisted text twice and grows at
twice the rate the cap admits descendants: measured on a testID'd card of 60
hoisting rows, one failing `assert { on: { id }, text }` came back at 1520 chars
under a device cap of 40 against 2210 under 100.

Two tests pin both arms, since the previous claim was the kind that only reads
wrong once someone measures it.
…or is in

Both list-bearing targeting reasons hand the agent `xcrun simctl terminate
<udid> <bundleId>` for clearing a competing connected app, since argent exposes
no terminate tool. simctl scopes every operation to ONE device set, so that
command does not resolve a udid belonging to a configured
ios.additionalDeviceSets set - a Radon IDE simulator, say - which argent
otherwise supports end to end: list-devices surfaces those simulators tagged
with their owning deviceSet, and every one of argent's own per-device simctl
call sites goes through simctlArgsForUdid precisely to inject --set. These two
reasons were the only agent-facing simctl commands that did not.

Resolve the prefix the same way the internal call sites do, so the remedy comes
back as `xcrun simctl --set <dir> terminate …` for those devices. A default-set
device is unaffected, including in size: deviceSetForUdid answers null with no
probe when no additional sets are configured, so the reason budget is unchanged
for it - noted on MAX_TARGETING_REASON_CHARS, whose ceiling is measured there.
The non-injectable arm of the launch gate told the agent to drive the app with
raw point taps and `tool: await-ui-element` steps. Those are steps AFTER the one
that failed, and execSteps stops the run on a launch error - so rewriting them
changes nothing: the re-run spends another full gate cycle and comes back with
the identical reason. Verified on an iOS 26.5 simulator: a flow whose `launch`
directive errors reports launch:error and skips every later step.

What actually gets past the gate is not running the `launch` directive at all.
A raw `tool: restart-app` step performs the same terminate + relaunch but
dispatches through the registry rather than runLaunch, so it never reaches
treeSourceGate. Say that, since the recorder rewrites a bundle-id-only
restart-app into a `launch:` step and an agent would not otherwise look for the
hand edit. The same simulator runs the prescribed shape green: restart-app,
await-ui-element, point tap, await-ui-element - four passes against
com.apple.Preferences.

Pinned both halves: the reason names `tool: restart-app`, and a flow built the
prescribed way passes against a devtools service that never connects, without
resolving it at all.
cappedList and cappedAppDiagnostic both turn on `length <= MAX_LISTED_APPS`, but
nothing drove them AT that length - the cases sit at 1 app and at 4/5/16.
Relaxing both to `<` therefore ships green across the whole flow suite (43 files,
1046 tests), and the reasons it produces then read "com.example.driven,
com.example.stale (+0 more)" and "- (+0 more connected apps)": a withheld count
reported where nothing was withheld, in the two branches whose stated point is
that what got dropped is never silently lost.

Drive both branches at exactly MAX_LISTED_APPS and require every id verbatim
with no "(+N more)" marker. The constant is now exported so the case tracks it
rather than hardcoding 2, the way MAX_TARGETING_REASON_CHARS already is.
…butor

captureTapSelector now joins two independent caveats - the role-only selector
downgrade and the fallback-tree-source read - but each was only ever exercised
alone, so the join and its ordering were unheld. Replacing warnings.join("; ")
with warnings[0], which silently drops the source caveat on any capture where
both fire, leaves the flow suite green (43 files, 1047 tests).

Add the case where both fire: a fallback-source read is exactly the one most
likely to hand back an unlabeled node, so this is the realistic combination
rather than a contrived one.
@hubgan
hubgan force-pushed the fix/ios-flow-tree-depth-and-targeting branch from cac4fc5 to ffe7865 Compare August 10, 2026 08:55

@j-piasecki j-piasecki left a comment

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.

Checked on cac4fc5c:

  • launch-app does not terminate. launch-app/platforms/ios.ts:38 runs simctl launch only; restart-app/platforms/ios.ts:39-44 runs terminate then launch. Both build argv through simctlArgsForUdid, so the --set-aware terminate command the reasons hand out is the same shape argent itself issues.
  • argent has no terminate tool. 73 registered tool ids across src/tools; nothing matching terminate/kill/quit. The only stop-* are stop-metro, stop-simulator-server, stop-all-simulator-servers and the profiler/recording stops.
  • the native-devtools feature tools refuse it too. NON_INJECTABLE_NATIVE_WARNING (blueprints/native-devtools.ts:53) enumerates exactly the six that throw; native-devtools-status runs the 2-arg precheck and the native-profiler-* tools do not precheck.
  • tool: await-ui-element reads the AX tree. await-ui-element/index.ts:284 calls describeIos, whose non-injectable gate (describe/platforms/ios/index.ts:135) returns the ax-service tree with a hint rather than failing.
  • The requiresAppRestart branch is only a disconnect race. listConnectedBundleIds is [...connections.keys()] and requiresAppRestart returns false for anything in connections (blueprints/native-devtools.ts:646, :649), so the app was instrumented and the new wording is the only one that fits.
  • Device-wide DYLD / nextLaunchWillBeInjected. native-devtools-status.ts:127 reports nextLaunchWillBeInjected: envSetup, matching the SKILL.md parenthetical.
  • selectorMissNote never touches subtreeText. ui-tree-match.ts:767-769 collects label and value only, so the cost note's carve-out for the exists/visible arm holds.
  • The reason budget holds on every branch. Driven through the real queryFullHierarchyTree for all eight throw sites plus the service-unresolvable wrap, bundle ids 37 chars:
branch 1 app 2 apps 4 apps
ambiguous connected set (resolves as single) 702 729
single app not foreground 555 555 555
state probe failed, connections live 359 560 570
no connected app 594 594 594
target requires a restart 359 359 359
no windows 232 232 232
getFullHierarchy errored 82 82 82
service unresolvable 134 134 134

Worst case 729 against MAX_TARGETING_REASON_CHARS 760; 2 → 4 apps costs only the (+N more connected apps) line. "Every other branch has well over 100 chars of slack" holds — tightest is no connected app at 166.

  • Local state: test/flows/ 1065/1065, full tool-server suite 3875 passed / 1 skipped, tsc --noEmit -p tsconfig.test.json clean, prettier --check clean on all eight changed files. gates a com.apple.* launch on the connection like any other iOS bundle measures 9536 ms against its explicit 15000 ms budget; next slowest in that file is 1508 ms.

Nothing survived to an inline comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants