Skip to content

feat(limrun): serve iOS alert get/wait/accept/dismiss from tree capture - #3699

Open
thymikee wants to merge 12 commits into
mainfrom
t3/limrun-ios-alert-actions
Open

thymikee wants to merge 12 commits into
mainfrom
t3/limrun-ios-alert-actions

Conversation

@thymikee

@thymikee thymikee commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #3349. Across 28 files, Limrun iOS alert get/wait/accept/dismiss use existing capture/tap seams through the tree-alert driver and vocabulary shared with Android. Ambiguous choices refuse untapped; dismiss never authorizes ATT. Lookup reports visible|none|undecided.

Rebased onto 4c1c78696, preserving upstream Apple refusal contracts, macOS launch fixes and telephony facts.

Validation

Published head: be48a285a7bf57348634d2e4b9204647737c5ba6; mergeable.

  • Direct exact-head gate run-307YcC passed: exit 0, exactCommit: true, matching clean fingerprints, finished 19:26 UTC. Frozen install/build/format/typecheck and 129 focused tests passed.
  • Fresh isolated R5 found no new code findings and assessed the +7.8 kB growth/smaller-design alternatives. Its first provider-integration run failed: four timeouts and one live-claim collision. Subsequent 15-test and 201-test passes do not establish cause. Their cause remains unproven; request logs were pruned. A base comparison awaits a host without competing suites. The claimed overlap with the gate’s test step was disproved.
  • Current CI: 16 successes, one skip, one neutral, two Apple smoke lanes pending, no failures. Readiness label absent.
  • Previous 60aec193f gate/CI failures matched a coverage declaration defect reproduced on base; main's correction is included. Prior 957726b0d iOS deep-link failure remains unresolved; no CI retry.
  • Corrected Android live evidence remains at 957726b0d, without a tap-count claim. The global-reset violation/unknown effect remain disclosed in comment 6220298855. Limrun instrumented evidence remains at f584e8a15; SDK 0.64.1 cannot abort in-flight capture/tap requests.

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-3699/

Built to branch gh-pages at 2026-10-16 19:14 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.32 MB 5.33 MB +7.8 kB
Package (unpacked) 5.32 MB 5.33 MB +7.8 kB
Package (download) 1.60 MB 1.60 MB +2.6 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.4 ms 24.6 ms -1.8 ms
CLI --help 68.7 ms 68.2 ms -0.5 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 11 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/provider-limrun/src/alert-detection.ts
Comment thread CHANGELOG.md Outdated
Comment thread packages/provider-limrun/src/alert.ts Outdated
Comment thread packages/provider-limrun/src/alert-detection.fixtures.ts
Comment thread packages/provider-limrun/src/alert.test.ts Outdated
Comment thread packages/provider-limrun/src/ios-interactor-alert.test.ts Outdated
Comment thread packages/provider-limrun/src/ios-interactor-alert.test.ts
Comment thread packages/provider-limrun/src/alert-detection.ts Outdated
Comment thread packages/provider-limrun/src/alert-detection.fixtures.ts Outdated
Comment thread website/docs/docs/limrun.md Outdated
@thymikee

Copy link
Copy Markdown
Member Author

The Limrun alert legs duplicate the Android tree-alert driver, and that needs to change before merge (reviewed at 4a1862b). The alert get/wait/accept/dismiss behavior itself looks right.

Is there a reason confirmLimrunAlertDismissed, sameLimrunAlert, pollLimrunAlertCandidate and the response union in alert.ts are copied from confirmAndroidAlertDismissed, sameAndroidAlert, pollAndroidAlertCandidate and AndroidAlertResult in packages/platform-android/src/alert.ts? The 'alert not found' hint and the 'did not dismiss the visible alert' error and hint are copied almost word for word too. The ACCEPT/DISMISS label patterns in alert-detection.ts (lines 42-47) are the Android patterns plus a few labels. Two drivers now have to stay in sync by hand, and a fix to one, such as the transient-empty-capture success in confirm, must be made twice. The label sets already differ: 'ask app not to track' and 'allow once' exist only on Limrun, so alert dismiss acts differently on the two platforms for the same label. The rule is that every tree-based alert backend reaches poll, confirm, same-alert and response building through one backend-neutral driver, and its button roles through one label vocabulary. The driver would take {capture, findCandidate, choose, press, platform, timeoutMs}, and the shared labels would live beside it. Limrun would then supply only findLimrunAlertCandidate, chooseLimrunAlertButton and client.tap, and provider-limrun already depends on @agent-device/platform-android/mechanics, so no new package edge is needed. Could the Limrun-specific code shrink to the host-root detection plus the four interactor methods? Two things to settle first: which module owns the driver (the contracts alert contract or a mechanics export) within the eager-closure budgets, and the deadline order, since Android checks the deadline before the first read and Limrun reads first.

The two threads on Cubic's confirm-on-ambiguous-capture (alert.ts:173) and duplicate-label skipping (alert-detection.ts:152) should be settled once in that shared driver. Please also validate on a live Limrun run that alert accept on the ATT prompt taps once and a second alert get reports no alert.

These open threads still apply: pid boundary comment, CHANGELOG app dialogs, plus six lower-priority test and fixture threads. This docs thread does not apply, because the page describes how the backend acts and gives accurate user guidance; please resolve it.

