Repository navigation
feat: answer agent approvals from mobile notification actions - #107
Conversation
[Medium impact] The changes affect mobile approval alerts and the environment route that accepts their answers. The PR needs fixes before merging because notification answers can fail without a usable retry or lose still-pending alerts. Findings
Fix with agent prompt### Issue 1
apps/mobile/src/features/agent-awareness/notificationNavigation.ts:40-43
After a failed answer, the notification is reposted with the same identifier. But `handledResponseIds` keeps that identifier and action for the app's lifetime. Tapping the same button again skips `handleIosApprovalResponse`, so no HTTP answer runs. Give each retry a fresh notification identifier, or distinguish a new tap from duplicate delivery of the original response.
### Issue 2
packages/client-runtime/src/state/runtimeRequestHttp.ts:39
`resolver.prepare(entry)` tries only `entry.target`, even though the saved entry can contain `alternateRoutes`. If the preferred LAN address is unavailable on cellular, the answer reports “Couldn't reach” without trying a working saved T3 Connect route. Try the usable saved routes without opening a socket, as the normal connection path does.
### Issue 3
packages/client-runtime/src/state/runtimeRequestHttp.ts:39-51
The 15-second timeout starts after `resolver.prepare(entry)`, which can spend up to 30 seconds renewing a DPoP credential. Android gives the headless task only 30 seconds, and the iOS patch releases its background completion after 25 seconds. A slow renewal can consume that window before JS answers or shows failure, leaving Android stuck on “Allowing…” or “Declining…” without buttons. Bound preparation and the answer together, leaving time to restore the alert.
### Issue 4
infra/relay/src/agentActivity/FcmDeliveries.ts:304-310
`states` comes from `listForUser`, which includes only environment links with `liveActivitiesEnabled: true`. If the phone has Live Updates enabled but an environment allows notifications only, this code sends a list that omits that environment's pending requests. Android uses the list to cancel alerts across all environments, so a later push can erase an approval that still needs an answer. Include every environment covered by cancellation, or restrict cancellation to those covered by the list.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryAdds mobile notification buttons for answering agent approvals over authenticated HTTP.
Acknowledged scope and gaps:
Reviews (1) · Last reviewed commit: "test: provide the relay digest test's la..." · Reviewed by Greptile |
| const key = `${approval.notificationIdentifier}\n${approval.actionIdentifier}`; | ||
| if (!handledResponseIds.has(key)) { | ||
| handledResponseIds.add(key); | ||
| void handleIosApprovalResponse(approval); |
There was a problem hiding this comment.
After a failed answer, the notification is reposted with the same identifier. But handledResponseIds keeps that identifier and action for the app's lifetime. Tapping the same button again skips handleIosApprovalResponse, so no HTTP answer runs. Give each retry a fresh notification identifier, or distinguish a new tap from duplicate delivery of the original response.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/mobile/src/features/agent-awareness/notificationNavigation.ts
Line: 40-43
Comment:
**iOS retries do nothing**
After a failed answer, the notification is reposted with the same identifier. But `handledResponseIds` keeps that identifier and action for the app's lifetime. Tapping the same button again skips `handleIosApprovalResponse`, so no HTTP answer runs. Give each retry a fresh notification identifier, or distinguish a new tap from duplicate delivery of the original response.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Note
Written by claude-opus-5-5 on behalf of eyeveil
Fixed in 007cffc. The dedupe key now covers only the answer in flight. handledResponseIds drops it when handleIosApprovalResponse settles, so a tap on the reposted alert answers again. Duplicate cold-start deliveries arrive while the first answer is still running, so they stay deduped.
| } | ||
| const resolver = yield* ConnectionResolver.ConnectionResolver; | ||
| const params = { threadId: input.threadId, requestId: input.requestId }; | ||
| return yield* resolver.prepare(entry).pipe( |
There was a problem hiding this comment.
resolver.prepare(entry) tries only entry.target, even though the saved entry can contain alternateRoutes. If the preferred LAN address is unavailable on cellular, the answer reports “Couldn't reach” without trying a working saved T3 Connect route. Try the usable saved routes without opening a socket, as the normal connection path does.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/client-runtime/src/state/runtimeRequestHttp.ts
Line: 39
Comment:
**Answers ignore working routes**
`resolver.prepare(entry)` tries only `entry.target`, even though the saved entry can contain `alternateRoutes`. If the preferred LAN address is unavailable on cellular, the answer reports “Couldn't reach” without trying a working saved T3 Connect route. Try the usable saved routes without opening a socket, as the normal connection path does.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Note
Written by claude-opus-5-5 on behalf of eyeveil
Fixed in 007cffc. respondToRuntimeRequestOverHttp now tries usableRoutes(entry) in order through Effect.firstSuccessOf and routeEntry. That is the same route list the socket driver uses. It keeps one command id across routes, so if a route answered and then failed, the next route replays that receipt.
| return yield* resolver.prepare(entry).pipe( | ||
| Effect.flatMap( | ||
| Effect.fnUntraced(function* (prepared) { | ||
| return yield* executeAuthenticatedEnvironmentHttpRequest({ | ||
| group: "orchestration", | ||
| prepared, | ||
| signer: yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner), | ||
| remoteAuthorization: yield* Effect.serviceOption( | ||
| RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, | ||
| ), | ||
| method: "POST", | ||
| url: (urls) => urls.respondToRuntimeRequest({ params }), | ||
| timeoutMs: RESPOND_TIMEOUT_MS, |
There was a problem hiding this comment.
Answers outlast background time
The 15-second timeout starts after resolver.prepare(entry), which can spend up to 30 seconds renewing a DPoP credential. Android gives the headless task only 30 seconds, and the iOS patch releases its background completion after 25 seconds. A slow renewal can consume that window before JS answers or shows failure, leaving Android stuck on “Allowing…” or “Declining…” without buttons. Bound preparation and the answer together, leaving time to restore the alert.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/client-runtime/src/state/runtimeRequestHttp.ts
Line: 39-51
Comment:
**Answers outlast background time**
The 15-second timeout starts after `resolver.prepare(entry)`, which can spend up to 30 seconds renewing a DPoP credential. Android gives the headless task only 30 seconds, and the iOS patch releases its background completion after 25 seconds. A slow renewal can consume that window before JS answers or shows failure, leaving Android stuck on “Allowing…” or “Declining…” without buttons. Bound preparation and the answer together, leaving time to restore the alert.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Note
Written by claude-opus-5-5 on behalf of eyeveil
Fixed in 007cffc. A single 20 s budget now covers prepare and the request across all routes, through an Effect.timeout around the whole attempt. That leaves time under Android's 30 s headless task and the patch's 25 s iOS hold to show the outcome.
| if (preferences.value.liveActivitiesEnabled) { | ||
| const digests = yield* Effect.forEach(pendingRequestIds(states), (requestId) => | ||
| sha256Hex(requestId).pipe( | ||
| Effect.map((hex) => hex.slice(0, PENDING_REQUEST_DIGEST_LENGTH)), | ||
| ), | ||
| ); | ||
| data.activity_pending_requests = digests.join(","); |
There was a problem hiding this comment.
states comes from listForUser, which includes only environment links with liveActivitiesEnabled: true. If the phone has Live Updates enabled but an environment allows notifications only, this code sends a list that omits that environment's pending requests. Android uses the list to cancel alerts across all environments, so a later push can erase an approval that still needs an answer. Include every environment covered by cancellation, or restrict cancellation to those covered by the list.
Prompt To Fix With AI
This is a comment left during a code review.
Path: infra/relay/src/agentActivity/FcmDeliveries.ts
Line: 304-310
Comment:
**Pending alerts disappear**
`states` comes from `listForUser`, which includes only environment links with `liveActivitiesEnabled: true`. If the phone has Live Updates enabled but an environment allows notifications only, this code sends a list that omits that environment's pending requests. Android uses the list to cancel alerts across all environments, so a later push can erase an approval that still needs an answer. Include every environment covered by cancellation, or restrict cancellation to those covered by the list.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Note
Written by claude-opus-5-5 on behalf of eyeveil
c71e0c3 removed the pending-request list. Android now cancels per thread: each push for a thread carries alert_cancel_group and alert_keep_request_id from getForUserThread, which filters only revoked links, not liveActivitiesEnabled. A push for one thread never cancels another thread's alert, so a notification-only environment's pending alert stays.
|
This PR changes the test suite by +21 test cases (browser 0, unit +16, server +5). Keep tests that would fail for a real reason, and test logic below the UI where it can be. See Testing. |
…etry a failed answer
… routes, recover unreported answers [skip ci]
… only its own alert
b629452 to
159e945
Compare
This reverts commit cf994a1. The fork has no push delivery path and the user dropped building one, so the actions could never fire.
## Why The fork has no push delivery path: no relay and no Firebase or APNs project. The user decided not to build one. Without delivery, the lock-screen Allow once and Decline actions from #107 can never fire, so this removes them. ## What changed - Reverts #107 (`cf994a16803`) in full: the mobile notification actions, the HTTP respond endpoint, `UserCommandDispatch`, the relay payload fields, the expo-notifications patch change, and its docs. ## Scope - Nothing merged after #107, and #105, #106 and #108 don't use its code. The resulting tree is identical to `5d6f358859` (#108's merge), whose PR head passed all 31 checks. - No upstream relay or push code is touched beyond what #107 added. ## Verification - `git rev-parse HEAD^{tree}` equals `5d6f358859^{tree}` (`3d644cc0f5`). <!-- codesmith:footer --> --- <a href="https://backend.blacksmith.sh/track/enable-autofix?expires=1797243155&installation_model_id=440102&pr_number=109&ref=codesmith_pr_footer&repository=issuelt%2Ft3code&return_to=https%3A%2F%2Fgithub.com%2Fissuelt%2Ft3code%2Fpull%2F109&signature=71b556a9ccd1cb02ca9901dd8a6ce11977a43176037b02cd545da59e1cc34fbf"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img alt="Autofix with [code]smith" src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png" width="190" height="28"></picture></a> <sup><a href="https://app.blacksmith.sh/issuelt/codesmith/t3code/pr/109?autoLogin=true&ref=codesmith_pr_footer">Open in [code]smith</a> · Need help on this PR? Tag <code>@codesmith-bot</code> with what you need.</sup> <!-- codesmith:autofix:disabled --> <!-- /codesmith:footer -->





Why
Approval alerts on a phone only open the app. On Android 12 and later this adds Allow once and Decline to the alert. Both ask for the device's PIN, pattern or biometrics first, and the app stays closed. The phone answers over HTTP with its stored DPoP credential, and pushes carry only IDs.
State of delivery. Android 12 and later is proven on an emulator, with FCM-shaped pushes delivered to the app's real FCM service. iOS has the code (category, patch, repost) and its unit tests, but it isn't proven on a device and the fork has no iOS delivery path yet, so this PR doesn't claim iOS lock-screen support. Live delivery on either platform waits on a fork relay deploy and a fork Firebase project, which don't exist yet.
What changed
RelayAgentActivityState.pendingApproval(IDs plusactionable) is part of the publish identity. The aggregate row schema is unchanged. The orchestrator fails with a typedRuntimeRequestNotPendingError.POST /api/orchestration/threads/:threadId/runtime-requests/:requestId/respond(orchestration:operate) answersResponded | AlreadyResolvedthrough theUserCommandDispatchservice, which the websocket now shares. That service records the turn analytics with the caller's source (webormobile). An alert is actionable only when the request is pending, has a live capability, and carries no provider-specific options.alert_request_*fields and the APNsaps.category: t3.approvalcome from the per-thread state. A second approval on a still-waiting thread alerts once, becausealert_idnames the request. Every Android thread update carriesalert_cancel_groupandalert_keep_request_id, so the device cancels that thread's answered approvals, also with Live Updates off.configurepassesAPPROVAL_ACTIONSto native as the single source of ids, labels and progress text. On Android 12 (API 31) and later the alert gets actions withsetAuthenticationRequired(true). Older Android shows the alert without actions, because it cannot require unlocking.AgentApprovalActionReceiverstarts theT3ApprovalActionheadless task, which calls the endpoint without a socket. It races every saved route under one 20 s budget with one command id, and the server's command receipt dedupes a second arrival. Only an answer wins the race; a route that rejects (for example one paired with fewer scopes) yieldsRejectedonly after every other route has failed too. The outcome isAnswered,Rejected(a missing scope, a gone thread or an invalid command: "This connection cannot respond to approvals." or "This approval can no longer be answered.", without actions) orUnreachable("Couldn't reach ", actions kept). An answer whose task ends without reporting, or that stays sending past 60 s, gets its actions back. The task service maps each headless task id to its own alert, so one task ending never fails another alert still sending. iOS registers thet3.approvalcategory, reposts a failed answer with its original data, deep link and thread grouping, and the expo-notifications patch holdscompletionHandleruntil JS acknowledges. While the app runs, the shell stream clears answered alerts.Scope
Tradeoffs
Clearing per thread sends a data push for every thread update to notification-only Android users. That is more FCM traffic, but it needs no relay state and covers users with Live Updates off.
Blast Radius
The websocket
dispatchCommandnow runs throughUserCommandDispatch, with the same startup gate and provenance. A late response reportsRuntimeRequestNotPendingErrorinstead of a stringOrchestratorDispatchError, with the same message.Verification
5d6f358859. Focused runs under Node 26: server 87, relay 39, client-runtime 7, mobile 20.vp run check:changedpasses except "unused patches", which fails on main too: a fresh resolution pullsunenv2.0.0-rc.25 against@cloudflare/unenv-preset's rc.24 peer ininfra/relay. Robolectric on the Mac: 154 tests, 0 failures (the rebase touched no Kotlin).CloudAuthProviderdeactivates the relay account on every JS boot and clears all alerts. Release builds attach Clerk instead and deactivate only on sign-out.Model and harness: Claude Opus 5.5 (implementation, review, proof) via Claude Code in T3 Code.