mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
v0.20.6
1333 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f4fc089099 | 0.20.6 v0.20.6 | ||
|
|
628406390b |
fix(daemon): report the device claim retained by a failed close (#1647)
* fix(daemon): report the device claim retained by a failed close When close cannot confirm the device was released, it deliberately keeps the advisory claim (handing an unconfirmed device to the next session would be worse) but deletes the session record on the next line regardless, leaving a claim naming a session the daemon no longer tracks with no trace. Emit a warn diagnostic naming the device key and session, mirroring the open path's existing rollbackNewSessionClaim handling. Retention policy is unchanged. * test(daemon): pin claim retention on the cleanup-failure branch too The retention decision reads `platformCloseError ?? cleanupAggregate`, and the existing pair only drove the first input. A best-effort cleanup failure — a wedged perfetto stop, a dead helper — is the branch operators hit more often and reaches the same retention through a different value, so narrowing the diagnostic to the platform-close branch left every existing test green. Verified red against exactly that: gating the emit on `platformCloseError` fails this test alone, 28 others unaffected. Live evidence for the device-facing path is still outstanding; this closes the untested residual the review named, not that requirement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Rva4YGtSCAKJqH5PbpcCU --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
f4ebd6f14f |
fix(ios): synthesize hidden-keyboard text through responder (#1657)
* fix(ios): synthesize hidden-keyboard text through responder * docs: document iOS text synthesis failure |
||
|
|
fd1ed578d5 |
fix(ios): keep the baseline presentation when a tap corroboration has no request flags (#1646)
* fix(ios): keep the baseline presentation when a tap corroboration has no request flags matchingCaptureFlags dropped the baseline snapshot's scope/depth/raw whenever the incoming request carried no flags, so the post-action corroboration capture ran at the default presentation and could never match a non-default baseline's presentationKey. The corroboration then silently declined to engage, leaking the raw XCTEST_RECORDED_FAILURE it exists to eliminate. * docs(test): correct tap corroboration reachability |
||
|
|
b19ee116b5 |
fix(remote): stop persisting the daemon bearer token, and authenticate forced-reconnect release correctly (#1648)
* fix(remote): stop persisting the daemon bearer token in connection state ADR 0007 requires generated connection profiles to strip daemon and Metro bearer tokens; only the Metro half was honored. `connect` was writing the daemon bearer token into the 0600 connection-state file, and every later command read it back out. Stop writing `authToken` into `RemoteConnectionState['daemon']` and resolve it at each reader from the existing flag -> environment (AGENT_DEVICE_DAEMON_AUTH_TOKEN) -> remote-config-profile chain instead, matching src/cli/auth-session.ts's precedence. Behavior change: a user who ran `connect --daemon-auth-token <value>` and relied on later commands picking the token back up from the state file will now get an auth failure. They must export AGENT_DEVICE_DAEMON_AUTH_TOKEN, set daemonAuthToken in their remote config, or pass --daemon-auth-token on each command. website/docs/docs/remote-proxy.md is updated to show the supported env-var workflow. * fix(remote): authenticate forced-reconnect lease release with the previous endpoint's own credential connect --force released the previous connection's lease using the new connection's ambient daemonAuthToken instead of the previous endpoint's own credential, and swallowed the resulting auth failure — silently orphaning the old lease when replacing a connection with a differently-authenticated one. Resolve the release token from the previous connection's own remote-config profile first, fall back to the ambient token only when the two connections share the same daemon endpoint, and otherwise skip the release and surface an actionable notice (tenant, run id, lease id, endpoint) through the existing connect notice channel instead of hiding the failure. * fix(remote): stop merging ambient env defaults into the previous lease's own token resolvePreviousOwnDaemonAuthToken read the previous connection's profile through resolveRemoteConfigProfile, which folds AGENT_DEVICE_DAEMON_AUTH_TOKEN (and other env defaults) into the result. When the previous config file declared no token and the new connection's credential came from that same global env var, it was misclassified as belonging to the previous endpoint and sent there on forced-reconnect release — recreating the credential leak the prior fix was meant to close, just via env instead of --daemon-auth-token. Read the previous profile with the new readRemoteConfigFile (a provenance- preserving, file-only load with no ambient env/CLI merging), so only a token the previous config file itself declares can satisfy rule 1. Rules 2 and 3 are unchanged. * fix(remote): verify the previous config file still speaks for its endpoint Rule 1 reads the previous connection's own config file to recover a credential that provably belongs to the previous endpoint. It re-read `previous.remoteConfigPath` and trusted whatever token that file holds *now* — but a config path is routinely reused, so "connect to A from ./remote.json, re-point ./remote.json at B, connect --force" classified B's token as A's own and sent it to A during lease release. Same cross-endpoint leak the env-merge fix closed, arriving through the file instead of the environment. The file must now still vouch for the previous endpoint, by either of two independent facts: its bytes still hash to the `remoteConfigHash` recorded at connect time (so it is literally the declaration that stood up the previous connection), or — if it changed — it still declares the same daemon base URL. The second is what keeps an ordinary credential rotation releasing its lease instead of orphaning one; endpoint equality, not the fact of an edit, is what separates rotation from re-pointing. Endpoint comparison runs both sides through `buildRemoteConnectionDaemonState`, the same normalizer that produced the stored `daemon.baseUrl`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Rva4YGtSCAKJqH5PbpcCU * fix(remote): bind previous config token to its endpoint --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
09a0881e6c |
refactor(ios): runner error classification as data; recovery tested at the transport seam (#1644)
* refactor(ios): runner error classification as data; recovery tested at the transport seam (#1631) Error-retry classification lived in four places consulted from one catch dispatch: two overlapping substring chains in runner-contract.ts, a code-keyed fatality chain in runner-session.ts, and an inline composite in runner-lifecycle.ts. RUNNER_ERROR_RULES is now the one declaration (mirroring RUNNER_COMMAND_TRAIT_MANIFEST): each rule names an error shape and its verdicts across the four axes (retryable, connect-retry, session-fatal reason, restart-before-send); the predicates keep their signatures and derive from the table. One deliberate widening, noted at the declaration: restart-before-send matching is now case-insensitive like the other axes (the message is our own transport literal). runner-command-recovery.ts gets its first direct coverage — through the real stack: a scripted fake iOS runner (local HTTP server) stands in for the XCTest runner process, executeRunnerCommandWithSession and the transport fetch run for real, and invalidation is observed through the module's existing invalidateSession parameter. Seven scenarios (retained response, runner-reported failure, in-flight, unknown lifecycle, probe failure, missing command id, read-only completed-without-response) plus an 11-case classification suite. Zero vi.mocks of runner internals. * test(ios): prove the recovery wiring and the recycled-pid guarantee (#1644 review) P1 was right, and I verified it before fixing: disabling the shipped recovery callsite in runner-lifecycle.ts left all seven recovery tests green. They call handleRunnerTransportErrorAfterCommandSend directly, so they prove the module, not the wiring. runner-recovery-wiring.test.ts enters at runAppleRunnerCommand — the production facade and provider seam — and fakes only session creation (the xcodebuild spawn). Real: command-id assignment, provider resolution, executeRunnerCommand's catch/classification, the recovery callsite, the recovery module, executeRunnerCommandWithSession, the transport fetch, and response parsing. Disabling the callsite now turns all three red. Entering one layer lower silently defeats it: the id is assigned by the facade and recovery declines to probe a command without one. P2: runner-lease-recycled-pid.test.ts covers #1621 with a REAL spawned process and real readProcessStartTime — no mock choreography. It asserts through the injected cleanup adapter rather than by observing a kill, so a regression reports a pid instead of signalling a live process; removing the identity guard turns it red. Only the refusal direction, which is contention-proof: the recorded start time never matches, so a timed-out verification read yields the same verdict. The opposite direction needs a second live `ps` read inside production code and flakes under load, so it stays with the mocked cases in runner-session.test.ts where pinning is the point. The fake runner now scripts per command rather than as one queue, because production sends a readiness `uptime` probe before a mutating command and `status` during recovery; a rigid queue coupled every test to that order. * test(ios): route the recycled-pid test through exec.ts and off the real lease tree (#1644 review) Two fixes to the same test: - Process execution goes through utils/exec.ts's runCmdBackground, per AGENTS.md's hard rule, instead of node:child_process spawn. The kill-induced `wait` rejection is owned deliberately. - The lease root defaults to the developer's REAL ~/.agent-device tree, so the test was writing leases there. It now redirects AGENT_DEVICE_IOS_RUNNER_LEASE_DIR to a temp dir per test and restores it, writing nothing outside the sandbox. (Verified by inspecting and cleaning the leases the earlier revision left behind.) |
||
|
|
10ff339d14 |
refactor: declare selector resolution policy as data (#1649)
* refactor: declare selector resolution policy as data (#1630) Five native consumers of "resolve a selector against the screen" each hand-declared their ambiguity contract as inline requireUnique/ disambiguateAmbiguous literals, so the repo's real policy matrix was only discoverable by reading four files. SELECTOR_RESOLUTION_POLICIES (packages/selectors) now declares one row per caller — ambiguity kind plus the structural columns (rect, occlusion, off-screen guard, promotion, poll) — and selectorResolutionKnobs turns a row into the engine knobs it stands for. Callers consume rows; zero ambiguity literals remain in src. Semantics are unchanged by construction: each row was read off its call site. The matrix names what was previously implicit — act and get text disambiguate, is/get attrs fail closed, exists/find-reads and wait take the first match, mutating find rejects candidates unless narrowed (#1625). `reject-candidates` is declaration-only and rejected by selectorResolutionKnobs at the type level, because find enforces it through its own narrowing rather than engine knobs. resolution-policy-parity.test.ts gate-tests the matrix against the callers (ADR 0011's declared-plus-gate-tested pattern): knobs must match the named ambiguity contract, every claimed structural column must appear in the caller's source, the read/wait pipelines must genuinely lack the machinery they disclaim, and no caller may reintroduce an inline literal. Verified revert-sensitive: flipping readUnique to disambiguate and faking wait's occlusion column each fail it. Out of scope, unchanged, per the issue: the Maestro engine (ADR 0015) and the open click-implicit-wait product decision. * refactor: route wait and mutating find through the policy interface (#1649 review) P1 was right: the first head declared seven rows but genuinely routed five. selector-wait.ts never imported its row (it called listSelectorChainMatches directly), findAct consumed only requireRect while its ambiguity contract stayed bespoke, and the parity test sniffed marker strings in source files — so it stayed green across exactly that gap. Asserting about the layer I had edited instead of the behavior it produces. resolveSelectorChainWithPolicy is now the one policy-driven entry: it returns a discriminated outcome (none / resolved / ambiguous) because the rows genuinely disagree about what several matches mean, which is what previously forced each caller to re-derive its contract inline. wait and find's selector branch both route through it; find additionally asserts its row still says reject-candidates rather than assuming. The parity test is rebuilt on fixture trees driven through that interface — no source sniffing. Wiring verified revert-sensitive: flipping the wait row fails the policy tests, and flipping findAct fails REAL find handler tests (ambiguous-candidate listing), which is the proof the previous version could not produce. One behavior nuance the fixture work surfaced and now pins: disambiguation declines on genuinely indistinguishable candidates (the tiebreak is evidence, not a coin flip), so an acting row surfaces ambiguity there rather than binding one silently. * fix(test): let fallow see the host-process mock helper's real consumers Rebase onto main brought #1642's host-process-mock.ts into this PR's fallow scope, where its export reports as unused. It is not: three suites consume it, but only through `(await import(...)).pinOwnProcessStartTime` inside vi.mock factories — vitest hoists those above static imports, so the dynamic form is required and fallow cannot trace it statically. Documented suppression rather than a restructure that would break the hoisting contract. Latent on main rather than introduced here: the audit gate is changed-files-only, so main sees the file in scope only from a PR whose diff contains it. * fix: keep every candidate when a policy resolves one winner (#1649 review P1) A real regression I introduced, not a test gap: routing wait through the policy interface collapsed the candidate set to the winner, and the #1349 landmark check is satisfied when SOME match carries the recorded identity. A first same-selector impostor therefore hid a later genuine landmark and timed the wait out. The resolved outcome now carries `matchedNodes` — the full candidate set of the alternative the winner came from — so a policy that picks one node no longer throws the rest away. wait passes that straight to the landmark check, restoring the original semantics. Regression test added at the within-one-poll shape the existing suite did not cover (both candidates in the SAME capture, impostor first); verified it goes red against the singleton reconstruction it replaces. * refactor: declare only the policy fields the matrix enforces (#1649 review) The occlusion / offscreenGuard / promotion / poll columns were never consumed by resolveSelectorChainWithPolicy or selectorResolutionKnobs: changing any of them left behavior and the suite green, so they were unverifiable claims that read as truth. (My earlier source-sniffing test "verified" them by grepping caller files for marker strings — which is why it also stayed green when a row was disconnected entirely.) The matrix now declares exactly what it enforces: the ambiguity contract and the rect requirement, both consumed by the resolution interface and pinned behaviorally. A new test asserts every row's field set, so an unenforceable column cannot reappear without coverage — verified by re-adding one and watching it fail. Routing the structural stages into typed behavior is tracked in #1656 with the constraint that each field must be consumed, not merely declared. * fix(selectors): flatten the policy outcome at the package boundary `PolicyResolutionOutcome.resolution` was typed as `AstSelectorResolution` and the root façade returned it unchanged, so the parser AST #1589 confined to `@agent-device/selectors/ast` came back through a nested field. `selector-wait.ts` reading `outcome.resolution.selector.raw` was the runtime proof. The existing boundary gate reads exported *names*, so it could not see this. The public outcome now lives beside `SelectorResolution` in public-resolution-types.ts with its selector as text; the parser-side shape is renamed `AstPolicyResolutionOutcome` and stays package-private, and the façade wrapper flattens on the way out — the same treatment `resolveSelectorChain` already gave `AstSelectorResolution`. Two new pins, both verified red against the shape they replace: a behavioral one asserting the façade returns selector text under every policy row, and a structural one asserting resolution shapes are re-exported from public-resolution-types.ts rather than from a parser-side module — which is what distinguishes the leak from a correct re-export in a name list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Rva4YGtSCAKJqH5PbpcCU --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
0033002ed5 |
fix(daemon): say why a no-effect claim was withheld (#1655)
* fix(daemon): say why a no-effect claim was withheld A vetoed gestureNoEffect claim is invisible from outside: the response looks exactly like a gesture that worked. #1620 spent weeks unable to tell a cross-backend pair from capture drift from real movement for precisely this reason, and its own suggested next step was to log the corroboration inputs and re-run. Every veto on an accept-stale verdict now emits post_gesture_no_effect_vetoed with a reason: baseline_rebased (the pair is cross-backend, #1569) or surface_divergence carrying onlyInBaseline/onlyInCurrent/rectMismatched/shared from a new summarizeDiscriminatingSurfaceDivergence — one-sided keys are membership drift, rectMismatched is movement. Measured live against the seeded Bluesky fixture (167 nodes, depth-56, private-ax): inert scrolls corroborate and the warning fires 2/2, successful gestures never claim 2/2, zero rebases, and the one veto observed was rectMismatched:4 shared:158 onlyIn*:0 from ambient feed motion over an artificial 105s gap. #1620's truncation-drift half does not reproduce; membership is stable under the remembered depth cap. Also: marking no longer stores an empty baseline signature. '[]' is truthy, so an invented baseline was being rebased onto a post-gesture capture; 'no usable baseline' now has one representation, matching markPendingInteractionOutcome. * test(daemon): pin the veto diagnostic's payload, not just its phase The claim tests counted `post_gesture_no_effect_vetoed` events by phase. The operator-facing behavior this PR adds is the *reason* and the divergence counts, and both survive an event tally: emitting the wrong reason, or dropping the counts entirely, keeps every count at 1. The four veto tests now assert the whole emitted payload, read back out of a diagnostics trace file so the assertion pins the serialized NDJSON line rather than an in-memory event. Backend rebase pins `reason: baseline_rebased` with no counts riding along (the pair is cross-backend; there is nothing comparable to count over). Both divergence cases pin `reason: surface_divergence` with exact onlyInBaseline/onlyInCurrent/rectMismatched/shared — the numbers that separate scope drift from movement, and the two membership-drift directions from each other. Verified red both ways the reviewer named: dropping the counts fails 2 tests, forcing the reason to surface_divergence fails 2. The old count assertions stayed green under both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Rva4YGtSCAKJqH5PbpcCU --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
d85072d935 |
perf(cli): route command aliases through the help fast path (#1641)
* perf(cli): route command aliases through the help fast path bin.ts's `--help` fast path resolved aliases through a hand-written two-entry table that had drifted out of sync with the real CLI_COMMAND_ALIASES registry (five entries). `tap`, `launch`, and `relaunch` missed the table and silently fell through to a full runCli() bootstrap just to print static help text (~150-165ms vs ~45-50ms for aliases already in the table). Delegate to the shared normalizeCliCommandAlias registry instead of the stale local table, so every alias the registry knows about gets the fast path automatically. * test(cli): add R12 layering guard for bin.ts's alias delegation The unit test added for the alias fast-path fix (cli-help-alias-fast-path.test.ts) calls normalizeCliCommandAlias directly, so it stays green even if bin.ts itself reverts to a hand-rolled table — it pins the registry composition, not bin.ts's own wiring, and bin.ts cannot be safely unit-imported (it runs unguarded top-level dispatch on import and is deliberately excluded from coverage). Add an AST-based structural guard instead, in the style already established by scripts/layering/session-state.ts, facade-exports.ts, and zero-dep-jobs.ts (oxc-parser's module/program records, not a line scan, so a fixture's string literal can't produce a false hit). R12 asserts two facts about src/bin.ts: it holds a value import of normalizeCliCommandAlias from commands/cli-command-aliases.ts, and it contains none of the registry's own alias tokens as string literals. The token list is read out of the registry's own source (CLI_COMMAND_ALIASES's `alias:` property values), not hard-coded, so a future sixth alias is covered automatically. Both facts were false on the pre-fix bin.ts, verified by reverting locally and capturing the failure before restoring the fix. Wired into the existing check:layering chain (already part of check:tooling), next to R7's session-state ownership rule, which pins the same "delegate to your single owner" shape. * test(cli): pin the alias-resolver call into buildCommandUsageText (R12 P2) Maintainer review of R12 (PR #1641): import-presence and literal-absence alone let bin.ts regress to buildCommandUsageText(helpTarget) while the normalizeCliCommandAlias import stays in place, used harmlessly elsewhere (or not at all) — the real-tree gate stayed green through that exact regression. Add a third fact: bin.ts's call to buildCommandUsageText must receive, as its argument, a call to the LOCAL binding the resolver was imported as (aliasResolverLocalName + usageTextCallsResolver, both AST-based). Binding by local name rather than the literal export name means a renamed import (`as resolveAlias`) still verifies, and an unrelated same-named local cannot be mistaken for it. Verified by reverting locally to exactly the missed regression — import left in place, call reverted to buildCommandUsageText(helpTarget) — and confirming R12 now fails where the two-fact version passed; restored after. Two negative fixtures pin the scenario going forward: import present but unused, and import present but used only unrelated to the call. * test(cli): make R12's delegation fact universal and value-bound The previous fact 3 asked whether *any* `buildCommandUsageText(resolver(...))` existed in bin.ts. That quantifier is satisfied by a decoy call while the line that actually ships resolves nothing: void buildCommandUsageText(normalizeCliCommandAlias('open')); const commandHelp = buildCommandUsageText(helpTarget); Fact 3 now requires EVERY `buildCommandUsageText` call to receive the imported resolver applied to the fast path's own help-target binding, which rejects both lines above independently. The help-target name is read from bin.ts (the variable initialized by `resolveSimpleHelpTarget`), so renaming it re-points the guard instead of disarming it. Because fact 3 claims binding identity by name, it also now rejects a local shadow of the resolver and an ambiguous second help-target declaration — a same-named local would otherwise let the composition read as delegation while calling something that resolves nothing. The predicate returns the reason rather than a boolean, so the gate names which of the several distinct failures happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Rva4YGtSCAKJqH5PbpcCU --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
3937036e5e |
feat: support --settle on scroll and back (#1638) (#1650)
* feat: support --settle on scroll and back (#1638) Scroll-then-observe and back-then-observe are legitimate agent pairs, but the post-action observation registry never grew past the touch commands, so `--settle` on either was rejected with INVALID_ARGS — burning a tool call each in AppControlBench's bsky-16. Both commands now carry the `settle` descriptor trait, and every surface derives from it rather than a hand list: CLI allowed flags, MCP/SDK input fields, the flag-sourced timeout envelope, and MCP ref-pinning. The CLI flag/metadata helpers moved out of the interaction family into post-action-observation-grammar.ts (back is a system command), and SETTLE_REF_ISSUING_TOOLS became a derivation — a hand list would have silently stopped pinning the new commands' refs. settleAfterInteraction and the new settleObservationCommand are two entry points over one engine: same loop, storage, hints, and diff bounds, with the target-less path supplying its own baseline and no proximity point. The daemon reaches that command through the runtime surface, never by importing `commands/` (R2) — the same seam the touch handlers use for press/fill — and generic-settle.ts is loaded through a lazy `await import` returning a closure, so the interaction runtime subgraph stays out of this dispatcher's static graph (a static edge folded ~18 files into the daemon-server type cycle; R10 caught it). Both of generic-settle's orderings are load-bearing and tested: the baseline is frozen before dispatch (and before the Android dialog preflight), and the observation runs after markDeferredInteractionOutcome so settle's first capture folds in the #1542 stabilization rather than racing it. The ADR 0014 "a settled diff publishes refs" rule moved to settle-ref-issuance.ts, shared by both routes. One divergence is deliberate: scroll/back resolve no element, so the diff baseline is the session's STORED pre-action tree — "settled tree vs the last tree you observed" — not press's freshly resolved pre-action capture. Both commands also switch to preserve-daemon on timeout, which changes the non-settle path too: with --settle their dominant hang mode is now a wedged accessibility bridge, and a timed-out capture must not reset the daemon and lose every session (#1105). The reviewed-set gate records it. Live-validated on an iOS 26.2 simulator (Settings): scroll --settle settled in 1786ms with a +6/-6 diff carrying fresh refs; back --settle in 771ms with +15/-6. Alternating cost runs, one call vs the pair it replaces: scroll 2.9-3.0s vs 5.3-5.6s, back 3.1-3.2s vs 4.7-5.1s. Those include the #1627 deep-capture extension. * fix: render settled-diff refs paste-ready in CLI output A settled diff activates a PARTIAL ref frame (ADR 0014), which admits only the pinned `@eN~s<gen>` form of the refs it issued. The unchanged-interactive tail already rendered that way, but the diff's own added lines rendered the bare `@eN` embedded in the snapshot line — so a CLI caller who copied the ref the diff just handed them got `plain_ref_requires_complete_frame` and had to append the generation by hand. Added lines now render pinned when the response carries `refsGeneration`, exactly like the tail. Removed lines render verbatim: they name elements that just left the screen, and `SettleDiffLine` never gives them a ref. This is not new to scroll/back — press/click/fill/longpress had the same gap since #1101. MCP was never affected: its ref-pin store rewrites plain refs on the way in, which is why the model never sees a suffix. Live: `scroll down --settle` now emits `+ @e14~s218078 [cell] "Game Center"`, and `press @e14~s218078` copied straight out of that line taps successfully. * test: record the pinned-diff-ref bytes in the output-economy baseline Rendering added diff-line refs pinned costs 8 bytes in the two settle CLI text samples (two `~s<gen>` suffixes). The output-economy baseline is the tripwire for exactly this, so the increase takes an explicit reviewed waiver rather than a silent baseline bump — the same one the settled TAIL's pins already carry, for the same ADR 0014 reason. Only `bytes` moves: lines, refs, hints, and shape are unchanged, which is the evidence that this is a suffix on existing refs and not a new payload. Caught by CI, not locally: `pnpm test:unit` runs unit-core and subprocess-stub only, while the Coverage lane runs every vitest project. * test: prove the generic settle degrades when its runtime cannot be built `createGenericSettleRuntime` catches and returns undefined so an observation that cannot even start does not fail an action that already succeeded. That was a claim in a docstring with nothing behind it — the one changed line the coverage gate reported uncovered (95/96). The test puts the session in the state the catch exists for: the router handed us a session that is no longer in the store, so building the settle runtime throws SESSION_NOT_FOUND. The response keeps its scroll result and simply carries no settle payload. Removing the try/catch fails it. * build: teach fallow that vi.mock reaches pinOwnProcessStartTime dynamically Not from this PR: #1642 added `pinOwnProcessStartTime` on main, and its three consumers reach it the only way a Vitest module mock can — `vi.mock(path, async (importOriginal) => (await import('...')).pinOwnProcessStartTime(...))`. Dependency analysis cannot follow that dynamic import to a consumer, so the export reads as dead the moment any PR pulls that file into its audit scope. This PR is the one that did. The entry records the consumers by path and the reason, matching the daemon route-handler entry directly above it, which exists for the same dynamic-`import()` limitation. * refactor: adopt the best of the parallel #1653 implementation Two sessions independently built #1638 (PR #1650 and PR #1653) and converged on the same architecture — trait in the registry, one engine with two entry points, runtime-command seam, lazy import, preserve-daemon, stored-baseline honesty. #1650 continues; this folds in what #1653 did better: - The agent-facing help core loop (cli-help.ts) now names scroll and back as settle-capable. Without this, the benchmarked closed-grammar help line kept instructing agents that --settle is only for press/click/fill/longpress — actively steering the AppControlBench models away from what #1638 shipped. - issueSettleRefs moves into session-snapshot.ts, beside the partial-frame primitive it wraps, deleting the single-function settle-ref-issuance module. - Their seam tests: back reader→writer settle plumbing, back CLI settle rendering, and a trait-less generic command (home) ignoring a stray settle flag rather than observing or rejecting. What #1650 had that #1653 lacked, for the record: the SETTLE_REF_ISSUING_TOOLS registry derivation (without it, MCP never pins a scroll/back settle diff's refs and the partial frame rejects every follow-up), BackCommandResult.settle in contracts, back's MCP output schema, paste-ready pinned diff refs, and the docs/changelog/baseline surfaces. * bench: help-conformance case for settled scroll-to-find planning The #1638 extension of the closed --settle grammar to scroll/back is the feature's entire payoff — collapsing scroll-then-observe into one call — and the closed command list is an enumerated N whose enumerator is this bench. The regex over the help text proves the sentence exists; this case checks whether a model plans differently because of it. One focused case, deliberately not coached: a pinned visible-first snapshot (rendered by formatSnapshotText, pinned by the sample-producers gate) whose wanted row is summarized off-screen with no ref anywhere in the output. The tempting pre-#1638 plan is `scroll` plus a separate `snapshot -i`; acceptance is the single settled call. Scoring was verified against eight plan shapes in both directions before recording. Model-backed record (claude-haiku-4-5, 3 trials, current help): 0/3 — but the decomposition is the finding. Settle eligibility GENERALIZED (3/3 trials put --settle on scroll unprompted; the mutation-suffix framing concern did not materialize) and the two-call habit is residual (1/3). All three trials failed on `scroll @e3 down --settle` — the pre-existing #1366 scroll-takes-no-target confusion, which the live CLI recovers with a dedicated hint but a single-shot bench cannot. The recorded gap is therefore a first-30 doc gap (nothing teaches that scroll takes no target), not a settle-eligibility gap; tuning the case until it passes would just delete the evidence. |
||
|
|
8030fc10d6 |
fix: make Android record stop survive static screens, slow moov finalization, and dead-pid recovery (#1651)
* fix: make Android record stop survive static screens, slow moov finalization, and dead-pid recovery Three live-reproduced defects on a loaded Pixel_7_CI emulator shared the "pulled file is not a playable MP4" / "manifest could not be verified" symptom family: - The Swift video validator required duration > 0, permanently rejecting valid single-frame recordings of fully static screens (AVFoundation reports their duration as 0). Whether a run passed depended on whether anything — even the status-bar clock — changed during the window. - screenrecord finalizes by patching a front-reserved moov in place, so the remote file size never changes; the copy path now re-pulls with escalating delays (750/1500/3000ms) and detects finalization from the pulled bytes via a mandatory ftyp+moov container sniff, which also preserves the truncation detection the duration check provided by accident. The local waitForStableFile call is gone: a pull is complete when adb returns. - toybox `ps -p <missing-pid>` exits 1 with empty output — the normal pid-gone signature — but the recovery liveness probe read every non-zero exit as an uncertain adb failure, making a finished recording behind a live-status manifest unrecoverable forever. Empty-output failures now corroborate via the full process list: healthy listing with the pid absent recovers the finished recording; listing failure stays conservatively uncertain (a stale verdict deletes the manifest, so transport health is proven first). Transport failures keep stderr and exec-layer timeouts throw, which is what makes the empty-output signature safe to trust. Provider-scenario coverage: in-place same-size finalization landing past the first retry, and dead-pid recovery on a responsive device — both fail on the previous implementation with the live-observed errors. * refactor: extract Android liveness probe and split recording scenarios per file-shape rule record-trace-android-recovery.ts (763 lines) and android-recording.test.ts (1,764 lines) were both past the 500-LOC extraction tripwire. The screenrecord liveness/probe concept this PR modified now lives in record-trace-android-liveness.ts, and the new scenarios moved to test files mirroring the source modules they cover (record-trace-android-copy.test.ts, record-trace-android-liveness.test.ts) with shared scenario plumbing in the android-recording-fixtures.ts sibling. android-recording.test.ts shrinks to 1,476 lines — below its pre-PR size. |
||
|
|
58a9d4bf71 |
refactor(daemon): consolidate surface-evidence helpers, delete the wording heuristic (#1615)
Re-derived onto #1633's split (`post-gesture-stabilization.ts` became `post-gesture-stability.ts` + `deferred-interaction-outcome.ts` + `gesture-no-effect.ts`). None of this had been subsumed by that refactor — it relocated the code and carried every one of these forward untouched. - Three copies of the four-field rect comparison in `interaction-outcome-policy.ts` become one `rectsWithinTolerance`. - `identifiedContent` returns the entry instead of `{ entry }`, dropping the `.entry` indirection at every call site. - `haveIdenticalDiscriminatingSurfaces` records why it keys on `key` while `classifyBaselineSurfaceEvidence` keys on `identity` — opposite choices, once, at the function that makes the stricter one. - `SnapshotCaptureBackend` names the capture-strategy union in kernel/snapshot beside `SnapshotBackend`, and `PostGestureStabilization.baselineBackend` uses it instead of `string`, closing the silent-typo gap on the comparison the field exists for. Deliberately NOT applied to `post-gesture-stability.ts`: #1633 made that module generic over the surface type, and `backend: string` is right for an interface that must not know about iOS capture strategies. - `formatGestureNoEffectWarning` echoes positionals verbatim. The `/^[\d.-]+$/` filter it replaces ate all four coordinates of `swipe <x1> <y1> <x2> <y2>` and emitted a contentless bare "swipe"; the warning names the gesture the agent issued, and `scroll down 1` is what they issued. - `PostGestureStabilization.positionals` is required — its only writer always sets it, so the `?? []` at the read site guarded an impossible state. Tightening it caught six test fixtures building the state directly. Red evidence: restoring the numeric filter fails the wording test ("scroll down 1 produced no visible change"); 95 files / 757 tests green with it deleted. |
||
|
|
0b8c1d7a7f |
test: pin own-pid start time in device-claim tests to stop load flakes (#1642)
* test: pin own-pid start time in device-claim tests to stop load flakes
device-claim-prune.test.ts and device-claims.test.ts seed "live" claims
from readCurrentOwnerIdentity() — a real `ps -p <pid> -o lstart=` read of
the vitest worker's own start time — and classification later re-reads it
through a second `ps` shell-out with a 1s timeout. Under full-suite CPU
contention that second read can miss its deadline and return null,
mismatching the seeded value and flipping the genuinely-live owner to
'owner-process-dead' (intermittent assertion failures like 2 !== 1).
Apply the same hardening as
|
||
|
|
d2f28d790f |
docs: pin the version-skew invariant and the four legitimate compat axes (#1643)
* docs: pin the version-skew invariant and the four legitimate compat axes A local client and daemon can never disagree on version — daemon reuse requires exact version and code-signature equality, otherwise the client replaces the daemon (both directions). Compat code is therefore justified only for: the remote daemon boundary, separately versioned runner/helper binaries, persisted artifacts, and released API consumers. Documented in CONTEXT.md Terms next to the RPC protocol version entry, and the one comment that said 'older daemon' without scoping it is now explicit about the remote boundary. Swept src/ and packages/ for fallbacks that only tolerate local skew: none exist — every current compat site names one of the four axes. * docs: tighten the version-skew invariant to its three load-bearing facts |
||
|
|
d5f11f6e2f |
refactor: import package types directly — no internal re-export laundering (#1640)
* refactor: import package types directly instead of re-exporting from internal modules
Post-#1636 review feedback: internal src modules were re-exporting package
types (export type { X } from '@agent-device/...'), giving one declaration
several import paths and hiding its provenance. New rule applied repo-wide:
internal modules import directly from the owning package; only published
entry surfaces (src/sdk/* entries, client-types, finders, metro composition,
remote-config-schema) may re-export.
Eleven internal re-exports removed and ~110 import sites redirected to the
packages, the big two being CommandFlags (core/dispatch chain, 39 sites) and
SessionAction (daemon/types.ts, 19 sites). Two were already dead
(RefFrameEffect via daemon-command-registry, DiffSnapshotCommandResult via
capture/runtime/snapshot). Entry-surface chains now re-export from the
package rather than laundering through a second internal module
(client-types/client-metro MetroBridgeScope).
Side effect: the R9 type cycle shrinks again, 49 -> 47 (daemon-server
19 -> 17); ceilings lowered to match.
* refactor: drop command-schema's CliFlags re-export (#1640 review P2)
The one consumer (cli/parser/args.ts, a multi-line import the sweep's
single-line scan missed) now imports CliFlags from contracts/command;
FlagDefinition/FlagKey stay — they are src-declared types, not package
laundering.
|
||
|
|
870d12c406 |
fix: honest find contract — press/tap aliases, read-only list action, selector uniqueness (#1637)
* fix: honest find contract — press/tap aliases, read-only list, selector uniqueness (#1625) Three defects in find's contract, fixed together because they are one vocabulary: press/tap are the same action as click everywhere else in this CLI, yet find rejected them — agents using the vocabulary the tool itself established burned a tool call per attempt (four in one bench run). Both parsers now normalize press/tap to click; longpress/swipe stay real exclusions. The #1602 recovery hint told agents to run bare find to 'list matches', but bare find CLICKS a unique match — inspection guidance pointing at a mutation (the #1625 report: 'find Dictionary' navigated into Dictionary). find <q> list is the read-only surface that guidance needed: every match with its @ref, unique match included, never a tap. Captured UNSCOPED (the label-scope optimization narrows to the first match, exactly wrong for listing), published as an ADR 0014 partial frame authorizing every listed ref. Selector-shaped queries skipped the ambiguity check and took the first match silently — the mis-binding path the AMBIGUOUS_MATCH recovery advice itself pointed agents at, while --first/--last were documented as explicit opt-ins. Selector and text queries now share one contract: multiple matches reject with the #1597 candidates listing unless --first/--last narrows explicitly. The hint is rewritten around the new contract; docs and the MCP find output schema follow. Regressions at every layer: both parsers (alias, list token, unsupported-action hint shape), the daemon handler (selector ambiguity with candidates, --first opt-out, list returns all matches with zero action dispatches, unique-match list does not tap). * refactor: single-home the find read result and flatten parseFindArgs (fallow) The daemon's DaemonFindResult had drifted into an identical structural twin of the engine's FindReadCommandResult — the two grew the list variant in parallel and crossed the clone threshold. The shape now lives in contracts as FindReadResult (below both zones, per R2's own remedy) with the engine and daemon both aliasing it. parseFindArgs collapses the four bare single-token actions into one membership check and extracts the get sub-action parser, bringing it back under the complexity threshold instead of waiving it. * style: merge duplicate contracts import (lint) * fix: accept list on the MCP input surface and pin every listed ref (review) - FIND_ACTION_VALUES gains 'list' so field-metadata/MCP input no longer rejects the action the CLI parser accepts - FindCommandResponseData types 'matches' (public client response) - MCP mergeFindRefPins learns every matches[] ref, so a plain @eN press after find-list forwards pinned and the partial frame admits it - CLI/MCP text renders every listed match as its own pinned line via the snapshot-line role/label normalizers - regressions: daemon partial-frame scope, pin store, CLI output, MCP schema + find-list->press chain, typed client list response |
||
|
|
3e4828d68d |
feat: add scale-only screenshot sizing (#1617)
* feat: add scale-only screenshot sizing
* fix: refuse retired --max-size inputs on every released surface
Released sizing inputs must fail closed with migration guidance instead of
silently producing native-size artifacts:
- contracts: RETIRED_SCREENSHOT_MAX_SIZE declaration + SCREENSHOT_SCALE_LIMITS
as the single source for the scale bounds and migration messages
- .ad parser: released 'screenshot ... --max-size N' and 'record start ...
--max-size N' lines now refuse at parse time (frozen replay-compat witnesses)
- daemon: screenshot rejects old-client screenshotMaxSize like recording does;
the recording guard now shares the same contract data
- Node client: screenshot/record daemon writers refuse the removed { maxSize }
option before transport
- CLI: --max-size unknown-flag error carries the migration guidance
- config/env: stale screenshotMaxSize config keys and the retired
AGENT_DEVICE_SCREENSHOT_MAX_SIZE env var are refused for sizing commands
(other commands keep working)
Quality: numberField now reuses the canonical readOptionalNumber contract
helper (AppError bounds instead of plain Error); png-resize inlines one-use
wrappers and restores the worker-thread rationale; docs typo fixed.
* test: drop retired maxSize entries from the MCP undocumented-input allowlist
* fix: refuse retired maxSize at the MCP field-projection seam + release-provenance corpus witnesses
- readFieldInput silently dropped undeclared keys before the daemon writers
could refuse them, so an MCP call carrying { maxSize } reached transport and
returned native-size success. New retiredField() combinator declares the
removed key in the field map: the projection seam refuses it with the
canonical migration message and the JSON schema no longer advertises it.
Real-route MCP executor regressions cover screenshot and record.
- replay-compat corpus: derived v0.20.5 witnesses for the released screenshot
and record --max-size forms (SHA-256 pinned, new retired-capture-size
coverage surface) so check:replay-compat proves the shipped syntax refuses
with migration guidance instead of degrading silently.
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
|
||
|
|
f93b259d15 |
fix: stop the slow-snapshot warning from firing on a single cold start (#1628)
* fix: stop the slow-snapshot warning from firing on a single cold start The session's first capture folds one-time startup (runner launch, helper install) into its duration, and nearest-rank p95 over a small sample set equals its largest one or two values — so one 12s cold start produced 'snapshots are slow in this run: p95 12417ms over 1 captures' with hints blaming device load or a stale daemon, inviting exactly the restart spirals the hints exist to prevent (observed on every AppControlBench run). The warning now judges only warm captures (first sample excluded) and only once at least three exist; the displayed stats still cover every sample, so the cold start remains visible as maxMs. * refactor: single-home the slow-snapshot warning policy (review) Push the warm-judging rule down into summarizeSnapshotTimingSamples so the interactive session path and all three replay handler paths share one policy, and summarizeSnapshotDiagnostics returns to a one-line delegate. Merge no longer judges slowness from lossy order-less reconstructed samples (a run's cold start comes back as both its p95 and max): it aggregates display stats and carries a warning only when a constituent run judged one itself. MIN_WARNING_SAMPLE_COUNT renamed to MIN_WARM_SAMPLE_COUNT — it gates warm samples, not total captures. Cold-start regression tests move to the shared layer; suite-aggregation tests now pin that individually-silent runs merge silent. * style: oxfmt * fix: quorum-gate the warm warning and make the merged message speak about warned runs (review) A single warm outlier could still fire the warning (nearest-rank p95 is the maximum through nineteen samples): chronic now additionally requires at least two slow warm captures. And the merged warning formatted its number from the reconstructed aggregate, so one slow run among many fast ones produced 'slow: p95 <fast number>' — the merged message now reports how many runs warned and the worst warned run's own p95, never the aggregate. Regressions for both: one-warm-outlier stays silent; a slow run merged with many fast ones warns with the slow run's number while the aggregate p95 sits below the threshold. |
||
|
|
3d2a9a05e8 |
ci: remove package smoke workflow (#1624)
* ci: remove package smoke workflow * ci: align affected package check * ci: verify packaged tarball before publish |
||
|
|
a67c72c211 |
fix(ios): pin tap-outcome corroboration probes to the baseline's backend (#1634)
* fix(ios): pin tap-outcome corroboration probes to the baseline's backend The recorded-failure screens are exactly where the capture plan flips between XCTest and private-AX (the penalty boundary), so #1605's same-backend requirement failed closed right where XCTest tap false negatives actually happen: the baseline was captured via private-AX under penalty, the probe came back via tree, and a landed tap surfaced as XCTEST_RECORDED_FAILURE. In the AppControlBench bsky-16 run this fired four times, each sending the model into a re-observe/retry spiral. The comparison stays same-backend by design (backends are not comparable views of a screen); instead the probe is now CAPTURED the way its baseline was: a new internal preferredBackend option (never CLI-exposed) threads daemon -> runner, and a private-AX-preferred capture takes the exact penalized route — privateAX-first plan, 'deferred' verdict, no degradation warning, no settle budget reset. Live-verified on the deterministic repro (Bluesky drawer-menu press under penalty, seeded bench feed): errored with the backend-mismatch diagnostic before, corroborates as landed after, with no mismatch phase in the request diagnostics. Daemon tests cover pinned and unpinned baselines end to end through the dispatch context; the Swift plan gate is a pure function with an executed in-bundle test (added to the ios.yml regression list). * style: oxfmt * fix: exclude raw baselines from corroboration and prove the pin end to end (review) Raw baselines could not be pinned: the raw diagnostic plan keeps tree-first error propagation by contract and is never rerouted by the penalty or the preferred backend, so preserving 'raw: true' on the probe recreated exactly the backend-mismatch false failure this PR removes. Corroboration now declines raw baselines up front (they are diagnostics, not evidence baselines) with a regression pinning that no probe capture is dispatched at all. The wire is now regression-proven at every hop: a dispatch-level test drives dispatchCommand with the context flag and asserts the emitted RunnerCommand carries preferredBackend (red if handleSnapshotCommand or the interactor stops forwarding); the injected-transport test asserts the interactor's snapshot payload both ways; and a runner unit test decodes the wire JSON, projects it through the extracted snapshotOptions(from:), and composes it with the plan rule — pinned regular plan defers to privateAX-first, RAW plan stays untouched. Executed on-simulator; added to the ios.yml regression list. |
||
|
|
d5f99bab1c |
refactor: sink backend.ts's cycle-closing types below both zones (#1632) (#1636)
backend.ts imported RepeatedInput up from commands/command-input.ts and ScreenshotResultData up from utils/screenshot-result.ts — the interface hub typed in terms of the zones that depend on it, R6's textbook inversion shape. - RepeatedInput now lives in @agent-device/contracts/interaction; command-input.ts re-exports it for its existing importers. - ScreenshotResultData already had a byte-identical canonical declaration in contracts/snapshot-types.ts (exported via contracts/capture); the utils copy is now a re-export of it, deleting the duplicate outright. Measured member-by-member: the R9 type cycle collapses 76 -> 49 files. backend.ts, runtime-contract.ts, commands/runtime-types.ts, and commands/runtime-common.ts all leave the component (27 files stranded out at once); zone ceilings lowered to the measured values (commands 33 -> 14, platforms 7 -> 2, root 5 -> 3, daemon-server 20 -> 19) and CONTEXT.md's hub list recomputed (core/dispatch.ts 8, command-catalog.ts 7, resolution.ts 6, command-descriptor/registry.ts 6). No TYPE_INVERSION_BASELINE additions. |
||
|
|
d919876cb0 |
refactor(daemon): one interface for the deferred interaction outcome (#1633)
* refactor(daemon): one interface for the deferred interaction outcome (#1629) The machinery answering "did that mutation actually take effect?" was three modules coordinated only through raw SessionState fields: two independent marking sites (finalizeTouchInteraction vs dispatchGenericCommand, plus a third in session-open), a resolve side buried as private functions in snapshot-capture.ts with no direct tests, freshness heuristics split across the seam, and two raw reads of session.postGestureStabilization outside the owning module. - src/daemon/deferred-interaction-outcome.ts is now the one interface: markDeferredInteractionOutcome (every mutating route, one ordering) and resolveDeferredInteractionOutcome (every snapshot capture, parameterized over the capture primitive so it is directly testable). - getAndroidFreshnessReason moves beside its state machine in android-snapshot-freshness.ts, with the module's first direct test file. - isPostGestureStabilizationPending replaces the raw field reads in direct-ios-selector.ts and selector-capture-runtime.ts. - snapshot-capture.ts shrinks 617 -> 359 lines and keeps only capture, state building, and scope resolution. - No behavior change. The R9 type cycle drops 76 -> 74 files; zone ceiling lowered accordingly. CONTEXT.md gains the "deferred interaction outcome" term. * style: format android-snapshot-freshness.test.ts * fix(layering): record the honest R9 delta — the new module joins the cycle (+1 node) The earlier 76 -> 74 measurement was an artifact: the layering scan reads tracked files, and deferred-interaction-outcome.ts was still untracked. The real delta vs main is 76 -> 77 / daemon-server 20 -> 21: the choke point sits inline on value paths that already ran member-to-member, so the cycle gains one node and zero new edges. Ceiling raised explicitly with the rationale at the baseline, per the R9 rule's own escape hatch. * refactor(daemon): host the deferred-outcome seam in the stabilization owner (#1633 review) Zero R9 growth, per review: a NEW aggregator file cannot stay out of the cycle by shedding type imports — its value imports of the two member owners close the loop regardless. The unique zero-growth host is an existing cycle node, and the stabilization owner is the only legal one (the policy module cannot value-import stabilization back, R4; the freshness module would join as a new member). So deferred-interaction-outcome.ts absorbs the post-gesture-stabilization implementation and becomes the R7 owner of postGestureStabilization: the seam lives in a node that was already on the member-to-member paths it concentrates. - markPostGestureStabilization is now module-private behind markDeferredInteractionOutcome; stabilization marking tests drive the public interface. - The freshness retry loop moves beside its classifier in android-snapshot-freshness.ts; gesture-no-effect helpers move to a leaf file. Both stay outside the cycle. - Ratchet reverted to main's exact values (76 files, daemon-server 20) — measured member-by-member vs main: the diff is empty. * refactor(daemon): extract the pure stability loop into a leaf (#1633 review) The deferred-interaction-outcome owner keeps the seam, the pending-record ownership, and the R7 clear; the quiet-window polling loop and the baseline-distrust verdict move to post-gesture-stability.ts, parameterized by hooks (capture, surface reader, the three signature comparators) so the leaf imports no cycle owners and no SessionState — verified outside the R9 cycle, which stays at main's exact 76/20. Owner drops 539 -> 374 lines. The no-effect corroboration keeps comparing against the ORIGINAL pre-gesture baseline (never a mid-loop rebased one), now stated in the leaf's doc. The stabilization loop suite drives the unchanged public adapter; the verdict suite wires the real classifier through the leaf's hook. |
||
|
|
4a3ed1e8c3 |
feat(ios): extend depth-capped private-AX captures via element-rooted requests (#1627)
* feat(ios): extend depth-capped private-AX captures via element-rooted requests The AX server's depth limit is per-request (kAXErrorIllegalArgument above a tree-size-dependent value), so a capture capped at depth 56 on Bluesky-class React Native trees returned chains of unlabeled [other] containers and hid every actionable control below the cap — agents fell back to screenshot-and-coordinate guessing. After a capped serialization, the bridge now re-issues the same snapshot request rooted at each deepest-level childless node's live accessibility element, splicing the returned children in and chaining further while capped subtrees remain — bounded by a call limit, the shared node budget, and the capture-plan deadline. The AX server counts node levels, not edges: a maxDepth=56 request emits nodes to depth 55, so the frontier is the deepest observed level, kept only when it sits at the cap; a tree that ends naturally above the cap yields no frontiers and costs nothing. A capture whose frontier extension drained every capped node no longer reports itself depth-limited — the re-run hint it used to trigger could not add anything. Explicit --depth requests stay exact captures with no extension. Live on the seeded Bluesky bench feed: 8 extension calls (~106ms each) turn the 117-node all-[other] tree into a 182-node tree carrying post text, testID links, and every feed control (Reply/Repost/Like/options); steady-state capture 0.52s -> 1.37s. Observation-only captures paying the extension needlessly is #1626. * fix: count missed frontiers and tighten deep-extension shape (review) The completeness verdict inverted in the failure paths: a frontier whose live element vanished or whose re-rooted request failed was silently dropped, so an all-miss extension reported pendingFrontiers=0 and the capture presented as complete while whole subtrees were missing. Missed frontiers are now counted, logged, and keep the depth-limited verdict — the pure decision lives in privateAXDepthLimited with in-bundle tests. The truncation hint no longer advertises --depth (an explicit --depth capture disables extension, so following it returned strictly less than the capture that produced the hint); the honest remedy is a plain re-run with a fresh extension budget. Shape: candidates collected only at the cap boundary and not at all when extension is disabled (exact --depth captures pay zero bookkeeping); the zero-caller 3-arg overload is gone; the response shape's keys are shared constants; the unsupported-selector diagnostic is restored; the exact-depth policy is hoisted to one named local. * test: execute the deep-extension miss-path contract in CI (review) The depth-limited regression compiled but never ran (absent from ios.yml's -only-testing list), and the pure Swift consumer test was vacuous against the Objective-C producer — a literal missedFrontiers proves nothing about the increments. RunnerAXSnapshotFrontier and extendSnapshotFrontiers move to the header as the executed contract seam, and testDeepExtensionCountsMissedFrontiers drives both real miss paths with fabricated snapshots: a nil accessibilityElement (explicit nil property — bare NSObject resolves the key through a UIKit category and takes the call path instead) counts missed without consuming a call; a resolving element whose client cannot serve the re-rooted request consumes a call AND counts missed. Both tests join the executed ios.yml list. Red-before verified on-simulator: with both increments stripped the producer test fails at the counter assertion (0 != 2); restored, both tests pass. |
||
|
|
46abd0f48d | chore: Update GitHub Sponsors usernames in FUNDING.yml | ||
|
|
9dd1cddf30 | fix: resolve Dependabot security alerts (#1623) | ||
|
|
4269ca6d88 |
fix: update MCP registry namespace (#1618)
* fix: update MCP registry namespace
* feat: inherit MCP descriptions from CLI help
* refactor: project command guidance per surface
* refactor: make command guidance a single canonical description
The guidance type carried seven fields, but only three were ever set, and all
twenty call sites used it the same way: to hold a second, hand-written MCP
string next to a near-identical CLI one. That is the drift the abstraction was
meant to remove, so the type no longer offers a per-surface description at all.
A command now has one canonical description plus an optional tail per surface:
guidance: {
description: 'Shared body.',
cliDetail: 'Flags, positional syntax, terminal examples.',
mcpDetail: 'When-to-use and sequencing hints.',
}
Because a surface can only append, CLI help and MCP tool text cannot diverge —
the guard against CLI syntax in MCP descriptions becomes structural rather than
a review tripwire, since flag vocabulary only lives in cliDetail. All twenty
commands that previously carried two descriptions now share one body.
Also:
- Drop `summary` from the description fallback chain. It is the short list-view
line, so falling back to it replaced the full description with a fragment on
both surfaces: artifacts, boot, and shutdown each lost their real description.
- Stop writing the MCP variant back over `metadata.description`. That field
feeds CLI help, `explain`, and docs; `explain` was printing MCP-only text.
MCP now reads a separate `mcpDescription`.
- Drop `mcp.parameters`. It restated inputSchema property descriptions inside
the tool description — 1232 characters duplicated verbatim across six tools,
and three of sixteen declared hints silently rendered nothing because the
property had no description. Those properties are documented in the schema
instead, which serves MCP, --help, and docs at once.
- Drop `cli.flags`. Its one use appended "Relevant flags: --surface,
--launch-console." to help text that already named both flags inline.
Tests assert the structural property (both surfaces share a canonical prefix)
and the summary-fallback regression, alongside the existing CLI-syntax guard.
CLI help wording assertions follow the new copy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dp3J8UUgYxtw5vzJzvjkSf
* test: gate undocumented MCP tool inputs
Guidance no longer restates input fields in prose, so a tool's inputSchema is
the only place its inputs are documented — for the model, for --help, and for
the docs site. An undescribed property is a silent gap rather than a cosmetic
one, which is exactly the failure mode the removed `mcp.parameters` selection
had: it dropped hints for properties that carried no description and reported
nothing.
Describe the two trigger-app-event inputs that mechanism used to name, and add
a ratcheting gate over every MCP tool input. A property key that is not already
in the budget fails immediately; the total may never grow, and lowering it is
required once properties gain descriptions, so the 132 remaining stay visible
instead of settling in as permanent debt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dp3J8UUgYxtw5vzJzvjkSf
* fix: project the canonical description to every surface
Storing only the MCP variant left the shared body unpropagated: `metadata.description`
and the executable definition kept their pre-guidance text, so `explain click` reported
"Click or tap a semantic UI target..." while CLI help and the MCP tool both used the
canonical "Activate a UI target...". 53 commands were affected — the CLI schema base,
`explain`, and docs all read `metadata.description`.
`projectCommandGuidance` now returns the canonical body plus the MCP-only tail, and
`defineCommandFacet` writes the body to both metadata and the definition. Only the tail
is stored apart, as `mcpDetail`, so the body has exactly one home instead of a second
full copy that could drift; `composeMcpDescription` joins them for the tool surface.
The surface gate pins the invariant: definition, metadata, and `explain` must report the
identical body, and neither CLI help nor the MCP description may do anything but extend
it. Both arms verified by breaking them.
Also replace the undocumented-input ratchet's bare-key allowlist plus aggregate budget
with exact `tool.property` identities. The old shape stayed green while a gap migrated:
describing `foo.text` and adding an undescribed `bar.text` left both the allowed-name set
and the total of 132 unchanged, and stale names kept authorizing later gaps. Verified
with that exact scenario — `app` was already an allowed name via push/reinstall/settings
and the total held at 132, yet a newly undescribed `open.app` now fails. Recording a fix
requires deleting its baseline entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dp3J8UUgYxtw5vzJzvjkSf
* refactor: drop guidance.description in favour of the command's own
`guidance.description` restated what `metadata.description` already is. Setting it
shadowed the metadata literal rather than replacing it, so every command that used it
shipped two bodies: the canonical one and a terse original that no surface could
observe — 768 bytes of unreachable strings across 20 commands.
Move each canonical body to the metadata literal where it belongs and delete the field.
Guidance is now tails only, `cliDetail` and `mcpDetail`, which also removes the question
of where a body is written: there is one place, and no chain to consult. Three guidance
blocks held nothing else and are gone entirely.
registry.js drops 1117 bytes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dp3J8UUgYxtw5vzJzvjkSf
* refactor: give every command one text block with a mandatory summary
The CLI carried four prose fields with a fallback chain between them, and one of
them — `helpDescription` — was authored on 45 commands and generated on the rest.
That ambiguity is why surfaces drifted: whichever field a reader looked at, some
other field might be the one actually rendered.
Prose now lives in a single `CommandText`, and `CommandSchema` keeps only grammar:
summary what is this command, in a list of ninety? (mandatory)
description what does it do, and when do I reach for it? (mandatory)
cliDetail flags, argument shapes, terminal examples
mcpDetail sequencing and cross-tool hints
`--help`, the command list, the MCP tool description and `explain` are projections
computed where they render, so nothing derived is stored and no field can be both
input and output. The four-field model was validated against the whole surface
before the migration: all 67 commands reproduce their MCP text exactly and derive
their help body from `description`, so none needed a fifth field.
Making `summary` mandatory fixes a regression this branch introduced. 23 commands
had none, so the command list fell back to the full detail paragraph; lengthening
those descriptions earlier turned `click`'s list entry from 61 characters into 267,
`fill`'s into 214, `devices`' into 159. Every command now states its own line, and
a gate holds them under 72 characters, non-empty, period-free, and distinct from
the description.
Two duplications go with it: the per-command help printed its synopsis twice, once
as a header and again under `Usage:`, and `press` said "use longpress" in both its
body and its tail.
* refactor: tighten the command text plumbing
Self-review follow-ups on the text model, all quality-only:
`command-text.ts` moves from `cli-schema/` to `commands/`. It is a command concept
that MCP reads as much as the CLI does; living under `cli-schema` made the MCP
surface import a CLI module to render its own tool descriptions.
`defineCommandFacet` no longer casts. It took a facet and returned it with the
schema completed, but claimed to return the input type, which needed
`as unknown as` — a double cast is the type system reporting that the signature
was wrong. Splitting `CommandFacetInput` from `CommandFacet` states the completion
in the return type, so both that cast and the registry's `as CommandSchema` go.
`push`'s summary duplicated its description apart from a trailing period, which the
gate missed by comparing exact strings; it now compares normalised text, and the
summary says something the description does not. `install-from-source`'s summary
loses a clause it did not need.
---------
Co-authored-by: Claude <noreply@anthropic.com>
|
||
|
|
d81ac0a092 |
fix(ios): corroborate recorded tap outcomes (#1605)
* fix(ios): corroborate recorded tap outcomes * fix(ios): preserve corroborated tap target identity * fix(ios): suppress corroborated tap retries * fix: require comparable iOS tap evidence * fix: bound iOS tap corroboration baseline * test(ios): deterministic injection seam for recorded-tap-failure corroboration (#1605 merge gate) The field failure cannot be reproduced on this head: the tap false-failures were a downstream symptom of XCTest-channel saturation, which the #1587 capture fixes removed. The seam records a real XCTIssue AFTER the real gesture inside the per-command failure-count window, so xctestRecordedFailureResponse and target invalidation fire byte-for-byte like the field failure. Armed via a decrementing /tmp flag file (the daemon regenerates tampered xctestrun templates, so env plumbing cannot reach a daemon-spawned runner); compiled only under AGENT_DEVICE_RUNNER_UNIT_TESTS. Live evidence on a daemon-spawned runner (Bluesky, ad-bsky-repro sim): - landed case: injected failure on a real Search-tab tap -> success with the corroboration warning, screen verifiably on Search, no redispatch, runner serving next commands; flag consumed exactly once. - unchanged case: injected failure on a dead-coordinate tap -> capture unchanged -> XCTEST_RECORDED_FAILURE preserved with the new honest hint; runner still usable. - field-shape race (relaunch -> full snapshot -> immediate press, 5 attempts): no natural recorded failure occurs on this head — the hostile tree needed for channel saturation is gone, corroborating the causal story. * fix: reconcile tap corroboration with current interaction semantics |
||
|
|
3b1431c634 |
fix(ios): never signal a recycled runner pid from a stale lease (#1621)
* fix(ios): never signal a recycled runner pid from a stale lease (#1596) A runner lease file can outlive its runner by days (SIGKILLed daemon), and pids get recycled: cleanupLeasedRunnerProcesses killed lease.runnerPid raw — group kill + direct kill + pkill -P — while only the lease OWNER pid had identity verification. After a pid-space wrap the tree kill lands on whatever process now holds the pid. - record runnerStartTime in the lease at construction (both the fresh-spawn and adoption sites go through buildRunnerLease) - verify the pid before the SIGTERM/SIGKILL tree kills: start-time match, or for legacy leases without one, a runner-shaped xcodebuild command line; otherwise skip the tree kill and emit ios_runner_lease_recycled_pid_skipped - the pattern-based xcodebuild pkill still runs unconditionally, so genuinely stray runner processes are still collected - adoption skips a recycled pid too: the adopted session's disposal would later signal it * fix: re-verify lease pid identity before each signal and during legacy adoption The stale-lease cleanup verified the runner pid once and reused it for both SIGTERM and SIGKILL; the runner usually dies on the SIGTERM, and the pid can be recycled while the awaited xcodebuild sweep runs before the escalation. Each signal now resolves a freshly verified pid. Adoption skipped identity verification entirely for legacy leases without a recorded runnerStartTime, then re-stamped the lease with the live pid's start time — laundering a recycled pid into a strongly verified lease that disposal would later kill. Adoption now shares the disposal path's verification, including the runner-shaped command-line fallback for legacy leases. * fix: re-verify pid identity after the awaited adoption probe The uptime probe is the last await before the adopted lease re-stamps the pid with its live start time; the xcodebuild can exit and its pid be recycled during that network round-trip while the old port still answers. Re-verify after the probe — everything from there to the lease write is synchronous. |
||
|
|
98fa7b9240 | build: eliminate tsdown bundle warnings (#1607) | ||
|
|
8c800ae53f |
refactor(contracts): one viewport-root predicate for the whole repo (#1613)
* refactor(contracts): one viewport-root predicate for the whole repo "Is this the Application/Window root" was written nine times: three spellings normalizing `type|role|subrole`, five lowercasing `type` alone, and one comparing the normalized type for EQUALITY. Two of the nine sat in `contracts/snapshot-visibility.ts` itself, disagreeing with each other. Measured before collapsing, using #1592's method — ground the comparison in what each backend ACTUALLY emits, not in fixture strings. Over the 31 names iOS's `elementTypeName` can return, the 18 fully-qualified class names Android emits, and the 24 mapped/raw forms the macOS helper produces, the nine agreed on 71 of 73. The two exceptions are macOS window subroles, and the only spelling that disagreed is maestro's `===`, whose platform union is `android | ios` — so it can never see them. The duplication was textual, not behavioral, which is what made the collapse safe. `isViewportRootNode` reads role and subrole because the macOS helper is the only backend populating them and the only one able to emit a window whose `type` does not say so: `normalizedSnapshotType` returns the raw subrole for a non-standard window, so an `AXWindow` with subrole `AXSystemDialog` or `AXUnknown` reads as neither from `type` alone. Those two shapes are the whole behavioral delta of this change, at the six call sites that were type-only, and they are windows by role. `snapshot-viewport-root.test.ts` pins the predicate over those three emitted vocabularies. Red evidence: reverting the canonical definition to the type-only spelling fails 2 of 5 cells, to the equality spelling 4 of 5. Also drops two kernel re-declarations this made visible: maestro's local `containsPoint` and `rectsOverlap` were character-identical to `@agent-device/kernel/rect`'s `containsPoint` and `isRectVisibleInViewport`, in a file that already imports from that module. And `resolveViewportRect` loses three `as Rect` casts that only existed because `.filter()` cannot narrow `node.rect` — one `flatMap` states the same thing honestly. Deliberately NOT in this change: the three viewport RESOLVERS still diverge, and on Android that is a live defect rather than duplication. Filed separately with the measurement. * test(contracts): enumerate the macOS emitter's real vocabulary Review found the table claimed to pin "the vocabulary each backend actually emits" while omitting most of it. `normalizedSnapshotType` has three output classes and only two were represented: 1. thirteen roles mapped to fixed short names — six were missing (StaticText, TextField, TextArea, MenuBarItem, Menu, MenuItem); 2. AXWindow, whose output is the SUBROLE unless it is AXStandardWindow; 3. the `default:` arm, `subrole ?? role`, emitting the raw AX-prefixed value for every unmapped role. All three are now enumerated, and the table asserts its own completeness against the emitter's fixed-output set — a role added to that switch without being added here fails, which is the emitter-drift protection the docblock was promising but not delivering. Re-measuring over the complete tables also corrected the header's own numbers. The claim was "71 of 73 agree, 2 disagree"; over 75 names it is 71 agree and FOUR disagree, because AXSystemDialog and AXUnknown were absent from the old table. Those two are the behavioral delta of this PR — an AXWindow whose subrole is emitted as the type, invisible to the six type-only spellings and named exactly by `role` — so the incomplete table had been hiding the very rows that justify reading role/subrole. The other two (AXFloatingWindow, AXSystemFloatingWindow) remain inert: only the `===` spelling misses them and its platform union is `android | ios`. * test(contracts): derive the macOS fixed-output set from the emitter Two test-validity defects from review, both real. The raw-fallback row `{ type: 'AXSearchField', role: 'AXTextField', subrole: 'AXSearchField' }` was unreachable: the `AXTextField` arm returns `TextField` whatever the subrole, so no emitter run can produce it. Replaced with `{ type: 'AXSortButton', role: 'AXCell', subrole: 'AXSortButton' }` — a subrole on a genuinely unmapped role, which is what the `subrole ?? role` default arm actually emits. `MACOS_FIXED_OUTPUTS` was a hand-kept twin compared against a hand-kept table, which is circular: a new mapped Swift role is absent from BOTH, so they agree and the gate stays green. The "emitter-drift protection" the docblock promised did not exist. The set is now parsed out of `normalizedSnapshotType` in SnapshotTraversal.swift, so the comparison is against the emitter rather than against a copy of the table's own assumptions. `case "AXWindow"` returns a subrole expression rather than a literal and is deliberately outside the literal-return set. Red evidence: adding `case "AXDisclosureTriangle": return "DisclosureTriangle"` to the Swift switch fails with `expected [ 'DisclosureTriangle' ] to deeply equal []`; 6 pass once reverted. The parser throws rather than silently matching nothing if the function is renamed or moved. * chore: restore maestro conformance corpus to main 45 corpus YAMLs carried an unrelated quote-style churn ("Button" -> 'Button'). They were already modified in the worktree when this branch started and a `git add -A` swept them into the predicate commit. Nothing in this PR reads them. Restored verbatim to main. |
||
|
|
d8b309c6db |
refactor(contracts): name façade exports explicitly and retire the pin table (#1614)
* refactor(contracts): name façade exports explicitly and retire the pin table Thirteen of the fourteen `@agent-device/contracts` façades were bare `export *` barrels. `facades/snapshot.ts`, added by #1582, was the one exception — explicit named re-exports — and that is now the rule. Everything #1574 built to cope with `export *` goes with them: scripts/layering/facade-symbols.ts -980 (816 pinned names) scripts/layering/facade-exports.ts -192 (readFacadeExports) scripts/layering/facade-exports.test.ts -234 (star semantics) scripts/layering/package-boundaries.test.ts -55 `readFacadeExports` re-implemented ESM `GetExportedNames`/`ResolveExport` — star-chain resolution, ambiguity rejection, diamond binding identity, cycle guards, spec-accurate `default` filtering at the star rather than the source. All of it existed to enumerate what `export *` hides. 523 of the 816 pinned names belonged to contracts, i.e. to those thirteen files. Once a façade names its exports, the façade file IS the pin, and it is visible in the diff of the file that widened rather than in a separate table a reviewer has to cross-check. `readNamedExports` (20 lines) stays and is enough: it already throws on bare `export *` and on `export default`. The pin is replaced by one structural gate — no façade may contain a bare star — which reuses that rejection rather than adding a regex. Surface equivalence verified independently, not asserted: main's own `readFacadeExports` run over the new façades, compared against main's own `FACADE_SYMBOLS` table — 31 subpaths, 0 added, 0 removed. Red evidence for the new gate: planting `export * from '../request-progress.ts'` back into facades/progress.ts fails it with the file named and the reason quoted; 12 pass / 0 fail once reverted. Not included: the `lowerAndroidTouchPlan` tuple-assertion drive-by. It needs `sampleGestureOffsets` to carry a min-arity tuple through `.map()`, which TypeScript will not infer without a typed helper — a real change to the gesture-plan contract rather than a drive-by, so it stays out. * test(layering): assert façades stay exhaustive over their sources Review on #1614 caught this conversion silently narrowing the public surface. The explicit lists were generated against the surface at fork time; #1567 landed 13 exports meanwhile — `DragOptions`, the drag-gesture vocabulary (`COORDINATE_GESTURE_KINDS`, `CoordinateGesturePayload`, the three `DEFAULT_DRAG_*` constants, `DragGestureInput`, `DragGesturePayload`, `GestureCommandInput`, `buildDragGesturePlan`, `dragGesturePayloadFromPositionals`, `normalizeGestureCommandInput`) and `MultiTargetAnnotationV1`. The `export *` barrels had been forwarding all 13 automatically; the rebase dropped every one, and only a human diff caught it. The star-rejection gate could not: it only proves a façade does not WIDEN invisibly. Narrowing is the failure an explicit list newly makes possible, because `export *` could not narrow by construction. So the property the stars gave for free is now asserted directly — every name a re-exported source declares must appear in the façade. Scoped to `packages/*/src/facades/`, the barrels this PR converted. A hand-curated package `index.ts` is a different thing: `ad-replay` deliberately publishes two values out of a much larger `internal/`, and forcing exhaustiveness there would widen a surface its owner narrowed on purpose (#1555). A source that itself carries a bare `export *` is skipped — unknowable from that file alone, and reachable because the façade re-exports the starred module directly too, which IS checked. Red evidence: dropping `MultiTargetAnnotationV1` from facades/replay.ts — one of the 13 the old gate was blind to — fails with the file, the source and the symbol named. 13 pass / 0 fail once restored. * fix(layering): close the exhaustiveness gate's starred-source hole Two review findings, plus a third the gate caught on itself. P1 — the three `DEFAULT_DRAG_*` constants join the existing public-façade suppression, alongside `COORDINATE_GESTURE_KINDS` and `normalizePublicGesture` which the same conversion surfaced. All five are #1567's drag vocabulary, made individually visible to `--production` analysis for the first time because a bare star used to hide them from that exact check. Kept rather than narrowed, for the reason the existing entry already states: the façade's surface stays byte-identical to what the retired pin table asserted, and narrowing is a follow-up with its own review. P2 — the exhaustiveness gate skipped any source carrying a bare `export *`, which dropped that module's DIRECT exports from the check too. `gesture-plan.ts` stars `gesture-plan-types.ts`, so removing `buildDragGesturePlan` from the façade narrowed the public surface and still passed. `readDirectNamedExports` now reads exactly the names a module declares or re-exports BY NAME and ignores the star, so direct exports are checked while the starred set stays covered by the façade's own direct re-export of that module. Red evidence: removing `buildDragGesturePlan` from facades/interaction.ts now fails naming file, source and symbol; 13 pass / 0 fail restored. Third, and the reason the gate is worth having: rebasing onto main after #1612 merged silently dropped `TEXT_ENTRY_ROUTES`, `TextEntryRoute` and `TypeTextBackendResult` from the interaction façade — the same narrowing class as the #1567 one review caught by hand, one merge later. The gate failed on it before CI did. Restored. |
||
|
|
959d858334 |
refactor(ios): share one private-XCTest event bridge between gesture and text synthesis (#1608)
RunnerSynthesizedGesture.m and RunnerSynthesizedTextEntry.m each independently reflected into XCSynthesizedEventRecord/XCPointerEventPath. Extract the common resolution (classes, addPointerEventPath:/setTargetProcessID:/ synthesizeWithError:/processID, the RunnerRequireClass/Selector/ ApplicationSelector checks, the shared objc_msgSend typedefs, and the "name: reason" exception formatter) into RunnerXCTestEventBridge.h/.m. Each caller resolves its own extras on top of the shared core: gesture resolves the 2-arg initWithName:interfaceOrientation: plus touch-path selectors, text entry resolves the 1-arg initWithName: plus text-input selectors. |
||
|
|
ee473b6adc |
refactor(daemon): give the Maestro fallback and ambiguous-match details real types (#1612)
Three places smuggled structured data through untyped bags and re-read it
with runtime guards. Each gets an explicit typed boundary.
A. The resolution-suppression rule was encoded twice in
interaction-touch-response.ts — a spread ternary in the runner-payload
branch and an unconditional destructure used conditionally in the runtime
branch, with the ADR 0012 rationale living on only one source variant.
Both branches now read one `suppressesResolutionDisclosure(source)`
predicate through one `applyResolutionDisclosurePolicy` helper, where the
reason is stated once. The union field is renamed
`maestroCoordinateFallbackDispatched` (the dispatch path that ran) and
hoisted into a shared base. handleFillCommand's two-arm interactor.fill
call collapses to one.
B. `Interactor.type` narrows from `Record<string, unknown> | void` to
`TypeTextBackendResult | void`; the Apple runner boundary is the single
place the wire payload becomes that type. `maestroFallbackDetails` returns
a typed `{ used, extra }` instead of a bag both call sites re-read.
C. `details.candidates` meant two incompatible things. The device-domain
resolvers now key their list `devices`, so the shared renderer drops its
shape-disambiguation guards and the device list actually renders.
|
||
|
|
36f44ca2cc |
docs: clarify iOS drag synthesis profiles (#1616)
* docs: clarify iOS drag synthesis profiles * refactor: consolidate Apple gesture event lifecycle |
||
|
|
9fc266351f |
fix: stabilize Replay Nightly fixture boundaries (#1610)
* fix: stabilize replay nightly fixture boundaries * test: stabilize exit flush integration coverage * chore: drop the deleted exit-naive fixture from fallow's entry list (#1610 review P3) |
||
|
|
a13a6832ee |
feat: add selector-targeted drag gestures (#1567)
* feat: add selector-targeted drag gestures * fix: address drag gesture review feedback * fix: satisfy drag review quality gates * fix(android): lower drag trajectories piecewise * test(replay): validate drag fixture selectors * fix(ios): ignore full-viewport chrome containers * test(drag): prove destination on live devices |
||
|
|
4c7a899a05 |
fix: prevent iOS text entry runner wedge (#1604)
* fix: prevent iOS text entry runner wedge * fix: preserve iOS hardware-keyboard text entry * fix: expire iOS text-entry tap witnesses * fix: fail interrupted iOS text entry * test: run interrupted iOS typing regression |
||
|
|
23a3016e9b |
fix: stop the unit suite from leaking temp directories (#1593)
* fix: stop the unit suite from leaking temp directories ~650 test call sites across the unit suite create scratch directories via fs.mkdtemp(path.join(os.tmpdir(), ...)) or shared factories (makeSessionStore) with no cleanup, ever. Over time this accumulated 1.16M+ orphaned directories in the real system tmpdir, slow enough to make tools that enumerate $TMPDIR at startup (e.g. opencode) take 1-2 minutes to launch. Rather than migrate every call site, redirect os.tmpdir() itself for the lifetime of the whole `vitest run` invocation: scripts/vitest-tmpdir-global-setup.ts wires in as vitest's globalSetup/globalTeardown, points TMPDIR at one /tmp-rooted directory (verified: env mutations here propagate to every forked worker, confirmed empirically), and removes it in one recursive rm after every worker across every project finishes. Since os.tmpdir() reads TMPDIR on every call, this covers all ~650 call sites without touching any of them. Rooted at /tmp rather than nested inside the current (already deep, on macOS) os.tmpdir(): that broke real AF_UNIX socket tests (runner-usbmux.test.ts) by pushing socket paths past the 104-byte sun_path limit. A per-file afterAll hook was tried first but proved unreliable — 5 of 7 workers in one run never ran it before their process was torn down; the global setup/teardown pair (one process, confirmed single execution) is the mechanism that's actually guaranteed to run once. Also adds: - scripts/check-tmpdir-leaks.ts: CI/local guard asserting no agent-device-test-run-* directory survives a run (a leftover one means a worker was killed before cleanup could run). - src/__tests__/test-utils/tmp-dir.ts: documented mkdtempForTest / mkdtempForTestSync helpers, the discoverable way to get a scratch dir going forward (mirrors the src/utils/exec.ts pattern for node:child_process). - scripts/check-test-tmpdir-helper.ts: ratchet guard capping raw fs.mkdtemp/mkdtempSync call sites in test files at today's count (632); it can only shrink as call sites migrate to the helper. * fix: make check-tmpdir-leaks scan the same root the fix actually uses check-tmpdir-leaks.ts was scanning os.tmpdir() for leftover run directories, but vitest-tmpdir-global-setup.ts creates them under a hard-coded /tmp. On macOS those are different paths (TMPDIR is a deep per-user /var/folders/.../T/ directory) — the guard could never find a leak on the exact platform the original leak happened on, only on Linux CI where os.tmpdir() already is /tmp. Export TEST_RUN_TMP_ROOT and TEST_RUN_TMP_PREFIX from the global-setup module and import them in the leak check instead of recomputing a path that can drift. Switched from a fixed pid-based directory name to fs.mkdtempSync so a same-named leftover from a prior killed run (or, on a shared machine, another user) can't collide with a live run. Also fixes two stale comments (in this file and ci.yml) that still described the per-file afterAll hook design that was abandoned in favor of the global setup/teardown pair, and notes the check only covers vitest runs, not the node --test lanes (test:smoke, test:integration:node). Verified live: with the old code the guard reported no leaks even with a real orphaned /tmp/agent-device-test-run-* directory present (left by a command that got killed mid-run); with this fix it correctly found and reported it. * simplify: drop the tmpdir ratchet guard, keep the leak check local-only Two guards were more than this needed: - check:test-tmpdir-helper (ratchet on raw fs.mkdtemp call counts) protects nothing a bug could actually trigger — the leak is already fixed architecturally regardless of call-site count, so this was pure style/discoverability nudging. Dropped the script and its check:tooling/CI wiring; kept mkdtempForTest/mkdtempForTestSync in tmp-dir.ts as the documented option without enforcing it. - check:tmpdir-leaks in CI added little: GitHub-hosted runners are destroyed after each job, so a leftover directory there is harmless by construction, and a worker getting killed mid-run would already surface as a job failure some other way. Its real value is local, on the long-lived dev machines where the original leak actually accumulated — kept it wired into check:unit, dropped the CI step. * fix: don't flag a concurrent vitest run's tmpdir as a leak check-tmpdir-leaks.ts reported every agent-device-test-run-* directory as a leak, but a concurrent vitest run in another worktree legitimately keeps its own directory present until its own teardown finishes. On a machine that regularly runs several worktrees at once, that made check:unit fail on unrelated in-progress work. Embed the owning process's pid in the directory name (still random- suffixed via mkdtempSync, so same-pid reuse across separate runs can't collide) and have the leak check skip any directory whose pid is still alive (process.kill(pid, 0)) — only directories whose owning process already exited without running its globalTeardown are real leaks. Split the pure logic into check-tmpdir-leaks-model.ts (findLeakedRunDirectories, with an injectable liveness check for testing) so it has a real regression suite, including the concurrent-run case, instead of only being exercised by hand. * refactor: migrate raw fs.mkdtemp call sites to mkdtempForTest(Sync) Migrates 629 raw fs.mkdtemp(Sync)(path.join(os.tmpdir(), PREFIX)) call sites across 168 test files to the mkdtempForTest / mkdtempForTestSync helpers (src/__tests__/test-utils/tmp-dir.ts), so there's one documented, discoverable way to get a scratch dir in a test — cleanup already didn't depend on the call-site shape (the global TMPDIR redirect covers any of them), this is purely for consistency and discoverability, same reasoning as src/utils/exec.ts for node:child_process. Existing manual per-test cleanup (fs.rm in finally/afterEach/onTestFinished blocks) is untouched — the global teardown is a fallback for killed workers, not a replacement for tests cleaning up after themselves. Migrated with a one-off AST-based codemod (oxc-parser, since regex mismatched multi-line calls and complex prefix expressions like `options?.tempPrefix ?? 'default-'`) rather than by hand across 168 files. The codemod isn't included — it doesn't need to survive this commit. Caught and fixed one real bug in it during review: a small number of files declare a second import statement later in the file, after some of the matched call sites, which broke a naive "insert after the textually-last ImportDeclaration" placement; fixed to insert after the top contiguous import block instead, plus a self-check that re-parses every generated file before writing it. Also fixes 5 fallow dead-code findings the branch introduced: the vitest globalSetup functions (setup/teardown) are only referenced by the config-string path vitest.config.ts hands to globalSetup, invisible to static analysis — suppressed with the documented convention. isProcessAlive didn't need to be exported (nothing outside the module uses it). And dropped a barrel re-export of the new helpers from test-utils/index.ts: nothing actually imports through the barrel (matching the existing makeSessionStore convention, which is imported directly from store-factory.ts everywhere despite also being barrel-exported), so the re-export was genuinely dead. Documents the convention in docs/agents/testing.md. Verified: full unit suite (5308 tests) passes except the one pre-existing, unrelated package-exports.test.ts failure; typecheck, lint, and format all clean; fallow audit clean against the PR base. * fix: correct fallow suppression token and drop unused barrel re-export These were meant to be part of 397cdd6d7 (verified locally before that commit) but didn't actually get staged — caught by CI's Fallow Code Quality check re-running against the pushed commit, which still had the plural 'unused-exports' token (fallow expects singular 'unused-export') and the dead barrel re-export. * fix: migrate the two mkdtemp call sites the rebase silently reintroduced Rebasing onto main pulled in #1594's two new test cases in this file, added independently of this branch's migration, still using raw fs.mkdtempSync(path.join(os.tmpdir(), ...)). Git's line-based merge found no textual conflict with this branch's removal of the os import (the changes touch non-overlapping regions), so it silently produced a file that doesn't typecheck. Migrated both to mkdtempForTestSync for consistency with the rest of the file, caught by CI's Typecheck, Fallow Code Quality, and FreeRange checks re-running against the pushed commit. * test: pin Vitest tmpdir lifecycle * fix: preserve the Swift cache across test runs |
||
|
|
611858103e |
fix(ios): harden Bluesky-class interaction reliability (#1588)
* fix: type into focused iOS inputs without AX * fix: fill AX-hostile iOS text inputs * fix: keep scrolling containers from stealing taps * fix: stop agents after explicit task success * chore: format benchmark guidance * fix(ios): preserve fill semantics across fast paths * test: retire direct selector fill expectations * test: assert runtime selector fill evidence * fix(ios): preserve verified and Maestro fill paths * refactor(ios): isolate synthesized text entry * fix(client): preserve open diagnostic paths * fix(ios): expose structured text entry route * fix(packaging): strip text entry policy tests |
||
|
|
dea65cbd58 |
fix(daemon): judge post-gesture movement by identity, not by the intersection (#1573)
Fixes #1569. The post-gesture baseline check asked "did anything in the intersection of the two captures move". On a scroll that is the wrong question: a scroll REPLACES content rather than sliding shared elements around, so the intersection is whatever sat still. Measured on a live checkout-form `scroll down 0.6` that moved the entire form, the baseline and the post-scroll capture had exactly five identifiers in common and all five were tab-bar icons, which cannot move. What carried the verdict instead was anonymous layout containers matched by ordinal position among other anonymous nodes. That real pair had 8 of them on one side and 10 on the other, and the six "moved" entries reported deltas of -920, +37 and -7 px for a ~500px scroll. Removing them from the same pair flipped the verdict to 'unchanged' — for a scroll that replaced every element on screen. That is the saturation in #1569 finding 1: the oracle was reading capture composition, not the screen. Three changes: - Signature entries carry an `identity` (identifier|label|value|type) alongside the existing `key`. The key keeps folding in hittable/enabled/selected and an occurrence index, which is right for "did two back-to-back captures agree" and wrong across a gesture: scrolling flips `hittable` the moment a node's centre leaves the viewport, evicting exactly the elements whose movement was the evidence. Anonymous nodes get no identity and leave the comparison. - The verdict is set membership. Content leaving AND arriving is replacement, so 'changed'. One-sided difference is scope drift between differently scoped captures, so 'unchanged' — the subset tolerance the old rule needed, kept. Rect movement of a survivor is still 'changed'. - The loop records the baseline's snapshot backend and re-baselines rather than concluding when it changes. Backends do not return comparable views: on one live screen private AX returned 139 nodes including 43 scrolled off-viewport where the tree backend returned 48. A plan fallback or the XCTest-channel penalty can swap backends mid-poll at any time. The distrust budget is untouched — with an oracle that can see movement, a real scroll settles on the first quiet pair instead of running to the cap. Counterfactuals, each failing the pin it belongs to: putting `hittable` back into identity fails the hittability-flip test; admitting anonymous nodes fails the fluctuating-container test; removing the backend guard fails the re-baseline test. |
||
|
|
20e903c117 |
fix(maestro): unify the scrollable-ancestor walks and fix Android scroll-container selection (#1592)
* refactor(maestro): collapse the duplicate scrollable-ancestor walk
fallow reported three structurally identical "walk up the parent chain to
the nearest scrollable ancestor" implementations as clone groups
(dup:1b401a24, dup:ce01e1de). Two of the three predicates classify
identically, one does not.
snapshot-policy.ts's isScrollableNode is logically identical to contracts'
isScrollableNodeLike -- same six type patterns, same `=== 'table'`
equality, same role/subrole fallback, and neither normalizes the type
first. The walks match too, so findScrollableAncestorRect collapses onto
findNearestScrollableAncestor with `(n) => Boolean(n.rect)`.
runtime-port-geometry.ts's isScrollableSnapshotType does NOT agree. It
equality-matches the NORMALIZED type, so over 227 node-type strings
harvested from the repo's fixtures and tests it disagrees in both
directions: Android ListView/GridView/RecyclerView, HorizontalScrollView,
AXScrollBar and role-only scrollables clip but are not swipe containers,
while XCUIElementTypeTable and AXTable are swipe containers but do not
clip (the clip predicate compares 'table' against the unnormalized type,
so prefixed forms miss). Only bare `table` satisfies both. It stays
separate, with the divergence and the reason each call site needs its own
answer written down where it can be read.
The new test is load-bearing rather than decorative: replacing
isScrollableSnapshotType with the contracts predicate leaves
`pnpm maestro:conformance` at 46/46 and the pre-existing maestro suite at
206/206 green. The oracle does not cover scroll-container selection, so
nothing else in the repo fails on that collapse.
resolveRootViewport is deliberately left alone -- it resembles contracts'
resolveViewportRect but lacks its third "largest containing rect of any
node" fallback, so it is a real divergence and not the next dedup.
* fix(maestro): recognize Android scroll containers when aiming scrollUntilVisible
The divergence note added in the previous commit was wrong, and it was
covering for a bug rather than describing a design.
Grounding the comparison at the call site instead of in fixture text
changes the answer. `node.type` is never normalized on the way in -- it
carries the raw platform string -- so the domain of each predicate is
exactly what each platform emits:
iOS `elementTypeName` returns 31 fixed short names ("Table",
"ScrollView", "CollectionView", ...), never "XCUIElementType*".
Android `attrs.className`, fully qualified.
macOS role-mapped short names; outside Maestro's platform union.
Over all 31 iOS names the two predicates agree on every single one. The
claimed `XCUIElementTypeTable` / `AXTable` divergence was measured on
strings the runner cannot produce; the real emission is "Table", which
both predicates accept. The role/subrole arm is macOS-helper-only, so it
is inert for Maestro entirely.
What remains is Android, one-directional, and a defect: matching a
normalized type for EQUALITY recognizes bare `android.widget.ScrollView`
and silently misses HorizontalScrollView, NestedScrollView, RecyclerView,
ListView and GridView. `scrollUntilVisible` therefore selected no
container and fell back to a screen-centred swipe inside essentially
every RecyclerView-backed list -- contradicting the function's own
documented intent, and contradicting the existing Android test that
expects `android.widget.ScrollView` to be selected.
So the third walk collapses onto the shared helper too: the substring
predicate is also the better fit for Android's open class-name space,
where an allow-list would keep missing NestedScrollView and every custom
subclass. All three walks now share
`@agent-device/contracts/snapshot`, and the explanatory comment is gone
because there is nothing left to explain.
The test is rewritten to pin the classification over the vocabulary each
platform actually emits, with the Android rows as the regression guard.
|
||
|
|
543e9f8c05 |
fix(ios): give keyboard dismiss a safe-area-tap fallback (#1598) (#1606)
* fix(ios): give keyboard dismiss a safe-area-tap fallback (#1598) The runner already tapped a keyboard's own Hide/Dismiss/Done key when the AX tree exposed one, but iPhone's default software keyboard has no such key, so `keyboard dismiss` returned UNSUPPORTED_OPERATION on the common case and agents proceeded with the keyboard (and any live QuickType predictive-text bar) still up. Live-validated on throwaway simulators before choosing a design: hardware escape key (no effect without a connected hardware keyboard), swipe-down starting on the keyboard (does not trigger UIKit's interactive dismissal on Settings/Safari/Contacts), and a private `performAction:onElement:value:error:` AX call (hung the runner for 90s on a guessed action name, force-killed by the daemon timeout) were all ruled out. The dismiss-key tap remains the primary mechanism (iPad, or any app with an inputAccessoryView Done/Cancel button); a new snapshot-derived safe-area tap is added as the disclosed last resort, computed to land outside both the keyboard and every currently-hittable element so it is a safe no-op even when it fails to dismiss. The response now discloses which mechanism actually fired (`mechanism: 'dismissKey' | 'safeAreaTap'`) across the CLI/daemon dispatch path, the SDK runtime.backend surface, and session-event summaries, so callers can tell a real dismiss-key press apart from a best-effort tap. UNSUPPORTED_OPERATION now says both mechanisms were tried. * fix: satisfy CI formatting and complexity gates oxfmt on three touched files; buildKeyboardActionSummary split so the dismiss wording (incl. the safeAreaTap mechanism disclosure) lives in its own helper below the complexity threshold. * fix: any-element obstacle rule for the safe-area dismiss tap (#1606 review P1) A role allowlist cannot prove a point is AX-empty: an unlabeled RN Pressable surfaces as a hittable Other, and a tappable parent can cover a point its static-text child does not. Every known element frame now counts as an obstacle regardless of role or hittability, with only ~window-sized structural frames exempt (isStructuralRootFrame, 95% coverage) — exempting those is what keeps the rule satisfiable, and a genuinely tappable full-screen backdrop staying exempt is the disclosed, accepted behavior of this fallback. One .any resolution replaces ten typed queries (single tree snapshot, no per-element isHittable round trips), so the stricter rule is also cheaper. * fix: drop the safe-area tap — background-tap dismissal is unsupported (#1606 review P1, round 2) No geometry or role query can prove a coordinate is side-effect-free: after the any-element rule, the structural-root exemption still deliberately removed full-screen actionable elements (RN Pressable backdrops) from the obstacle set, so the tap could navigate or submit — and report success because the mutation hid the keyboard. Per review, generic background-tap dismissal is now explicitly unsupported: the dismiss key is the only mechanism the runner vouches for, UNSUPPORTED_OPERATION says so and steers callers to press-the-next-target / keyboard enter, and the mechanism field narrows to 'dismissKey'. Unrecognized wire mechanisms degrade to the bare message and are dropped from event details. |
||
|
|
65450dd3a7 |
fix: flush stdout/stderr before every CLI process.exit() (#1596) (#1603)
* fix: flush stdout/stderr before every CLI process.exit() (#1596) Node only flushes process.stdout/stderr synchronously to a file or TTY; on a pipe (the normal condition for this CLI when driven as a subprocess) a write queued right before process.exit() can be silently dropped. handleRunCliFailure's --debug daemon-log-tail dump made this reachable from the exact path that renders a SESSION_NOT_FOUND error right after a daemon replace, matching field reports of the driving process going silent immediately after "Replacing daemon ... unreachable" plus the SESSION_NOT_FOUND error. Add exitAfterFlush() and route every process.exit() in src/cli.ts and src/bin.ts through it, so a piped caller always receives the full structured error (with its "run open first" hint) before the process terminates. Also bound the --debug log-tail dump to a byte cap instead of an unbounded 200 lines. Verified directly against the real CLI (piped subprocess, pre-fix vs post-fix): a live daemon with a seeded >64KB log truncates its --debug error output before the fix and delivers it in full after. * fix: satisfy CI gates on #1596 (format, fallow, coverage) - oxfmt formatting on the new integration test file. - Register the two exit-flush regression fixtures (support/exit-naive.ts, support/exit-after-flush.ts) as fallow entry points: they're run as real subprocesses via a string path (runCmdSync), which fallow's static dependency analysis can't follow, same as the existing test/contention-retry-fixtures/* entries. exit-payload.ts becomes reachable transitively through their static imports. Also switched the integration test's local PAYLOAD_MARKER duplicate to import the one fallow flagged as unused from exit-payload.ts. - Added real unit coverage for the new exitAfterFlush code paths, since node --test integration files aren't measured by the vitest coverage gate: src/utils/__tests__/process-exit.test.ts exercises the already-drained, backlogged-then-drains, and never-drains/timeout branches directly against a fake stream; src/__tests__/cli-exit-paths.test.ts drives runCli() for --version, bare help, no-command, and web to cover their exitAfterFlush call sites, plus a --debug case with a >64KB seeded daemon.log proving printDaemonLogTailOnError's new byte cap actually trims the oldest lines. Changed-line coverage gate now passes at 92.59% (was 59.26%); the two remaining uncovered lines are the bottom-of-file `isDirectRun` catch handler, which only runs when cli.ts is executed as the literal entry script and is not reachable by importing it as a module in a test (the same shape as bin.ts's already-excluded top-level fast paths). |
||
|
|
3a64ee7587 |
fix(daemon): surface proven no-effect gestures to the agent (#1601)
* fix(daemon): surface proven no-effect gestures to the agent (#1600) The #1542 post-gesture stabilization loop already PROVES when a scroll, swipe, or pan moved nothing: its accept-stale verdict fires only when the quiet post-gesture capture still equals the pre-gesture baseline after the distrust cap. But the verdict went to the diagnostics stream only — the agent-facing response reported plain success, and the benchmark showed the cost: element-18 burned ~40 tool calls re-issuing scrolls the daemon knew did nothing ("Scrolled up by 1200px" fifteen times over a byte-identical tree) before stumbling onto raw swipe. The stabilization loop now returns the verdict alongside the capture, and the snapshot handler appends a warning through the existing annotations channel: it names the exact gesture, admits the at-edge ambiguity the platform cannot resolve, and hands over the raw-drag escape hatch that moved the stuck list in the field. Live-verified on the seeded Bluesky feed: scroll up at top warns, scroll down with real room moves silently, a fling at the bottom edge warns — all three truthful. * fix: require full-surface evidence before the no-effect claim (#1601 review P1) accept-stale alone is subset-tolerant by design: a successful scroll that replaced every list cell under fixed chrome still classifies 'unchanged' on the shared chrome alone, and #1573 has live evidence of that shape. The agent-facing claim now additionally requires every discriminating entry of the quiet capture to match the baseline exactly, in both directions (haveIdenticalDiscriminatingSurfaces): any appeared or vanished real element — including scope drift — vetoes the warning. Silence is the safe failure mode for a message that steers the agent's next move. Red evidence: the new fixed-chrome + replaced-list regression test fails on the previous PR commit (gestureNoEffect wrongly present), passes with the gate. * refactor: extract the corroborated-accept result builder capturePostGestureStabilizedResult crossed the complexity threshold (14 cyclomatic) after the #1601 review gate; the accept/veto construction now lives in buildAcceptedStabilizedResult, which also keeps the corroboration rule stated in one place. |
||
|
|
8ba5f9b8de |
fix: surface AMBIGUOUS_MATCH candidates and name find's supported actions (#1602)
* fix: surface AMBIGUOUS_MATCH candidates and name find's supported actions (#1597) AMBIGUOUS_MATCH errors now list the matching candidates (ref, role, label/identifier) rendered the same way as snapshot -i lines, capped at 5 with a "+N more" marker. buildAmbiguousMatchError (the single producer, src/daemon/handlers/find.ts) reuses formatSnapshotLine to build the list; formatAmbiguousMatchCandidateLines (src/utils/output.ts) renders it unconditionally on both text surfaces an agent actually reads (CLI printHumanError and MCP formatToolErrorText) — previously the candidates lived only in details, which neither surface printed. find's "Unsupported find action: X" (e.g. from `find <text> press`) now attaches a hint naming every action find actually supports and the two-step recovery shape: run find "<text>" to resolve the ref, then dispatch the gesture as its own command (press @eNN). The hint is a single exported constant (UNSUPPORTED_FIND_ACTION_HINT) shared by both throw sites — packages/selectors' raw-token parser and the CLI's typed reader (src/commands/interaction/selectors.ts) — so they can't drift. Matching semantics are unchanged; ambiguous rejection stays by-design. The help-conformance corpus's AMBIGUOUS_MATCH quiz is updated: its premise ("candidate refs were not shown") no longer holds, but with 3 identically-labeled candidates the lesson (don't guess a specific ref) still holds. * fix: guard the AMBIGUOUS_MATCH candidate renderer against device-domain shapes Review on #1602 (P2): formatAmbiguousMatchCandidateLines ran for every normalized error and stringified details.candidates unconditionally, but device-domain AMBIGUOUS_MATCH/APP_NOT_INSTALLED errors (findBootedAppleSimulatorWithApp, src/core/dispatch-resolve.ts) reuse that key for { id, name } device objects with no `matches` field — CLI and MCP would have printed "Candidates: [object Object]" for those. The renderer now requires numeric details.matches AND every candidate to be a string before rendering anything, restricting it to buildAmbiguousMatchError's element-match shape; unrecognized shapes render nothing, same as before this feature existed. Added regression tests against the exact device-error shape on both text surfaces. Also unexports AMBIGUOUS_MATCH_CANDIDATE_LIMIT (fallow flagged it as an unused production export) — it has no consumer outside find.ts. |
||
|
|
3835c41d89 |
fix(ios): stop shipping runner unit tests in the npm package (#1594)
The Apple runner ships as source in dist/apple/runner, and packaging strips #if AGENT_DEVICE_RUNNER_UNIT_TESTS blocks — but tests outside such blocks shipped whole and compiled on every user's machine. Two files leaked six tests this way (RunnerTests+LifecycleCacheTests, RunnerTests+SnapshotTraversalIdentityTests). Wrap the strays and close the class: packaging now fails if any XCTest-shaped method (func test*) survives stripping, with testCommand in RunnerTests.swift as the only allowlisted entrypoint. |
||
|
|
74efcbbd84 |
fix(snapshot): one-shot recovered warning for internally armed penalties (#1590)
* fix(snapshot): one-shot recovered warning for internally armed penalties The deferred-capture suppression assumed the capture that armed the XCTest-channel penalty already rendered the full 'overly complex or slow accessibility tree' warning. Internal captures (selector resolution, settle observation loops, system-modal probes) can arm the penalty without any user-facing render, leaving the next public snapshot with only the structured verdict and no CLI warning line. The runner cannot tell user-facing from internal captures, so the daemon now holds a per-session one-shot latch (snapshot-quality-latch.ts) applied at the snapshot/diff response seam: a genuine recovered render sets it silently, the first public 'deferred' verdict without the latch re-renders the full warning once and sets it, a healthy public verdict clears it (the penalty window is over), and an app switch supersedes it. Internal observation responses (observationOnly) neither consume nor clear the latch. Follow-up to PR #1587 review (non-blocking hardening). * chore(layering): declare the deferred-warning latch owner and ratchet baseline The R7 session-state gate requires every SessionState field to have a declared writer owner: recoveredSnapshotWarningLatch is owned solely by snapshot-quality-latch.ts (matching the field's 'managed only through' contract), and the R10 pressure baseline grows deliberately to 23 writer-owned fields / 29 owner claims. Also oxfmt-formats the new latch test. * fix(snapshot): latch on the captured verdict, not the retained session snapshot Review P2 on #1590: the latch seam read a diff capture's verdict back from session.snapshot, but an empty ref-scoped capture deliberately retains the previous stored snapshot (shouldKeepCurrentSnapshot) — so a deferred capture could consult a retained healthy verdict, clearing the latch and omitting the one-shot warning. The daemon snapshot backend now fills a per-request CapturedSnapshotQuality slot on every capture, and the seam latches on that just-captured verdict for both snapshot and diff. New production-path regression: an empty ref-scoped diff over a retained healthy snapshot with a deferred capture warns once (verified red against the previous seam). * test(snapshot): pin app-switch latch supersession through the dispatch seam Cross-vendor review follow-up: the app-switch transition was pinned only at the pure-function level; a regression in how the seam keys the latch by the session's appBundleId would not have been caught. Two-dispatch integration test: latch held for app A, bundle switched, app B's first deferred verdict warns once and rekeys the latch. |
||
|
|
4f8dc3f31e |
refactor: move selector engine into workspace package (#1589)
* refactor: move selector engine into workspace package
* refactor(selectors): trim the package façade to its real consumers
Follow-up to the selector-package cutover, from a structural review of it.
- Drop 15 façade symbols with no consumer anywhere in the repo:
selectorUsesKey (added by the cutover, never called), isNodeVisible /
isNodeEditable (the real helpers are contracts/snapshot's), normalizeText,
splitIsSelectorArgs, IS_PREDICATE_REQUIRED_MESSAGE, four nested Replay
types, SelectorDisambiguationDisclosure, and the four kernel type
re-exports every consumer already imports from kernel directly.
- Delete SelectorCapturePolicyInput.selectorExpression, which
deriveSelectorCapturePolicy never read; the policy varies only by
predicate, so it takes one now. Two of the four tests asserted that the
unread parameter had no effect and could not fail; they go with it.
- Return the Maestro export vocabulary to the maestro package. The cutover
inlined MAESTRO_TEXT/STATE_SELECTOR_KEYS' values into the CLI call site,
leaving both constants dead in the package that owns the concept and no
gate over the two copies. MAESTRO_SELECTOR_PROJECTION is now the one
statement of it.
- Dedupe SelectorDiagnostics and SelectorDisambiguationDisclosure, declared
character-for-character twice across the AST/string seam, and name the two
shared option shapes once instead of five inline copies. The parser-side
resolution types take an Ast prefix so the twins read as twins.
- Delete three identity wrappers: parsePrivateSelector,
selectorExpressionToMaestro, and the formatSelectorFailure forwarder —
nothing passes it a chain any more, so the SelectorChain | string union
and its branch go too.
- Delete internal/index.ts, an AST barrel whose only consumer was one test
in the same directory (renamed to engine.test.ts), and the match.ts
pass-through that existed to feed it.
- ReplaySelectorGrammar had three variants for two behaviors; 'wait' and
'ordinary' were the same path. It is 'is' | 'positional' now.
- Drop the deleted src/sdk/selectors.ts from .fallowrc.json's entry list.
Behavior unchanged. pnpm check green: 598 unit files / 5278 tests, smoke
35 passed / 3 live skipped, layering 71/71, depgraph 22/22, mutation config
45/45, fallow clean, package smoke sound. Counterfactual: pointing
MAESTRO_SELECTOR_PROJECTION.textKeys at the state keys turns three
replay-maestro-export cells red; restored before commit.
* test(selectors): split the engine aggregation test by source concept
`internal/index.test.ts` (renamed `engine.test.ts` when its barrel went away)
was a 708-line aggregation over the whole engine — past the 500-line tripwire
and mirroring no source module, so it also ran as one serial unit.
It becomes five files that each mirror what they test, plus the parser cells
folded into the existing parse test:
resolve.test.ts alternative fallback, strict uniqueness,
first-match existence
resolve-disambiguation.test.ts ADR 0012 ranking: deepest, smallest-area,
winner-vs-challenger disclosure, tie fallback
resolve-viewport.test.ts the visibility half: on-screen beats
off-screen, including inside an off-screen
scroll container
match.test.ts per-key matching semantics (text, role,
focused, appname/windowtitle, decoded
newline labels)
arguments.test.ts where the selector ends and the command's
positionals begin, both grammars
parse.test.ts +6 grammar/escape cells beside the existing
property tests
The login-form tree shared by resolve.test.ts and match.test.ts moves to
`__tests__/login-form-nodes.ts` rather than being copied into both.
All 27 cells are carried over unchanged and still pass; no file now exceeds
224 lines. pnpm check green: 602 unit files / 5278 tests, layering 71/71,
depgraph 22/22, mutation config 45/45, fallow clean over 127 changed files.
* revert(selectors): keep agent-device/selectors public, behind one AST subpath
The cutover removed the `agent-device/selectors` public subpath as part of
tightening the API. It is in use, so the removal is reverted: the subpath ships
the same ten symbols v0.20.5 shipped, with the same signatures.
That has to coexist with the reason the package façade is string-only, so the
AST leaves through one named door instead of the main one:
@agent-device/selectors string-in/string-out; every in-repo consumer
@agent-device/selectors/ast the published parser surface; one consumer,
src/sdk/selectors.ts
`packages/selectors/src/ast.ts` re-exports parseSelectorChain,
tryParseSelectorChain, isSelectorToken, the AST-taking findSelectorChainMatch
and resolveSelectorChain, isNodeVisible, isNodeEditable, and types
SelectorChain / SelectorDiagnostics. `formatSelectorFailure` keeps its
published `SelectorChain | string` first parameter as a shim here rather than
widening internal/resolve.ts back to a union — the compatibility obligation
sits at the boundary that owes it.
This is strictly narrower than main, where the AST was reachable from anywhere
in src/ via src/selectors/*. Two gates hold it there: facade-symbols.ts pins
./ast to exactly the v0.20.5 list, and package-boundaries.test.ts asserts
src/sdk/selectors.ts is the only file outside the package that imports it.
Restored alongside: the ./selectors export and tsdown entry/chunk group, the
.fallowrc.json entry, the package-exports supported-subpath list, and both
client-api.md sections. No CHANGELOG entry — nothing is removed any more.
pnpm check green: 602 unit files / 5278 tests, smoke 35 passed / 3 live
skipped, layering 71/71 (10 packages, 32 subpaths), depgraph 22/22, mutation
config 45/45, fallow clean over 129 changed files, package smoke imported all
12 published entry points with publint and attw passing. Verified functionally
against the built dist: the doc's parse -> findSelectorChainMatch example
returns the same shapes as before, resolveSelectorChain still returns an AST
`selector`, and formatSelectorFailure still accepts a chain.
* fix(selectors): correct the two expectations that still assume the removal
Review P1s on a792415a: restoring the public subpath left two gates asserting
it was gone.
- installed-package-metro.test.ts moved `agent-device/selectors` into the
blocked-specifier list. It goes back to the subpath smoke set, running the
same `isSelectorToken('||')` + `parseSelectorChain` check it ran before the
removal, so the file's only remaining delta from main is a formatter reflow.
- owner-files-no-leak.test.ts asserted `dist/src/sdk-selectors.js` was absent.
It requires the stable named chunk again, and still rejects an auto-numbered
`selectors2.js` fallback — the pair is what proves the restored tsdown chunk
group is doing its job, verified against a clean build.
PR body corrected: the removal is no longer described as intentional API
tightening.
* refactor(selectors): satisfy the widened fallow scope after rebase
main's #1591 (the follow-up filed from this review) removed `packages/**` from
.fallowrc.json's ignorePatterns, so the new package is audited for the first
time. Everything below is a finding fallow could not previously see.
Dead surface, all confirmed consumer-free:
- 12 type re-exports from the `.` façade whose shapes consumers only ever
reach structurally.
- MAESTRO_TEXT_SELECTOR_KEYS / MAESTRO_STATE_SELECTOR_KEYS, orphaned by this
branch's own MAESTRO_SELECTOR_PROJECTION change, and the test-util
SELECTOR_VALUE_HAZARDS. All three are module-local now.
- IS_PREDICATE_USAGE_HINT fails --production because its only consumer is the
is-argument-surface parity test. It gets a commented `ignoreExports` entry
rather than deletion: the constant is what makes the daemon and CLI raise
ONE hint instead of two copied strings (ADR 0010), so the test asserting
that is the point, not an accident.
`fast-check` is now declared by the package that imports it.
Duplication, split by what could be proven:
- `isUsefulVisibilityAnchor` existed character-for-character in both
packages/selectors and packages/maestro. Moved to
@agent-device/contracts/snapshot, which both already depend on and which
already owns this vocabulary. Safe because the `normalizeType` each copy
called is itself character-identical to the contracts one — checked before
moving, since a different normalizer would have silently changed which
nodes anchor.
- maestro additionally reimplemented `normalizeType`, `buildSnapshotNodeMap`
(as `buildSnapshotNodeByIndex`) and `findSnapshotAncestor`, all
character-identical to contracts'. Deleted in favour of the shared ones.
- The three scroll-ancestor walks are NOT deduped. They are structurally the
same walk but each uses a different scrollable predicate, and I have no
evidence the three agree; collapsing them would be a Maestro-conformance
change, not a cleanup. Both maestro sites now say so, and the work is filed
separately.
`projectSelectorExpression` (15 cyclomatic / 22 cognitive, written by the
cutover) splits into a dispatcher plus `readAgreedTextValue` and
`projectSelectorTerms`; all three are under threshold.
Rebase note: the one conflict, in package-boundaries.test.ts, resolved to
NEITHER side — #1591 had already deleted `AdReplayVerifiedTargetGuard` as an
unused export, and this branch deletes the seven ReplaySelectorPort names, so
the conflicting block is empty.
* build: record fast-check for packages/selectors in the lockfile
Declaring the dependency in packages/selectors/package.json without
regenerating pnpm-lock.yaml made every CI job fail in its install step with
ERR_PNPM_OUTDATED_LOCKFILE. My local `pnpm install --frozen-lockfile` printed
"+ 1 dependencies were added: fast-check@^4.9.0" and exited 0, which read as
success but was the same mismatch CI refuses.
Regenerated with the pinned pnpm 11.17.0, not the 11.5.3 on this machine:
11.5.3 rewrites peer-dependency resolution keys repo-wide (dropping
`(supports-color@7.2.0)` suffixes) and produced a 222-line diff. With the
pinned version the diff is the 4 lines this change actually needs, plus
pnpm's alphabetical re-sort of the root selectors entry.
|
||
|
|
eb3fc5b28d |
chore: scan packages/** with fallow instead of ignoring it (#1591)
`ignorePatterns: ["packages/**"]` landed in #1494 W0 with the recorded reason "its resolver cannot follow workspace specifiers". That was either wrong at the time or never re-checked: the fallow version has not moved (^2.95.0 then and now) and it resolves @agent-device/* through each package's exports map today. packages/kernel alone exposes 8 subpaths and ~110 exports reachable only via workspace specifiers, and scanning it reports zero findings — a resolver that could not follow the specifier would report all of them. The cost of the ignore is that every package extraction silently removes its code from dead-code analysis. #1589 moved the selector engine into packages/selectors/ and shipped a façade with 15 zero-consumer exports, including `selectorUsesKey`, written in that PR and never called. A follow-up commit removed them by hand; nothing would have caught them. Removing the pattern surfaced 43 findings, driven to zero by deleting the dead code rather than by baselining or excluding it (fallow-baselines/*.json are empty on purpose — the posture is fix-or-document-the-exemption, so a first baseline entry would be a policy change): - 38 are deleted. 24 façade type re-exports whose only claim was that a consumer might one day want to name them — typecheck is green without every one, so the claim was theoretical; 5 façade value re-exports; 9 `export` keywords on symbols used only inside their own file. Every deleted façade symbol comes off scripts/layering/facade-symbols.ts (and ad-replay's inline pin in package-boundaries.test.ts) in the same change, so R11 is narrowed with the façade, never weakened around it. - 4 stale suppressions in src/provider-limrun-runtime.ts existed only because packages/ was invisible. - 5 have consumers analysis genuinely cannot see, and get an `ignoreExports` entry naming the consumer per the existing `comment` convention: four test-tree importers that --production does not walk, and `LimrunIosCommandExecution`, which src/sdk/limrun.ts republishes as agent-device/limrun — its only importer compiles in a temp checkout, so no static edge reaches it. test/integration/limrun-public-types.test.ts is the standing proof that one is real API. Three doc comments named types their façade no longer exports and are corrected rather than left asserting something false — including #1555's claim in session-replay-target-verification.ts that the daemon imports `AdReplayVerifiedTargetGuard` directly. It does not; it reaches that shape through `AdReplayTargetClassification`/`AdReplayDispatchGuard`, which is why the name read as dead. `scripts/maestro-conformance/**` was ignored wholesale to cover its corpus data. Narrowed to `corpus/**`, which un-hides the tooling beside it and turned up one more file-local export (`buildManifest`); regenerate.mjs's importer of `fixtureContentHash` becomes visible, so that needs no exemption at all. scripts/check-affected/model.ts deliberately did not select the `fallow` check for packages/*/src/**, carrying the same stale rationale as a comment. Without that selection the new scope would never run in the affected-driven lane, so the ignore removal would have bought nothing. model.test.ts now pins the selection. Verified: check:fallow and check:production-exports green with packages in scope; full-repo `fallow dead-code` back to its one pre-existing finding; typecheck, layering (R11), lint, format, build, check:package, and the limrun published-types integration test all pass. Probed by adding a fresh zero-consumer export to the xml façade — check:production-exports reports it, so the #1589 case now fails the gate. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8d526f2402 |
perf(ios): halve hostile-screen capture cost under the XCTest-channel penalty (#1587)
* perf(ios): stop re-paying known-failing work on penalized private-AX captures Live-measured on the Bluesky bench feed (139-171 nodes), each private-AX capture wasted ~1.35s of its ~1.65s runner-side cost re-doing work a prior capture already proved futile: - ~1000ms: the viewport read is XCTest main-thread work; under the channel penalty it reliably burns its full timeout and falls back to the root frame anyway. Honor the penalty in privateAXSnapshotViewport the same way capture plans do. - ~310ms: the depth ladder re-paid the kAXErrorIllegalArgument rejection of the default depth on every capture. Remember the accepted rung per bundle (penalty-duration TTL, cleared on target process change); expiry re-probes the full depth so screens that recover are not capped forever. Explicit --depth requests bypass the memory in both directions. Steady-state hostile-screen captures drop 2.65s -> ~0.55s CLI wall, and press --settle round-trips drop ~5.4s -> ~2s (settle needs two captures). * fix(snapshot): distinguish penalty-deferred captures from genuine recoveries A capture whose backend was PRE-selected by the XCTest-channel penalty was stamped with the same recovered verdict as one that ground through a live failure. Two costs followed on hostile screens (Bluesky bench: 130 repeats per 30-task run): - the daemon repeated the full fell-back warning on every capture, long after the arming capture had already said it once, and - settle's one-shot private-AX budget reset fired on every loop even though the capture paid no grind to give the budget back for. The deferred plan now stamps reasonCode 'deferred' (a new code; older daemons drop unknown codes and keep today's behavior). The daemon keeps the verdict recovered but suppresses the repeated warning, keeps the depth-cap line, and skips the settle budget reset for deferred captures. * style: wrap reasonCode union to satisfy oxfmt * fix(ios): bind accepted-depth memory to the process, pin deferred through the wire parser Review follow-up (#1587): - The depth memory was cleared only in refreshCachedTargetIfProcessChanged; resetTargetAfterExternalRelaunch -> invalidateCachedTarget drops the cached PID without clearing it, so the next activation had no old PID to compare and could reuse a stale shallow rung for up to 120s (also A->B->A when A restarted while inactive). The memory now stores the PID it was learned under and only matches the same live process; recording without a PID is refused. Every invalidation path is covered automatically because they all drop currentAppProcessIdentifier. - The deferred settle/warning tests constructed typed verdicts directly, so removing 'deferred' from the accepted reason-code set would silently restore the repeated warning and budget reset while tests stayed green. They now parse a raw runner-wire object through readSnapshotQualityVerdict (red on base: parser strips the code -> warning re-appears, reset fires). - Extracted shouldReadPrivateAXViewportViaXCTest() and pinned the penalized viewport skip with an in-bundle regression test. |