From c6271b3d98d687669bce07cef22493e437c0186a Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:55:09 -0700 Subject: [PATCH 1/6] feat(mobile): suppress only the on-screen thread's alert on Android Android used to silence every agent alert while the app was foregrounded, while iOS (since the SDK 58 handler) only quiets the thread already on screen. The navigation hook now reports the thread route to the Kotlin FCM handler, which compares it with the alert path before suppressing, so both platforms behave the same. Co-Authored-By: Claude Fable 5 --- .../t3agentnotifications/AgentNotifications.kt | 15 ++++++++++++--- .../T3AgentNotificationsModule.kt | 4 ++++ .../AgentNotificationsTest.kt | 10 ++++++++++ .../agent-awareness/androidNotifications.ts | 6 ++++++ .../agent-awareness/notificationNavigation.ts | 6 ++++++ docs/operations/android-notifications.md | 2 +- 6 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt index 09f9e278f12f..c0c9dcb07085 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -91,6 +91,13 @@ object AgentNotifications { .forEach { manager.cancel(it.tag, it.id) } } + /** Records the thread route the app is showing, or null when none is open. */ + @Volatile private var threadOnScreen: String? = null + + fun setThreadOnScreen(path: String?) { + threadOnScreen = path + } + @Synchronized fun dismiss(context: Context) { context.getSharedPreferences( @@ -138,9 +145,11 @@ object AgentNotifications { val seen = prefs.getString("seenAlertsOrdered", null)?.split('\n') ?: prefs.getStringSet("seenAlerts", emptySet()).orEmpty().toList() if (alertId != null && alertId !in seen) { - // Match iOS foreground presentation. Consume suppressed alerts as well, - // so a delivery retry cannot surface them after the app backgrounds. - if (!ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + // Consume suppressed alerts so retries cannot resurface them later. + val resumed = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) + val visibleThread = threadOnScreen + val onScreen = resumed && visibleThread != null && data["alert_path"] == visibleThread + if (!onScreen) { postAlert(context, scheme, data, alertId) } prefs.edit().remove("seenAlerts").putString( diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt index 0a2d2b30589f..cbef915cb7da 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt @@ -30,6 +30,10 @@ class T3AgentNotificationsModule : Module() { appContext.reactContext?.let { AgentNotifications.showcase(it, scheme, data) } } + Function("setThreadOnScreen") { path: String? -> + AgentNotifications.setThreadOnScreen(path) + } + Function("openLiveUpdateSettings") { val context = appContext.reactContext if (context == null || Build.VERSION.SDK_INT < 36) { diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt index 3852e3e2a0b2..4db30f0bccc6 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -49,6 +49,8 @@ class AgentNotificationsTest { ) AgentNotifications.clear(context) AgentNotifications.configure(context, "device", "user", "t3code-dev", true) + // The fixtures alert for this thread; a resumed app is showing it. + AgentNotifications.setThreadOnScreen(context, "/threads/environment/thread") } private fun update(alertId: String, active: Boolean) = mapOf( @@ -93,6 +95,14 @@ class AgentNotificationsTest { ) } + @Test + fun foregroundAlertsForThreadsThatAreNotOnScreen() { + lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.setThreadOnScreen(context, "/threads/environment/other") + AgentNotifications.receive(context, update("elsewhere", false)) + assertEquals("t3-agent-alert", manager.activeNotifications.single().tag) + } + @Test fun foregroundSuppressesAlertsWhileOngoingActivityStillUpdatesAndClears() { lifecycle.currentState = Lifecycle.State.RESUMED diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.ts index a2a0945faaf1..73f84434d89d 100644 --- a/apps/mobile/src/features/agent-awareness/androidNotifications.ts +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.ts @@ -5,6 +5,7 @@ import { Linking, Platform } from "react-native"; interface AndroidAgentNotifications { configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void; clear(): void; + setThreadOnScreen?(path: string | null): void; openLiveUpdateSettings?(): boolean; showShowcaseActivity?(scheme: string, data: Record): void; } @@ -42,6 +43,11 @@ export function clearAndroidAgentNotifications(): void { native?.clear?.(); } +/** Tells the FCM handler which thread route is on screen so its alerts stay quiet. */ +export function setAndroidThreadOnScreen(path: string | null): void { + native?.setThreadOnScreen?.(path); +} + export function supportsAndroidLiveUpdateSettings(): boolean { return Platform.OS === "android" && Number(Platform.Version) >= 36; } diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts index ff65371ba3b6..7a33fa912d77 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import * as Notifications from "expo-notifications"; import { useLinkTo } from "@react-navigation/native"; +import { setAndroidThreadOnScreen } from "./androidNotifications"; import { foregroundNotificationBehavior } from "./foregroundNotificationBehavior"; import { routeAgentNotificationResponseOnce, threadDeepLinkOnScreen } from "./notificationPayload"; import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; @@ -13,6 +14,11 @@ export function useAgentNotificationNavigation(pathname: string): void { // current route without re-registering on every navigation. const deepLinkOnScreen = useRef(null); deepLinkOnScreen.current = threadDeepLinkOnScreen(pathname); + // Android alerts are built natively from FCM data, so the route travels + // to Kotlin instead of through a JS handler. + useEffect(() => { + setAndroidThreadOnScreen(threadDeepLinkOnScreen(pathname)); + }, [pathname]); useEffect(() => { Notifications.setNotificationHandler({ diff --git a/docs/operations/android-notifications.md b/docs/operations/android-notifications.md index 3ae100c39f5c..aa29ef3e1a7b 100644 --- a/docs/operations/android-notifications.md +++ b/docs/operations/android-notifications.md @@ -68,7 +68,7 @@ vp run push:android:smoke /path/service-account.json /path/device.json completed Supported states are `running`, `approval`, `input`, `completed`, `failed`, and `end`. Firebase acceptance is not proof that a device displayed the message. Check the actual notification, background the app, and test a notification tap. Also test dismissal, disabling ongoing activity, sign-out, token rotation, and delivery after the app process has exited. Android Settings **Force stop** intentionally prevents delivery until the app is opened again. -Android suppresses ordinary alerts while the app is foregrounded, matching iOS notification presentation. Activity cards still update in the foreground and retain finished results silently. Check that completion stays quiet with the app open, that a later completion alerts after backgrounding, and that retrying a foreground-suppressed alert does not show it later. This uses the app lifecycle on the receiving phone, not thread visibility on other clients. +With the app in the foreground, Android suppresses an alert only for the thread currently on screen, matching iOS notification presentation; alerts for other threads still show. Activity cards still update in the foreground and retain finished results silently. Check that completion stays quiet while its thread is open, alerts while another screen is open, alerts after backgrounding, and that retrying a suppressed alert does not show it later. This uses the app lifecycle and route on the receiving phone, not thread visibility on other clients. With ongoing activity enabled, verify two threads entering approval/input together produce one `2 agents need attention` alert, and two observed active threads completing/failing together produce one `2 agents finished` alert. The body lists their titles. The relay shares iOS transition selection and retains its delivered baseline when work finishes; publishing the same states again must not produce another alert. Grouped alerts open the aggregate’s priority thread; individual alerts retain their thread link. From 3450abdbe004367e3aa327d454fa48d8b0b07c98 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 30 Sep 2026 10:51:17 -0700 Subject: [PATCH 2/6] fix(mobile): publish visible notification route after commit --- .../src/features/agent-awareness/notificationNavigation.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts index 7a33fa912d77..840f5ce2e0a3 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useLayoutEffect, useRef } from "react"; import * as Notifications from "expo-notifications"; import { useLinkTo } from "@react-navigation/native"; @@ -13,7 +13,9 @@ export function useAgentNotificationNavigation(pathname: string): void { // Read through a ref so the native handler registered once below sees the // current route without re-registering on every navigation. const deepLinkOnScreen = useRef(null); - deepLinkOnScreen.current = threadDeepLinkOnScreen(pathname); + useLayoutEffect(() => { + deepLinkOnScreen.current = threadDeepLinkOnScreen(pathname); + }, [pathname]); // Android alerts are built natively from FCM data, so the route travels // to Kotlin instead of through a JS handler. useEffect(() => { From c90754cb1f9655a5b42da757a2ed6188cb4e6156 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 30 Sep 2026 10:58:37 -0700 Subject: [PATCH 3/6] style(mobile): format visible-thread alert grouping --- .../expo/modules/t3agentnotifications/AgentNotifications.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt index c0c9dcb07085..5c7cd2e43560 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -146,7 +146,9 @@ object AgentNotifications { ?: prefs.getStringSet("seenAlerts", emptySet()).orEmpty().toList() if (alertId != null && alertId !in seen) { // Consume suppressed alerts so retries cannot resurface them later. - val resumed = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) + val resumed = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast( + Lifecycle.State.RESUMED + ) val visibleThread = threadOnScreen val onScreen = resumed && visibleThread != null && data["alert_path"] == visibleThread if (!onScreen) { From ba7670bdc2b8b85fb6d91ebd38b1311ac0ed4e68 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 30 Sep 2026 16:24:50 -0700 Subject: [PATCH 4/6] fix(mobile): synchronize visible notification routes at commit --- .../agent-awareness/notificationNavigation.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts index 840f5ce2e0a3..099fb5055990 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts @@ -14,12 +14,11 @@ export function useAgentNotificationNavigation(pathname: string): void { // current route without re-registering on every navigation. const deepLinkOnScreen = useRef(null); useLayoutEffect(() => { - deepLinkOnScreen.current = threadDeepLinkOnScreen(pathname); - }, [pathname]); - // Android alerts are built natively from FCM data, so the route travels - // to Kotlin instead of through a JS handler. - useEffect(() => { - setAndroidThreadOnScreen(threadDeepLinkOnScreen(pathname)); + const thread = threadDeepLinkOnScreen(pathname); + deepLinkOnScreen.current = thread; + // Android alerts are built natively from FCM data, so update the native + // route at commit time alongside the iOS handler's route reference. + setAndroidThreadOnScreen(thread); }, [pathname]); useEffect(() => { From 8cef46dd616fa24a7af01596b09ad779fe4f328e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 30 Sep 2026 16:45:05 -0700 Subject: [PATCH 5/6] fix(notifications): keep multi-thread Android alerts visible --- .../src/agentActivity/FcmDeliveries.test.ts | 47 ++++++++++--------- .../relay/src/agentActivity/FcmDeliveries.ts | 6 ++- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 91ad9ad301e3..46254e1e8b43 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -225,28 +225,31 @@ describe("Android delivery routing", () => { ["waiting_for_approval", "waiting_for_input", "2 agents need attention", "true"], ["completed", "failed", "2 agents finished", "false"], ] as const) { - it.effect(`groups ${firstPhase} and ${secondPhase} once across their queued jobs`, () => { - const h = harness(); - h.current.otherStates = [secondState]; - return Effect.gen(function* () { - const delivery = yield* FcmDeliveries; - yield* delivery.process(h.job); - h.current.state = { ...state, phase: firstPhase }; - h.current.otherStates = [{ ...secondState, phase: secondPhase }]; - yield* delivery.process({ ...h.job, state: h.current.state }); - yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); - yield* delivery.process({ ...h.job, state: h.current.state }); - const alerts = h.sent.filter((message) => message.alert); - expect(alerts).toHaveLength(1); - expect(alerts[0]?.data).toMatchObject({ - alert_title: title, - alert_body: "Fix notifications, Second thread", - alert_path: firstPhase === "completed" ? secondState.deepLink : state.deepLink, - active, - }); - expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); - }).pipe(Effect.provide(h.layer)); - }); + it.effect( + `routes grouped ${firstPhase} and ${secondPhase} to the overview once across their queued jobs`, + () => { + const h = harness(); + h.current.otherStates = [secondState]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process(h.job); + h.current.state = { ...state, phase: firstPhase }; + h.current.otherStates = [{ ...secondState, phase: secondPhase }]; + yield* delivery.process({ ...h.job, state: h.current.state }); + yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); + yield* delivery.process({ ...h.job, state: h.current.state }); + const alerts = h.sent.filter((message) => message.alert); + expect(alerts).toHaveLength(1); + expect(alerts[0]?.data).toMatchObject({ + alert_title: title, + alert_body: "Fix notifications, Second thread", + alert_path: "/", + active, + }); + expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); + }).pipe(Effect.provide(h.layer)); + }, + ); } it.effect("filters disabled event types before counting a group", () => { diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 3433055f5dca..8f830a8af7b4 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -22,7 +22,7 @@ import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as FcmDeliveryQueueSender from "./FcmDeliveryQueueSender.ts"; import * as FcmClient from "./FcmClient.ts"; -import { androidActivityData, androidActivityHero, fitFcmData } from "./fcmPayloads.ts"; +import { androidActivityData, fitFcmData } from "./fcmPayloads.ts"; import { makeAggregateState, statusForPhase } from "./agentActivityAggregate.ts"; import { isExpiredAgentActivityState, notificationForActivity } from "./agentActivityPayloads.ts"; import { @@ -109,7 +109,9 @@ export function androidAlertForAggregate(input: { ), alert_title: alert.title, alert_body: alert.body, - alert_path: androidActivityHero(input.nextAggregate)?.deepLink ?? "/", + // A multi-thread alert targets the overview so one visible thread cannot + // suppress notifications for the other threads in the group. + alert_path: "/", }; } From 82db0c5935e175e195939cfe4899b765cd42a937 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 1 Oct 2026 10:38:21 -0700 Subject: [PATCH 6/6] test(notifications): align native tests with visible-thread suppression --- .../t3agentnotifications/AgentNotificationsTest.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt index 4db30f0bccc6..7492bf743dcb 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -50,7 +50,7 @@ class AgentNotificationsTest { AgentNotifications.clear(context) AgentNotifications.configure(context, "device", "user", "t3code-dev", true) // The fixtures alert for this thread; a resumed app is showing it. - AgentNotifications.setThreadOnScreen(context, "/threads/environment/thread") + AgentNotifications.setThreadOnScreen("/threads/environment/thread") } private fun update(alertId: String, active: Boolean) = mapOf( @@ -98,7 +98,7 @@ class AgentNotificationsTest { @Test fun foregroundAlertsForThreadsThatAreNotOnScreen() { lifecycle.currentState = Lifecycle.State.RESUMED - AgentNotifications.setThreadOnScreen(context, "/threads/environment/other") + AgentNotifications.setThreadOnScreen("/threads/environment/other") AgentNotifications.receive(context, update("elsewhere", false)) assertEquals("t3-agent-alert", manager.activeNotifications.single().tag) } @@ -197,7 +197,7 @@ class AgentNotificationsTest { } @Test - fun foregroundSuppressedGroupCannotAppearOnBackgroundRetry() { + fun foregroundGroupForOtherThreadsAlertsOnceAcrossBackgroundRetry() { val grouped = update("group-attention", true) + mapOf( "alert_title" to "2 agents need attention", "alert_body" to "First thread, Second thread", @@ -205,10 +205,13 @@ class AgentNotificationsTest { ) lifecycle.currentState = Lifecycle.State.RESUMED AgentNotifications.receive(context, grouped) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-alert" }) lifecycle.currentState = Lifecycle.State.CREATED AgentNotifications.receive(context, grouped) - assertEquals("t3-agent-activity", manager.activeNotifications.single().tag) + assertEquals(2, manager.activeNotifications.size) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-alert" }) } @Test