The failing Smoke Tests job runs the Apple XCTest-runner simulator smoke. This diff touches only provider-limrun, the changelog and the Limrun docs page, so I read that failure as unrelated. No conflicts. Next, send the Limrun alert legs through the shared driver and vocabulary.

thymikee added a commit that referenced this pull request Oct 16, 2026
…red driver

PR #3699 review: the Limrun iOS alert legs duplicated the Android tree-alert
driver (poll, confirm, same-alert, response building) and its label
vocabulary. Both backends now reach those steps through one backend-neutral
driver, runTreeAlertAction in platform-android (exported via the mechanics
facet provider-limrun already depends on), and classify button roles
through one English vocabulary that lives in contracts/alert-contract with
the alert budgets and typed reasons (contracts owns vocabulary only; the
driver loop owns the AppError/timer mechanics R18 keeps out of contracts).

Each backend supplies only its own seams: Android wraps its dump reader
and keeps its platform-id roles, neutral/Back fallbacks, and tap/Back
presses; Limrun supplies the blank-root process-exclusive host reader,
the label-strict chooser, and client.tap. The confirm-on-ambiguous-capture
and duplicate-label threads settle once in the shared code: the three-way
alert|none|undecided observation gates confirmation in the driver, and
selectUniqueAlertButtonByRole refuses duplicated roles at the shared
choice boundary. The deadline order resolves to read-first: over a
positive window both predecessor loops already observed before waiting.
The alert-not-found and undecided refusals now carry the same typed
details.reason and dispatched: 'no' on both backends.
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed a27302c. The earlier findings are fixed except the ones below, and one new regression remains in the shared button choice. The 20 checks pass at this commit, but the Android alert suites pass only because no fixture has a multi-grant permission sheet. I know of no conflicts.

chooseAndroidAlertButton now resolves accept through selectUniqueAlertButtonByRole, which returns null when two buttons share a role. The Android 11+ runtime permission dialog shows permission_allow_foreground_only_button ("While using the app") and permission_allow_one_time_button ("Only this time"). The id classifier at line 192 marks both as accept. With three buttons the single-button fallback does not fire, so alert accept throws ALERT_BUTTON_UNDECIDED, where main pressed the first accept button. The same rule makes Limrun iOS alert accept refuse on the standard location prompt ("Allow Once" / "Allow While Using App" / "Don't Allow"), so #3349 works only on single-grant sheets like ATT. Users now get a refusal on the most common Android system alert, which regresses a shipped route. The rule should be: refuse only when the capture cannot name one physical button for the action, meaning the same label at two points, not distinct labels that share a role. Please declare a preference order in the shared vocabulary in contracts/alert-contract.ts (for accept, "while using the app" / "allow while using app" before "only this time" / "allow once"), pick by that order, and refuse only on equal-rank duplicates. Please add Android and Limrun tests with the real three-button permission sheets that fail on this head.

The PR body still lists the final-head live evidence as pending, and the device route has changed since the earlier fail-before run: it now goes through the shared driver, read-first deadline order and the uniqueness boundary. Unit fixtures cannot show that the Limrun tap lands, or that the confirm leg sees none and not undecided once the sheet leaves. On a live Limrun iOS instance at the current head, please show: alert get --json on the ATT prompt reports the alert; alert accept reports handled with exactly one tap in the session request log; a second alert get reports "No alert visible"; alert dismiss on a fresh prompt presses "Ask App Not to Track"; and alert get over an in-app UIAlertController reports no alert. Please also run a location-permission prompt (iOS three-button, and Android 11+ runtime permission) to show the accept fix.

I could not read the Limrun captures behind the "no hittable key" claim, so I accepted it on consistency. I did not run tests, and I did not confirm on a live Android 11+ device that both grant buttons appear with permission_allow_* ids. The f1 regression comes from reading main and this head.

Simplicity question: could the shared driver and vocabulary stay, with the choice boundary as a declared role-plus-preference table in contracts/alert-contract.ts, refusing only on equal-rank labels seen twice? Each backend would then keep its own fallback (Android neutral/Back, Limrun none) without inheriting a stricter rule. Limrun alert-detection.ts is also about half module and function prose, which could shrink to the stated invariants.

On the open Cubic threads: these are fixed at this head and can be resolved: blank-root pid subtree, changelog residual, Android dismiss retry, typed refusal reason, driver reason test, one-tap dismiss test, and fixture comment. The label dedupe thread is fixed too, but the fix went too far into role-level uniqueness, as described above. Two threads do not apply: hittable:true because captures omit the key and only an explicit false vetoes, and limrun.md wording because the page describes backend behavior accurately.

Before merge, please restore single-press accept on multi-grant permission sheets with regression tests, then post the live Limrun ATT evidence.

@thymikee
thymikee marked this pull request as draft October 16, 2026 09:50
@thymikee
thymikee marked this pull request as ready for review October 16, 2026 11:33

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/platform-android/src/alert.ts
Comment thread packages/platform-android/src/alert.ts Outdated
Comment thread packages/provider-limrun/src/alert.ts
Comment thread packages/provider-limrun/src/ios-interactor-alert.test.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 10 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/platform-android/src/alert-detection.ts
Comment thread packages/contracts/src/alert-contract.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 13 files (changes from recent commits).

