hermes-agent/apps/desktop/src/store/native-notifications.test.ts
Brooklyn Nicholson e9bb4c3951 fix(desktop): don't alert for prompts a reconnect replayed
A socket opening replays state that already existed — a session parked on
an approval re-emits its request so the UI can draw the prompt. Those
arrive as ordinary events, so launching Hermes, switching profiles, or
riding out a reconnect fired an OS notification for a prompt the user had
known about for an hour.

Hold native notifications for a beat after any gateway opens. The sidebar
row and the inline approval bar still appear immediately; only the OS
notification waits for something that actually just happened.
2026-07-27 15:47:45 -05:00

236 lines
8.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $gateway } from './gateway'
import {
dispatchNativeNotification,
NATIVE_NOTIFICATION_KINDS,
respondToApprovalAction,
sendTestNativeNotification,
setNativeNotifyEnabled,
setNativeNotifyKind
} from './native-notifications'
import { __resetNativeNotifyBaselineForTests, markNativeNotifyBaseline } from './notify-baseline'
import { $approvalRequest, setApprovalRequest } from './prompts'
import { $activeSessionId, setActiveSessionId } from './session'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
const notify = vi.fn().mockResolvedValue(true)
function setWindowState({ focused = true, hidden = false }: { focused?: boolean; hidden?: boolean }) {
Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
Object.defineProperty(document, 'hasFocus', { configurable: true, value: () => focused })
}
let counter = 0
// Unique session id per call dodges the per-(kind,session) throttle so each
// assertion starts clean.
function freshSession(): string {
counter += 1
return `session-${counter}`
}
beforeEach(() => {
notify.mockClear()
desktopWindow.hermesDesktop = { notify } as unknown as Window['hermesDesktop']
setNativeNotifyEnabled(true)
for (const kind of NATIVE_NOTIFICATION_KINDS) {
setNativeNotifyKind(kind, true)
}
setActiveSessionId(null)
setWindowState({ focused: false, hidden: true })
__resetNativeNotifyBaselineForTests()
})
afterEach(() => {
if (initialHermesDesktop) {
desktopWindow.hermesDesktop = initialHermesDesktop
} else {
delete desktopWindow.hermesDesktop
}
})
describe('dispatchNativeNotification focus gating', () => {
it('fires a completion notification for the active session when the window is hidden', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
expect(notify).toHaveBeenCalledTimes(1)
})
it('fires a completion notification when the window is visible but unfocused (alt-tab)', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
setWindowState({ focused: false, hidden: false })
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
expect(notify).toHaveBeenCalledTimes(1)
})
it('suppresses a completion notification when the window is focused', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
setWindowState({ focused: true, hidden: false })
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
expect(notify).not.toHaveBeenCalled()
})
it('suppresses a completion notification for a non-active background session (no gateway spam)', () => {
setActiveSessionId('on-screen')
dispatchNativeNotification({ kind: 'turnDone', sessionId: 'busy-bot-session', title: 'done' })
expect(notify).not.toHaveBeenCalled()
})
it('fires an attention notification for an off-screen session even when focused', () => {
setWindowState({ focused: true, hidden: false })
setActiveSessionId('on-screen')
dispatchNativeNotification({ kind: 'approval', sessionId: 'background', title: 'approve' })
expect(notify).toHaveBeenCalledTimes(1)
})
it('suppresses an attention notification for the active session when focused', () => {
setWindowState({ focused: true, hidden: false })
setActiveSessionId('on-screen')
dispatchNativeNotification({ kind: 'approval', sessionId: 'on-screen', title: 'approve' })
expect(notify).not.toHaveBeenCalled()
})
it('fires a global completion notification while away with no active session (pet gen)', () => {
setActiveSessionId(null)
dispatchNativeNotification({ global: true, kind: 'backgroundDone', title: 'Your pet hatched' })
expect(notify).toHaveBeenCalledTimes(1)
})
it('suppresses a global notification when the window is focused', () => {
setWindowState({ focused: true, hidden: false })
setActiveSessionId(null)
dispatchNativeNotification({ global: true, kind: 'backgroundDone', title: 'Your pet hatched' })
expect(notify).not.toHaveBeenCalled()
})
})
describe('dispatchNativeNotification preferences', () => {
it('suppresses everything when the master switch is off', () => {
setNativeNotifyEnabled(false)
dispatchNativeNotification({ kind: 'approval', sessionId: freshSession(), title: 'approve' })
dispatchNativeNotification({ kind: 'turnDone', sessionId: freshSession(), title: 'done' })
expect(notify).not.toHaveBeenCalled()
})
it('suppresses only the disabled kind', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
setNativeNotifyKind('turnDone', false)
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
expect(notify).not.toHaveBeenCalled()
dispatchNativeNotification({ kind: 'turnError', sessionId, title: 'boom' })
expect(notify).toHaveBeenCalledTimes(1)
})
it('forwards kind and sessionId to the bridge', () => {
setActiveSessionId('abc')
dispatchNativeNotification({ body: 'hi', kind: 'turnError', sessionId: 'abc', title: 'boom' })
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ body: 'hi', kind: 'turnError', sessionId: 'abc', title: 'boom' })
)
})
})
describe('dispatchNativeNotification post-connect baseline', () => {
it('suppresses a prompt replayed right after a socket opens', () => {
markNativeNotifyBaseline()
dispatchNativeNotification({ kind: 'approval', sessionId: freshSession(), title: 'approve' })
expect(notify).not.toHaveBeenCalled()
})
it('suppresses a completion replayed right after a socket opens', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
markNativeNotifyBaseline()
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
expect(notify).not.toHaveBeenCalled()
})
it('fires again once the window has passed', () => {
vi.useFakeTimers()
try {
markNativeNotifyBaseline()
vi.advanceTimersByTime(5000)
dispatchNativeNotification({ kind: 'approval', sessionId: freshSession(), title: 'approve' })
expect(notify).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
})
describe('dispatchNativeNotification throttle', () => {
it('collapses duplicate kind+session within the throttle window', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done again' })
expect(notify).toHaveBeenCalledTimes(1)
})
})
describe('sendTestNativeNotification', () => {
it('fires regardless of focus or active session', () => {
setWindowState({ focused: true, hidden: false })
setActiveSessionId('on-screen')
sendTestNativeNotification('Hermes', 'works')
expect(notify).toHaveBeenCalledTimes(1)
})
})
describe('$activeSessionId wiring', () => {
it('reflects the setter used for gating', () => {
setActiveSessionId('xyz')
expect($activeSessionId.get()).toBe('xyz')
})
})
describe('respondToApprovalAction', () => {
const request = vi.fn().mockResolvedValue({ resolved: true })
beforeEach(() => {
request.mockClear()
$gateway.set({ request } as unknown as ReturnType<typeof $gateway.get>)
})
afterEach(() => {
$gateway.set(null)
})
it('approves via approval.respond {choice: "once"} and clears the prompt', async () => {
setActiveSessionId('bg')
setApprovalRequest({ command: 'rm -rf /', description: 'dangerous', sessionId: 'bg' })
await respondToApprovalAction('bg', 'approve')
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'once', session_id: 'bg' })
expect($approvalRequest.get()).toBeNull()
})
it('rejects via approval.respond {choice: "deny"}', async () => {
await respondToApprovalAction('bg', 'reject')
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'bg' })
})
it('ignores unknown action ids', async () => {
await respondToApprovalAction('bg', 'snooze')
expect(request).not.toHaveBeenCalled()
})
it('no-ops without a gateway', async () => {
$gateway.set(null)
await respondToApprovalAction('bg', 'approve')
expect(request).not.toHaveBeenCalled()
})
})