mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(push): preserve distinct Android alerts while offline (#20066)
This commit is contained in:
@@ -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<string, unknown>
|
||||
data: Record<string, string>
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? {}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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<string, string> }
|
||||
}
|
||||
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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user