Files
hermes-agent/ui-tui/src/__tests__/createGatewayEventHandler.test.ts
T
Brooklyn Nicholson dff1c8fcf1 fix(tui): tool inline_diff renders inline with the active turn
Reported during TUI v2 blitz retest: code-review diffs from tool.complete
appeared at the top of the current interaction thread, out of sequence
with the agent's messages and tool rows below them.

Root cause — `sys(inline_diff)` appends to `historyItems`, which sits
above the `StreamingAssistant` pane that renders the active turn.
Until the turn closed, the diff visually floated above everything
else happening in the same turn.

Route the diff through `turnController.appendSegmentMessage` instead
so it flushes any pending streaming text first, then lands in the
segment stream beside assistant output and tool calls.  On
`message.complete` the segment list is committed to history in emit
order (diff → final text), matching what the gateway sent.

Adds a regression test that exercises tool.complete → message.complete
with an inline_diff payload and asserts both the streaming and final
placement.
2026-04-21 18:35:59 -05:00

198 lines
6.3 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js'
import { resetOverlayState } from '../app/overlayStore.js'
import { turnController } from '../app/turnController.js'
import { resetTurnState } from '../app/turnStore.js'
import { patchUiState, resetUiState } from '../app/uiStore.js'
import { estimateTokensRough } from '../lib/text.js'
import type { Msg } from '../types.js'
const ref = <T>(current: T) => ({ current })
const buildCtx = (appended: Msg[]) =>
({
composer: {
dequeue: () => undefined,
queueEditRef: ref<null | number>(null),
sendQueued: vi.fn()
},
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()
},
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()
}
}) as any
describe('createGatewayEventHandler', () => {
beforeEach(() => {
resetOverlayState()
resetUiState()
resetTurnState()
turnController.fullReset()
patchUiState({ showReasoning: true })
})
it('persists completed tool rows when message.complete lands immediately after tool.complete', () => {
const appended: Msg[] = []
turnController.reasoningText = 'mapped the page'
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { context: 'home page', name: 'search', tool_id: 'tool-1' },
type: 'tool.start'
} as any)
onEvent({
payload: { name: 'search', preview: 'hero cards' },
type: 'tool.progress'
} as any)
onEvent({
payload: { summary: 'done', tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
onEvent({
payload: { text: 'final answer' },
type: 'message.complete'
} as any)
expect(appended).toHaveLength(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[] = []
turnController.reasoningText = 'mapped the page'
createGatewayEventHandler(buildCtx(appended))({
payload: { context: 'home page', name: 'search', tool_id: 'tool-1' },
type: 'tool.start'
} as any)
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { name: 'search', preview: 'hero cards' },
type: 'tool.progress'
} as any)
onEvent({
payload: { summary: 'done', tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
onEvent({
payload: { text: 'final answer' },
type: 'message.complete'
} as any)
expect(appended).toHaveLength(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)
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(1)
expect(appended[0]?.thinking).toBe(streamed)
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(streamed))
})
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(1)
expect(appended[0]?.thinking).toBe(fromServer)
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer))
})
it('routes inline_diff into the active segment stream, not historyItems', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const diff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
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)
// While streaming, nothing has flowed to historyItems yet — diff must be
// held in segmentMessages so the transcript renders it inline with the
// current turn rather than above it.
expect(appended).toHaveLength(0)
expect(turnController.segmentMessages).toContainEqual({ role: 'system', text: diff })
onEvent({
payload: { text: 'patch applied' },
type: 'message.complete'
} as any)
// After the turn closes, the diff lands in history in the order the
// gateway emitted it — before the assistant's final text, not above it.
expect(appended).toHaveLength(2)
expect(appended[0]).toMatchObject({ role: 'system', text: diff })
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'patch applied' })
})
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'
})
})
})