Files
hermes-agent/apps/desktop/src/lib/voice-stop-word.test.ts
T
Teknium ba13132298 fix(voice): bare stop phrase ends the voice chat on every surface, spoken or typed
Saying OR typing a configured stop phrase (voice.stop_phrases, default
"stop") now ends the voice chat everywhere, not just classic CLI PTT:

- hermes_cli/voice.py: new explicit on_stop_phrase callback through
  start_continuous/stop_continuous. The force-transcribe path previously
  DISCARDED the stop phrase silently — with auto_restart=False the client
  re-arms the next capture, so the conversation never ended. Both halt
  paths now fire on_stop_phrase (fallback: on_silent_limit for legacy
  callers) as user intent, distinct from the no-speech timeout.
- tui_gateway/server.py: voice.record wires on_stop_phrase and emits
  voice.transcript {stop_phrase: true} after flipping HERMES_VOICE(_TTS)
  off and stopping streaming TTS — same teardown as /voice off. The TTS
  barge-in monitor stop-checks its transcript too. prompt.submit consumes
  a TYPED bare stop phrase at the server-side choke point when voice mode
  is on (returns {voice_stopped: true}, no turn starts).
- ui-tui: voice.transcript {stop_phrase} ends voice mode with a clear
  'voice chat ended' notice (distinct from the no-speech-limit message);
  submitPrompt releases the busy latch on a consumed voice_stopped reply.
- cli.py: _typed_voice_stop in process_loop — typing a bare stop phrase
  while voice mode/continuous is active ends voice mode instead of
  sending 'stop' to the agent; typed 'stop' outside voice mode is
  unchanged. Voice transcripts skip the check (already stop-checked).
- desktop: interceptsTypedVoiceStop — the composer's onSubmit ends the
  live voice conversation (same path as clicking end on the pill) when a
  bare stop command is typed with no attachments; renderer-owned loop, so
  handled client-side like the existing spoken isVoiceStopCommand.
- tools/voice_mode.py: transcribe_recording never lets the Whisper
  hallucination filter swallow a configured stop phrase (e.g. 'bye'
  configured as a stop phrase is both a hallucination-blocklist entry and
  a stop phrase — stop-phrase check now wins).

Tests: continuous-loop signal (sabotage-verified), force-transcribe stop
signal + legacy fallback, hallucination-filter ordering, typed-stop CLI
unit tests (voice on/off/longer text), prompt.submit typed-stop gateway
tests, TUI vitest for stop_phrase event handling, desktop vitest for the
typed-stop interceptor.
2026-07-29 00:01:06 -07:00

87 lines
2.6 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { interceptsTypedVoiceStop, isVoiceStopCommand } from './voice-stop-word'
describe('isVoiceStopCommand', () => {
it('matches bare stop commands', () => {
for (const phrase of ['stop', 'Stop', 'STOP', 'stop.', 'stop!', ' stop ', 'stop…']) {
expect(isVoiceStopCommand(phrase)).toBe(true)
}
})
it('matches multi-word stop phrases', () => {
for (const phrase of [
'stop listening',
'stop it',
'please stop',
'stop please',
"that's all",
'that is all',
'never mind',
'nevermind',
'end conversation',
'end the conversation',
'goodbye',
'bye',
'cancel'
]) {
expect(isVoiceStopCommand(phrase)).toBe(true)
}
})
it('matches stop commands addressed to Hermes', () => {
for (const phrase of ['hermes stop', 'hey hermes stop', 'hey hermes, stop', 'ok stop', 'okay stop']) {
expect(isVoiceStopCommand(phrase)).toBe(true)
}
})
it('does NOT match substantive requests that merely contain "stop"', () => {
for (const phrase of [
'stop the docker container',
'how do I stop a running process',
'can you stop the deployment',
'stop the music and play something else',
"don't stop now",
'the bus stop is closed'
]) {
expect(isVoiceStopCommand(phrase)).toBe(false)
}
})
it('does not match bare address words or empty input', () => {
for (const phrase of ['', ' ', 'hermes', 'hey hermes', 'ok', 'okay', 'hey']) {
expect(isVoiceStopCommand(phrase)).toBe(false)
}
})
it('does not match unrelated short utterances', () => {
for (const phrase of ['hello', 'yes', 'what time is it', 'thanks']) {
expect(isVoiceStopCommand(phrase)).toBe(false)
}
})
})
describe('interceptsTypedVoiceStop', () => {
it('intercepts a typed bare stop command while the conversation is active', () => {
for (const text of ['stop', 'Stop.', 'never mind', 'hey hermes, stop']) {
expect(interceptsTypedVoiceStop(true, text)).toBe(true)
}
})
it('never intercepts when the voice conversation is inactive', () => {
for (const text of ['stop', 'never mind', 'goodbye']) {
expect(interceptsTypedVoiceStop(false, text)).toBe(false)
}
})
it('passes through substantive messages during a conversation', () => {
for (const text of ['stop the docker container', 'how do I stop a process', 'hello']) {
expect(interceptsTypedVoiceStop(true, text)).toBe(false)
}
})
it('passes through when attachments ride along (real payload)', () => {
expect(interceptsTypedVoiceStop(true, 'stop', 1)).toBe(false)
})
})