Skip to content

feat: answer agent approvals from mobile notification actions - #107

Merged
eyeveil merged 14 commits into
mainfrom
feat/lockscreen-approvals
Nov 14, 2026
Merged

eyeveil merged 14 commits into
mainfrom
feat/lockscreen-approvals

Conversation

@eyeveil

@eyeveil eyeveil commented Nov 14, 2026 •

Copy link
Copy Markdown
Collaborator

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

  • Contract and server: RelayAgentActivityState.pendingApproval (IDs plus actionable) is part of the publish identity. The aggregate row schema is unchanged. The orchestrator fails with a typed RuntimeRequestNotPendingError. POST /api/orchestration/threads/:threadId/runtime-requests/:requestId/respond (orchestration:operate) answers Responded | AlreadyResolved through the UserCommandDispatch service, which the websocket now shares. That service records the turn analytics with the caller's source (web or mobile). An alert is actionable only when the request is pending, has a live capability, and carries no provider-specific options.
  • Relay: the FCM alert_request_* fields and the APNs aps.category: t3.approval come from the per-thread state. A second approval on a still-waiting thread alerts once, because alert_id names the request. Every Android thread update carries alert_cancel_group and alert_keep_request_id, so the device cancels that thread's answered approvals, also with Live Updates off.
  • Mobile: configure passes APPROVAL_ACTIONS to native as the single source of ids, labels and progress text. On Android 12 (API 31) and later the alert gets actions with setAuthenticationRequired(true). Older Android shows the alert without actions, because it cannot require unlocking. AgentApprovalActionReceiver starts the T3ApprovalAction headless 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) yields Rejected only after every other route has failed too. The outcome is Answered, 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) or Unreachable ("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 the t3.approval category, reposts a failed answer with its original data, deep link and thread grouping, and the expo-notifications patch holds completionHandler until JS acknowledges. While the app runs, the shell stream clears answered alerts.

Scope

  • Leaves out: an inbox screen, Allow session, deny feedback, questions, grouped actions, Live Activity buttons, and web and desktop.
  • iOS: the code ships but is unproven on a device; the simulator proof waits for a Mac T3 restart, and the fork has no iOS delivery path. Neither this PR nor the docs claim iOS support until that proof runs.

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 dispatchCommand now runs through UserCommandDispatch, with the same startup gate and provenance. A late response reports RuntimeRequestNotPendingError instead of a string OrchestratorDispatchError, with the same message.

Verification

  • Rebased on main 5d6f358859. Focused runs under Node 26: server 87, relay 39, client-runtime 7, mobile 20. vp run check:changed passes except "unused patches", which fails on main too: a fresh resolution pulls unenv 2.0.0-rc.25 against @cloudflare/unenv-preset's rc.24 peer in infra/relay. Robolectric on the Mac: 154 tests, 0 failures (the rebase touched no Kotlin).
  • Emulator (API 35) with a PIN, a real dev server and devReplay approvals. Alerts were relay-built and delivered through the app's FCM service. Allow once from a killed app asked for the PIN, never opened the app, and resolved the request. A read-only pairing got 403 and showed the Rejected text without actions; the request stayed pending. With the backend unreachable the alert showed "Couldn't reach workstation" with the actions kept.
  • The rejected and offline cases ran with the app process alive. The proof build has no cloud config, so CloudAuthProvider deactivates the relay account on every JS boot and clears all alerts. Release builds attach Clerk instead and deactivate only on sign-out.
Locked, with actions Tap asks for PIN Read-only pairing Offline
locked pin rejected offline

Model and harness: Claude Opus 5.5 (implementation, review, proof) via Claude Code in T3 Code.

@github-actions github-actions Bot added vouch:trusted Author is trusted by repo permissions or the VOUCHED list. size:XXL labels Nov 14, 2026
@greptile-apps

greptile-apps Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 1/5

[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

  1. P1 iOS retries do nothing ▶
  2. P1 Answers ignore working routes ▶
  3. P1 Answers outlast background time ▶
  4. P1 Pending alerts disappear ▶
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.

Summary

Adds mobile notification buttons for answering agent approvals over authenticated HTTP.

  • Phone alerts can approve or decline a waiting request.
  • Approval pushes carry the request details mobile actions need.
  • Resolved approval alerts clear when the phone learns they are no longer pending.

Acknowledged scope and gaps:

  • eyeveil states that live fork delivery awaits a fork relay deployment and Firebase project.
  • eyeveil explicitly leaves grouped actions out of scope.
  • eyeveil acknowledges no Android background clearing with Live Updates off and defers iOS device proof until a Mac T3 restart.

Reviews (1) · Last reviewed commit: "test: provide the relay digest test's la..." · Reviewed by Greptile

Comment on lines +40 to +43
const key = `${approval.notificationIdentifier}\n${approval.actionIdentifier}`;
if (!handledResponseIds.has(key)) {
handledResponseIds.add(key);
void handleIosApprovalResponse(approval);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment on lines +39 to +51
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment on lines +304 to +310
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(",");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@github-actions

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

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.

@eyeveil

eyeveil commented Nov 14, 2026

Copy link
Copy Markdown
Collaborator Author

Note

Written by claude-opus-5-5 on behalf of eyeveil

Android emulator screenshots (API 35, PIN set), fixture data only (made-up thread and project, made-up server name workstation). Before this PR, the same alert had no actions. The relay built each alert's payload, and a simulated FCM broadcast delivered it, because the fork has no relay or Firebase project yet. iOS was not run on a simulator.

The approval alert on the PIN-locked phone
alert

Allow once asks for the PIN, then answers without opening the app
pin
after

A connection without approval permission says so, with no actions
rejected

Server unreachable: the actions stay for a manual retry
offline

@eyeveil
eyeveil force-pushed the feat/lockscreen-approvals branch from b629452 to 159e945 Compare November 14, 2026 09:56
@eyeveil
eyeveil merged commit cf994a1 into main Nov 14, 2026
30 of 31 checks passed
eyeveil added a commit that referenced this pull request Nov 14, 2026
This reverts commit cf994a1. The fork has no push delivery path and the user dropped building one, so the actions could never fire.
eyeveil added a commit that referenced this pull request Nov 14, 2026
## 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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted Author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant