diff --git a/cloud/apps/push/src/fcm-client.test.ts b/cloud/apps/push/src/fcm-client.test.ts index 4a8d1fb41f7..8843e7aab95 100644 --- a/cloud/apps/push/src/fcm-client.test.ts +++ b/cloud/apps/push/src/fcm-client.test.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { describe, expect, it } from 'vitest' -import { fcmCollapseKey, FcmClient, type FcmRequest, type FcmResponse } from './fcm-client.js' +import { FcmClient, type FcmRequest, type FcmResponse } from './fcm-client.js' import { buildPushDelivery } from './push-delivery-message.js' const NOW = 1_700_000_000_000 @@ -20,6 +20,7 @@ function delivery(agentState: 'needs-input' | null = 'needs-input') { agentState, title: 'Agent needs input', body: 'Waiting on your answer', + paneKey: 'tab-b:pane-1', worktreeId: 'wt-1' } }) @@ -59,27 +60,14 @@ describe('fcm client', () => { expect(JSON.parse(request.body)).toEqual({ message: { token: TOKEN, - notification: { title: 'Agent needs input', body: 'Waiting on your answer' }, - android: { - priority: 'HIGH', - ttl: '300s', - collapse_key: createHash('sha256') - .update( - createHash('sha256') - .update(JSON.stringify([HOST, 'note-1'])) - .digest('hex') - ) - .digest('hex') - .slice(0, 32), - notification: { - channel_id: 'orca-desktop', - tag: createHash('sha256') - .update(JSON.stringify([HOST, 'note-1'])) - .digest('hex') - } - }, + android: { priority: 'HIGH', ttl: '300s' }, data: { + title: 'Agent needs input', + message: 'Waiting on your answer', + tag: delivery().collapseId, + channelId: 'orca-desktop', hostFingerprint: HOST, + paneKey: 'tab-b:pane-1', worktreeId: 'wt-1', notificationId: 'note-1', notificationSeq: '7', @@ -96,7 +84,7 @@ describe('fcm client', () => { await fcm.send(delivery(null), { token: TOKEN }) const message = JSON.parse(fake.requests[0]!.body) as { message: { - android: { collapse_key: string; notification: { tag: string } } + android: Record data: Record } } @@ -108,9 +96,10 @@ describe('fcm client', () => { .update(JSON.stringify([HOST, 'note-1'])) .digest('hex') expect(message.message.data.coalescedCount).toBeUndefined() - expect(message.message.android.notification.tag).toBe(tag) - expect(message.message.android.collapse_key).toBe(fcmCollapseKey(tag)) - expect(message.message.android.collapse_key).toHaveLength(32) + expect(message.message.data.tag).toBe(tag) + expect(message.message.android).not.toHaveProperty('collapse_key') + expect(message.message).not.toHaveProperty('notification') + expect(message.message.data).not.toHaveProperty('body') }) it('marks an unregistered token dead from the status or the error detail', async () => { diff --git a/cloud/apps/push/src/fcm-client.ts b/cloud/apps/push/src/fcm-client.ts index c22bd3309cd..0b58aae80f5 100644 --- a/cloud/apps/push/src/fcm-client.ts +++ b/cloud/apps/push/src/fcm-client.ts @@ -1,5 +1,4 @@ import { providerRetryAfter } from './provider-retry-delay.js' -import { createHash } from 'node:crypto' import { PUSH_DEFAULTS } from '@orca-cloud/push-contract' import { orcaDataStrings, type PushDelivery } from './push-delivery-message.js' import type { PushProviderOutcome } from './push-provider-outcome.js' @@ -22,12 +21,6 @@ type FcmErrorBody = { error?: { status?: unknown; message?: unknown; details?: { errorCode?: unknown }[] } } -// FCM collapse_key is a short opaque string, so the collapse id is hashed -// rather than truncated: truncation would merge unrelated notifications. -export function fcmCollapseKey(collapseId: string): string { - return createHash('sha256').update(collapseId).digest('hex').slice(0, 32) -} - export function fcmMessageBody(input: { delivery: PushDelivery token: string @@ -39,24 +32,23 @@ export function fcmMessageBody(input: { return JSON.stringify({ message: { token: input.token, - ...(delivery.orca.kind === 'dismiss' - ? {} - : { notification: { title: delivery.title, body: delivery.body } }), android: { priority: 'HIGH', - ttl: `${Math.max(0, Math.ceil((delivery.expiresAt - now) / 1000))}s`, - collapse_key: fcmCollapseKey(delivery.collapseId), + ttl: `${Math.max(0, Math.ceil((delivery.expiresAt - now) / 1000))}s` + }, + // Notification payloads collapse offline; Expo renders these data messages natively. + data: { + ...orcaDataStrings(delivery.orca), ...(delivery.orca.kind === 'dismiss' ? {} : { - notification: { - channel_id: - delivery.sound === false ? `${input.channelId}-silent` : input.channelId, - tag: delivery.collapseId - } + title: delivery.title, + message: delivery.body, + tag: delivery.collapseId, + channelId: delivery.sound === false ? `${input.channelId}-silent` : input.channelId, + ...(delivery.sound === false ? { sound: '' } : {}) }) - }, - data: orcaDataStrings(delivery.orca) + } } }) } diff --git a/cloud/apps/push/src/push-delivery-message.ts b/cloud/apps/push/src/push-delivery-message.ts index bc2c1a5d9b2..3bd2dae6e7c 100644 --- a/cloud/apps/push/src/push-delivery-message.ts +++ b/cloud/apps/push/src/push-delivery-message.ts @@ -5,6 +5,7 @@ export type PushOrcaData = { kind?: 'alert' | 'dismiss' hostFingerprint: string worktreeId?: string + paneKey?: string notificationId?: string notificationSeq: number notificationEpoch: string @@ -51,6 +52,7 @@ export function buildPushDelivery(input: { orca: { ...(notification.kind ? { kind: notification.kind } : {}), hostFingerprint, + ...(notification.paneKey === undefined ? {} : { paneKey: notification.paneKey }), ...(notification.worktreeId === undefined ? {} : { worktreeId: notification.worktreeId }), ...(notification.notificationId === undefined ? {} diff --git a/cloud/apps/push/src/push-dismissal-provider.test.ts b/cloud/apps/push/src/push-dismissal-provider.test.ts index 15362105777..65572e8401c 100644 --- a/cloud/apps/push/src/push-dismissal-provider.test.ts +++ b/cloud/apps/push/src/push-dismissal-provider.test.ts @@ -23,5 +23,9 @@ it('dismissal provider payloads cannot display a new alert or play a sound', () const android = JSON.parse(fcmMessageBody({ delivery, token: 'test', channelId: 'test' })).message expect(android).not.toHaveProperty('notification') expect(android.android).not.toHaveProperty('notification') + expect(android.android).not.toHaveProperty('collapse_key') + expect(android.data).not.toHaveProperty('title') + expect(android.data).not.toHaveProperty('message') + expect(android.data).not.toHaveProperty('sound') expect(android.data.kind).toBe('dismiss') }) diff --git a/cloud/apps/push/src/push-notification-sound.test.ts b/cloud/apps/push/src/push-notification-sound.test.ts index 4dd1b85504f..ede4dcd3291 100644 --- a/cloud/apps/push/src/push-notification-sound.test.ts +++ b/cloud/apps/push/src/push-notification-sound.test.ts @@ -23,7 +23,11 @@ it('carries a silent preference through validation to APNs and Android payloads' expect(JSON.parse(apnsBody(delivery)).aps).not.toHaveProperty('sound') expect( JSON.parse(fcmMessageBody({ delivery, token: 'test-token', channelId: 'orca-desktop' })).message - .android.notification.channel_id + .data.channelId ).toBe('orca-desktop-silent') + expect( + JSON.parse(fcmMessageBody({ delivery, token: 'test-token', channelId: 'orca-desktop' })).message + .data.sound + ).toBe('') expect(JSON.parse(apnsBody({ ...delivery, sound: undefined })).aps.sound).toBe('default') }) diff --git a/cloud/apps/push/src/push-pane-routing.test.ts b/cloud/apps/push/src/push-pane-routing.test.ts new file mode 100644 index 00000000000..66ce0b3a38c --- /dev/null +++ b/cloud/apps/push/src/push-pane-routing.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from 'vitest' +import { PushNotificationSchema } from '@orca-cloud/push-contract' +import { buildPushDelivery, orcaDataStrings } from './push-delivery-message.js' + +it('preserves pane identity for both APNs and FCM, and accepts older workspace-only messages', () => { + const base = { + notificationSeq: 1, + notificationEpoch: 'epoch', + source: 'agent-task-complete', + agentState: 'finished', + title: 'Done', + body: '', + worktreeId: 'folder:/work' + } + const paneKey = 'tab-b:11111111-1111-4111-8111-111111111111' + for (const extra of [{}, { paneKey }]) { + const notification = PushNotificationSchema.parse({ ...base, ...extra }) + const delivery = buildPushDelivery({ + notification, + hostFingerprint: 'host', + registrationId: 'phone', + expiresAt: Date.now() + 300000 + }) + expect(delivery.orca.paneKey).toBe('paneKey' in extra ? paneKey : undefined) + expect(orcaDataStrings(delivery.orca).paneKey).toBe('paneKey' in extra ? paneKey : undefined) + } +}) diff --git a/cloud/apps/push/src/push-server-send.test.ts b/cloud/apps/push/src/push-server-send.test.ts index 2a93020ed1f..4b14b77eaec 100644 --- a/cloud/apps/push/src/push-server-send.test.ts +++ b/cloud/apps/push/src/push-server-send.test.ts @@ -56,7 +56,7 @@ describe('push gateway send route', () => { await harness.flushDeliveries() expect(harness.fcmRequests).toHaveLength(1) expect(JSON.parse(harness.fcmRequests[0]!.body)).toMatchObject({ - message: { token: FCM_TOKEN, notification: { title: 'Agent needs input' } } + message: { token: FCM_TOKEN, data: { title: 'Agent needs input' } } }) const afterDeath = await harness.post( @@ -179,7 +179,7 @@ describe('push gateway send route', () => { const message = JSON.parse(harness.fcmRequests[0]!.body) as { message: { android: { notification: { tag: string } }; data: Record } } - expect(message.message.android.notification.tag).toMatch(/^[a-f0-9]{64}$/) + expect(message.message.data.tag).toMatch(/^[a-f0-9]{64}$/) expect(message.message.data.coalescedCount).toBeUndefined() }) diff --git a/cloud/packages/push-contract/src/send-messages.ts b/cloud/packages/push-contract/src/send-messages.ts index 57d1c2e2745..a8959c18b05 100644 --- a/cloud/packages/push-contract/src/send-messages.ts +++ b/cloud/packages/push-contract/src/send-messages.ts @@ -26,7 +26,8 @@ export const PushNotificationSchema = z agentState: PushAgentStateSchema.nullable(), title: z.string().min(1).max(PUSH_LIMITS.titleMaxChars), body: z.string().max(PUSH_LIMITS.bodyMaxChars), - worktreeId: z.string().min(1).max(2048).optional() + worktreeId: z.string().min(1).max(2048).optional(), + paneKey: z.string().min(1).max(2048).optional() }) .strict() .refine(