Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12963,15 +12963,28 @@ def _on_wake_word(self):
except Exception as e:
logger.debug("wake word new_session failed: %s", e)

# Single-utterance capture (not continuous) via the voice pipeline;
# VAD auto-stop transcribes and queues the transcript for process_loop.
# Continuous voice capture (VAD loop): the wake turns into a
# multi-turn hands-free conversation — the user keeps talking after
# each reply (auto-restart in process_loop), says a voice stop phrase
# ("stop" / config voice.stop_phrases) to end it, and the wake-word
# listener only re-arms once the voice chat actually ends
# (see _start_wake_watchdog: it keeps the detector paused while
# continuous mode holds the microphone).
with self._voice_lock:
self._voice_mode = True
self._voice_continuous = False
self._voice_continuous = True
try:
self._voice_start_recording()
except Exception as e:
_cprint(f"{_DIM}Wake capture failed: {e}{_RST}")
# Recording never started — roll back the continuous-voice flags so
# the watchdog doesn't read busy forever (it checks
# `_voice_continuous` to keep the detector paused during a live
# voice chat). Without this, a failed capture start leaves the
# listener suspended until the user toggles wake off/on.
with self._voice_lock:
self._voice_mode = False
self._voice_continuous = False
# Leave _wake_suspended set; the watchdog resumes once idle.

def _start_wake_watchdog(self):
Expand All @@ -12992,6 +13005,7 @@ def _loop():
self._agent_running
or self._voice_recording
or getattr(self, "_voice_processing", False)
or self._voice_continuous
or not self._pending_input.empty()
)
if busy:
Expand Down
149 changes: 147 additions & 2 deletions tests/tools/test_wake_word.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@

No live audio or network: the sounddevice import is faked, engines are stubbed,
and lazy-dep availability is monkeypatched. Covers config resolution, engine
dispatch, the requirements probe, the detector fire/cooldown loop, and the
process-wide singleton lifecycle.
dispatch, the requirements probe, the detector fire/cooldown loop, the
process-wide singleton lifecycle, and the CLI wake → continuous-conversation
wiring (cli.py _on_wake_word + wake watchdog).
"""

import multiprocessing
import os
import queue
import sys
import threading
import time
import types
from pathlib import Path
from unittest.mock import MagicMock

import pytest

Expand Down Expand Up @@ -624,3 +627,145 @@ def test_machine_lock_is_released_when_owner_process_exits(tmp_path):
if process.is_alive():
process.terminate()
process.join(10)


# ── CLI wake → continuous-conversation wiring ───────────────────────────
#
# cli.py _on_wake_word now enters CONTINUOUS voice mode (multi-turn VAD
# conversation) instead of capturing a single utterance. These tests pin the
# two invariants that make that safe:
# 1. On wake, _voice_continuous is set True (the process_loop auto-restart
# turns one wake into a full conversation, ended by a voice stop phrase).
# 2. The wake watchdog treats _voice_continuous as "mic busy": it must NOT
# resume the hotword listener mid-conversation (two input streams on one
# device is unreliable), and only re-arms once the conversation ends.

def _make_wake_cli(**overrides):
"""Minimal HermesCLI with only the attrs the wake path touches.

Bypasses __init__ (no config/env/API setup); mirrors the pattern in
tests/tools/test_voice_cli_integration.py.
"""
from cli import HermesCLI

cli = HermesCLI.__new__(HermesCLI)
cli._voice_lock = threading.Lock()
cli._voice_mode = False
cli._voice_tts = False
cli._voice_recorder = None
cli._voice_recording = False
cli._voice_processing = False
cli._voice_continuous = False
cli._voice_tts_done = threading.Event()
cli._voice_tts_done.set()
cli._voice_tts_stop = None
cli._voice_barge_capture = threading.Event()
cli._pending_input = queue.Queue()
cli._app = None
cli._attached_images = []
cli._should_exit = False
cli._agent_running = False
cli._wake_word_active = False
cli._wake_suspended = False
cli._wake_start_new_session = False
cli._wake_watchdog_started = False
for k, v in overrides.items():
setattr(cli, k, v)
return cli


def test_wake_enters_continuous_voice(monkeypatch):
"""Wake starts a multi-turn conversation: _voice_continuous is True."""
cli = _make_wake_cli()
monkeypatch.setattr("tools.wake_word.pause_listening", lambda **kw: True)
monkeypatch.setattr("tools.wake_word.get_last_match", lambda: None)
cli._voice_start_recording = MagicMock()

cli._on_wake_word()

assert cli._voice_mode is True
assert cli._voice_continuous is True
cli._voice_start_recording.assert_called_once()


def test_wake_capture_failure_rolls_back_continuous_flags(monkeypatch):
"""If recording can't start, _voice_continuous must be reset so the
watchdog doesn't read busy forever and keep the listener suspended.

Regression test for the AI-triage finding on PR #79696: the flag was set
before the try, and the except path returned with it stuck True — the
busy-check (`or self._voice_continuous`) then held the detector paused
until the user toggled wake off/on.
"""
cli = _make_wake_cli()
monkeypatch.setattr("tools.wake_word.pause_listening", lambda **kw: True)
monkeypatch.setattr("tools.wake_word.get_last_match", lambda: None)

def boom():
raise RuntimeError("no input device")
cli._voice_start_recording = boom

cli._on_wake_word()

assert cli._voice_mode is False
assert cli._voice_continuous is False


def test_wake_skips_when_agent_busy(monkeypatch):
"""Wake during an in-flight turn is ignored (single-flight guard)."""
cli = _make_wake_cli(_agent_running=True)
cli._voice_start_recording = MagicMock()

cli._on_wake_word()

cli._voice_start_recording.assert_not_called()


def test_wake_pause_failure_does_not_record(monkeypatch):
"""If the detector can't release the mic, we don't start recording."""
cli = _make_wake_cli()
monkeypatch.setattr("tools.wake_word.pause_listening", lambda **kw: False)
cli._voice_start_recording = MagicMock()

cli._on_wake_word()

cli._voice_start_recording.assert_not_called()


def test_wake_watchdog_holds_listener_during_continuous(monkeypatch):
"""While _voice_continuous is True the watchdog must NOT resume the mic."""
cli = _make_wake_cli()
cli._wake_word_active = True
cli._wake_suspended = True
cli._voice_continuous = True

resume_calls = []
monkeypatch.setattr("tools.wake_word.resume_listening", lambda **kw: resume_calls.append(1) or True)

cli._start_wake_watchdog()
time.sleep(1.2) # several watchdog polls while continuous

assert resume_calls == []
cli._wake_word_active = False
time.sleep(0.3)


def test_wake_watchdog_resumes_after_continuous_ends(monkeypatch):
"""After the voice chat ends, the watchdog re-arms the listener."""
cli = _make_wake_cli()
cli._wake_word_active = True
cli._wake_suspended = True
cli._voice_continuous = False

resume_calls = []
monkeypatch.setattr("tools.wake_word.resume_listening", lambda **kw: resume_calls.append(1) or True)

cli._start_wake_watchdog()
deadline = time.time() + 3.0
while not resume_calls and time.time() < deadline:
time.sleep(0.05)

assert resume_calls, "watchdog should resume the listener once idle"
cli._wake_word_active = False
time.sleep(0.3)