Download ui-tui/src/__tests__/createGatewayEventHandler.test.ts from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 86.7 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/ui-tui/src/__tests__/createGatewayEventHandler.test.ts
- Command line
-
hf download hf://SaylorTwift/hermes-agent/ui-tui/src/__tests__/createGatewayEventHandler.test.ts
-
curl -L -o createGatewayEventHandler.test.ts https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/ui-tui/src/__tests__/createGatewayEventHandler.test.ts
86.7 kB
| import { beforeEach, describe, expect, it, vi } from 'vitest' | |
| import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js' | |
| import { createServerRequestHandler } from '../app/createServerRequestHandler.js' | |
| import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js' | |
| import { resetServerRequestsForTests } from '../app/serverRequestStore.js' | |
| import { turnController } from '../app/turnController.js' | |
| import { getTurnState, resetTurnState } from '../app/turnStore.js' | |
| import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' | |
| import { ZERO } from '../domain/usage.js' | |
| import { estimateTokensRough } from '../lib/text.js' | |
| import type { Msg } from '../types.js' | |
| // Mock the external-URL opener so the billing.step_up.verification test can | |
| // assert it's invoked without spawning a real browser process. | |
| const openExternalUrlMock = vi.fn((_url: string) => true) | |
| vi.mock('../lib/openExternalUrl.js', () => ({ | |
| openExternalUrl: (url: string) => openExternalUrlMock(url) | |
| })) | |
| 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 | |
| /** Deliver one serverβclient request (`tui_gateway/server_requests.py`) to the TUI's request handler. */ | |
| const serverRequest = (method: string, params: Record<string, unknown>, id = `srq-${method}`) => { | |
| const respond = vi.fn() | |
| const handled = createServerRequestHandler({ ringPromptBell: vi.fn(), setStatus: status => patchUiState({ status }) })({ | |
| fail: vi.fn(), | |
| id, | |
| method, | |
| params, | |
| respond | |
| }) | |
| return { handled, respond } | |
| } | |
| describe('createGatewayEventHandler', () => { | |
| beforeEach(() => { | |
| resetOverlayState() | |
| resetUiState() | |
| resetTurnState() | |
| resetServerRequestsForTests() | |
| turnController.fullReset() | |
| patchUiState({ showReasoning: true }) | |
| }) | |
| it('heals missed completion and blocking prompts only from the focused authoritative idle snapshot', () => { | |
| patchUiState({ sid: 'focused' }) | |
| const onEvent = createGatewayEventHandler(buildCtx([])) | |
| onEvent({ session_id: 'focused', payload: {}, type: 'message.start' } as any) | |
| serverRequest('approval', { session_id: 'focused', request_id: 'approval', command: 'test' }) | |
| const busyOverlay = getOverlayState().approval | |
| expect(getUiState().busy).toBe(true) | |
| expect(busyOverlay).not.toBeNull() | |
| const snapshot = { model: 'test', skills: {}, tools: {} } | |
| onEvent({ session_id: 'other', payload: { ...snapshot, running: false }, type: 'session.info' } as any) | |
| onEvent({ session_id: 'focused', payload: snapshot, type: 'session.info' } as any) | |
| onEvent({ session_id: 'focused', payload: { ...snapshot, running: true }, type: 'session.info' } as any) | |
| expect(getUiState().busy).toBe(true) | |
| expect(getOverlayState().approval).toEqual(busyOverlay) | |
| onEvent({ session_id: 'focused', payload: { ...snapshot, running: false }, type: 'session.info' } as any) | |
| expect(getUiState().busy).toBe(false) | |
| expect(getUiState().status).toBe('ready') | |
| expect(getOverlayState().approval).toBeNull() | |
| expect(getTurnState().tools).toEqual([]) | |
| }) | |
| 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.complete' } 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('opens a billing confirm dialog routing Nous to /topup', () => { | |
| const appended: Msg[] = [] | |
| const ctx = buildCtx(appended) | |
| const onEvent = createGatewayEventHandler(ctx) | |
| onEvent({ | |
| payload: { | |
| billing: { | |
| billing_url: null, | |
| is_nous: true, | |
| message: 'out of credits', | |
| model: 'm', | |
| provider: 'nous', | |
| provider_label: 'Nous Portal' | |
| }, | |
| text: 'Billing or credits exhausted: ...' | |
| }, | |
| type: 'message.complete' | |
| } as any) | |
| const { confirm } = getOverlayState() | |
| expect(confirm?.title).toContain('Nous') | |
| expect(confirm?.confirmLabel).toBe('Top up') | |
| confirm!.onConfirm() | |
| expect(ctx.submission.submitRef.current).toHaveBeenCalledWith('/topup') | |
| }) | |
| it('deep-links a third-party provider billing page from the confirm dialog', () => { | |
| const appended: Msg[] = [] | |
| const ctx = buildCtx(appended) | |
| const onEvent = createGatewayEventHandler(ctx) | |
| openExternalUrlMock.mockClear() | |
| onEvent({ | |
| payload: { | |
| billing: { | |
| billing_url: 'https://openrouter.ai/settings/credits', | |
| is_nous: false, | |
| message: 'out of credits', | |
| model: 'm', | |
| provider: 'openrouter', | |
| provider_label: 'OpenRouter' | |
| }, | |
| text: 'Billing or credits exhausted: ...' | |
| }, | |
| type: 'message.complete' | |
| } as any) | |
| const { confirm } = getOverlayState() | |
| expect(confirm?.confirmLabel).toBe('Open billing page') | |
| confirm!.onConfirm() | |
| expect(openExternalUrlMock).toHaveBeenCalledWith('https://openrouter.ai/settings/credits') | |
| }) | |
| 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.complete' } 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.complete' } 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)β¦') | |
| expect(getUiState().compacting).toBe(true) | |
| }) | |
| it('keeps auto-compaction status visible until compaction finishes (#97239)', () => { | |
| const ctx = buildCtx([]) | |
| const onEvent = createGatewayEventHandler(ctx) | |
| const idleLine = 'π€ Resumed after 747s idle β compacting ~44,579 tokens before continuing.' | |
| vi.useFakeTimers() | |
| patchUiState({ busy: true, status: 'runningβ¦' }) | |
| try { | |
| onEvent({ | |
| payload: { kind: 'compacting', text: idleLine }, | |
| type: 'status.update' | |
| } as any) | |
| expect(ctx.system.sys).toHaveBeenCalledWith(idleLine) | |
| expect(getUiState().compacting).toBe(true) | |
| expect(getUiState().status).toBe(idleLine) | |
| vi.advanceTimersByTime(4001) | |
| expect(getUiState().status).toBe(idleLine) | |
| expect(getUiState().compacting).toBe(true) | |
| onEvent({ | |
| payload: { kind: 'compacted', text: 'β Context compaction complete β continuing turn...' }, | |
| type: 'status.update' | |
| } as any) | |
| expect(getUiState().compacting).toBe(false) | |
| } finally { | |
| vi.useRealTimers() | |
| } | |
| }) | |
| 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.complete' } 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: { 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('home page') | |
| 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: { 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('renders moa.reference as a labelled thinking-style segment', () => { | |
| const appended: Msg[] = [] | |
| const onEvent = createGatewayEventHandler(buildCtx(appended)) | |
| onEvent({ payload: {}, type: 'message.start' } as any) | |
| onEvent({ | |
| payload: { count: 2, index: 1, label: 'openrouter:openai/gpt-5.5', text: 'Paris.' }, | |
| type: 'moa.reference' | |
| } as any) | |
| onEvent({ | |
| payload: { count: 2, index: 2, label: 'openrouter:anthropic/claude-opus-4.8', text: 'Paris.' }, | |
| type: 'moa.reference' | |
| } as any) | |
| const segments = getTurnState().streamSegments | |
| const refBlocks = segments.filter(m => typeof m.thinking === 'string' && m.thinking.includes('Reference')) | |
| expect(refBlocks).toHaveLength(2) | |
| expect(refBlocks[0]?.thinking).toContain('Reference 1/2 β openrouter:openai/gpt-5.5') | |
| expect(refBlocks[0]?.thinking).toContain('Paris.') | |
| expect(refBlocks[1]?.thinking).toContain('Reference 2/2 β openrouter:anthropic/claude-opus-4.8') | |
| }) | |
| it('renders moa.reference even when showReasoning is off (it is the MoA process, not reasoning)', () => { | |
| patchUiState({ showReasoning: false }) | |
| const appended: Msg[] = [] | |
| const onEvent = createGatewayEventHandler(buildCtx(appended)) | |
| onEvent({ payload: {}, type: 'message.start' } as any) | |
| onEvent({ | |
| payload: { label: 'openrouter:openai/gpt-5.5', text: 'Four.' }, | |
| type: 'moa.reference' | |
| } as any) | |
| const segments = getTurnState().streamSegments | |
| const refBlocks = segments.filter(m => typeof m.thinking === 'string' && m.thinking.includes('Reference')) | |
| expect(refBlocks).toHaveLength(1) | |
| expect(refBlocks[0]?.thinking).toContain('openrouter:openai/gpt-5.5') | |
| }) | |
| it('moa.aggregating does not append a transcript segment', () => { | |
| const appended: Msg[] = [] | |
| const onEvent = createGatewayEventHandler(buildCtx(appended)) | |
| onEvent({ payload: {}, type: 'message.start' } as any) | |
| const before = getTurnState().streamSegments.length | |
| onEvent({ payload: { aggregator: 'openrouter:anthropic/claude-opus-4.8' }, type: 'moa.aggregating' } as any) | |
| expect(getTurnState().streamSegments.length).toBe(before) | |
| }) | |
| 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('picks the polarity-matching paired palette from gateway.ready skins', async () => { | |
| const appended: Msg[] = [] | |
| const skin = { | |
| colors: { banner_title: '#00FF88', banner_text: '#FFF8DC' }, | |
| light_colors: { banner_title: '#8B0000', banner_text: '#22201C' } | |
| } | |
| // Dark terminal (clean env): the dark-authored `colors` block wins. | |
| vi.stubEnv('HERMES_TUI_BACKGROUND', '') | |
| createGatewayEventHandler(buildCtx(appended))({ payload: skin, type: 'skin.changed' } as any) | |
| expect(getUiState().theme.color.primary).toBe('#00FF88') | |
| // Light terminal: the hand-tuned light_colors block wins over adaptation. | |
| vi.stubEnv('HERMES_TUI_BACKGROUND', '#ffffff') | |
| createGatewayEventHandler(buildCtx(appended))({ payload: skin, type: 'skin.changed' } as any) | |
| expect(getUiState().theme.color.primary).toBe('#8B0000') | |
| vi.unstubAllEnvs() | |
| }) | |
| it('a skin that owns the background paints BOTH terminal defaults (OSC 11 bg + OSC 10 fg)', () => { | |
| // Default-fg tokens (markdown body, borders) render with the TERMINAL's | |
| // default foreground. A dark skin on a light terminal repaints the | |
| // backdrop black via OSC-11 β without the OSC-10 pair, those tokens stay | |
| // the host's near-black: invisible. The invariant is fg == theme text. | |
| const writes: string[] = [] | |
| const write = vi.spyOn(process.stdout, 'write').mockImplementation(chunk => { | |
| writes.push(String(chunk)) | |
| return true | |
| }) | |
| const tty = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY') | |
| Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }) | |
| try { | |
| const handle = createGatewayEventHandler(buildCtx([])) | |
| handle({ payload: { colors: { background: '#000000', ui_text: '#ff9f0a' } }, type: 'skin.changed' } as any) | |
| const joined = writes.join('') | |
| expect(joined).toContain('\x1b]11;#000000\x07') | |
| expect(joined).toContain(`\x1b]10;${getUiState().theme.color.text}\x07`) | |
| // Dropping the background releases BOTH defaults back to the terminal. | |
| writes.length = 0 | |
| handle({ payload: { colors: { ui_text: '#ff9f0a' } }, type: 'skin.changed' } as any) | |
| expect(writes.join('')).toContain('\x1b]111\x07') | |
| expect(writes.join('')).toContain('\x1b]110\x07') | |
| } finally { | |
| write.mockRestore() | |
| if (tty) { | |
| Object.defineProperty(process.stdout, 'isTTY', tty) | |
| } | |
| } | |
| }) | |
| it('infers polarity from the OSC-10 foreground only when the answer is decisive', async () => { | |
| const { polarityBackgroundFromForeground } = await import('../app/createGatewayEventHandler.js') | |
| // Bright foreground = dark theme; dark foreground = light theme. | |
| expect(polarityBackgroundFromForeground('#cccccc')).toBe('#1e1e1e') | |
| expect(polarityBackgroundFromForeground('#333333')).toBe('#ffffff') | |
| // Unset-default fingerprints and ambiguous mid-grays commit nothing. | |
| expect(polarityBackgroundFromForeground('#000000')).toBeUndefined() | |
| expect(polarityBackgroundFromForeground('#ffffff')).toBeUndefined() | |
| expect(polarityBackgroundFromForeground('#808080')).toBeUndefined() | |
| expect(polarityBackgroundFromForeground('not-a-color')).toBeUndefined() | |
| }) | |
| it('claims wake-word ownership when the gateway becomes ready', () => { | |
| const ctx = buildCtx([]) | |
| createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any) | |
| expect(ctx.gateway.rpc).toHaveBeenCalledWith('wake.start', { surface: 'tui' }) | |
| }) | |
| it('ends voice mode on a stop-phrase transcript without submitting a turn', () => { | |
| const ctx = buildCtx([]) | |
| const onEvent = createGatewayEventHandler(ctx) | |
| onEvent({ payload: { stop_phrase: true, text: 'stop' }, type: 'voice.transcript' } as any) | |
| expect(ctx.voice.setVoiceEnabled).toHaveBeenCalledWith(false) | |
| expect(ctx.voice.setRecording).toHaveBeenCalledWith(false) | |
| expect(ctx.voice.setProcessing).toHaveBeenCalledWith(false) | |
| expect(ctx.system.sys).toHaveBeenCalledWith('voice: stop phrase β voice chat ended') | |
| // The stop phrase is user intent to END the chat β never a turn. | |
| expect(ctx.submission.submitRef.current).not.toHaveBeenCalled() | |
| }) | |
| it('ends voice mode on a typed stop phrase consumed server-side', () => { | |
| const ctx = buildCtx([]) | |
| const onEvent = createGatewayEventHandler(ctx) | |
| onEvent({ payload: { stop_phrase: true, typed: true }, type: 'voice.transcript' } as any) | |
| expect(ctx.voice.setVoiceEnabled).toHaveBeenCalledWith(false) | |
| expect(ctx.submission.submitRef.current).not.toHaveBeenCalled() | |
| }) | |
| it('still submits ordinary voice transcripts as turns', async () => { | |
| const ctx = buildCtx([]) | |
| const onEvent = createGatewayEventHandler(ctx) | |
| onEvent({ payload: { text: 'stop the docker container' }, type: 'voice.transcript' } as any) | |
| await vi.waitFor(() => expect(ctx.submission.submitRef.current).toHaveBeenCalledWith('stop the docker container')) | |
| expect(ctx.voice.setVoiceEnabled).not.toHaveBeenCalled() | |
| }) | |
| it('leaves voice transcripts editable when voice.submit_mode is draft', async () => { | |
| const ctx = buildCtx([]) | |
| let composerInput = 'existing draft' | |
| ctx.gateway.rpc = vi.fn(async (method: string) => | |
| method === 'config.get' ? { config: { voice: { submit_mode: 'draft' } } } : null | |
| ) | |
| ctx.composer.setInput = vi.fn((next: string | ((current: string) => string)) => { | |
| composerInput = typeof next === 'function' ? next(composerInput) : next | |
| }) | |
| const onEvent = createGatewayEventHandler(ctx) | |
| onEvent({ payload: { text: ' edit this first ' }, type: 'voice.transcript' } as any) | |
| await vi.waitFor(() => expect(composerInput).toBe('existing draft edit this first')) | |
| expect(ctx.submission.submitRef.current).not.toHaveBeenCalled() | |
| expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.get', { key: 'full' }) | |
| }) | |
| it('falls back to direct submit for an invalid voice.submit_mode', async () => { | |
| const ctx = buildCtx([]) | |
| ctx.gateway.rpc = vi.fn(async (method: string) => | |
| method === 'config.get' ? { config: { voice: { submit_mode: 'refine' } } } : null | |
| ) | |
| const onEvent = createGatewayEventHandler(ctx) | |
| onEvent({ payload: { text: 'send safely' }, type: 'voice.transcript' } as any) | |
| await vi.waitFor(() => expect(ctx.submission.submitRef.current).toHaveBeenCalledWith('send safely')) | |
| expect(ctx.composer.setInput).toHaveBeenCalledWith('') | |
| }) | |
| it('opens a fresh session before starting voice after wake detection', async () => { | |
| const ctx = buildCtx([]) | |
| ctx.session.newSession = vi.fn(async () => patchUiState({ sid: 'wake-session' })) | |
| patchUiState({ sid: 'old-session' }) | |
| createGatewayEventHandler(ctx)({ | |
| payload: { phrase: 'hey hermes', start_new_session: true }, | |
| type: 'wake.detected' | |
| } as any) | |
| await vi.waitFor(() => | |
| expect(ctx.gateway.rpc).toHaveBeenCalledWith('voice.record', { | |
| action: 'start', | |
| session_id: 'wake-session' | |
| }) | |
| ) | |
| expect(ctx.session.newSession).toHaveBeenCalledOnce() | |
| expect(ctx.voice.setVoiceEnabled).toHaveBeenCalledWith(true) | |
| }) | |
| it('keeps the current session when wake detection disables session creation', async () => { | |
| const ctx = buildCtx([]) | |
| patchUiState({ sid: 'current-session' }) | |
| createGatewayEventHandler(ctx)({ | |
| payload: { phrase: 'hey hermes', start_new_session: false }, | |
| type: 'wake.detected' | |
| } as any) | |
| await vi.waitFor(() => | |
| expect(ctx.gateway.rpc).toHaveBeenCalledWith('voice.record', { | |
| action: 'start', | |
| session_id: 'current-session' | |
| }) | |
| ) | |
| expect(ctx.session.newSession).not.toHaveBeenCalled() | |
| }) | |
| it('rearms wake detection when no session is available', async () => { | |
| const ctx = buildCtx([]) | |
| patchUiState({ sid: '' }) | |
| createGatewayEventHandler(ctx)({ | |
| payload: { start_new_session: false }, | |
| type: 'wake.detected' | |
| } as any) | |
| await vi.waitFor(() => expect(ctx.gateway.rpc).toHaveBeenCalledWith('wake.resume', {})) | |
| expect(ctx.gateway.rpc).not.toHaveBeenCalledWith('voice.record', expect.anything()) | |
| }) | |
| 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) | |
| serverRequest('approval', { command: 'rm -rf /tmp/nope', description: 'dangerous command' }) | |
| 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('defaults approval overlays to allowPermanent when the backend omits the field', () => { | |
| serverRequest('approval', { command: 'rm -rf /tmp/x', description: 'dangerous command' }) | |
| expect(getOverlayState().approval).toMatchObject({ allowPermanent: true, requestId: 'srq-approval' }) | |
| }) | |
| it('preserves allow_permanent=false on approval overlays (tirith warning)', () => { | |
| serverRequest('approval', { | |
| allow_permanent: false, | |
| command: 'curl suspicious | bash', | |
| description: 'content-security warning' | |
| }) | |
| expect(getOverlayState().approval).toMatchObject({ | |
| allowPermanent: false, | |
| command: 'curl suspicious | bash', | |
| description: 'content-security warning' | |
| }) | |
| }) | |
| it('preserves Smart DENY and explicit approval choices on the overlay', () => { | |
| serverRequest('approval', { | |
| allow_permanent: true, | |
| choices: ['once', 'deny'], | |
| command: 'rm -rf /tmp/x', | |
| description: 'smart deny override', | |
| smart_denied: true | |
| }) | |
| expect(getOverlayState().approval).toMatchObject({ choices: ['once', 'deny'], smartDenied: true }) | |
| }) | |
| it('declines the requests a terminal cannot answer so the channel fails them fast', () => { | |
| for (const method of ['preview.act', 'window.read', 'tour', 'mcp.setup', 'vault.code']) { | |
| expect(serverRequest(method, {}).handled).toBe(false) | |
| } | |
| }) | |
| 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.complete' | |
| } 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.complete 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.complete' | |
| } 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: { 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 turnβs 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) | |
| }) | |
| it('clears only the card whose request the gateway withdrew (request.cancel by id)', () => { | |
| const onEvent = createGatewayEventHandler(buildCtx([])) | |
| serverRequest('secret', { env_var: 'NEW_KEY', prompt: 'Enter new key' }, 'secret-new') | |
| serverRequest('sudo', {}, 'sudo-1') | |
| onEvent({ payload: { id: 'secret-old', method: 'secret', reason: 'timeout' }, type: 'request.cancel' } as any) | |
| expect(getOverlayState().secret?.requestId).toBe('secret-new') | |
| onEvent({ payload: { id: 'secret-new', method: 'secret', reason: 'timeout' }, type: 'request.cancel' } as any) | |
| expect(getOverlayState().secret).toBeNull() | |
| expect(getOverlayState().sudo?.requestId).toBe('sudo-1') | |
| onEvent({ payload: { id: 'sudo-1', method: 'sudo', reason: 'interrupted' }, type: 'request.cancel' } as any) | |
| expect(getOverlayState().sudo).toBeNull() | |
| }) | |
| // ββ Batch (multi-question) clarify βββββββββββββββββββββββββββββββββ | |
| it('parses a batch clarify request into a questions overlay', () => { | |
| serverRequest( | |
| 'clarify', | |
| { | |
| questions: [ | |
| { choices: ['a', 'b'], qid: 'q0', question: 'One?' }, | |
| { choices: null, qid: 'q1', question: 'Two?' } | |
| ] | |
| }, | |
| 'req-batch' | |
| ) | |
| const clarify = getOverlayState().clarify | |
| expect(clarify?.requestId).toBe('req-batch') | |
| expect(clarify?.questions).toHaveLength(2) | |
| expect(clarify?.questions?.[0]?.qid).toBe('q0') | |
| expect(clarify?.questions?.[1]?.choices).toBeNull() | |
| expect(clarify?.answers).toEqual({}) | |
| }) | |
| it('seeds locked answers from a reconnect-replayed batch clarify request', () => { | |
| serverRequest( | |
| 'clarify', | |
| { | |
| answers: { q0: 'a' }, | |
| questions: [ | |
| { choices: ['a', 'b'], qid: 'q0', question: 'One?' }, | |
| { choices: null, qid: 'q1', question: 'Two?' } | |
| ] | |
| }, | |
| 'req-replay' | |
| ) | |
| expect(getOverlayState().clarify?.answers).toEqual({ q0: 'a' }) | |
| }) | |
| it('drops malformed batch entries and falls back to single-question shape when none survive', () => { | |
| serverRequest( | |
| 'clarify', | |
| { | |
| choices: ['x', 'y'], | |
| question: 'Fallback?', | |
| questions: [ | |
| { qid: '', question: 'no qid' }, | |
| { qid: 'q1', question: ' ' } | |
| ] | |
| }, | |
| 'req-bad' | |
| ) | |
| const clarify = getOverlayState().clarify | |
| expect(clarify?.questions).toBeUndefined() | |
| expect(clarify?.question).toBe('Fallback?') | |
| expect(clarify?.choices).toEqual(['x', 'y']) | |
| }) | |
| it('persists an abandoned batch clarify with its locked partials on tool.complete', () => { | |
| const appended: Msg[] = [] | |
| const onEvent = createGatewayEventHandler(buildCtx(appended)) | |
| patchOverlayState({ | |
| clarify: { | |
| answers: { q0: 'alpha' }, | |
| choices: null, | |
| question: '', | |
| questions: [ | |
| { choices: ['alpha', 'beta'], qid: 'q0', question: 'One?' }, | |
| { choices: null, qid: 'q1', question: 'Two?' } | |
| ], | |
| requestId: 'req-batch-timeout' | |
| } | |
| }) | |
| onEvent({ payload: { name: 'clarify', tool_id: 'clar-b' }, type: 'tool.complete' } as any) | |
| const record = appended.find(msg => msg.role === 'system' && msg.text.startsWith('ask (2 questions)')) | |
| expect(record).toBeDefined() | |
| expect(record?.text).toContain('β One? β alpha') | |
| expect(record?.text).toContain('Β· Two? (no answer)') | |
| expect(getOverlayState().clarify).toBeNull() | |
| }) | |
| // ββ 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() | |
| }) | |
| }) | |
| describe('billing.step_up.verification', () => { | |
| beforeEach(() => { | |
| openExternalUrlMock.mockClear() | |
| }) | |
| it('renders the verification link + code and opens the browser', () => { | |
| const ctx = buildCtx([]) | |
| const onEvent = createGatewayEventHandler(ctx) | |
| onEvent({ | |
| payload: { user_code: 'WXYZ-9999', verification_url: 'https://portal.example/device?code=WXYZ' }, | |
| type: 'billing.step_up.verification' | |
| } as any) | |
| const printed = (ctx.system.sys as ReturnType<typeof vi.fn>).mock.calls.map(c => c[0]).join('\n') | |
| expect(printed).toContain('https://portal.example/device?code=WXYZ') | |
| expect(printed).toContain('WXYZ-9999') | |
| expect(openExternalUrlMock).toHaveBeenCalledWith('https://portal.example/device?code=WXYZ') | |
| }) | |
| it('no-ops on a missing verification_url (never opens a browser)', () => { | |
| const ctx = buildCtx([]) | |
| const onEvent = createGatewayEventHandler(ctx) | |
| onEvent({ payload: { verification_url: '' }, type: 'billing.step_up.verification' } as any) | |
| expect(openExternalUrlMock).not.toHaveBeenCalled() | |
| }) | |
| }) | |
| describe('session.usage', () => { | |
| it('merges a live usage tick into uiState (payload.usage shape, see tui_gateway _start_usage_ticker)', () => { | |
| patchUiState({ sid: 'sess-1' }) | |
| const onEvent = createGatewayEventHandler(buildCtx([])) | |
| onEvent({ | |
| payload: { usage: { calls: 3, context_percent: 42, input: 1200, output: 80, total: 1280 } }, | |
| session_id: 'sess-1', | |
| type: 'session.usage' | |
| } as any) | |
| expect(getUiState().usage).toMatchObject({ context_percent: 42, input: 1200, total: 1280 }) | |
| }) | |
| it('keeps existing usage fields when the tick only carries a subset', () => { | |
| patchUiState({ sid: 'sess-1', usage: { calls: 2, input: 500, output: 40, total: 540 } }) | |
| const onEvent = createGatewayEventHandler(buildCtx([])) | |
| onEvent({ | |
| payload: { usage: { context_percent: 55 } }, | |
| session_id: 'sess-1', | |
| type: 'session.usage' | |
| } as any) | |
| expect(getUiState().usage).toMatchObject({ context_percent: 55, input: 500, total: 540 }) | |
| }) | |
| it('drops a tick for a non-focused session', () => { | |
| patchUiState({ sid: 'focused', usage: ZERO }) | |
| const onEvent = createGatewayEventHandler(buildCtx([])) | |
| onEvent({ | |
| payload: { usage: { input: 9999, total: 9999 } }, | |
| session_id: 'background', | |
| type: 'session.usage' | |
| } as any) | |
| expect(getUiState().usage).toEqual(ZERO) | |
| }) | |
| }) | |
| describe('message.interim', () => { | |
| it('finalizes an interim segment without settling the turn', () => { | |
| const appended: Msg[] = [] | |
| const onEvent = createGatewayEventHandler(buildCtx(appended)) | |
| onEvent({ payload: {}, type: 'message.start' } as any) | |
| onEvent({ payload: { text: 'streaming text' }, type: 'message.delta' } as any) | |
| onEvent({ payload: { already_streamed: true, text: 'streaming text' }, type: 'message.interim' } as any) | |
| // Turn is still active β busy stays true, no completion messages appended | |
| expect(getUiState().busy).toBe(true) | |
| expect(appended).toHaveLength(0) | |
| }) | |
| it('keeps identical interim and terminal replies as separate messages without response_previewed', () => { | |
| const appended: Msg[] = [] | |
| const onEvent = createGatewayEventHandler(buildCtx(appended)) | |
| onEvent({ payload: {}, type: 'message.start' } as any) | |
| onEvent({ payload: { already_streamed: true, text: 'same reply' }, type: 'message.interim' } as any) | |
| onEvent({ payload: { text: 'same reply' }, type: 'message.complete' } as any) | |
| const assistantMsgs = appended.filter(m => m.role === 'assistant' && m.text) | |
| expect(assistantMsgs).toHaveLength(2) | |
| }) | |
| it('settles identical terminal reply onto interim when response_previewed', () => { | |
| const appended: Msg[] = [] | |
| const onEvent = createGatewayEventHandler(buildCtx(appended)) | |
| onEvent({ payload: {}, type: 'message.start' } as any) | |
| onEvent({ payload: { already_streamed: true, text: 'same reply' }, type: 'message.interim' } as any) | |
| onEvent({ payload: { response_previewed: true, text: 'same reply' }, type: 'message.complete' } as any) | |
| // With response_previewed, the terminal reply is the same model | |
| // response that was published provisionally β settle onto the | |
| // interim instead of duplicating. (#65919 review) | |
| const assistantMsgs = appended.filter(m => m.role === 'assistant' && m.text) | |
| expect(assistantMsgs).toHaveLength(1) | |
| expect(assistantMsgs[0]?.text).toBe('same reply') | |
| }) | |
| it('deduplicates flushed chunks within the terminal message after an interim boundary', () => { | |
| const appended: Msg[] = [] | |
| const onEvent = createGatewayEventHandler(buildCtx(appended)) | |
| onEvent({ payload: {}, type: 'message.start' } as any) | |
| // Interim seals the first segment | |
| onEvent({ payload: { already_streamed: true, text: 'interim answer' }, type: 'message.interim' } as any) | |
| // Post-interim deltas that match the final text β these get deduped | |
| onEvent({ payload: { text: 'final answer' }, type: 'message.delta' } as any) | |
| onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any) | |
| const texts = appended.filter(m => m.role === 'assistant' && m.text).map(m => m.text) | |
| // interim + final, no duplication of the final | |
| expect(texts).toContain('interim answer') | |
| expect(texts.filter(t => t === 'final answer')).toHaveLength(1) | |
| }) | |
| it('ignores malformed message.interim payload', () => { | |
| const appended: Msg[] = [] | |
| const onEvent = createGatewayEventHandler(buildCtx(appended)) | |
| onEvent({ payload: {}, type: 'message.start' } as any) | |
| // No payload at all | |
| onEvent({ type: 'message.interim' } as any) | |
| // Empty text | |
| onEvent({ payload: { text: '' }, type: 'message.interim' } as any) | |
| // Undefined text | |
| onEvent({ payload: { text: undefined }, type: 'message.interim' } as any) | |
| // Turn continues without finalizing or throwing | |
| expect(getUiState().busy).toBe(true) | |
| expect(appended).toHaveLength(0) | |
| }) | |
| }) | |
| }) | |