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 {
8484 cancelActivity(context)
8585 context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit().clear().apply()
8686 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 }
8891 .forEach { manager.cancel(it.tag, it.id) }
8992 }
9093
......@@ -157,13 +160,35 @@ object AgentNotifications {
157160 // Grouped alerts list up to five 120-character thread titles.
158161 val body = data["alert_body"].orEmpty().take(608)
159162 val id = alertId.hashCode()
163 val group = data["alert_group"]?.takeIf { it.isNotBlank() } ?: ALERT_TAG
160164 val notification = base(context, ALERT_CHANNEL)
161165 .setContentTitle(title).setContentText(body)
162166 .setStyle(NotificationCompat.BigTextStyle().bigText(body))
163167 .setAutoCancel(true)
168 .setGroup(group)
164169 .setContentIntent(contentIntent(context, scheme, data["alert_path"], id))
165170 .build()
166171 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 }
167192 }
168193
169194 /**
apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt+27-3
......@@ -60,6 +60,7 @@ class AgentNotificationsTest {
6060 "activity_body" to "Test thread · Working",
6161 "activity_path" to "/threads/environment/thread",
6262 "alert_id" to alertId,
63 "alert_group" to "environment/thread",
6364 "alert_title" to "Test thread",
6465 "alert_body" to "Done: Test project",
6566 "alert_path" to "/threads/environment/thread",
......@@ -76,6 +77,12 @@ class AgentNotificationsTest {
7677 assertEquals("alert-0".hashCode(), manager.activeNotifications.single().id)
7778 }
7879
80 @Test
81 fun alertsStackByThreadGroup() {
82 AgentNotifications.receive(context, update("grouped", false))
83 assertEquals("environment/thread", manager.activeNotifications.single().notification.group)
84 }
85
7986 @Test
8087 fun missingLauncherDoesNotDiscardTheAlert() {
8188 shadowOf(context.packageManager).removeActivity(ComponentName(context, Activity::class.java))
......@@ -142,6 +149,22 @@ class AgentNotificationsTest {
142149 assertEquals("background-completion".hashCode(), manager.activeNotifications.single().id)
143150 }
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
145168 @Test
146169 fun groupedAlertDisplaysEveryThreadAndRetriesStaySilent() {
147170 val titles = (1..5).map { "Thread $it " + "x".repeat(111) }.joinToString(", ")
......@@ -286,11 +309,11 @@ class AgentNotificationsTest {
286309 context,
287310 update("older-alert", false) + ("updated_at" to (now - 1000).toString())
288311 )
289 assertEquals(3, manager.activeNotifications.size)
312 assertEquals(4, manager.activeNotifications.size)
290313 assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" })
291314 shadowOf(manager).setNotificationsEnabled(false)
292315 AgentNotifications.receive(context, update("revoked-permission", true))
293 assertEquals(3, manager.activeNotifications.size)
316 assertEquals(4, manager.activeNotifications.size)
294317 }
295318
296319 @Test
......@@ -503,7 +526,8 @@ class AgentNotificationsTest {
503526 AgentNotifications.expire(context, expiresAt + 60_000)
504527 assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" })
505528 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" })
507531 assertTrue(alarms.scheduledAlarms.isEmpty())
508532 }
509533
infra/relay/src/agentActivity/ApnsClient.test.ts+1
......@@ -187,6 +187,7 @@ describe("ApnsClient", () => {
187187 body: "Input: Project",
188188 },
189189 sound: "default",
190 "thread-id": "env/thread",
190191 },
191192 environmentId: "env",
192193 threadId: "thread",
infra/relay/src/agentActivity/ApnsClient.ts+9
......@@ -168,6 +168,12 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA
168168 };
169169}
170170
171function notificationThreadId(notification: ApnsNotificationPayload): string {
172 return notification.threadId.length > 0
173 ? `${notification.environmentId}/${notification.threadId}`
174 : "t3-agent-alerts";
175}
176
171177function makePushNotificationRequest(input: {
172178 readonly token: string;
173179 readonly notification: ApnsNotificationPayload;
......@@ -182,6 +188,9 @@ function makePushNotificationRequest(input: {
182188 body: input.notification.body,
183189 },
184190 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),
185194 },
186195 environmentId: input.notification.environmentId,
187196 threadId: input.notification.threadId,
infra/relay/src/agentActivity/FcmDeliveries.test.ts+25
......@@ -454,9 +454,34 @@ describe("Android delivery routing", () => {
454454 ).toMatchObject({
455455 alert_title: `${"T".repeat(117)}...`,
456456 alert_body: `Done: ${"P".repeat(111)}...`,
457 alert_group: JSON.stringify([state.environmentId, state.threadId]),
457458 });
458459 });
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
460485 it.effect(
461486 "queues Android devices and sends the latest aggregate instead of a stale running state",
462487 () => {
infra/relay/src/agentActivity/FcmDeliveries.ts+4-2
......@@ -67,6 +67,7 @@ export function androidAlertForState(
6767 const notification = notificationForActivity({ ...state, status: statusForPhase(state.phase) });
6868 return {
6969 alert_id: JSON.stringify([state.environmentId, state.threadId, state.phase, state.updatedAt]),
70 alert_group: JSON.stringify([state.environmentId, state.threadId]),
7071 alert_title: notification.title,
7172 alert_body: notification.body,
7273 alert_path: notification.deepLink,
......@@ -92,6 +93,7 @@ export function androidAlertForAggregate(input: {
9293 const notification = notificationForActivity(first);
9394 return {
9495 alert_id: JSON.stringify([first.environmentId, first.threadId, first.phase, first.updatedAt]),
96 alert_group: JSON.stringify([first.environmentId, first.threadId]),
9597 alert_title: notification.title,
9698 alert_body: notification.body,
9799 alert_path: notification.deepLink,
......@@ -210,7 +212,7 @@ export const make = Effect.gen(function* () {
210212 const previousAggregate = target.last_aggregate_json
211213 ? Option.getOrNull(decodePreviousActivity(target.last_aggregate_json))
212214 : null;
213 let alert: ReturnType<typeof androidAlertForState> = null;
215 let alert: ReturnType<typeof androidAlertForState | typeof androidAlertForAggregate> = null;
214216 // Deletion jobs can observe another thread's newly completed state. They
215217 // update the card, but must leave that transition for its own alert job.
216218 // Registration replay deliberately establishes a silent baseline.
......@@ -291,7 +293,7 @@ export const make = Effect.gen(function* () {
291293 // A registration replay must clear an orphan even when the relay has
292294 // already forgotten its baseline. Finished cards are visible, but idle.
293295 if (!displayedAggregate && !alert && !previousAggregate && job.state !== null) return;
294 const data = {
296 const data: Record<string, string> = {
295297 t3_kind: "agent_activity",
296298 device_id: job.deviceId,
297299 user_id: job.userId,