Requires human review: Auto-approval blocked because this review re-detected 2 unresolved issues already reported by Cubic.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread website/docs/docs/commands.md Outdated
Comment thread packages/platform-android/src/input-actions.ts Outdated
Comment thread CHANGELOG.md Outdated
Comment thread packages/platform-android/src/alert.ts Outdated
@thymikee

Copy link
Copy Markdown
Member Author

The earlier gaps from a27302c are fixed at 8d5e9d2: the preference table now lives in the shared alert contract, each backend keeps its own fallback, and the regression tests cover the earlier cases. Live proof at this head is still missing, and the cancellation work added in this round goes beyond what #3349 asks for.

The device route changed again in this round: ranked choice, window-guarded observer, signal-gated press, and a separate confirm window. The PR body says final-head live verification is still outstanding, and unit fakes cannot show that the Limrun tap lands once or that confirm sees none after the sheet leaves. So the Limrun iOS alert accept/dismiss route from #3349 and the changed Android permission route have no live proof at the head that will ship. Please run at 8d5e9d2 (or the next head) on a live Limrun iOS instance. On an ATT prompt, alert get --json should show status visible, alert accept should report handled with exactly one tap in the session request log, and a second alert get should show status none. On a fresh ATT prompt, alert dismiss should press "Ask App Not to Track". On a location prompt, alert accept should press "Allow While Using App". With an in-app UIAlertController, alert get should report none. On Android 11+, alert accept on the runtime permission sheet should press "While using the app".

Is the cancellation and window apparatus needed for #3349? It adds a second feature to the shared driver: request signals through every seam, wrapped errors keyed on the window-expired reason, dispatched: 'unknown', signal threading through Android press, back and snapshot, and a new status response field. On Limrun it cannot bound the transport, and the PR body says the late-capture case it targets still reproduces. Could this PR keep the shared driver, the ranked vocabulary and the Limrun legs, and check deadline.isExpired() once after each capture inside treeAlertObserver, so a late Limrun capture is not acted on? The signal threading, the unknown typing, the status field and the AbortSignal.timeout windows could then move to a follow-up with their own evidence. Signal-based bounds only apply to Limrun once the SDK exposes abort on elementTree and tap.

Not blocking, and you can take or leave these: alertSeamInterrupted is declared to return an error but throws in its canceled branch, and the request signal reaches the backends through two channels (runnerContext.signal on Android, options.signal on Limrun), so returning the error and reading the signal from AlertInteractorOptions alone would be tidier.

On the open threads from other reviewers, these still apply: the Limrun capture ignoring the seam signal, same-label buttons on Android, unranked accept filtered before the tie check, the docs on alert-window-expired, the changelog claim about bounded budgets and the AbortSignal.timeout overflow. The adb executor options thread does not apply, because every production caller passes only {signal}, so you can resolve it, or narrow the type to {signal} to close it.

CI is still pending: all 9 non-passing jobs are queued or in progress, with no failure logs yet. The diff overlaps Lint/Typecheck, Coverage and possibly Android smoke, while the Apple smoke lanes do not overlap it. I did not run the tests, and I did not reproduce the "20 ms wait returns alert at 59 ms" case. Before merge, this needs the live Limrun iOS and Android 11+ runs at the final head, and a decision on moving the cancellation work to its own PR.

thymikee added a commit that referenced this pull request Oct 16, 2026
…red driver

PR #3699 review: the Limrun iOS alert legs duplicated the Android tree-alert
driver (poll, confirm, same-alert, response building) and its label
vocabulary. Both backends now reach those steps through one backend-neutral
driver, runTreeAlertAction in platform-android (exported via the mechanics
facet provider-limrun already depends on), and classify button roles
through one English vocabulary that lives in contracts/alert-contract with
the alert budgets and typed reasons (contracts owns vocabulary only; the
driver loop owns the AppError/timer mechanics R18 keeps out of contracts).

Each backend supplies only its own seams: Android wraps its dump reader
and keeps its platform-id roles, neutral/Back fallbacks, and tap/Back
presses; Limrun supplies the blank-root process-exclusive host reader,
the label-strict chooser, and client.tap. The confirm-on-ambiguous-capture
and duplicate-label threads settle once in the shared code: the three-way
alert|none|undecided observation gates confirmation in the driver, and
selectUniqueAlertButtonByRole refuses duplicated roles at the shared
choice boundary. The deadline order resolves to read-first: over a
positive window both predecessor loops already observed before waiting.
The alert-not-found and undecided refusals now carry the same typed
details.reason and dispatched: 'no' on both backends.
@thymikee
thymikee force-pushed the t3/limrun-ios-alert-actions branch from 8d5e9d2 to 482cff1 Compare October 16, 2026 12:52
@thymikee

Copy link
Copy Markdown
Member Author

At 482cff1, the earlier gaps are mostly closed, but two defects remain, and the Android smoke check now fails because of one of them. The earlier a27302c review asked for a status field and for signal-bound captures and presses. Both now exist, and the tests cover the status field. The PR also has merge conflicts with main.

