mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-13 14:02:16 +00:00
OpenRouter/Nous image gen now runs a quality-first model chain by default: attempt the highest-fidelity OpenAI image model first, then fall back to Gemini 3 Pro Image when it's access-gated/unavailable/times out. An explicit OPENROUTER_IMAGE_MODEL / config model override pins one model with no fallback. Atlas validation rejects malformed model output instead of shipping it: adds a per-state collapse guard (a single sliver/fragment row no longer passes because other rows are healthy), on top of the existing postage-stamp + multi-pose checks. Desktop: pet-gen native notifications are now "global" (not tied to a chat session), so a background generation started from the command center fires an OS notification when the user is away even with no active session. Adds a neutral "This can take up to 5 minutes." banner on step 1, and lets the provider picker auto-size. Tests updated/added for the OpenRouter fallback chain, the collapse guard, and the global notification path.
205 lines
7.3 KiB
TypeScript
205 lines
7.3 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 { $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 })
|
|
})
|
|
|
|
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 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()
|
|
})
|
|
})
|