fix(flow): raise the flow tree's depth cap and say why targeting failed - #721
fix(flow): raise the flow tree's depth cap and say why targeting failed#721hubgan wants to merge 25 commits into
Conversation
ed821f3 to
21f2505
Compare
21f2505 to
3fe5857
Compare
3fe5857 to
fff6208
Compare
j-piasecki
left a comment
There was a problem hiding this comment.
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-apptake the 2-argprecheckNativeDevtools, so acom.apple.*launch step really does execute rather than throwing at the precheck — the gate skip is reachable.describeIosreads 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-appissimctl launchwith no terminate, and the recorder only rewritesrestart-appinto alaunch:step (flow-add-step.ts:1067) — the SKILL.md wording and the error-message advice both match the code.withoutExplicitBundleIdAdvicematches both wordingsresolveNativeTargetAppemits,firstClauseleaves a dotted identifier intact, andgetFailureSignal'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.
latekvo
left a comment
There was a problem hiding this comment.
[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.
| * 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 |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
588c68b to
02f50bb
Compare
6edf745 to
cac4fc5
Compare
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.
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.
cac4fc5 to
ffe7865
Compare
j-piasecki
left a comment
There was a problem hiding this comment.
Checked on cac4fc5c:
launch-appdoes not terminate.launch-app/platforms/ios.ts:38runssimctl launchonly;restart-app/platforms/ios.ts:39-44runsterminatethenlaunch. Both build argv throughsimctlArgsForUdid, 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 acrosssrc/tools; nothing matching terminate/kill/quit. The onlystop-*arestop-metro,stop-simulator-server,stop-all-simulator-serversand 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-statusruns the 2-arg precheck and thenative-profiler-*tools do not precheck.tool: await-ui-elementreads the AX tree.await-ui-element/index.ts:284callsdescribeIos, whose non-injectable gate (describe/platforms/ios/index.ts:135) returns the ax-service tree with a hint rather than failing.- The
requiresAppRestartbranch is only a disconnect race.listConnectedBundleIdsis[...connections.keys()]andrequiresAppRestartreturnsfalsefor anything inconnections(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:127reportsnextLaunchWillBeInjected: envSetup, matching the SKILL.md parenthetical. selectorMissNotenever touchessubtreeText.ui-tree-match.ts:767-769collectslabelandvalueonly, so the cost note's carve-out for theexists/visiblearm holds.- The reason budget holds on every branch. Driven through the real
queryFullHierarchyTreefor 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, fulltool-serversuite 3875 passed / 1 skipped,tsc --noEmit -p tsconfig.test.jsonclean,prettier --checkclean on all eight changed files.gates a com.apple.* launch on the connection like any other iOS bundlemeasures 9536 ms against its explicit 15000 ms budget; next slowest in that file is 1508 ms.
Nothing survived to an inline comment.
Depth cap: 40 → 100
The flow selector tree capped raw
UIViewnesting at 40. That does not clear the invisible wrapper layers React Native stacks on every screen — nested navigators (RNSScreenStackView→RNSScreenView, 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:andtext: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'stextarm quotes the matched node's hoistedsubtreeTextverbatim into a failingassert/awaitreason, and nothing truncates it downstream. Descendants a depth-40 read dropped now hoist, so that quoted string grows with the cap — and on one armcompatibilityMissNotequotes 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 thegetFullHierarchypayload 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 explicitly—resolveNativeTargetApp'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:launch-app(which does not terminate, so the instrumentation it already has survives), and clear the others withxcrun simctl terminate(argent exposes no terminate tool, andrestart-appwould 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 configuredios.additionalDeviceSets.restart-appor a flowlaunchstep.launch-app, or a flow launch step— this was wrong in a way that wastes a cycle.launch-appdoes 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. Onlyrestart-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.FailureErrordata 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-appalaunchstep runs, and a flowassertagainst 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, thelaunchdirective itself is what stops the run, so the remedy names the edit that gets past it: replace it with a rawtool: restart-appstep (same terminate + relaunch, dispatched through the registry without the gate) and drive the app with point taps andtool: await-ui-elementsteps, which read the AX tree instead of selectors.