The Android smoke step dismiss Android native alert fails with reason alert-window-expired and the message "the action budget closed before an alert resolved". The cause is in treeAlertObserver at https://github.com/callstack/agent-device/blob/482cff1/packages/platform-android/src/alert.ts#L250. It guards the first probe with a 2 s window. A single helper snapshot that takes longer than 2 s is killed or refused, and accept or dismiss never presses. On main, the 2 s retry only bounded polling, so the first observation always finished and then acted. The doc block says the first observation is unguarded, but the code does the opposite, and no test covers this refusal. alert wait <ms> will fail the same way for short windows. The rule: a budget bounds how long the leg keeps polling. It never refuses or cuts off an observation that started inside the window. Let pollUntilSettled's deadline own the window, and pass only the request signal into capture and press. Please add a driven-clock test where the first capture takes 2.5 s and accept still presses once.

Localized Android permission sheets also regress, at https://github.com/callstack/agent-device/blob/482cff1/packages/contracts/src/alert-contract.ts#L118. selectRankedAlertButtonByRole drops candidates whose label is not in the English table before it checks for a tie. On Android, permission_allow_* ids give the accept role from the id alone. A Spanish sheet ('Mientras la app está en uso' / 'Solo esta vez') then has two accepts and zero ranked ones, so alert accept refuses where main tapped the foreground-only grant. I did not observe this on a device. I reasoned it from classifyAndroidAlertButtonById. Labels such as Android 10's 'Allow only while using the app' are also dropped, so 'Allow all the time' wins, which is the opposite of the table's intent. The rule: every role-carrying candidate gets a rank from its owning backend, and an unranked role counts in the tie check instead of being dropped. For Android, that means a rank by permission id: foreground-only, then always, then one-time. Please add a test with localized labels on permission_allow_* ids.

Could the PR get smaller? Keep the preference table, but rank by backend-declared keys: label rank for Limrun, permission-id rank for Android. Then drop the per-capture window code (treeAlertWindow, captureAlertState, alertSeamInterrupted, the window-expired reason and its phrasings, the press-signal checks). Let pollUntilSettled bound polling, thread only the request signal into capture and press, and document that Limrun's non-abortable SDK can overrun by one capture. That removes most of the ~320 added lines in alert.ts and fixes the first defect. The status field could ship separately across all alert backends if you want it. Nothing has to change first, since the cancellation work already goes beyond #3349.

