fix(notifications): stop clearing phone alerts that were never sent (#37095)

Reading an agent on the desktop rebuilt notification ids for turns that
never alerted (every chat pane, silenced workers) and forwarded each one
to the phone as a silent dismiss push plus a dismissal-store record.
Only forward a clear when the store holds a live delivery for that id.
This commit is contained in:
Kelvin Amoaba
2026-11-01 18:29:17 +00:00
committed by GitHub
parent 40fea68072
commit 5b341ff163
3 changed files with 48 additions and 6 deletions
@@ -141,6 +141,36 @@ describe('notifications:dismiss by acknowledged subject', () => {
expect(dismissMobileNotification).toHaveBeenCalledWith('agent:phone-only')
})
// Reads rebuild ids for turns that never alerted; each forwarded one was a silent push and a store slot.
it('clears on the phone only alerts that reached it, including ones sent before a restart', async () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-clear-delivered-'))
const original = new RuntimeMobileNotificationController()
const restarted = new RuntimeMobileNotificationController()
try {
original.configureDismissalStore(directory)
original.dispatch({
type: 'notification',
source: 'agent-task-complete',
title: 'Done',
body: '',
notificationId: 'agent:sent'
})
await original.closeDismissals()
restarted.configureDismissalStore(directory)
const events: MobileNotificationEvent[] = []
restarted.onDispatched((event) => events.push(event))
register({ suppressWhenFocused: false }, restarted)
getDismissHandler()({}, ['agent:never-sent', 'agent:sent'], [PANE])
getDismissHandler()({}, ['agent:sent'], [PANE])
expect(events.map((event) => [event.type, event.notificationId])).toEqual([
['dismiss', 'agent:sent']
])
} finally {
await restarted.closeDismissals()
rmSync(directory, { recursive: true, force: true })
}
})
it('records nothing for a request neither the desktop nor the phone announced', async () => {
const { dismissMobileNotification } = register({ suppressWhenFocused: false })
const dispatch = getDispatchHandler()
@@ -121,15 +121,19 @@ export class MobileNotificationDismissalStore {
return this.flush()
}
/** Unreadable history cannot rule a delivery out. */
hasLiveDelivery(notificationId: string): boolean {
const now = Date.now()
return (
this.unreadable ||
this.entries.some((entry) => entry.notificationId === notificationId && isLive(entry, now))
)
}
liveDeliveries(prefix = ''): DeliveredNotificationRecord[] {
const now = Date.now()
return this.entries
.filter(
(entry) =>
entry.expiresAt > now &&
entry.notificationId.startsWith(prefix) &&
entry.dismissedThrough < entry.notificationSeq
)
.filter((entry) => entry.notificationId.startsWith(prefix) && isLive(entry, now))
.map(({ notificationId, notificationEpoch, notificationSeq, structuredOrigin }) => ({
notificationId,
notificationEpoch,
@@ -153,6 +157,10 @@ export class MobileNotificationDismissalStore {
}
}
function isLive(entry: RecordEntry, now: number): boolean {
return entry.expiresAt > now && entry.dismissedThrough < entry.notificationSeq
}
/** Keep records with unknown future origin kinds, degrading only their origin to none. */
function readEntry(value: unknown): RecordEntry[] {
if (!isEntry(value)) {
@@ -188,6 +188,10 @@ export class RuntimeMobileNotificationController {
}
dismiss(notificationId: string): void {
// Reads rebuild ids for alerts that never went out (chat panes, silenced workers); each would cost a silent push.
if (this.dismissalStore && !this.dismissalStore.hasLiveDelivery(notificationId)) {
return
}
this.dispatch({ type: 'dismiss', notificationId })
}