mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
The TUI had drifted from the CLI's voice model in two ways:
- /voice on was lighting up the microphone immediately and Ctrl+B was
interpreted as a mode toggle. The CLI separates the two: /voice on
just flips the umbrella bit, recording only starts once the user
presses Ctrl+B, which also sets _voice_continuous so the VAD loop
auto-restarts until the user presses Ctrl+B again or three silent
cycles pass.
- /voice tts was missing entirely, so users couldn't turn agent reply
speech on/off from inside the TUI.
This commit brings the TUI to parity.
Python
- hermes_cli/voice.py: continuous-mode API (start_continuous,
stop_continuous, is_continuous_active) layered on the existing PTT
wrappers. The silence callback transcribes, fires on_transcript,
tracks consecutive no-speech cycles, and auto-restarts — mirroring
cli.py:_voice_stop_and_transcribe + _restart_recording.
- tui_gateway/server.py:
- voice.toggle now supports on / off / tts / status. The umbrella
bit lives in HERMES_VOICE + display.voice_enabled; tts lives in
HERMES_VOICE_TTS + display.voice_tts. /voice off also tears down
any active continuous loop so a toggle-off really releases the
microphone.
- voice.record start/stop now drives start_continuous/stop_continuous.
start is refused with a clear error when the mode is off, matching
cli.py:handle_voice_record's early return on `not _voice_mode`.
- New voice.transcript / voice.status events emit through
_voice_emit (remembers the sid that last enabled the mode so
events land in the right session).
TypeScript
- gatewayTypes.ts: voice.status + voice.transcript event
discriminants; VoiceToggleResponse gains tts; VoiceRecordResponse
gains status for the new "started/stopped" responses.
- interfaces.ts: GatewayEventHandlerContext gains composer.setInput +
submission.submitRef + voice.{setRecording, setProcessing,
setVoiceEnabled}; InputHandlerContext.voice gains enabled +
setVoiceEnabled for the mode-aware Ctrl+B handler.
- createGatewayEventHandler.ts: voice.status drives REC/STT badges;
voice.transcript auto-submits when the composer is empty (CLI
_pending_input.put parity) and appends when a draft is in flight.
no_speech_limit flips voice off + sys line.
- useInputHandlers.ts: Ctrl+B now calls voice.record (start/stop),
not voice.toggle, and nudges the user with a sys line when the
mode is off instead of silently flipping it on.
- useMainApp.ts: wires the new event-handler context fields.
- slash/commands/session.ts: /voice handles on / off / tts / status
with CLI-matching output ("voice: mode on · tts off").
Backward compat preserved for voice.record (was always PTT shape;
gateway still honours start/stop with mode-gating added).
323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js'
|
|
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
|
import { turnController } from '../app/turnController.js'
|
|
import { getTurnState, 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(),
|
|
setInput: vi.fn()
|
|
},
|
|
gateway: {
|
|
gw: { request: vi.fn() },
|
|
rpc: vi.fn(async () => null)
|
|
},
|
|
session: {
|
|
STARTUP_RESUME_ID: '',
|
|
colsRef: ref(80),
|
|
newSession: vi.fn(),
|
|
resetSession: vi.fn(),
|
|
resumeById: vi.fn(),
|
|
setCatalog: vi.fn()
|
|
},
|
|
submission: {
|
|
submitRef: { current: vi.fn() }
|
|
},
|
|
system: {
|
|
bellOnComplete: false,
|
|
sys: vi.fn()
|
|
},
|
|
transcript: {
|
|
appendMessage: (msg: Msg) => appended.push(msg),
|
|
panel: (title: string, sections: any[]) =>
|
|
appended.push({ kind: 'panel', panelData: { sections, title }, role: 'system', text: '' }),
|
|
setHistoryItems: vi.fn()
|
|
},
|
|
voice: {
|
|
setProcessing: vi.fn(),
|
|
setRecording: vi.fn(),
|
|
setVoiceEnabled: vi.fn()
|
|
}
|
|
}) as any
|
|
|
|
describe('createGatewayEventHandler', () => {
|
|
beforeEach(() => {
|
|
resetOverlayState()
|
|
resetUiState()
|
|
resetTurnState()
|
|
turnController.fullReset()
|
|
patchUiState({ showReasoning: true })
|
|
})
|
|
|
|
it('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('attaches inline_diff to the assistant completion body', () => {
|
|
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'
|
|
|
|
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 buffered for message.complete and sanitized (ANSI stripped).
|
|
expect(appended).toHaveLength(0)
|
|
expect(turnController.pendingInlineDiffs).toEqual([cleaned])
|
|
|
|
onEvent({
|
|
payload: { text: 'patch applied' },
|
|
type: 'message.complete'
|
|
} as any)
|
|
|
|
// Diff is rendered in the same assistant message body as the completion.
|
|
expect(appended).toHaveLength(1)
|
|
expect(appended[0]).toMatchObject({ role: 'assistant' })
|
|
expect(appended[0]?.text).toContain('patch applied')
|
|
expect(appended[0]?.text).toContain('```diff')
|
|
expect(appended[0]?.text).toContain(cleaned)
|
|
})
|
|
|
|
it('does not append inline_diff twice when assistant text already contains it', () => {
|
|
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)
|
|
|
|
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 queued inline diffs', () => {
|
|
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)
|
|
|
|
expect(appended).toHaveLength(1)
|
|
expect(appended[0]?.text).not.toContain('┊ review diff')
|
|
expect(appended[0]?.text).toContain('--- a/foo.ts')
|
|
})
|
|
|
|
it('suppresses inline_diff when assistant already wrote a 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)
|
|
|
|
expect(appended).toHaveLength(1)
|
|
expect(appended[0]?.tools?.[0]).toContain('Review Diff')
|
|
expect(appended[0]?.tools?.[0]).not.toContain('--- a/foo.ts')
|
|
expect(appended[0]?.text).toContain('```diff')
|
|
})
|
|
|
|
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('keeps gateway noise informational and approval out of Activity', async () => {
|
|
const appended: Msg[] = []
|
|
const ctx = buildCtx(appended)
|
|
ctx.gateway.rpc = vi.fn(async () => {
|
|
throw new Error('cold start')
|
|
})
|
|
|
|
const onEvent = createGatewayEventHandler(ctx)
|
|
|
|
onEvent({ payload: { line: 'Traceback: noisy but non-fatal' }, type: 'gateway.stderr' } as any)
|
|
onEvent({ payload: { preview: 'bad framing' }, type: 'gateway.protocol_error' } as any)
|
|
onEvent({
|
|
payload: { command: 'rm -rf /tmp/nope', description: 'dangerous command' },
|
|
type: 'approval.request'
|
|
} as any)
|
|
onEvent({ payload: {}, type: 'gateway.ready' } as any)
|
|
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
|
|
expect(getOverlayState().approval).toMatchObject({ description: 'dangerous command' })
|
|
expect(getTurnState().activity).toMatchObject([
|
|
{ text: 'Traceback: noisy but non-fatal', tone: 'info' },
|
|
{ text: 'protocol noise detected · /logs to inspect', tone: 'info' },
|
|
{ text: 'protocol noise: bad framing', tone: 'info' },
|
|
{ text: 'command catalog unavailable: cold start', tone: 'info' }
|
|
])
|
|
})
|
|
|
|
it('still surfaces terminal turn failures as errors', () => {
|
|
const appended: Msg[] = []
|
|
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
|
|
|
onEvent({ payload: { message: 'boom' }, type: 'error' } as any)
|
|
|
|
expect(getTurnState().activity).toMatchObject([{ text: 'boom', tone: 'error' }])
|
|
})
|
|
})
|