The open inline threads on same-label buttons in alert-detection.ts (#3699 (comment)), unranked candidates in alert-contract.ts (#3699 (comment)), and the commands.md alert-not-found wording (#3699 (comment)) still apply. One more thread on AbortSignal.timeout range (#3699 (comment)) also applies, and it goes away with the first fix. These threads are fixed at this head and can be resolved: the status field (#3699 (comment)), signal-bound captures (#3699 (comment)), late Limrun captures (#3699 (comment)), the single elementTree call assertion (#3699 (comment)), and the CHANGELOG wording (#3699 (comment)). This thread does not apply and can be resolved: #3699 (comment), because the only callers pass { signal } and nobody passes allowFailure, so it is a type-width point only.

The failing Android smoke step looks related to this change. Only the new actOnTreeAlert catch branch emits that message, after src/core/interactors/android.ts started passing the 2 s signal into snapshotAndroid. I did not read the job's request log, so the capture time is inferred from the error shape. Bundle Size and Coverage were still pending, and I ran no tests.

Before merge, please stop the per-capture window from refusing the first Android capture, resolve the conflicts, and then show two live runs at the final head. First, the Android smoke automation-system scenario must pass 'dismiss Android native alert', and alert accept on an Android 11+ location permission sheet must report button 'While using the app' with one tap. Second, a Limrun iOS run must show alert get with status visible on a location or ATT sheet, then alert accept returning alertHandled with button 'Allow While Using App' (or 'Allow' for ATT), then a follow-up alert get with status none.

thymikee added a commit that referenced this pull request Oct 16, 2026
…red driver

PR #3699 review: the Limrun iOS alert legs duplicated the Android tree-alert
driver (poll, confirm, same-alert, response building) and its label
vocabulary. Both backends now reach those steps through one backend-neutral
driver, runTreeAlertAction in platform-android (exported via the mechanics
facet provider-limrun already depends on), and classify button roles
through one English vocabulary that lives in contracts/alert-contract with
the alert budgets and typed reasons (contracts owns vocabulary only; the
driver loop owns the AppError/timer mechanics R18 keeps out of contracts).

Each backend supplies only its own seams: Android wraps its dump reader
and keeps its platform-id roles, neutral/Back fallbacks, and tap/Back
presses; Limrun supplies the blank-root process-exclusive host reader,
the label-strict chooser, and client.tap. The confirm-on-ambiguous-capture
and duplicate-label threads settle once in the shared code: the three-way
alert|none|undecided observation gates confirmation in the driver, and
selectUniqueAlertButtonByRole refuses duplicated roles at the shared
choice boundary. The deadline order resolves to read-first: over a
positive window both predecessor loops already observed before waiting.
The alert-not-found and undecided refusals now carry the same typed
details.reason and dispatched: 'no' on both backends.
@thymikee
thymikee force-pushed the t3/limrun-ios-alert-actions branch from 482cff1 to 5b1e1c1 Compare October 16, 2026 14:00

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 5 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/platform-android/src/alert.ts
Comment thread packages/platform-android/src/alert.ts Outdated
thymikee added a commit that referenced this pull request Oct 16, 2026
…red driver

PR #3699 review: the Limrun iOS alert legs duplicated the Android tree-alert
driver (poll, confirm, same-alert, response building) and its label
vocabulary. Both backends now reach those steps through one backend-neutral
driver, runTreeAlertAction in platform-android (exported via the mechanics
facet provider-limrun already depends on), and classify button roles
through one English vocabulary that lives in contracts/alert-contract with
the alert budgets and typed reasons (contracts owns vocabulary only; the
driver loop owns the AppError/timer mechanics R18 keeps out of contracts).

Each backend supplies only its own seams: Android wraps its dump reader
and keeps its platform-id roles, neutral/Back fallbacks, and tap/Back
presses; Limrun supplies the blank-root process-exclusive host reader,
the label-strict chooser, and client.tap. The confirm-on-ambiguous-capture
and duplicate-label threads settle once in the shared code: the three-way
alert|none|undecided observation gates confirmation in the driver, and
selectUniqueAlertButtonByRole refuses duplicated roles at the shared
choice boundary. The deadline order resolves to read-first: over a
positive window both predecessor loops already observed before waiting.
The alert-not-found and undecided refusals now carry the same typed
details.reason and dispatched: 'no' on both backends.
@thymikee
thymikee force-pushed the t3/limrun-ios-alert-actions branch from 2482e24 to f584e8a Compare October 16, 2026 15:30
@thymikee

Copy link
Copy Markdown
Member Author

Head f584e8a1 (rebased onto current main) addresses the two defects from your 482cff1 comment and carries the receipts you asked for.

Android smoke step. The dismiss Android native alert failure at 5b1e1c1 had two causes, both fixed. The session's alert probes ran without the app-backed helperSessionScope, so each probe cold-started the snapshot helper inside the 2 s window that bounds it — the request log shows the post-press confirm capture aborted at 2.3 s while the canary proves the dialog was dismissed. Alert reads now scope like every other app-backed read. And the wording lied: with no confirm capture ever answering, "did not dismiss the visible alert" accused a dialog that was already gone. Now only the latest post-press capture that answered and names the same alert earns that failure; an unconfirmed press says exactly that, and every spent-budget/cut-off exit carries typed alert-window-expired (the two newest cubic threads are fixed here with fail-before pins).

Live proofs at this head (repo-built CLI / instrumented real client seam, isolated state):

  • Android 11+ live ACCESS_FINE_LOCATION sheet: accept → button: "While using the app" (declared preference over "Only this time"), dumpsys granted=true, second get none; dismiss on no-alert refuses with alert-not-found/dispatched: no and zero taps.
  • Limrun iOS: app-owned UIAlertController → get none, accept refuses with zero taps; ATT accept taps exactly once (counter at the client seam), second get none; fresh-bundle dismiss taps the denying choice once — never authorizes; three-button location sheet accepts "Allow While Using App" with one tap, second get none.

Receipts: direct gate pnpm check:affected --run --head f584e8a15… passed with exactCommit: true on a clean checkout; CI on this head 16 pass / 0 fail while writing this; all seven review threads have per-thread dispositions. A fresh independent design review of this head (including the smallest-interface assessment of the ~1.1k production lines and the window/observer seam) is running; I'll fold in what it finds rather than declare it done.

Kept honest, not widened: Limrun SDK 0.64.1 exposes no abort on elementTree/tap, so its guards answer post-capture and pre-press and an already-sent request runs on — stated in code, docs, and CHANGELOG rather than papered over; budgets are capped at MAX_ALERT_WINDOW_MS, not clamped; the residual ownership limit (an app dialog under a blanked root is not separable by capture facts) still resolves to refusal, never a guess. Native Swift alert choice stays #3627.

@thymikee

Copy link
Copy Markdown
Member Author

Coverage run 38924441176 at f584e8a1 failed one real test, and the driver — not the oracle — was wrong. confirmTreeAlertDismissed's cutoff catch downgraded every thrown window-expiry to "could not be confirmed", discarding the post-press captures that had answered. On CI the final confirm capture starts inside the 2 s window and lands past it (throws), so an answered same-alert sighting got erased and the error claimed the latest check never answered a dialog that two answered reads had just re-named. Locally the same timeline ends via the never-captures sentinel path, which honors the observation — that dual-path divergence is why check:affected at the exact head passed while Coverage failed (the affected selection doesn't run test/integration/provider-scenarios, and I didn't run it locally; my error).

Fixed at f4b928974: the still-visible claim rides the latest capture that answered, at both the thrown and the sentinel exit; "could not be confirmed" stays reserved for a phase where no completed post-press read named the alert — the 5b1e1c1 artifact timeline, where no confirm capture ever answered, still yields unconfirmed. The undecided-overrules-earlier-match case keeps expecting unconfirmed (undecided is itself an answered observation). The integration oracle's expected message is unchanged; my hung-after-match unit pin was flipped to the CI timeline as the detecting test. 957726b0d batches the round-1 review's three P3 items (wait-only [timeout] docs, a mutation-checked dispatched: unknown press pin, _device nit, one dedup helper). Round-2 independent review of both commits is running; the direct gate for 957726b0d is queued in host serialization and its receipt will be posted here.

@thymikee

Copy link
Copy Markdown
Member Author

Receipts for 957726b0d, per the disclosure above:

  • Direct gate: pnpm check:affected --run --head 957726b0de7e2049b556a0b58efceca0e4af31a6 — passed, exactCommit: true, checkout unchanged (.tmp/validation/run-Sh88Va/receipt.json). Its selection lists provider-integration as deduplicated, so that suite also ran explicitly (197/197) here and in the reviewer's checkout.
  • Independent round-2 review (/tmp/3349-review-9577-report.md): merge-ready, no new findings. Verified independently: the fix is at the driver's error-classification branch with the integration oracle unmodified; the thrown-cutoff and sentinel paths cannot disagree (same predicate, both pinned — the f584 gap was exactly the missing thrown+same-alert pin, which now has fail-before evidence); the 2482e24 zero-answered-confirms timeline still yields unconfirmed on the thrown path; dispatched: 'unknown' re-confirmed mutation-checked by the reviewer; messages byte-compared with no drift; production growth of the two commits is +12 net lines with one duplication removed. Round 1's three P3 items are closed each with a detecting artifact.
  • CI at this head: 17 pass, 0 fail (final iOS smoke matrix finishing; parent watches).

No further changes planned on my side for the alert family at this head.

@thymikee

thymikee commented Oct 16, 2026 •

Copy link
Copy Markdown
Member Author

Corrected Android live proof at 957726b0d, replacing the earlier run's precondition attribution.

The earlier proof script (android-live-f584b.sh) ran adb shell pm reset-permissions com.example.locprobe, which AOSP applies globally — the package argument does not scope it. That violated the no-global-privacy-resets rule; its artifact and disclosure stand, and no global undo was attempted (the scoped revoke re-revokes the probe's own two permissions).

Corrected run: emulator-5554 via T3 device_list/device_open (unclaimed lane), CLI built at clean 957726b0de7e2049b556a0b58efceca0e4af31a6 (git status empty), isolated --session/--state-dir. Preconditions only: own-bundle pm revoke of FINE and COARSE location (exits 0/0; grants true before, false after the revokes), own-bundle force-stop; no reset-permissions, no logcat -c.

Results (raw JSON + per-command exits in /private/tmp/3349-proof-957/): alert get → status: visible, the five-button multi-grant sheet (exit 0); alert accept → handled: true, button: "While using the app", coords (540,1443) (exit 0); dumpsys after: FINE+COARSE granted=true; second alert get → status: none (exit 0); alert dismiss on the quiet screen refused pre-press with typed alert-window-expired, dispatched: no (exit 1) — the actual typed pre-press cutoff result; detailed request timing was not retained. Note: no client-seam tap counter exists on this public CLI route, so no tap-count claim is made here; the exactly-once rule is unit-pinned.

Limits honestly kept: this Android leg does not instrument tap counts; the Limrun iOS instrumented-seam proof remains banked at the earlier head (PROBE_EXIT 0), whose attribution to that head stands — the newer commits change only the cut-off error path.

thymikee added a commit that referenced this pull request Oct 16, 2026
…red driver

PR #3699 review: the Limrun iOS alert legs duplicated the Android tree-alert
driver (poll, confirm, same-alert, response building) and its label
vocabulary. Both backends now reach those steps through one backend-neutral
driver, runTreeAlertAction in platform-android (exported via the mechanics
facet provider-limrun already depends on), and classify button roles
through one English vocabulary that lives in contracts/alert-contract with
the alert budgets and typed reasons (contracts owns vocabulary only; the
driver loop owns the AppError/timer mechanics R18 keeps out of contracts).

Each backend supplies only its own seams: Android wraps its dump reader
and keeps its platform-id roles, neutral/Back fallbacks, and tap/Back
presses; Limrun supplies the blank-root process-exclusive host reader,
the label-strict chooser, and client.tap. The confirm-on-ambiguous-capture
and duplicate-label threads settle once in the shared code: the three-way
alert|none|undecided observation gates confirmation in the driver, and
selectUniqueAlertButtonByRole refuses duplicated roles at the shared
choice boundary. The deadline order resolves to read-first: over a
positive window both predecessor loops already observed before waiting.
The alert-not-found and undecided refusals now carry the same typed
details.reason and dispatched: 'no' on both backends.
@thymikee
thymikee force-pushed the t3/limrun-ios-alert-actions branch from 957726b to 326aa3b Compare October 16, 2026 17:23
thymikee added a commit that referenced this pull request Oct 16, 2026
…red driver

PR #3699 review: the Limrun iOS alert legs duplicated the Android tree-alert
driver (poll, confirm, same-alert, response building) and its label
vocabulary. Both backends now reach those steps through one backend-neutral
driver, runTreeAlertAction in platform-android (exported via the mechanics
facet provider-limrun already depends on), and classify button roles
through one English vocabulary that lives in contracts/alert-contract with
the alert budgets and typed reasons (contracts owns vocabulary only; the
driver loop owns the AppError/timer mechanics R18 keeps out of contracts).

Each backend supplies only its own seams: Android wraps its dump reader
and keeps its platform-id roles, neutral/Back fallbacks, and tap/Back
presses; Limrun supplies the blank-root process-exclusive host reader,
the label-strict chooser, and client.tap. The confirm-on-ambiguous-capture
and duplicate-label threads settle once in the shared code: the three-way
alert|none|undecided observation gates confirmation in the driver, and
selectUniqueAlertButtonByRole refuses duplicated roles at the shared
choice boundary. The deadline order resolves to read-first: over a
positive window both predecessor loops already observed before waiting.
The alert-not-found and undecided refusals now carry the same typed
details.reason and dispatched: 'no' on both backends.
@thymikee
thymikee force-pushed the t3/limrun-ios-alert-actions branch from 326aa3b to 60aec19 Compare October 16, 2026 18:18
Limrun iOS direct sessions refused all four alert legs with
unsupported-provider-mode even though the session's element-tree capture
already shows the system alert and its touch seam can press its buttons.
Issue #3349.

Detect the alert from the capture: a system alert arrives as its own
blank-label AXApplication root hosting text and pressable buttons, while
the app's own UIAlertController rides the app's named root, so app-owned
dialogs stay app UI. Accept and dismiss press exactly the button whose
label states the asked-for choice and confirm the alert is gone; an
ambiguous choice is reported, never guessed, and dismiss never
authorizes tracking. The four legs bind through the same catalog and
facts the Android tree-based legs already use.
Review follow-up on the alert legs: a sheet must offer at least two
pressable choices, launch-icon buttons never qualify, a capture whose
host sheets disagree is refused instead of shadowing one with the
other, and an action fires only when exactly one button states its
choice. The legs' poll/confirm budgets now spend through an injected
sleep seam so their tests drive the clock, a malformed timeout fails as
a bounded default instead of an unbound loop, and the grant vocabulary
covers the iOS variants (Allow Once / While Using App / All the Time)
and Close App. User docs and CHANGELOG updated.
…red driver

PR #3699 review: the Limrun iOS alert legs duplicated the Android tree-alert
driver (poll, confirm, same-alert, response building) and its label
vocabulary. Both backends now reach those steps through one backend-neutral
driver, runTreeAlertAction in platform-android (exported via the mechanics
facet provider-limrun already depends on), and classify button roles
through one English vocabulary that lives in contracts/alert-contract with
the alert budgets and typed reasons (contracts owns vocabulary only; the
driver loop owns the AppError/timer mechanics R18 keeps out of contracts).

Each backend supplies only its own seams: Android wraps its dump reader
and keeps its platform-id roles, neutral/Back fallbacks, and tap/Back
presses; Limrun supplies the blank-root process-exclusive host reader,
the label-strict chooser, and client.tap. The confirm-on-ambiguous-capture
and duplicate-label threads settle once in the shared code: the three-way
alert|none|undecided observation gates confirmation in the driver, and
selectUniqueAlertButtonByRole refuses duplicated roles at the shared
choice boundary. The deadline order resolves to read-first: over a
positive window both predecessor loops already observed before waiting.
The alert-not-found and undecided refusals now carry the same typed
details.reason and dispatched: 'no' on both backends.
origin/main migrated the Android alert loops to pollUntilSettled/Deadline
and moved timing into fake timers at the test edge. The shared driver
adopts the same shape: poll/confirm run on the deadline primitive, so the
driver-owned sleep seam is dead weight and goes — Limrun's tests now drive
the clock the way the migrated Android tests do.
…y catch-name lint

Adds the maintainer's live-validated sequence at the unit seam: accept
taps exactly once, and a following get on the same seam reports no alert.
Renames catch params to satisfy oxlint's catch-error-name (three
findings the affected gate reported).
The unique-role choice boundary refused exactly the sheets the fix is
for: Android 11+'s permission_allow_foreground_only_button and
permission_allow_one_time_button both classify accept, and iOS's
'Allow Once'/'Allow While Using App' sheet refused the same way. The
shared vocabulary is now a per-role preference table: distinct grants
resolve by declared rank (a lasting grant before a one-time grant), and
the acting legs refuse only when the capture names no single physical
button — the same label at two points, or vocabulary-free multiplicity.
Android keeps its neutral-button/Back fallback, Limrun keeps none.
… owns

Three review findings on the shared tree-alert driver, plus the test
that masked a polling loop:

- alert get on a tree backend answered undecided exactly like absence
  (alert: null, message-only difference). The result now carries a typed
  status (visible/none/undecided) so clients switch on states, not text.
- The budgets bounded only the poll sleeps: a capture or press that hung
  could outrun the leg. Each leg now builds a window (deadline clock plus
  the joined window/caller signal) that reaches the capture and press
  seams; the Android legs hand it to the snapshot capture and the adb
  input sends, which kill their child on abort. A capture that lands
  once its window closed is refused on a typed alert-window-expired
  reason and never consumed or pressed; a press cut off mid-flight says
  dispatched: unknown, one refused before dispatch says no.
- Limrun's WebSocket transport exposes no abort on elementTree/tap
  (checked against the shipped typings), so the consumption rule is what
  keeps it honest, and the SDK gap is stated rather than papered over.
- The awaitAlert first-capture test now asserts the capture count; its
  same-alert fixture had made it vacuous.
The shared tree-alert driver consumed an answer from a poll that started
after its window closed (the loop tests settled before remaining, and an
expired-at-start probe rode unguarded), and its first probe paid the
Android helper cold start inside the action budget, aborting the very
read that leg was about to press on — the executed smoke failure. The
first observation is now read-first, unarmed, consumed once, and arms
the window behind it; a later probe starting past the deadline never
captures at all, and the press rides the armed window.

- contracts: a mix of ranked and unranked role candidates refuses
  instead of letting the one known label win (selectRankedAlertButton-
  ByRole); MAX_ALERT_WINDOW_MS names the timer ceiling the daemon
  refuses above (INVALID_ARGS) and the driver defaults above.
- android: same-label dedup keys on the physical center, so nested
  nodes collapse and two distinct OKs reach the shared refusal; the
  input dispatch option type owns cancellation only, and the tap
  forward picks fields so allowFailure cannot reach the transport.
- docs: alert-window-expired/none/undecided reasons stated exactly per
  phase, Limrun's non-abortable transport described as post-capture and
  pre-press guards with the hung-request limit named.

Prose at the driver trims to invariants.
…confirmed press

The executed Android smoke failure at 5b1e1c1: the session's alert probes
ran without the app-backed helper session scope, so each probe cold-started
the snapshot helper inside the window that bounds it — the request log shows
the post-press confirm capture aborted at 2.3s ("operation was aborted due
to timeout") while the canary proves the dialog was in fact dismissed. Alert
reads now carry the same helperSessionScope as every other app-backed read.

The failure also proved the wording lied: with no confirm capture ever
answering, "did not dismiss the visible alert"/"dialog is still visible"
accused a dialog that was already gone. Only a re-read naming the same alert
earns the still-visible failure; an unconfirmed press now says exactly that,
on both the cut-off and the spent-budget exits, with a hint to run alert get
before retrying. The same-alert stuck-alert failure keeps its message.

Pinned: interactor scoping regression (fail-before against 5b1e1c1) and
the three Limrun confirm-phase tests moved to the truthful split; the
Android still-visible pin keeps its message via the same-alert path.
Two review findings on the confirm split, one batch:

- The spent-budget exits of confirmTreeAlertDismissed omitted
  details.reason, while commands.md promises every post-press failure
  branches on alert-window-expired. Both unconfirmed and still-visible
  failures now carry the typed reason, pinned in the Android and Limrun
  suites.
- The same-alert flag latched on the first matching capture, so a later
  undecided re-read still reported the dialog as currently visible.
  Each probe now replaces the verdict with its own observation: the
  still-visible failure requires the latest capture that answered to
  name the same alert, and a fresher undecided read exits as an
  unconfirmed press. A probe whose capture never answered is not a
  completed observation, so the thrown cutoff keeps saying only that
  the press went unconfirmed, at any earlier match.

Pinned fail-before at the pre-fix alert.ts: same-alert then undecided
to budget-end, and same-alert then a hung capture, both expect the
unconfirmed message and the typed reason. The helper-scope comment is
trimmed to the non-obvious constraint per AGENTS.
…swered sighting

The Coverage run at f584e8a (38924441176) failed the maintainer-owned
provider-integration scenario 'Android alert accept fails when the dialog
stays visible' — a suite the affected gate's related-test selection does
not reach, so the local exact-head receipt never ran it. The driver, not
the oracle, was wrong: f584's confirm catch downgraded every thrown cutoff
to unconfirmed, discarding the captures that had answered before it. On CI
the final confirm capture starts inside the 2s window and lands past it
(the thrown path); locally it starts after the deadline (the sentinel
path), which is why the same commit passed one and failed the other. The
dialog had been re-named by answered reads; the error claimed the check
never answered.

The latest-completed-observation rule from review thread r4296070770 and
from commands.md is: the latest capture that ANSWERED carries the claim.
A hung probe is not an observation, so the cutoff now reports
still-visible when an answered capture named the alert, and unconfirmed
only when none did — the 2482e24 smoke case, with no answer at all. The
undecided-overrules-match case is unchanged: undecided is an answered
observation. Pin flipped to the CI timeline (fail-before at f584's catch);
all 128 alert-family tests and the 197-test provider-integration project
pass at this working tree.
The independent review of f584e8a returned merge-ready with three
follow-up-scale findings, batched here:

- The documented `dispatched: 'unknown'` press-cutoff failure had no test.
  Pinned in the Android suite with the real transport shape: the adb fake
  hangs the tap until the 2s action window closes and dies with the
  signal; the failure must disclose unknown, never 'no'. Mutation-checked:
  flipping the disclosure to 'no' fails the pin.
- The `[timeout]` positional is honored by `alert wait` only — the Apple
  acting legs pass fixed runner budgets and the tree legs run their own
  lookup/confirm windows; commands.md now says the argument is wait-only
  per backend instead of leaving the silence to surprise callers.
- `void device;` in limrunAlertOperationFacts follows the `_device`
  convention; the two byte-identical visible-after-press exits collapse
  into one helper, keeping the two hints that genuinely differ (latest
  read hung vs budget ended unresolved).

No behavior change for any message, reason, or tap.
@thymikee
thymikee force-pushed the t3/limrun-ios-alert-actions branch from 60aec19 to be48a28 Compare October 16, 2026 19:12

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Limrun iOS: alert returns unsupported-provider-mode although the alert is in the element tree

1 participant