authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-01 10:51:23-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-01 10:51:23-07:00
log438af295fefdd04cc38629f902d7faa07cf39712
treed9afa8a2b847a2596f021f4f4993df8d6c47ee8e
parent09388bf2da51b958fe5c11fb5bade155fe575e58
signature Signed by PGP key B5690EEEBB952194

feat(notifications): stack agent alerts by thread on both platforms (#12048)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

6 files changed, 92 insertions(+), 6 deletions(-)

apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt+26-1
...@@ -84,7 +84,10 @@ object AgentNotifications {...@@ -84,7 +84,10 @@ object AgentNotifications {
84 cancelActivity(context)84 cancelActivity(context)
85 context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit().clear().apply()85 context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit().clear().apply()
86 val manager = manager(context)86 val manager = manager(context)
87 manager.activeNotifications.filter { it.tag == ACTIVITY_TAG || it.tag == ALERT_TAG }87 manager.activeNotifications.filter {
88 it.tag == ACTIVITY_TAG || it.tag == ALERT_TAG ||
89 it.tag?.startsWith("$ALERT_TAG-summary:") == true
90 }
88 .forEach { manager.cancel(it.tag, it.id) }91 .forEach { manager.cancel(it.tag, it.id) }
89 }92 }
9093
...@@ -157,13 +160,35 @@ object AgentNotifications {...@@ -157,13 +160,35 @@ object AgentNotifications {
157 // Grouped alerts list up to five 120-character thread titles.160 // Grouped alerts list up to five 120-character thread titles.
158 val body = data["alert_body"].orEmpty().take(608)161 val body = data["alert_body"].orEmpty().take(608)
159 val id = alertId.hashCode()162 val id = alertId.hashCode()
163 val group = data["alert_group"]?.takeIf { it.isNotBlank() } ?: ALERT_TAG
160 val notification = base(context, ALERT_CHANNEL)164 val notification = base(context, ALERT_CHANNEL)
161 .setContentTitle(title).setContentText(body)165 .setContentTitle(title).setContentText(body)
162 .setStyle(NotificationCompat.BigTextStyle().bigText(body))166 .setStyle(NotificationCompat.BigTextStyle().bigText(body))
163 .setAutoCancel(true)167 .setAutoCancel(true)
168 .setGroup(group)
164 .setContentIntent(contentIntent(context, scheme, data["alert_path"], id))169 .setContentIntent(contentIntent(context, scheme, data["alert_path"], id))
165 .build()170 .build()
166 manager(context).notify(ALERT_TAG, id, notification)171 manager(context).notify(ALERT_TAG, id, notification)
172 val children = manager(context).activeNotifications.filter {
173 it.tag == ALERT_TAG && it.notification.group == group
174 }
175 if (children.size > 1) {
176 val style = NotificationCompat.InboxStyle()
177 children.forEach {
178 style.addLine(it.notification.extras.getCharSequence(android.app.Notification.EXTRA_TEXT))
179 }
180 val summary = base(context, ALERT_CHANNEL)
181 .setContentTitle(title)
182 .setContentText(body)
183 .setStyle(style)
184 .setGroup(group)
185 .setGroupSummary(true)
186 .setSilent(true)
187 .setAutoCancel(true)
188 .setContentIntent(contentIntent(context, scheme, data["alert_path"], 0))
189 .build()
190 manager(context).notify("$ALERT_TAG-summary:$group", 0, summary)
191 }
167 }192 }
168193
169 /**194 /**
apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt+27-3
...@@ -60,6 +60,7 @@ class AgentNotificationsTest {...@@ -60,6 +60,7 @@ class AgentNotificationsTest {
60 "activity_body" to "Test thread · Working",60 "activity_body" to "Test thread · Working",
61 "activity_path" to "/threads/environment/thread",61 "activity_path" to "/threads/environment/thread",
62 "alert_id" to alertId,62 "alert_id" to alertId,
63 "alert_group" to "environment/thread",
63 "alert_title" to "Test thread",64 "alert_title" to "Test thread",
64 "alert_body" to "Done: Test project",65 "alert_body" to "Done: Test project",
65 "alert_path" to "/threads/environment/thread",66 "alert_path" to "/threads/environment/thread",
...@@ -76,6 +77,12 @@ class AgentNotificationsTest {...@@ -76,6 +77,12 @@ class AgentNotificationsTest {
76 assertEquals("alert-0".hashCode(), manager.activeNotifications.single().id)77 assertEquals("alert-0".hashCode(), manager.activeNotifications.single().id)
77 }78 }
7879
80 @Test
81 fun alertsStackByThreadGroup() {
82 AgentNotifications.receive(context, update("grouped", false))
83 assertEquals("environment/thread", manager.activeNotifications.single().notification.group)
84 }
85
79 @Test86 @Test
80 fun missingLauncherDoesNotDiscardTheAlert() {87 fun missingLauncherDoesNotDiscardTheAlert() {
81 shadowOf(context.packageManager).removeActivity(ComponentName(context, Activity::class.java))88 shadowOf(context.packageManager).removeActivity(ComponentName(context, Activity::class.java))
...@@ -142,6 +149,22 @@ class AgentNotificationsTest {...@@ -142,6 +149,22 @@ class AgentNotificationsTest {
142 assertEquals("background-completion".hashCode(), manager.activeNotifications.single().id)149 assertEquals("background-completion".hashCode(), manager.activeNotifications.single().id)
143 }150 }
144151
152 @Test
153 fun repeatedThreadAlertsHaveOneSilentSummaryAndClearTogether() {
154 AgentNotifications.receive(context, update("first", false) + ("alert_group" to "thread-group"))
155 AgentNotifications.receive(context, update("second", false) + ("alert_group" to "thread-group"))
156
157 val summary = manager.activeNotifications.single {
158 it.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0
159 }
160 assertEquals("thread-group", summary.notification.group)
161 assertEquals(3, manager.activeNotifications.size)
162 assertEquals(null, summary.notification.sound)
163
164 AgentNotifications.clear(context)
165 assertTrue(manager.activeNotifications.isEmpty())
166 }
167
145 @Test168 @Test
146 fun groupedAlertDisplaysEveryThreadAndRetriesStaySilent() {169 fun groupedAlertDisplaysEveryThreadAndRetriesStaySilent() {
147 val titles = (1..5).map { "Thread $it " + "x".repeat(111) }.joinToString(", ")170 val titles = (1..5).map { "Thread $it " + "x".repeat(111) }.joinToString(", ")
...@@ -286,11 +309,11 @@ class AgentNotificationsTest {...@@ -286,11 +309,11 @@ class AgentNotificationsTest {
286 context,309 context,
287 update("older-alert", false) + ("updated_at" to (now - 1000).toString())310 update("older-alert", false) + ("updated_at" to (now - 1000).toString())
288 )311 )
289 assertEquals(3, manager.activeNotifications.size)312 assertEquals(4, manager.activeNotifications.size)
290 assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" })313 assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" })
291 shadowOf(manager).setNotificationsEnabled(false)314 shadowOf(manager).setNotificationsEnabled(false)
292 AgentNotifications.receive(context, update("revoked-permission", true))315 AgentNotifications.receive(context, update("revoked-permission", true))
293 assertEquals(3, manager.activeNotifications.size)316 assertEquals(4, manager.activeNotifications.size)
294 }317 }
295318
296 @Test319 @Test
...@@ -503,7 +526,8 @@ class AgentNotificationsTest {...@@ -503,7 +526,8 @@ class AgentNotificationsTest {
503 AgentNotifications.expire(context, expiresAt + 60_000)526 AgentNotifications.expire(context, expiresAt + 60_000)
504 assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" })527 assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" })
505 AgentNotifications.expire(context, expiresAt + 2 * 60 * 60 * 1000L)528 AgentNotifications.expire(context, expiresAt + 2 * 60 * 60 * 1000L)
506 assertTrue(manager.activeNotifications.all { it.tag == "t3-agent-alert" })529 assertEquals(2, manager.activeNotifications.count { it.tag == "t3-agent-alert" })
530 assertTrue(manager.activeNotifications.none { it.tag == "t3-agent-activity" })
507 assertTrue(alarms.scheduledAlarms.isEmpty())531 assertTrue(alarms.scheduledAlarms.isEmpty())
508 }532 }
509533
infra/relay/src/agentActivity/ApnsClient.test.ts+1
...@@ -187,6 +187,7 @@ describe("ApnsClient", () => {...@@ -187,6 +187,7 @@ describe("ApnsClient", () => {
187 body: "Input: Project",187 body: "Input: Project",
188 },188 },
189 sound: "default",189 sound: "default",
190 "thread-id": "env/thread",
190 },191 },
191 environmentId: "env",192 environmentId: "env",
192 threadId: "thread",193 threadId: "thread",
infra/relay/src/agentActivity/ApnsClient.ts+9
...@@ -168,6 +168,12 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA...@@ -168,6 +168,12 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA
168 };168 };
169}169}
170170
171function notificationThreadId(notification: ApnsNotificationPayload): string {
172 return notification.threadId.length > 0
173 ? `${notification.environmentId}/${notification.threadId}`
174 : "t3-agent-alerts";
175}
176
171function makePushNotificationRequest(input: {177function makePushNotificationRequest(input: {
172 readonly token: string;178 readonly token: string;
173 readonly notification: ApnsNotificationPayload;179 readonly notification: ApnsNotificationPayload;
...@@ -182,6 +188,9 @@ function makePushNotificationRequest(input: {...@@ -182,6 +188,9 @@ function makePushNotificationRequest(input: {
182 body: input.notification.body,188 body: input.notification.body,
183 },189 },
184 sound: "default",190 sound: "default",
191 // Notification Center stacks alerts by thread so a chatty thread does
192 // not bury the others; a grouped alert for several threads stays alone.
193 "thread-id": notificationThreadId(input.notification),
185 },194 },
186 environmentId: input.notification.environmentId,195 environmentId: input.notification.environmentId,
187 threadId: input.notification.threadId,196 threadId: input.notification.threadId,
infra/relay/src/agentActivity/FcmDeliveries.test.ts+25
...@@ -454,9 +454,34 @@ describe("Android delivery routing", () => {...@@ -454,9 +454,34 @@ describe("Android delivery routing", () => {
454 ).toMatchObject({454 ).toMatchObject({
455 alert_title: `${"T".repeat(117)}...`,455 alert_title: `${"T".repeat(117)}...`,
456 alert_body: `Done: ${"P".repeat(111)}...`,456 alert_body: `Done: ${"P".repeat(111)}...`,
457 alert_group: JSON.stringify([state.environmentId, state.threadId]),
457 });458 });
458 });459 });
459460
461 it("keeps notification groups distinct when identifiers contain slashes", () => {
462 const left = androidAlertForState(
463 {
464 ...state,
465 environmentId: EnvironmentId.make("a/b"),
466 threadId: ThreadId.make("c"),
467 phase: "completed",
468 },
469 preferences,
470 0,
471 );
472 const right = androidAlertForState(
473 {
474 ...state,
475 environmentId: EnvironmentId.make("a"),
476 threadId: ThreadId.make("b/c"),
477 phase: "completed",
478 },
479 preferences,
480 0,
481 );
482 expect(left?.alert_group).not.toBe(right?.alert_group);
483 });
484
460 it.effect(485 it.effect(
461 "queues Android devices and sends the latest aggregate instead of a stale running state",486 "queues Android devices and sends the latest aggregate instead of a stale running state",
462 () => {487 () => {
infra/relay/src/agentActivity/FcmDeliveries.ts+4-2
...@@ -67,6 +67,7 @@ export function androidAlertForState(...@@ -67,6 +67,7 @@ export function androidAlertForState(
67 const notification = notificationForActivity({ ...state, status: statusForPhase(state.phase) });67 const notification = notificationForActivity({ ...state, status: statusForPhase(state.phase) });
68 return {68 return {
69 alert_id: JSON.stringify([state.environmentId, state.threadId, state.phase, state.updatedAt]),69 alert_id: JSON.stringify([state.environmentId, state.threadId, state.phase, state.updatedAt]),
70 alert_group: JSON.stringify([state.environmentId, state.threadId]),
70 alert_title: notification.title,71 alert_title: notification.title,
71 alert_body: notification.body,72 alert_body: notification.body,
72 alert_path: notification.deepLink,73 alert_path: notification.deepLink,
...@@ -92,6 +93,7 @@ export function androidAlertForAggregate(input: {...@@ -92,6 +93,7 @@ export function androidAlertForAggregate(input: {
92 const notification = notificationForActivity(first);93 const notification = notificationForActivity(first);
93 return {94 return {
94 alert_id: JSON.stringify([first.environmentId, first.threadId, first.phase, first.updatedAt]),95 alert_id: JSON.stringify([first.environmentId, first.threadId, first.phase, first.updatedAt]),
96 alert_group: JSON.stringify([first.environmentId, first.threadId]),
95 alert_title: notification.title,97 alert_title: notification.title,
96 alert_body: notification.body,98 alert_body: notification.body,
97 alert_path: notification.deepLink,99 alert_path: notification.deepLink,
...@@ -210,7 +212,7 @@ export const make = Effect.gen(function* () {...@@ -210,7 +212,7 @@ export const make = Effect.gen(function* () {
210 const previousAggregate = target.last_aggregate_json212 const previousAggregate = target.last_aggregate_json
211 ? Option.getOrNull(decodePreviousActivity(target.last_aggregate_json))213 ? Option.getOrNull(decodePreviousActivity(target.last_aggregate_json))
212 : null;214 : null;
213 let alert: ReturnType<typeof androidAlertForState> = null;215 let alert: ReturnType<typeof androidAlertForState | typeof androidAlertForAggregate> = null;
214 // Deletion jobs can observe another thread's newly completed state. They216 // Deletion jobs can observe another thread's newly completed state. They
215 // update the card, but must leave that transition for its own alert job.217 // update the card, but must leave that transition for its own alert job.
216 // Registration replay deliberately establishes a silent baseline.218 // Registration replay deliberately establishes a silent baseline.
...@@ -291,7 +293,7 @@ export const make = Effect.gen(function* () {...@@ -291,7 +293,7 @@ export const make = Effect.gen(function* () {
291 // A registration replay must clear an orphan even when the relay has293 // A registration replay must clear an orphan even when the relay has
292 // already forgotten its baseline. Finished cards are visible, but idle.294 // already forgotten its baseline. Finished cards are visible, but idle.
293 if (!displayedAggregate && !alert && !previousAggregate && job.state !== null) return;295 if (!displayedAggregate && !alert && !previousAggregate && job.state !== null) return;
294 const data = {296 const data: Record<string, string> = {
295 t3_kind: "agent_activity",297 t3_kind: "agent_activity",
296 device_id: job.deviceId,298 device_id: job.deviceId,
297 user_id: job.userId,299 user_id: job.userId,