mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): render agent credit notices as toasts (#69808)
The desktop renderer had no handler for the `notification.show` / `notification.clear` WS events, so every credit-usage notice the backend sends (`agent/credits_tracker.py` → `tui_gateway/server.py`) was silently dropped. Credit warnings like "• Credits 50% used · $220.00 cap" never appeared, even though the Ink TUI renders them in its status bar. Add the two missing branches to the gateway-event dispatcher, delegating to a small, pure-testable module: - `store/agent-notices.ts` — `noticeToToast()` maps a notice to a toast (level → toast kind, sticky → durationMs 0, ttl → ttl_ms), and uses the notice `key` as the toast id. Re-emitting the same key REPLACES the toast, so the credits 50→75→90 line escalates in place instead of stacking, and a key-matched `notification.clear` maps straight to `dismissNotification(key)`. - The notice `text` already carries its own glyph (• ⚠ ✕ ✓), so no toast icon is added. - Notices are account-wide, so the toast shows regardless of which session is focused. The Ink TUI (`ui-tui/src/app/turnController.ts`) is the reference for the latest-wins / sticky-vs-ttl / key-matched-clear behavior. Export `NotificationInput` so the mapping's return type can be named.
This commit is contained in:
parent
a2172547a8
commit
58e3d41582
4 changed files with 195 additions and 1 deletions
|
|
@ -15,6 +15,7 @@ import { resolveGatewayEventSessionId } from '@/lib/gateway-events'
|
|||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { modelOptionsQueryKey } from '@/lib/model-options'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { type AgentNoticePayload, clearAgentNotice, showAgentNotice } from '@/store/agent-notices'
|
||||
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
|
||||
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
|
||||
import { clearClarifyRequest, normalizeChoices, setClarifyRequest, warnDroppedChoices } from '@/store/clarify'
|
||||
|
|
@ -865,6 +866,19 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
]
|
||||
}))
|
||||
}
|
||||
} else if (event.type === 'notification.show') {
|
||||
// Driver-agnostic agent notice (credits usage/grant/depleted/restored
|
||||
// from `agent/credits_tracker.py`). The Ink TUI renders these in its
|
||||
// status bar; the desktop renders them as toasts. The notice key doubles
|
||||
// as the toast id, so the escalating 50→75→90 credits line replaces in
|
||||
// place instead of stacking. Account-wide signal — shown regardless of
|
||||
// which session is focused.
|
||||
showAgentNotice(event.payload as AgentNoticePayload | undefined)
|
||||
} else if (event.type === 'notification.clear') {
|
||||
// Key-matched dismissal (e.g. credits restored clears the depleted
|
||||
// notice). notify() keys the toast by the notice key, so this maps
|
||||
// straight to dismissNotification(key).
|
||||
clearAgentNotice((event.payload as AgentNoticePayload | undefined)?.key)
|
||||
} else if (event.type === 'error') {
|
||||
const errorMessage = payload?.message || 'Hermes reported an error'
|
||||
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
||||
|
|
|
|||
102
apps/desktop/src/store/agent-notices.test.ts
Normal file
102
apps/desktop/src/store/agent-notices.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { beforeEach, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
type AgentNoticePayload,
|
||||
clearAgentNotice,
|
||||
noticeToToast,
|
||||
showAgentNotice
|
||||
} from './agent-notices'
|
||||
import { $notifications, clearNotifications } from './notifications'
|
||||
|
||||
function usage(overrides: Partial<AgentNoticePayload> = {}): AgentNoticePayload {
|
||||
return {
|
||||
key: 'credits.usage',
|
||||
kind: 'sticky',
|
||||
level: 'info',
|
||||
text: '• Credits 50% used · $220.00 cap',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearNotifications()
|
||||
})
|
||||
|
||||
// ── noticeToToast: the whole mapping contract ────────────────────────────────
|
||||
|
||||
test('drops a notice with no text', () => {
|
||||
expect(noticeToToast(undefined)).toBeNull()
|
||||
expect(noticeToToast({ text: '' })).toBeNull()
|
||||
expect(noticeToToast({ text: ' ' })).toBeNull()
|
||||
})
|
||||
|
||||
test('level maps to toast kind (warn → warning)', () => {
|
||||
expect(noticeToToast(usage({ level: 'info' }))?.kind).toBe('info')
|
||||
expect(noticeToToast(usage({ level: 'warn' }))?.kind).toBe('warning')
|
||||
expect(noticeToToast(usage({ level: 'error' }))?.kind).toBe('error')
|
||||
expect(noticeToToast(usage({ level: 'success' }))?.kind).toBe('success')
|
||||
})
|
||||
|
||||
test('unknown / missing level falls back to info', () => {
|
||||
expect(noticeToToast({ text: 'x', level: 'bogus' })?.kind).toBe('info')
|
||||
expect(noticeToToast({ text: 'x' })?.kind).toBe('info')
|
||||
})
|
||||
|
||||
test('sticky notices never auto-dismiss', () => {
|
||||
expect(noticeToToast(usage({ kind: 'sticky' }))?.durationMs).toBe(0)
|
||||
})
|
||||
|
||||
test('ttl notice carries its ttl_ms as the duration', () => {
|
||||
const toast = noticeToToast({ key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ restored', ttl_ms: 8000 })
|
||||
expect(toast?.durationMs).toBe(8000)
|
||||
})
|
||||
|
||||
test('ttl notice without a usable ttl_ms defers to notify()’s default', () => {
|
||||
expect(noticeToToast({ text: 'x', kind: 'ttl' })?.durationMs).toBeUndefined()
|
||||
expect(noticeToToast({ text: 'x', kind: 'ttl', ttl_ms: 0 })?.durationMs).toBeUndefined()
|
||||
})
|
||||
|
||||
test('the notice key is the toast id, falling back to id', () => {
|
||||
expect(noticeToToast(usage({ key: 'credits.usage' }))?.id).toBe('credits.usage')
|
||||
expect(noticeToToast({ text: 'x', id: 'n1', key: undefined })?.id).toBe('n1')
|
||||
})
|
||||
|
||||
test('the glyph-bearing text passes through verbatim as the message', () => {
|
||||
expect(noticeToToast(usage())?.message).toBe('• Credits 50% used · $220.00 cap')
|
||||
})
|
||||
|
||||
// ── show / clear: rendered through the notifications store ────────────────────
|
||||
|
||||
test('showAgentNotice renders a toast; empty text is a no-op', () => {
|
||||
showAgentNotice(usage())
|
||||
expect($notifications.get()).toHaveLength(1)
|
||||
expect($notifications.get()[0]?.id).toBe('credits.usage')
|
||||
|
||||
showAgentNotice({ text: '' })
|
||||
expect($notifications.get()).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('re-emitting the same key replaces the toast instead of stacking (50→75→90)', () => {
|
||||
showAgentNotice(usage({ level: 'info', text: '• Credits 50% used' }))
|
||||
showAgentNotice(usage({ level: 'warn', text: '• Credits 75% used' }))
|
||||
showAgentNotice(usage({ level: 'warn', text: '• Credits 90% used' }))
|
||||
|
||||
const toasts = $notifications.get().filter(item => item.id === 'credits.usage')
|
||||
expect(toasts).toHaveLength(1)
|
||||
expect(toasts[0]?.message).toBe('• Credits 90% used')
|
||||
expect(toasts[0]?.kind).toBe('warning')
|
||||
})
|
||||
|
||||
test('clearAgentNotice dismisses only the matching key', () => {
|
||||
showAgentNotice(usage())
|
||||
showAgentNotice({ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ paused' })
|
||||
expect($notifications.get()).toHaveLength(2)
|
||||
|
||||
clearAgentNotice('credits.usage')
|
||||
const ids = $notifications.get().map(item => item.id)
|
||||
expect(ids).toContain('credits.depleted')
|
||||
expect(ids).not.toContain('credits.usage')
|
||||
|
||||
clearAgentNotice(undefined)
|
||||
expect($notifications.get()).toHaveLength(1)
|
||||
})
|
||||
78
apps/desktop/src/store/agent-notices.ts
Normal file
78
apps/desktop/src/store/agent-notices.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { dismissNotification, type NotificationInput, type NotificationKind, notify } from '@/store/notifications'
|
||||
|
||||
/**
|
||||
* Wire shape of a `notification.show` payload — the driver-agnostic
|
||||
* `AgentNotice` spine (`agent/credits_tracker.py`) as forwarded by
|
||||
* `tui_gateway/server.py`. Snake_case to match the wire; the `text` already
|
||||
* carries its own leading glyph (• ⚠ ✕ ✓) from the Python policy, so a toast
|
||||
* NEVER adds another icon on top.
|
||||
*
|
||||
* - `level` is severity: info | warn | error | success.
|
||||
* - `kind` is lifetime: `sticky` (stays until an explicit clear) or `ttl`
|
||||
* (self-expires after `ttl_ms`).
|
||||
*/
|
||||
export interface AgentNoticePayload {
|
||||
text?: string
|
||||
level?: string
|
||||
kind?: string
|
||||
ttl_ms?: null | number
|
||||
key?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
const LEVEL_TO_TOAST_KIND: Record<string, NotificationKind> = {
|
||||
error: 'error',
|
||||
info: 'info',
|
||||
success: 'success',
|
||||
warn: 'warning'
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an agent notice to a toast input, or `null` when it carries no text.
|
||||
*
|
||||
* Pure and side-effect free so it can be unit-tested directly. The mapping is
|
||||
* the whole contract:
|
||||
* - `level` → toast kind (info/warn/error/success, warn→warning).
|
||||
* - `sticky` → `durationMs: 0` (persists); `ttl` → `durationMs: ttl_ms`.
|
||||
* - the notice `key` doubles as the toast `id`, so re-emitting the same key
|
||||
* REPLACES the prior toast — the credits 50→75→90 line escalates in place
|
||||
* instead of stacking, and a key-matched `notification.clear` can dismiss it.
|
||||
*/
|
||||
export function noticeToToast(payload: AgentNoticePayload | undefined): NotificationInput | null {
|
||||
const text = payload?.text?.trim()
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isTtl = payload?.kind === 'ttl'
|
||||
const ttl = isTtl && typeof payload?.ttl_ms === 'number' && payload.ttl_ms > 0 ? payload.ttl_ms : undefined
|
||||
|
||||
return {
|
||||
// sticky → 0 (never auto-dismiss); ttl with a ttl_ms → that value; a ttl
|
||||
// without a usable ttl_ms falls back to notify()'s per-kind default.
|
||||
durationMs: isTtl ? ttl : 0,
|
||||
id: payload?.key || payload?.id,
|
||||
kind: LEVEL_TO_TOAST_KIND[payload?.level ?? 'info'] ?? 'info',
|
||||
message: text
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a `notification.show` notice as a toast (no-op when it has no text). */
|
||||
export function showAgentNotice(payload: AgentNoticePayload | undefined): void {
|
||||
const toast = noticeToToast(payload)
|
||||
|
||||
if (toast) {
|
||||
notify(toast)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the toast a `notification.clear` targets. The clear only ever names a
|
||||
* `key`, which we used as the toast id, so this is a key-matched dismissal.
|
||||
*/
|
||||
export function clearAgentNotice(key: string | undefined): void {
|
||||
if (key) {
|
||||
dismissNotification(key)
|
||||
}
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ export interface AppNotification {
|
|||
placement?: NotificationPlacement
|
||||
}
|
||||
|
||||
interface NotificationInput {
|
||||
export interface NotificationInput {
|
||||
id?: string
|
||||
kind?: NotificationKind
|
||||
icon?: string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue