2026-04-15 10:20:56 -05:00
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
|
|
|
|
|
import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js'
|
|
|
|
|
import { resetOverlayState } from '../app/overlayStore.js'
|
2026-04-16 12:18:56 -05:00
|
|
|
import { turnController } from '../app/turnController.js'
|
|
|
|
|
import { resetTurnState } from '../app/turnStore.js'
|
2026-04-18 09:26:03 -05:00
|
|
|
import { patchUiState, resetUiState } from '../app/uiStore.js'
|
2026-04-15 10:20:56 -05:00
|
|
|
import { estimateTokensRough } from '../lib/text.js'
|
|
|
|
|
import type { Msg } from '../types.js'
|
|
|
|
|
|
|
|
|
|
const ref = <T>(current: T) => ({ current })
|
|
|
|
|
|
2026-04-16 12:18:56 -05:00
|
|
|
const buildCtx = (appended: Msg[]) =>
|
|
|
|
|
({
|
|
|
|
|
composer: {
|
|
|
|
|
dequeue: () => undefined,
|
|
|
|
|
queueEditRef: ref<null | number>(null),
|
|
|
|
|
sendQueued: 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(),
|
2026-04-17 10:58:01 -05:00
|
|
|
resumeById: vi.fn(),
|
2026-04-16 12:18:56 -05:00
|
|
|
setCatalog: vi.fn()
|
|
|
|
|
},
|
|
|
|
|
system: {
|
|
|
|
|
bellOnComplete: false,
|
|
|
|
|
sys: vi.fn()
|
|
|
|
|
},
|
|
|
|
|
transcript: {
|
|
|
|
|
appendMessage: (msg: Msg) => appended.push(msg),
|
2026-04-17 10:58:01 -05:00
|
|
|
panel: (title: string, sections: any[]) =>
|
|
|
|
|
appended.push({ kind: 'panel', panelData: { sections, title }, role: 'system', text: '' }),
|
2026-04-16 12:18:56 -05:00
|
|
|
setHistoryItems: vi.fn()
|
|
|
|
|
}
|
|
|
|
|
}) as any
|
|
|
|
|
|
2026-04-15 10:20:56 -05:00
|
|
|
describe('createGatewayEventHandler', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
resetOverlayState()
|
|
|
|
|
resetUiState()
|
2026-04-16 12:18:56 -05:00
|
|
|
resetTurnState()
|
|
|
|
|
turnController.fullReset()
|
2026-04-18 09:26:03 -05:00
|
|
|
patchUiState({ showReasoning: true })
|
2026-04-15 10:20:56 -05:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('persists completed tool rows when message.complete lands immediately after tool.complete', () => {
|
|
|
|
|
const appended: Msg[] = []
|
|
|
|
|
|
2026-04-16 12:18:56 -05:00
|
|
|
turnController.reasoningText = 'mapped the page'
|
|
|
|
|
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
2026-04-15 10:20:56 -05:00
|
|
|
|
|
|
|
|
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(1)
|
|
|
|
|
expect(appended[0]).toMatchObject({
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
text: 'final answer',
|
|
|
|
|
thinking: 'mapped the page'
|
|
|
|
|
})
|
|
|
|
|
expect(appended[0]?.tools).toHaveLength(1)
|
|
|
|
|
expect(appended[0]?.tools?.[0]).toContain('hero cards')
|
|
|
|
|
expect(appended[0]?.toolTokens).toBeGreaterThan(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps tool tokens across handler recreation mid-turn', () => {
|
|
|
|
|
const appended: Msg[] = []
|
|
|
|
|
|
2026-04-16 12:18:56 -05:00
|
|
|
turnController.reasoningText = 'mapped the page'
|
2026-04-15 10:20:56 -05:00
|
|
|
|
2026-04-16 12:18:56 -05:00
|
|
|
createGatewayEventHandler(buildCtx(appended))({
|
2026-04-15 10:20:56 -05:00
|
|
|
payload: { context: 'home page', name: 'search', tool_id: 'tool-1' },
|
|
|
|
|
type: 'tool.start'
|
|
|
|
|
} as any)
|
|
|
|
|
|
2026-04-16 12:18:56 -05:00
|
|
|
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
2026-04-15 10:20:56 -05:00
|
|
|
|
|
|
|
|
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(1)
|
|
|
|
|
expect(appended[0]?.tools).toHaveLength(1)
|
|
|
|
|
expect(appended[0]?.toolTokens).toBeGreaterThan(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('ignores fallback reasoning.available when streamed reasoning already exists', () => {
|
|
|
|
|
const appended: Msg[] = []
|
|
|
|
|
const streamed = 'short streamed reasoning'
|
|
|
|
|
const fallback = 'x'.repeat(400)
|
|
|
|
|
|
2026-04-16 12:18:56 -05:00
|
|
|
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
2026-04-15 10:20:56 -05:00
|
|
|
|
2026-04-16 12:18:56 -05:00
|
|
|
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)
|
2026-04-15 10:20:56 -05:00
|
|
|
|
|
|
|
|
expect(appended).toHaveLength(1)
|
|
|
|
|
expect(appended[0]?.thinking).toBe(streamed)
|
|
|
|
|
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(streamed))
|
|
|
|
|
})
|
2026-04-16 08:27:41 -05:00
|
|
|
|
|
|
|
|
it('uses message.complete reasoning when no streamed reasoning ref', () => {
|
|
|
|
|
const appended: Msg[] = []
|
|
|
|
|
const fromServer = 'recovered from last_reasoning'
|
|
|
|
|
|
2026-04-16 12:18:56 -05:00
|
|
|
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
2026-04-16 08:27:41 -05:00
|
|
|
|
2026-04-16 12:18:56 -05:00
|
|
|
onEvent({ payload: { reasoning: fromServer, text: 'final answer' }, type: 'message.complete' } as any)
|
2026-04-16 08:27:41 -05:00
|
|
|
|
|
|
|
|
expect(appended).toHaveLength(1)
|
|
|
|
|
expect(appended[0]?.thinking).toBe(fromServer)
|
|
|
|
|
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer))
|
|
|
|
|
})
|
2026-04-17 10:58:01 -05:00
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-04-15 10:20:56 -05:00
|
|
|
})
|