hermes-agent/ui-tui/src/__tests__/createGatewayEventHandler.test.ts
Austin Pickett e2145a5c9c
fix(ui-tui): stabilize embedded dashboard chat gateway (#44528)
Cherry-picked from #39840 by @flyinhigh and rebased cleanly on main.

- Defer config fetch in createGatewayEventHandler until gateway.ready to
  avoid render-phase RPC that can mutate transcript state and trigger
  React error 301 in embedded dashboard PTYs.
- Use undici WebSocket fallback when globalThis.WebSocket is unavailable
  (Node attach mode and sidecar mirror sockets).
- Add regression tests for both fixes.

Co-authored-by: flyinhigh <flyinhigh@users.noreply.github.com>
2026-06-11 19:47:53 -04:00

1541 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js'
import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { turnController } from '../app/turnController.js'
import { getTurnState, resetTurnState } from '../app/turnStore.js'
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
import { estimateTokensRough } from '../lib/text.js'
import type { Msg } from '../types.js'
const ref = <T>(current: T) => ({ current })
const buildCtx = (appended: Msg[]) =>
({
composer: {
dequeue: () => undefined,
queueEditRef: ref<null | number>(null),
sendQueued: vi.fn(),
setInput: vi.fn()
},
gateway: {
gw: { request: vi.fn() },
rpc: vi.fn(async () => null)
},
session: {
STARTUP_RESUME_ID: '',
colsRef: ref(80),
newSession: vi.fn(),
resetSession: vi.fn(),
resumeById: vi.fn(),
setCatalog: vi.fn()
},
submission: {
submitRef: { current: vi.fn() }
},
system: {
bellOnComplete: false,
sys: vi.fn()
},
transcript: {
appendMessage: (msg: Msg) => appended.push(msg),
panel: (title: string, sections: any[]) =>
appended.push({ kind: 'panel', panelData: { sections, title }, role: 'system', text: '' }),
setHistoryItems: vi.fn()
},
voice: {
setProcessing: vi.fn(),
setRecording: vi.fn(),
setVoiceEnabled: vi.fn()
}
}) as any
describe('createGatewayEventHandler', () => {
beforeEach(() => {
resetOverlayState()
resetUiState()
resetTurnState()
turnController.fullReset()
patchUiState({ showReasoning: true })
})
it('archives incomplete todos into transcript flow at end of turn so they scroll up', () => {
const appended: Msg[] = []
const todos = [
{ content: 'Gather ingredients', id: 'prep', status: 'completed' },
{ content: 'Boil water', id: 'boil', status: 'in_progress' },
{ content: 'Make sauce', id: 'sauce', status: 'pending' }
]
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.start' } as any)
expect(getTurnState().todos).toEqual(todos)
onEvent({ payload: { text: 'Started a todo list.' }, type: 'message.complete' } as any)
const trail = appended.find(msg => msg.kind === 'trail' && msg.todos?.length)
const finalText = appended.find(msg => msg.role === 'assistant' && msg.text === 'Started a todo list.')
expect(finalText).toBeDefined()
expect(trail).toMatchObject({ kind: 'trail', role: 'system', todos, todoIncomplete: true })
// Todo archive must sit ABOVE the final assistant text so the panel
// doesn't visibly jump across the final answer at end-of-turn.
expect(appended.indexOf(trail!)).toBeLessThan(appended.indexOf(finalText!))
expect(getTurnState().todos).toEqual([])
})
it('archives completed todos into transcript flow at end of turn', () => {
const appended: Msg[] = []
const todos = [{ content: 'Serve tiny latte', id: 'serve', status: 'completed' }]
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.start' } as any)
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
expect(getTurnState().todos).toEqual([])
expect(appended).toContainEqual({
kind: 'trail',
role: 'system',
text: '',
todoCollapsedByDefault: true,
todos
})
})
it('keeps the current todo list visible when the next message starts', () => {
const appended: Msg[] = []
const todos = [{ content: 'Boil water', id: 'boil', status: 'in_progress' }]
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.start' } as any)
expect(getTurnState().todos).toEqual(todos)
onEvent({ payload: {}, type: 'message.start' } as any)
expect(getTurnState().todos).toEqual(todos)
})
it('prints compaction progress status into the transcript', () => {
const appended: Msg[] = []
const ctx = buildCtx(appended)
const onEvent = createGatewayEventHandler(ctx)
onEvent({
payload: { kind: 'compressing', text: 'compressing 968 messages (~123,400 tok)…' },
type: 'status.update'
} as any)
expect(ctx.system.sys).toHaveBeenCalledWith('compressing 968 messages (~123,400 tok)…')
})
it('keeps goal verdict text in transcript but shows a brief idle status (#goal statusbar)', () => {
const appended: Msg[] = []
const ctx = buildCtx(appended)
const onEvent = createGatewayEventHandler(ctx)
const verdict = '✓ Goal achieved: long judge reason goes only in transcript, not merged with cwd label.'
vi.useFakeTimers()
try {
onEvent({
payload: { kind: 'goal', text: verdict },
type: 'status.update'
} as any)
expect(ctx.system.sys).toHaveBeenCalledWith(verdict)
expect(getUiState().status).toBe('✓ goal complete')
vi.advanceTimersByTime(6001)
expect(getUiState().status).toBe('ready')
} finally {
vi.useRealTimers()
}
})
it('maps goal status.update prefixes to short status strings', () => {
const ctx = buildCtx([])
const onEvent = createGatewayEventHandler(ctx)
onEvent({
payload: { kind: 'goal', text: '↻ Continuing toward goal (1/10): reason' },
type: 'status.update'
} as any)
expect(getUiState().status).toBe('↻ goal continuing')
onEvent({
payload: { kind: 'goal', text: '⏸ Goal paused — budget exhausted.' },
type: 'status.update'
} as any)
expect(getUiState().status).toBe('⏸ goal paused')
})
it('surfaces self-improvement review summaries as a persistent system line', () => {
const appended: Msg[] = []
const ctx = buildCtx(appended)
const onEvent = createGatewayEventHandler(ctx)
onEvent({
payload: { text: "💾 Self-improvement review: Skill 'hermes-release' patched" },
type: 'review.summary'
} as any)
expect(ctx.system.sys).toHaveBeenCalledWith(
"💾 Self-improvement review: Skill 'hermes-release' patched"
)
})
it('ignores review.summary events with empty or missing text', () => {
const appended: Msg[] = []
const ctx = buildCtx(appended)
const onEvent = createGatewayEventHandler(ctx)
onEvent({ payload: { text: '' }, type: 'review.summary' } as any)
onEvent({ payload: { text: ' ' }, type: 'review.summary' } as any)
onEvent({ payload: undefined, type: 'review.summary' } as any)
expect(ctx.system.sys).not.toHaveBeenCalled()
})
it('clears the visible todo list when the todo tool returns an empty list', () => {
const appended: Msg[] = []
const todos = [{ content: 'Boil water', id: 'boil', status: 'in_progress' }]
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.start' } as any)
expect(getTurnState().todos).toEqual(todos)
onEvent({ payload: { name: 'todo', todos: [], tool_id: 'todo-1' }, type: 'tool.complete' } as any)
expect(getTurnState().todos).toEqual([])
})
it('persists completed tool rows when message.complete lands immediately after tool.complete', () => {
const appended: Msg[] = []
turnController.reasoningText = 'mapped the page'
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { context: 'home page', name: 'search', tool_id: 'tool-1' },
type: 'tool.start'
} as any)
onEvent({
payload: { name: 'search', preview: 'hero cards' },
type: 'tool.progress'
} as any)
onEvent({
payload: { summary: 'done', tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
onEvent({
payload: { text: 'final answer' },
type: 'message.complete'
} as any)
expect(appended).toHaveLength(2)
expect(appended[0]).toMatchObject({ kind: 'trail', role: 'system', text: '', thinking: 'mapped the page' })
expect(appended[0]?.tools).toHaveLength(1)
expect(appended[0]?.tools?.[0]).toContain('hero cards')
expect(appended[0]?.toolTokens).toBeGreaterThan(0)
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
})
it('groups sequential completed tools into one trail when the turn completes', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { context: 'alpha', name: 'search_files', tool_id: 'tool-1' }, type: 'tool.start' } as any)
onEvent({
payload: { name: 'search_files', summary: 'first done', tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
onEvent({ payload: { context: 'beta', name: 'read_file', tool_id: 'tool-2' }, type: 'tool.start' } as any)
onEvent({ payload: { name: 'read_file', summary: 'second done', tool_id: 'tool-2' }, type: 'tool.complete' } as any)
expect(getTurnState().streamSegments.filter(msg => msg.kind === 'trail' && msg.tools?.length)).toHaveLength(1)
expect(getTurnState().streamSegments[0]?.tools).toHaveLength(2)
expect(getTurnState().streamPendingTools).toEqual([])
onEvent({ payload: { text: '' }, type: 'message.complete' } as any)
const toolTrails = appended.filter(msg => msg.kind === 'trail' && msg.tools?.length)
expect(toolTrails).toHaveLength(1)
expect(toolTrails[0]?.tools).toHaveLength(2)
expect(toolTrails[0]?.tools?.[0]).toContain('Search Files')
expect(toolTrails[0]?.tools?.[1]).toContain('Read File')
})
it('keeps tool tokens across handler recreation mid-turn', () => {
const appended: Msg[] = []
turnController.reasoningText = 'mapped the page'
createGatewayEventHandler(buildCtx(appended))({
payload: { context: 'home page', name: 'search', tool_id: 'tool-1' },
type: 'tool.start'
} as any)
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { name: 'search', preview: 'hero cards' },
type: 'tool.progress'
} as any)
onEvent({
payload: { summary: 'done', tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
onEvent({
payload: { text: 'final answer' },
type: 'message.complete'
} as any)
expect(appended).toHaveLength(2)
expect(appended[0]?.tools).toHaveLength(1)
expect(appended[0]?.toolTokens).toBeGreaterThan(0)
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
})
it('streams legacy thinking.delta into visible reasoning state', () => {
vi.useFakeTimers()
const appended: Msg[] = []
const streamed = 'short streamed reasoning'
const onEvent = createGatewayEventHandler(buildCtx(appended))
try {
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { text: streamed }, type: 'thinking.delta' } as any)
vi.runOnlyPendingTimers()
expect(getTurnState().reasoning).toBe(streamed)
expect(getTurnState().reasoningActive).toBe(true)
expect(getTurnState().reasoningTokens).toBe(estimateTokensRough(streamed))
} finally {
vi.useRealTimers()
}
})
it('ignores late thinking.delta after the turn has already completed', () => {
vi.useFakeTimers()
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
try {
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)
expect(getUiState().busy).toBe(false)
expect(getUiState().status).toBe('ready')
onEvent({ payload: { text: 'thinking...' }, type: 'thinking.delta' } as any)
vi.runOnlyPendingTimers()
expect(getUiState().status).toBe('ready')
expect(getTurnState().reasoning).toBe('')
} finally {
vi.useRealTimers()
}
})
it('preserves streamed reasoning as one completed thinking panel after segment flushes', () => {
const appended: Msg[] = []
const streamed = 'first reasoning chunk\nsecond reasoning chunk'
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { text: streamed }, type: 'reasoning.delta' } as any)
onEvent({ payload: { text: 'Before edit.' }, type: 'message.delta' } as any)
turnController.flushStreamingSegment()
onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)
expect(appended.map(msg => msg.thinking).filter(Boolean)).toEqual([streamed])
expect(appended[appended.length - 1]).toMatchObject({ role: 'assistant', text: 'final answer' })
})
it('filters spinner/status-only reasoning noise from completed thinking', () => {
const appended: Msg[] = []
const streamed = '(¬_¬) synthesizing...\nactual plan\n( ͡° ͜ʖ ͡°) pondering...\nnext step'
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { text: streamed }, type: 'reasoning.delta' } as any)
onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)
expect(appended[0]?.thinking).toBe(streamed)
expect(appended[0]?.text).toBe('')
expect(appended[appended.length - 1]).toMatchObject({ role: 'assistant', text: 'final answer' })
})
it('shows verbose reasoning even when normal reasoning display is off', () => {
vi.useFakeTimers()
patchUiState({ showReasoning: false })
const appended: Msg[] = []
const streamed = 'verbose-only reasoning'
try {
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { text: streamed, verbose: true }, type: 'reasoning.delta' } as any)
vi.runOnlyPendingTimers()
expect(turnController.reasoningText).toBe(streamed)
expect(getTurnState().reasoning).toBe(streamed)
} finally {
vi.useRealTimers()
}
})
it('ignores fallback reasoning.available when streamed reasoning already exists', () => {
const appended: Msg[] = []
const streamed = 'short streamed reasoning'
const fallback = 'x'.repeat(400)
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { text: streamed }, type: 'reasoning.delta' } as any)
onEvent({ payload: { text: fallback }, type: 'reasoning.available' } as any)
onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)
expect(appended).toHaveLength(2)
expect(appended[0]?.thinking).toBe(streamed)
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(streamed))
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
})
it('uses message.complete reasoning when no streamed reasoning ref', () => {
const appended: Msg[] = []
const fromServer = 'recovered from last_reasoning'
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { reasoning: fromServer, text: 'final answer' }, type: 'message.complete' } as any)
expect(appended).toHaveLength(2)
expect(appended[0]?.thinking).toBe(fromServer)
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer))
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
})
it('renders browser.progress events as system transcript lines as they stream in', () => {
const appended: Msg[] = []
const ctx = buildCtx(appended)
const handler = createGatewayEventHandler(ctx)
handler({
payload: { message: 'Chromium-family browser launched and listening on port 9222' },
type: 'browser.progress'
} as any)
expect(ctx.system.sys).toHaveBeenCalledWith('Chromium-family browser launched and listening on port 9222')
})
it('annotates gateway.start_timeout with stderr tail lines so users can diagnose without /logs', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: {
cwd: '/repo',
python: '/opt/venv/bin/python',
stderr_tail:
'[startup] timed out\nModuleNotFoundError: No module named openai\nFileNotFoundError: ~/.hermes/config.yaml'
},
type: 'gateway.start_timeout'
} as any)
const messages = getTurnState().activity.map(a => a.text)
expect(messages.some(m => m.includes('gateway startup timed out'))).toBe(true)
expect(messages.some(m => m.includes('ModuleNotFoundError'))).toBe(true)
expect(messages.some(m => m.includes('FileNotFoundError'))).toBe(true)
})
it('prefers raw text over Rich-rendered ANSI on message.complete (#16391)', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const raw = 'Hermes here.\n\nLine two.'
// Rich-rendered ANSI (`final_response_markdown: render`) used to win,
// which left visible escape codes in Ink output. Raw text must win.
const rendered = '\u001b[33mHermes here.\u001b[0m\n\n\u001b[2mLine two.\u001b[0m'
onEvent({ payload: { rendered, text: raw }, type: 'message.complete' } as any)
const assistant = appended.find(msg => msg.role === 'assistant')
expect(assistant?.text).toBe(raw)
expect(assistant?.text).not.toContain('\u001b[')
})
it('falls back to payload.rendered when text is missing on message.complete', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const rendered = 'fallback when gateway omitted text'
onEvent({ payload: { rendered }, type: 'message.complete' } as any)
const assistant = appended.find(msg => msg.role === 'assistant')
expect(assistant?.text).toBe(rendered)
})
it('always accumulates raw text in message.delta and ignores `rendered` (#16391)', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// Stream of partial text deltas; each delta carries an incremental
// Rich-ANSI fragment. Pre-fix code would replace the whole bufRef
// with the latest fragment, dropping prior text.
onEvent({ payload: { rendered: '\u001b[33mFi\u001b[0m', text: 'Fi' }, type: 'message.delta' } as any)
onEvent({ payload: { rendered: '\u001b[33mrst.\u001b[0m', text: 'rst.' }, type: 'message.delta' } as any)
onEvent({ payload: { text: ' second.' }, type: 'message.delta' } as any)
onEvent({ payload: {}, type: 'message.complete' } as any)
const assistant = appended.find(msg => msg.role === 'assistant')
expect(assistant?.text).toBe('First. second.')
})
it('anchors inline_diff as its own segment where the edit happened', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const diff = '\u001b[31m--- a/foo.ts\u001b[0m\n\u001b[32m+++ b/foo.ts\u001b[0m\n@@\n-old\n+new'
const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
const block = `\`\`\`diff\n${cleaned}\n\`\`\``
// Narration → tool → tool-complete → more narration → message-complete.
// The diff MUST land between the two narration segments, not tacked
// onto the final one.
onEvent({ payload: { text: 'Editing the file' }, type: 'message.delta' } as any)
onEvent({ payload: { context: 'foo.ts', name: 'patch', tool_id: 'tool-1' }, type: 'tool.start' } as any)
onEvent({ payload: { inline_diff: diff, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
// Diff is already committed to segmentMessages as its own segment.
expect(appended).toHaveLength(0)
expect(turnController.segmentMessages).toEqual([
{ role: 'assistant', text: 'Editing the file' },
{
kind: 'diff',
role: 'assistant',
text: block,
tools: [expect.stringMatching(/^Patch\("foo\.ts"\)(?: \([^)]+\))? ✓$/)]
}
])
onEvent({ payload: { text: 'patch applied' }, type: 'message.complete' } as any)
expect(appended).toHaveLength(4)
expect(appended[0]?.text).toBe('Editing the file')
expect(appended[1]).toMatchObject({ kind: 'diff', text: block })
expect(appended[1]?.tools?.[0]).toContain('Patch')
expect(appended[3]?.text).toBe('patch applied')
expect(appended[3]?.text).not.toContain('```diff')
})
it('keeps verbose result text on inline_diff tool completions', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const diff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
onEvent({
payload: { args_text: '{ "path": "foo.ts" }', context: 'foo.ts', name: 'patch', tool_id: 'tool-1' },
type: 'tool.start'
} as any)
onEvent({
payload: { inline_diff: diff, result_text: 'patched result', tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
expect(turnController.segmentMessages[0]).toMatchObject({ kind: 'diff' })
expect(turnController.segmentMessages[0]?.tools?.[0]).toContain('Args:\n{ "path": "foo.ts" }')
expect(turnController.segmentMessages[0]?.tools?.[0]).toContain('Result:\npatched result')
})
it('keeps full final responses from duplicating flushed pre-diff narration', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const diff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
const block = `\`\`\`diff\n${diff}\n\`\`\``
onEvent({ payload: { text: 'Before edit. ' }, type: 'message.delta' } as any)
onEvent({ payload: { context: 'foo.ts', name: 'patch', tool_id: 'tool-1' }, type: 'tool.start' } as any)
onEvent({ payload: { inline_diff: diff, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
onEvent({ payload: { text: 'After edit.' }, type: 'message.delta' } as any)
onEvent({ payload: { text: 'Before edit. After edit.' }, type: 'message.complete' } as any)
expect(appended.map(msg => msg.text.trim()).filter(Boolean)).toEqual(['Before edit.', block, 'After edit.'])
expect(appended[1]?.tools?.[0]).toContain('Patch')
})
it('drops the diff segment when the final assistant text narrates the same diff', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
const assistantText = `Done. Here's the inline diff:\n\n\`\`\`diff\n${cleaned}\n\`\`\``
onEvent({ payload: { inline_diff: cleaned, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
onEvent({ payload: { text: assistantText }, type: 'message.complete' } as any)
// Only the final message — diff-only segment dropped so we don't
// render two stacked copies of the same patch.
expect(appended).toHaveLength(1)
expect(appended[0]?.text).toBe(assistantText)
expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1)
})
it('strips the CLI "┊ review diff" header from inline diff segments', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const raw = ' \u001b[33m┊ review diff\u001b[0m\n--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
onEvent({ payload: { inline_diff: raw, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
// Tool trail first, then diff segment (kind='diff'), then final narration.
expect(appended).toHaveLength(2)
expect(appended[0]?.kind).toBe('diff')
expect(appended[0]?.text).not.toContain('┊ review diff')
expect(appended[0]?.text).toContain('--- a/foo.ts')
expect(appended[0]?.tools?.[0]).toContain('Tool')
expect(appended[1]?.text).toBe('done')
})
it('drops the diff segment when assistant writes its own ```diff fence', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const inlineDiff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
const assistantText = 'Done. Clean swap:\n\n```diff\n-old\n+new\n```'
onEvent({
payload: { inline_diff: inlineDiff, summary: 'patched', tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
onEvent({ payload: { text: assistantText }, type: 'message.complete' } as any)
expect(appended).toHaveLength(1)
expect(appended[0]?.text).toBe(assistantText)
expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1)
})
it('keeps tool trail terse when inline_diff is present', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const diff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
onEvent({
payload: { inline_diff: diff, name: 'review_diff', summary: diff, tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
// Tool row is now placed before the diff, so telemetry does not render
// below the patch that came from that tool.
expect(appended).toHaveLength(2)
expect(appended[0]?.kind).toBe('diff')
expect(appended[0]?.text).toContain('```diff')
expect(appended[0]?.tools?.[0]).toContain('Review Diff')
expect(appended[0]?.tools?.[0]).not.toContain('--- a/foo.ts')
expect(appended[1]?.text).toBe('done')
expect(appended[1]?.tools ?? []).toEqual([])
})
it('shows setup panel for missing provider startup error', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: {
message:
'agent init failed: No LLM provider configured. Run `hermes model` to select a provider, or run `hermes setup` for first-time configuration.'
},
type: 'error'
} as any)
expect(appended).toHaveLength(1)
expect(appended[0]).toMatchObject({
kind: 'panel',
panelData: { title: 'Setup Required' },
role: 'system'
})
})
it('does not fetch config while constructing the gateway event handler', () => {
const appended: Msg[] = []
const ctx = buildCtx(appended)
ctx.gateway.rpc = vi.fn(async () => null)
createGatewayEventHandler(ctx)
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
})
it('on gateway.ready with no STARTUP_RESUME_ID and auto_resume off, forges a new session', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = ''
ctx.gateway.rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
return { config: { display: { tui_auto_resume_recent: false } } }
}
return null
})
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
expect(resumeById).not.toHaveBeenCalled()
})
it('on gateway.ready after a crash, resumes the recovered session once and skips forge', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
// Mimic resumeById's synchronous status write so the test proves the
// "recovering session…" label is applied *after* (and survives) it.
ctx.session.resumeById = resumeById.mockImplementation(() => patchUiState({ status: 'resuming…' }))
ctx.session.STARTUP_RESUME_ID = ''
ctx.session.recoverSidRef = ref<null | string>('sess-crashed')
const onEvent = createGatewayEventHandler(ctx)
onEvent({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(resumeById).toHaveBeenCalledWith('sess-crashed'))
expect(newSession).not.toHaveBeenCalled()
// One-shot: the ref is consumed so a later ordinary restart forges/resumes
// per config instead of re-resuming the recovered session.
expect(ctx.session.recoverSidRef.current).toBeNull()
expect(getUiState().status).toBe('recovering session…')
})
it('on gateway.ready with auto_resume on and a recent session, resumes it', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = ''
ctx.gateway.rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
return { config: { display: { tui_auto_resume_recent: true } } }
}
if (method === 'session.most_recent') {
return { session_id: 'sess-most-recent' }
}
return null
})
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(resumeById).toHaveBeenCalledWith('sess-most-recent'))
expect(newSession).not.toHaveBeenCalled()
})
it('on gateway.ready with auto_resume on but no eligible session, falls back to new', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = ''
ctx.gateway.rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
return { config: { display: { tui_auto_resume_recent: true } } }
}
if (method === 'session.most_recent') {
return { session_id: null }
}
return null
})
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
expect(resumeById).not.toHaveBeenCalled()
})
it('on gateway.ready when config.get rejects, falls back to new session', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = ''
ctx.gateway.rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
throw new Error('gateway timeout')
}
return null
})
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
expect(resumeById).not.toHaveBeenCalled()
})
it('on gateway.ready when session.most_recent rejects, falls back to new session', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = ''
ctx.gateway.rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
return { config: { display: { tui_auto_resume_recent: true } } }
}
if (method === 'session.most_recent') {
throw new Error('db locked')
}
return null
})
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
expect(resumeById).not.toHaveBeenCalled()
})
it('on gateway.ready with STARTUP_RESUME_ID set, the env wins over config auto_resume', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = 'env-explicit'
ctx.gateway.rpc = vi.fn(async () => ({
config: { display: { tui_auto_resume_recent: true } }
}))
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(resumeById).toHaveBeenCalledWith('env-explicit'))
expect(newSession).not.toHaveBeenCalled()
})
it('keeps gateway noise informational and approval out of Activity', async () => {
const appended: Msg[] = []
const ctx = buildCtx(appended)
ctx.gateway.rpc = vi.fn(async () => {
throw new Error('cold start')
})
const onEvent = createGatewayEventHandler(ctx)
onEvent({ payload: { line: 'Traceback: noisy but non-fatal' }, type: 'gateway.stderr' } as any)
onEvent({ payload: { preview: 'bad framing' }, type: 'gateway.protocol_error' } as any)
onEvent({
payload: { command: 'rm -rf /tmp/nope', description: 'dangerous command' },
type: 'approval.request'
} as any)
onEvent({ payload: {}, type: 'gateway.ready' } as any)
await Promise.resolve()
await Promise.resolve()
expect(getOverlayState().approval).toMatchObject({ description: 'dangerous command' })
expect(getTurnState().activity).toMatchObject([
{ text: 'Traceback: noisy but non-fatal', tone: 'info' },
{ text: 'protocol noise detected · /logs to inspect', tone: 'info' },
{ text: 'protocol noise: bad framing', tone: 'info' },
{ text: 'command catalog unavailable: cold start', tone: 'info' }
])
})
it('still surfaces terminal turn failures as errors', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { message: 'boom' }, type: 'error' } as any)
expect(getTurnState().activity).toMatchObject([{ text: 'boom', tone: 'error' }])
})
it('accepts timeout/error subagent terminal statuses and ignores stale live events', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { goal: 'timeout child', subagent_id: 'sa-timeout', task_index: 0 },
type: 'subagent.start'
} as any)
onEvent({
payload: { goal: 'timeout child', status: 'timeout', subagent_id: 'sa-timeout', task_index: 0 },
type: 'subagent.complete'
} as any)
expect(getTurnState().subagents.find(s => s.id === 'sa-timeout')?.status).toBe('timeout')
// Late start/spawn updates must not clobber terminal timeout/error states.
onEvent({
payload: { goal: 'timeout child', subagent_id: 'sa-timeout', task_index: 0 },
type: 'subagent.start'
} as any)
onEvent({
payload: { goal: 'timeout child', subagent_id: 'sa-timeout', task_index: 0 },
type: 'subagent.spawn_requested'
} as any)
expect(getTurnState().subagents.find(s => s.id === 'sa-timeout')?.status).toBe('timeout')
onEvent({
payload: { goal: 'error child', subagent_id: 'sa-error', task_index: 1 },
type: 'subagent.start'
} as any)
onEvent({
payload: { goal: 'error child', status: 'error', subagent_id: 'sa-error', task_index: 1 },
type: 'subagent.complete'
} as any)
expect(getTurnState().subagents.find(s => s.id === 'sa-error')?.status).toBe('error')
})
it('normalizes unknown subagent.complete statuses to completed', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { goal: 'weird child', subagent_id: 'sa-weird', task_index: 2 },
type: 'subagent.start'
} as any)
onEvent({
payload: { goal: 'weird child', status: 'mystery_status', subagent_id: 'sa-weird', task_index: 2 },
type: 'subagent.complete'
} as any)
expect(getTurnState().subagents.find(s => s.id === 'sa-weird')?.status).toBe('completed')
})
it('nudges toward /agents on the first spawn_requested of a turn', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.spawn_requested'
} as any)
const hints = getTurnState().activity.filter(a => a.text.includes('/agents'))
expect(hints).toHaveLength(1)
expect(hints[0]).toMatchObject({ tone: 'info' })
})
it('nudges toward /agents on subagent.start (spawn_requested dropped in CLI path)', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// In the real CLI→gateway path the delegate callback drops
// spawn_requested, so `start` is the first event the TUI sees.
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(1)
})
it('nudges at most once per turn and resets on the next message.start', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// Multiple spawns in one turn → a single hint.
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.start'
} as any)
onEvent({
payload: { goal: 'child b', subagent_id: 'sa-b', task_index: 1 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(1)
// New turn clears activity AND the once-per-turn guard → nudges again.
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({
payload: { goal: 'child c', subagent_id: 'sa-c', task_index: 0 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(1)
})
it('does not nudge when the /agents overlay is already open', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// User already has the dashboard open → nothing to advertise.
patchOverlayState({ agents: true })
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(0)
})
it('nudges if the /agents overlay is closed mid-turn while delegation continues', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// Overlay open on the first delegation event → suppressed, but the
// turn's nudge credit must NOT be burned (the user is watching).
patchOverlayState({ agents: true })
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(0)
// User closes the dashboard mid-turn → the next delegation event nudges.
patchOverlayState({ agents: false })
onEvent({
payload: { goal: 'child b', subagent_id: 'sa-b', task_index: 1 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(1)
})
it('does not nudge when display.tui_agents_nudge is false', async () => {
const appended: Msg[] = []
const ctx = buildCtx(appended)
// config.get → full returns the disable flag.
ctx.gateway.rpc = vi.fn(async (method: string) =>
method === 'config.get' ? { config: { display: { tui_agents_nudge: false } } } : null
)
const onEvent = createGatewayEventHandler(ctx)
// Config fetch starts once the gateway is ready; let it resolve before any
// spawn (mirrors real usage — config lands well before first delegation).
onEvent({ payload: {}, type: 'gateway.ready' } as any)
await Promise.resolve()
await Promise.resolve()
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(0)
})
it('drops stale reasoning/tool/todos events after ctrl-c until the next message starts', () => {
// Repro for the discord report: ctrl-c interrupts, but late reasoning/tool
// events from the still-winding-down agent loop kept populating the UI for
// ~1s, making it look like the interrupt had been ignored.
//
// Fake timers because `interruptTurn` schedules a real setTimeout for
// its cooldown — without flushing it inside this test, the timeout
// can fire later and mutate uiStore/turnState during unrelated tests
// (cross-file flake).
vi.useFakeTimers()
try {
const appended: Msg[] = []
const ctx = buildCtx(appended)
ctx.gateway.gw.request = vi.fn(async () => ({ status: 'interrupted' }))
const onEvent = createGatewayEventHandler(ctx)
patchUiState({ sid: 'sess-1' })
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({
payload: {
context: 'pre',
name: 'search',
todos: [{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }],
tool_id: 't-1'
},
type: 'tool.start'
} as any)
// Pre-interrupt todos should land in turn state.
expect(getTurnState().todos).toEqual([{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }])
turnController.interruptTurn({
appendMessage: (msg: Msg) => appended.push(msg),
gw: ctx.gateway.gw,
sid: 'sess-1',
sys: ctx.system.sys
})
onEvent({ payload: { text: 'still thinking…' }, type: 'reasoning.delta' } as any)
// Post-interrupt tool.start with a todos payload — must NOT mutate todos.
onEvent({
payload: {
context: 'post',
name: 'browser',
todos: [{ content: 'late ghost', id: 'todo-ghost', status: 'pending' }],
tool_id: 't-2'
},
type: 'tool.start'
} as any)
// Late tool.generating must NOT push a 'drafting …' line into the trail.
const trailBefore = getTurnState().turnTrail.length
onEvent({ payload: { name: 'browser' }, type: 'tool.generating' } as any)
expect(getTurnState().turnTrail.length).toBe(trailBefore)
onEvent({ payload: { name: 'browser', preview: 'loading' }, type: 'tool.progress' } as any)
onEvent({ payload: { summary: 'done', tool_id: 't-2' }, type: 'tool.complete' } as any)
onEvent({ payload: { text: 'late chunk' }, type: 'message.delta' } as any)
expect(getTurnState().tools).toEqual([])
expect(turnController.reasoningText).toBe('')
expect(turnController.bufRef).toBe('')
expect(getTurnState().streamPendingTools).toEqual([])
expect(getTurnState().streamSegments).toEqual([])
// Stale post-interrupt todos must not have leaked through.
// (This test does not assert that pre-interrupt todos are cleared —
// current interrupt path leaves them visible until the next message.)
expect(getTurnState().todos.find(t => t.content === 'late ghost')).toBeUndefined()
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { text: 'fresh' }, type: 'reasoning.delta' } as any)
expect(turnController.reasoningText).toBe('fresh')
} finally {
// Drain pending fake timers BEFORE restoring real timers so a mid-
// test assertion failure can't leak the interrupt-cooldown setTimeout
// across test files (the original Copilot concern).
vi.runAllTimers()
vi.useRealTimers()
}
})
it('keepBusy interrupt holds busy until the gateway settles and suppresses the cancelled turns final_response', () => {
// Force-send: interrupt holds busy so the drain waits for the real settle
// instead of racing it (the race duplicated the bubble, leaked a "queued: …"
// note, and surfaced the cancelled turn's "Operation interrupted…" reply).
const appended: Msg[] = []
const ctx = buildCtx(appended)
ctx.gateway.gw.request = vi.fn(async () => ({ status: 'interrupted' }))
const onEvent = createGatewayEventHandler(ctx)
patchUiState({ sid: 'sess-1' })
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { text: 'thinking…' }, type: 'reasoning.delta' } as any)
expect(getUiState().busy).toBe(true)
turnController.interruptTurn(
{ appendMessage: (msg: Msg) => appended.push(msg), gw: ctx.gateway.gw, sid: 'sess-1', sys: ctx.system.sys },
{ keepBusy: true }
)
// Held busy: the drain effect keys off busy→false, so it must not fire yet.
expect(getUiState().busy).toBe(true)
// The cancelled turn settles with a backend interrupted final_response.
const before = appended.length
onEvent({
payload: { text: 'Operation interrupted: waiting for model response (4.1s elapsed).' },
type: 'message.complete'
} as any)
// Settle flips busy false (the single drain edge) and the backend
// "Operation interrupted…" line is suppressed (not appended).
expect(getUiState().busy).toBe(false)
expect(appended.slice(before).some(m => typeof m.text === 'string' && m.text.includes('Operation interrupted'))).toBe(
false
)
})
it('persists an abandoned (timed-out) clarify into the transcript when the clarify tool completes', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// Backend clarify timed out: the overlay is still live (Python returned an
// empty answer), and the clarify tool's own tool.complete then fires.
patchOverlayState({
clarify: { choices: ['Scope A', 'Scope B'], question: 'How do you want to scope?', requestId: 'req-1' }
})
onEvent({ payload: { duration_s: 300, name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any)
const record = appended.find(msg => msg.role === 'system' && msg.text.startsWith('ask How do you want to scope?'))
expect(record).toBeDefined()
expect(record?.text).toContain('1. Scope A')
expect(record?.text).toContain('2. Scope B')
expect(record?.text).toContain('timed out — no selection')
// The live overlay is cleared so it doesn't double-render with the record.
expect(getOverlayState().clarify).toBeNull()
})
it('only persists an abandoned clarify once even if tool.complete fires twice', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
patchOverlayState({
clarify: { choices: ['A'], question: 'Pick?', requestId: 'req-3' }
})
onEvent({ payload: { name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any)
// A duplicate clarify tool.complete must not re-persist the same prompt.
onEvent({ payload: { name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any)
const records = appended.filter(msg => msg.role === 'system' && msg.text.startsWith('ask Pick?'))
expect(records).toHaveLength(1)
})
it('does not flush the clarify overlay when a non-clarify tool completes', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// A clarify is live, but it's a *different* tool that just completed — the
// clarify itself is still pending, so we must not persist or clear it.
patchOverlayState({
clarify: { choices: ['A', 'B'], question: 'Pick?', requestId: 'req-4' }
})
onEvent({ payload: { name: 'search', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false)
expect(getOverlayState().clarify).not.toBeNull()
})
it('does not persist when an answered clarify already cleared the overlay before tool.complete', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// Answered path (answerClarify) clears the overlay before the agent's
// tool.complete arrives, so there's nothing live to persist.
onEvent({ payload: { duration_s: 4.2, name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any)
expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false)
})
// ── Credits notice (Strategy B) ──────────────────────────────────────
describe('credits notice', () => {
it('shows a notice immediately when idle (no turn in flight)', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({
payload: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ credits exhausted' },
type: 'notification.show'
} as any)
expect(getUiState().notice).toMatchObject({
key: 'credits.depleted',
kind: 'sticky',
level: 'error',
text: '✕ credits exhausted'
})
})
it('holds a notice arriving mid-turn (busy) and flushes it at message.complete', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({ payload: {}, type: 'message.start' } as any)
expect(getUiState().busy).toBe(true)
onEvent({
payload: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' },
type: 'notification.show'
} as any)
// Mid-turn: busy wins, notice is held, not visible yet.
expect(getUiState().notice).toBeNull()
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
// Turn end flushes the held notice.
expect(getUiState().notice).toMatchObject({ key: 'credits.90', text: '⚠ 90% used' })
})
it('flushes a held notice at interruptTurn (turn-end via ctrl-c)', () => {
vi.useFakeTimers()
try {
const ctx = buildCtx([])
ctx.gateway.gw.request = vi.fn(async () => ({ status: 'interrupted' }))
const onEvent = createGatewayEventHandler(ctx)
patchUiState({ sid: 'sess-1' })
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({
payload: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ out' },
type: 'notification.show'
} as any)
expect(getUiState().notice).toBeNull()
turnController.interruptTurn({
appendMessage: vi.fn(),
gw: ctx.gateway.gw,
sid: 'sess-1',
sys: ctx.system.sys
})
expect(getUiState().notice).toMatchObject({ key: 'credits.depleted', text: '✕ out' })
} finally {
vi.runAllTimers()
vi.useRealTimers()
}
})
it('flushes a held notice at recordError (turn-end via error)', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({
payload: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' },
type: 'notification.show'
} as any)
expect(getUiState().notice).toBeNull()
onEvent({ payload: { message: 'boom' }, type: 'error' } as any)
expect(getUiState().notice).toMatchObject({ key: 'credits.90', text: '⚠ 90% used' })
})
it('latest-wins: a second mid-turn notice replaces the first held one', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({
payload: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' },
type: 'notification.show'
} as any)
onEvent({
payload: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ exhausted' },
type: 'notification.show'
} as any)
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
// Only the latest held notice surfaces.
expect(getUiState().notice).toMatchObject({ key: 'credits.depleted', text: '✕ exhausted' })
})
it('clears a visible notice only when the clear key matches (no-op otherwise)', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({
payload: { key: 'credits.grant_spent', kind: 'sticky', level: 'warn', text: '⚠ grant spent' },
type: 'notification.show'
} as any)
expect(getUiState().notice).not.toBeNull()
// Stale/late clear for a DIFFERENT key must not wipe the newer notice.
onEvent({ payload: { key: 'credits.something_else' }, type: 'notification.clear' } as any)
expect(getUiState().notice).toMatchObject({ key: 'credits.grant_spent' })
// Matching key clears.
onEvent({ payload: { key: 'credits.grant_spent' }, type: 'notification.clear' } as any)
expect(getUiState().notice).toBeNull()
})
it('drops a held pending notice on a matching clear before it can surface', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({
payload: { key: 'credits.grant_spent', kind: 'sticky', level: 'warn', text: '⚠ grant spent' },
type: 'notification.show'
} as any)
// Clear arrives mid-turn before the held notice flushes.
onEvent({ payload: { key: 'credits.grant_spent' }, type: 'notification.clear' } as any)
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
// Nothing surfaces — the pending notice was dropped by the matching clear.
expect(getUiState().notice).toBeNull()
})
it('a ttl notice self-expires after ttl_ms when applied while idle', () => {
vi.useFakeTimers()
try {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({
payload: { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ access restored', ttl_ms: 8000 },
type: 'notification.show'
} as any)
expect(getUiState().notice).toMatchObject({ key: 'credits.restored' })
vi.advanceTimersByTime(7999)
expect(getUiState().notice).not.toBeNull()
vi.advanceTimersByTime(2)
expect(getUiState().notice).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('R3-C2: a ttl notice self-expires even when statusTimer is also armed (timer isolation)', () => {
// Regression guard for the whole reason `noticeTimer` is a separate
// timer from `statusTimer`. A concurrent `status.update` (goal path)
// arms `statusTimer` via restoreStatusAfter; if the two timers shared
// a slot, clearing statusTimer would cancel the TTL and the notice
// would never self-expire.
vi.useFakeTimers()
try {
const ctx = buildCtx([])
const onEvent = createGatewayEventHandler(ctx)
// 1. While idle, show a ttl notice → applies immediately, arms noticeTimer.
onEvent({
payload: { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ restored', ttl_ms: 8000 },
type: 'notification.show'
} as any)
expect(getUiState().notice).toMatchObject({ key: 'credits.restored' })
// 2. A goal status.update arms turnController.statusTimer (via restoreStatusAfter).
onEvent({
payload: { kind: 'goal', text: '✓ Goal achieved: some reason' },
type: 'status.update'
} as any)
// statusTimer is now live; notice must still be visible.
expect(getUiState().notice).toMatchObject({ key: 'credits.restored' })
// 3. Advance past the TTL — the notice's own dedicated timer fires.
vi.advanceTimersByTime(8001)
// 4. Notice self-expired: statusTimer did NOT cancel noticeTimer.
expect(getUiState().notice).toBeNull()
} finally {
vi.runAllTimers()
vi.useRealTimers()
}
})
it('starts the ttl clock when the notice becomes VISIBLE (at turn end), not on arrival', () => {
vi.useFakeTimers()
try {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({
payload: { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ restored', ttl_ms: 8000 },
type: 'notification.show'
} as any)
// Long busy turn: the TTL must NOT have started while held.
vi.advanceTimersByTime(10_000)
expect(getUiState().notice).toBeNull()
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
expect(getUiState().notice).toMatchObject({ key: 'credits.restored' })
// Full 8s starts now (on apply), so it survives nearly that long.
vi.advanceTimersByTime(7999)
expect(getUiState().notice).not.toBeNull()
vi.advanceTimersByTime(2)
expect(getUiState().notice).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('latest-wins cancels a prior ttl timer so it cannot wipe the newer notice', () => {
vi.useFakeTimers()
try {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({
payload: { id: 'a', key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ a', ttl_ms: 5000 },
type: 'notification.show'
} as any)
vi.advanceTimersByTime(4000)
// A newer sticky arrives before the first's TTL fires.
onEvent({
payload: { id: 'b', key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ b' },
type: 'notification.show'
} as any)
expect(getUiState().notice).toMatchObject({ id: 'b' })
// The first notice's stale TTL must NOT clear the newer one.
vi.advanceTimersByTime(2000)
expect(getUiState().notice).toMatchObject({ id: 'b', text: '✕ b' })
} finally {
vi.useRealTimers()
}
})
it('sticky survives a turn: applied with no pending notice does not clear it', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
// A standing sticky notice from a prior turn.
onEvent({
payload: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ exhausted' },
type: 'notification.show'
} as any)
expect(getUiState().notice).toMatchObject({ key: 'credits.depleted' })
// A new turn runs with NO new notice arriving.
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { text: 'reply' }, type: 'message.complete' } as any)
// The standing sticky must REappear untouched at turn end.
expect(getUiState().notice).toMatchObject({ key: 'credits.depleted', text: '✕ exhausted' })
})
it('reset()/fullReset() clears pending + timer + visible notice (no cross-session leak)', () => {
vi.useFakeTimers()
try {
const onEvent = createGatewayEventHandler(buildCtx([]))
// Session A: a visible sticky + a held pending notice mid-turn.
onEvent({
payload: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ A cut' },
type: 'notification.show'
} as any)
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({
payload: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ A 90%' },
type: 'notification.show'
} as any)
expect(getUiState().notice).toMatchObject({ key: 'credits.depleted' })
// Session boundary.
turnController.fullReset()
expect(getUiState().notice).toBeNull()
// Session B: a turn ends with nothing held — A's notice must not bleed in.
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { text: 'B reply' }, type: 'message.complete' } as any)
expect(getUiState().notice).toBeNull()
} finally {
vi.runAllTimers()
vi.useRealTimers()
}
})
it('ignores a notification.show with no text', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({ payload: { key: 'credits.90', level: 'warn' }, type: 'notification.show' } as any)
expect(getUiState().notice).toBeNull()
})
})
})