1. Anthropic + ElevenLabs TTS silence: forward full response to TTS callback for non-streaming providers (choices first, then native content blocks fallback). 2. Subprocess timeout kill: play_audio_file now kills the process on TimeoutExpired instead of leaving zombie processes. 3. Discord disconnect cleanup: leave all voice channels before closing the client to prevent leaked state. 4. Audio stream leak: close InputStream if stream.start() fails. 5. Race condition: read/write _on_silence_stop under lock in audio callback thread. 6. _vprint force=True: show API error, retry, and truncation messages even during streaming TTS. 7. _refresh_level lock: read _voice_recording under _voice_lock.
1234 lines
48 KiB
Python
1234 lines
48 KiB
Python
"""Tests for CLI voice mode integration -- command parsing, markdown stripping,
|
|
state management, streaming TTS activation, voice message prefix, _vprint."""
|
|
|
|
import ast
|
|
import os
|
|
import queue
|
|
import threading
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
def _make_voice_cli(**overrides):
|
|
"""Create a minimal HermesCLI with only voice-related attrs initialized.
|
|
|
|
Uses ``__new__()`` to bypass ``__init__`` so no config/env/API setup is
|
|
needed. Only the voice state attributes (from __init__ lines 3749-3758)
|
|
are populated.
|
|
"""
|
|
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._pending_input = queue.Queue()
|
|
cli._app = None
|
|
cli.console = SimpleNamespace(width=80)
|
|
for k, v in overrides.items():
|
|
setattr(cli, k, v)
|
|
return cli
|
|
|
|
|
|
# ============================================================================
|
|
# Markdown stripping — import real function from tts_tool
|
|
# ============================================================================
|
|
|
|
from tools.tts_tool import _strip_markdown_for_tts
|
|
|
|
|
|
class TestMarkdownStripping:
|
|
def test_strips_bold(self):
|
|
assert _strip_markdown_for_tts("This is **bold** text") == "This is bold text"
|
|
|
|
def test_strips_italic(self):
|
|
assert _strip_markdown_for_tts("This is *italic* text") == "This is italic text"
|
|
|
|
def test_strips_inline_code(self):
|
|
assert _strip_markdown_for_tts("Run `pip install foo`") == "Run pip install foo"
|
|
|
|
def test_strips_fenced_code_blocks(self):
|
|
text = "Here is code:\n```python\nprint('hello')\n```\nDone."
|
|
result = _strip_markdown_for_tts(text)
|
|
assert "print" not in result
|
|
assert "Done." in result
|
|
|
|
def test_strips_headers(self):
|
|
assert _strip_markdown_for_tts("## Summary\nSome text") == "Summary\nSome text"
|
|
|
|
def test_strips_list_markers(self):
|
|
text = "- item one\n- item two\n* item three"
|
|
result = _strip_markdown_for_tts(text)
|
|
assert "item one" in result
|
|
assert "- " not in result
|
|
assert "* " not in result
|
|
|
|
def test_strips_urls(self):
|
|
text = "Visit https://example.com for details"
|
|
result = _strip_markdown_for_tts(text)
|
|
assert "https://" not in result
|
|
assert "Visit" in result
|
|
|
|
def test_strips_markdown_links(self):
|
|
text = "See [the docs](https://example.com/docs) for info"
|
|
result = _strip_markdown_for_tts(text)
|
|
assert "the docs" in result
|
|
assert "https://" not in result
|
|
assert "[" not in result
|
|
|
|
def test_strips_horizontal_rules(self):
|
|
text = "Part one\n---\nPart two"
|
|
result = _strip_markdown_for_tts(text)
|
|
assert "---" not in result
|
|
assert "Part one" in result
|
|
assert "Part two" in result
|
|
|
|
def test_empty_after_stripping_returns_empty(self):
|
|
text = "```python\nprint('hello')\n```"
|
|
result = _strip_markdown_for_tts(text)
|
|
assert result == ""
|
|
|
|
def test_long_text_not_truncated(self):
|
|
"""_strip_markdown_for_tts does NOT truncate — that's the caller's job."""
|
|
text = "a" * 5000
|
|
result = _strip_markdown_for_tts(text)
|
|
assert len(result) == 5000
|
|
|
|
def test_complex_response(self):
|
|
text = (
|
|
"## Answer\n\n"
|
|
"Here's how to do it:\n\n"
|
|
"```python\ndef hello():\n print('hi')\n```\n\n"
|
|
"Run it with `python main.py`. "
|
|
"See [docs](https://example.com) for more.\n\n"
|
|
"- Step one\n- Step two\n\n"
|
|
"---\n\n"
|
|
"**Good luck!**"
|
|
)
|
|
result = _strip_markdown_for_tts(text)
|
|
assert "```" not in result
|
|
assert "https://" not in result
|
|
assert "**" not in result
|
|
assert "---" not in result
|
|
assert "Answer" in result
|
|
assert "Good luck!" in result
|
|
assert "docs" in result
|
|
|
|
|
|
# ============================================================================
|
|
# Voice command parsing
|
|
# ============================================================================
|
|
|
|
class TestVoiceCommandParsing:
|
|
"""Test _handle_voice_command logic without full CLI setup."""
|
|
|
|
def test_parse_subcommands(self):
|
|
"""Verify subcommand extraction from /voice commands."""
|
|
test_cases = [
|
|
("/voice on", "on"),
|
|
("/voice off", "off"),
|
|
("/voice tts", "tts"),
|
|
("/voice status", "status"),
|
|
("/voice", ""),
|
|
("/voice ON ", "on"),
|
|
]
|
|
for command, expected in test_cases:
|
|
parts = command.strip().split(maxsplit=1)
|
|
subcommand = parts[1].lower().strip() if len(parts) > 1 else ""
|
|
assert subcommand == expected, f"Failed for {command!r}: got {subcommand!r}"
|
|
|
|
|
|
# ============================================================================
|
|
# Voice state thread safety
|
|
# ============================================================================
|
|
|
|
class TestVoiceStateLock:
|
|
def test_lock_protects_state(self):
|
|
"""Verify that concurrent state changes don't corrupt state."""
|
|
lock = threading.Lock()
|
|
state = {"recording": False, "count": 0}
|
|
|
|
def toggle_many(n):
|
|
for _ in range(n):
|
|
with lock:
|
|
state["recording"] = not state["recording"]
|
|
state["count"] += 1
|
|
|
|
threads = [threading.Thread(target=toggle_many, args=(1000,)) for _ in range(4)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
assert state["count"] == 4000
|
|
|
|
|
|
# ============================================================================
|
|
# Streaming TTS lazy import activation (Bug A fix)
|
|
# ============================================================================
|
|
|
|
class TestStreamingTTSActivation:
|
|
"""Verify streaming TTS uses lazy imports to check availability."""
|
|
|
|
def test_activates_when_elevenlabs_and_sounddevice_available(self):
|
|
"""use_streaming_tts should be True when provider is elevenlabs
|
|
and both lazy imports succeed."""
|
|
use_streaming_tts = False
|
|
try:
|
|
from tools.tts_tool import (
|
|
_load_tts_config as _load_tts_cfg,
|
|
_get_provider as _get_prov,
|
|
_import_elevenlabs,
|
|
_import_sounddevice,
|
|
)
|
|
assert callable(_import_elevenlabs)
|
|
assert callable(_import_sounddevice)
|
|
except ImportError:
|
|
pytest.skip("tools.tts_tool not available")
|
|
|
|
with patch("tools.tts_tool._load_tts_config") as mock_cfg, \
|
|
patch("tools.tts_tool._get_provider", return_value="elevenlabs"), \
|
|
patch("tools.tts_tool._import_elevenlabs") as mock_el, \
|
|
patch("tools.tts_tool._import_sounddevice") as mock_sd:
|
|
mock_cfg.return_value = {"provider": "elevenlabs"}
|
|
mock_el.return_value = MagicMock()
|
|
mock_sd.return_value = MagicMock()
|
|
|
|
from tools.tts_tool import (
|
|
_load_tts_config as load_cfg,
|
|
_get_provider as get_prov,
|
|
_import_elevenlabs as import_el,
|
|
_import_sounddevice as import_sd,
|
|
)
|
|
cfg = load_cfg()
|
|
if get_prov(cfg) == "elevenlabs":
|
|
import_el()
|
|
import_sd()
|
|
use_streaming_tts = True
|
|
|
|
assert use_streaming_tts is True
|
|
|
|
def test_does_not_activate_when_elevenlabs_missing(self):
|
|
"""use_streaming_tts stays False when elevenlabs import fails."""
|
|
use_streaming_tts = False
|
|
with patch("tools.tts_tool._load_tts_config", return_value={"provider": "elevenlabs"}), \
|
|
patch("tools.tts_tool._get_provider", return_value="elevenlabs"), \
|
|
patch("tools.tts_tool._import_elevenlabs", side_effect=ImportError("no elevenlabs")):
|
|
try:
|
|
from tools.tts_tool import (
|
|
_load_tts_config as load_cfg,
|
|
_get_provider as get_prov,
|
|
_import_elevenlabs as import_el,
|
|
_import_sounddevice as import_sd,
|
|
)
|
|
cfg = load_cfg()
|
|
if get_prov(cfg) == "elevenlabs":
|
|
import_el()
|
|
import_sd()
|
|
use_streaming_tts = True
|
|
except (ImportError, OSError):
|
|
pass
|
|
|
|
assert use_streaming_tts is False
|
|
|
|
def test_does_not_activate_when_sounddevice_missing(self):
|
|
"""use_streaming_tts stays False when sounddevice import fails."""
|
|
use_streaming_tts = False
|
|
with patch("tools.tts_tool._load_tts_config", return_value={"provider": "elevenlabs"}), \
|
|
patch("tools.tts_tool._get_provider", return_value="elevenlabs"), \
|
|
patch("tools.tts_tool._import_elevenlabs", return_value=MagicMock()), \
|
|
patch("tools.tts_tool._import_sounddevice", side_effect=OSError("no PortAudio")):
|
|
try:
|
|
from tools.tts_tool import (
|
|
_load_tts_config as load_cfg,
|
|
_get_provider as get_prov,
|
|
_import_elevenlabs as import_el,
|
|
_import_sounddevice as import_sd,
|
|
)
|
|
cfg = load_cfg()
|
|
if get_prov(cfg) == "elevenlabs":
|
|
import_el()
|
|
import_sd()
|
|
use_streaming_tts = True
|
|
except (ImportError, OSError):
|
|
pass
|
|
|
|
assert use_streaming_tts is False
|
|
|
|
def test_does_not_activate_for_non_elevenlabs_provider(self):
|
|
"""use_streaming_tts stays False when provider is not elevenlabs."""
|
|
use_streaming_tts = False
|
|
with patch("tools.tts_tool._load_tts_config", return_value={"provider": "edge"}), \
|
|
patch("tools.tts_tool._get_provider", return_value="edge"):
|
|
try:
|
|
from tools.tts_tool import (
|
|
_load_tts_config as load_cfg,
|
|
_get_provider as get_prov,
|
|
_import_elevenlabs as import_el,
|
|
_import_sounddevice as import_sd,
|
|
)
|
|
cfg = load_cfg()
|
|
if get_prov(cfg) == "elevenlabs":
|
|
import_el()
|
|
import_sd()
|
|
use_streaming_tts = True
|
|
except (ImportError, OSError):
|
|
pass
|
|
|
|
assert use_streaming_tts is False
|
|
|
|
def test_stale_boolean_imports_no_longer_exist(self):
|
|
"""Confirm _HAS_ELEVENLABS and _HAS_AUDIO are not in tts_tool module."""
|
|
import tools.tts_tool as tts_mod
|
|
assert not hasattr(tts_mod, "_HAS_ELEVENLABS"), \
|
|
"_HAS_ELEVENLABS should not exist -- lazy imports replaced it"
|
|
assert not hasattr(tts_mod, "_HAS_AUDIO"), \
|
|
"_HAS_AUDIO should not exist -- lazy imports replaced it"
|
|
|
|
|
|
# ============================================================================
|
|
# Voice mode user message prefix (Bug B fix)
|
|
# ============================================================================
|
|
|
|
class TestVoiceMessagePrefix:
|
|
"""Voice mode should inject instruction via user message prefix,
|
|
not by modifying the system prompt (which breaks prompt cache)."""
|
|
|
|
def test_prefix_added_when_voice_mode_active(self):
|
|
"""When voice mode is active and message is str, agent_message
|
|
should have the voice instruction prefix."""
|
|
voice_mode = True
|
|
message = "What's the weather like?"
|
|
|
|
agent_message = message
|
|
if voice_mode and isinstance(message, str):
|
|
agent_message = (
|
|
"[Voice input — respond concisely and conversationally, "
|
|
"2-3 sentences max. No code blocks or markdown.] "
|
|
+ message
|
|
)
|
|
|
|
assert agent_message.startswith("[Voice input")
|
|
assert "What's the weather like?" in agent_message
|
|
|
|
def test_no_prefix_when_voice_mode_inactive(self):
|
|
"""When voice mode is off, message passes through unchanged."""
|
|
voice_mode = False
|
|
message = "What's the weather like?"
|
|
|
|
agent_message = message
|
|
if voice_mode and isinstance(message, str):
|
|
agent_message = (
|
|
"[Voice input — respond concisely and conversationally, "
|
|
"2-3 sentences max. No code blocks or markdown.] "
|
|
+ message
|
|
)
|
|
|
|
assert agent_message == message
|
|
|
|
def test_no_prefix_for_multimodal_content(self):
|
|
"""When message is a list (multimodal), no prefix is added."""
|
|
voice_mode = True
|
|
message = [{"type": "text", "text": "describe this"}, {"type": "image_url"}]
|
|
|
|
agent_message = message
|
|
if voice_mode and isinstance(message, str):
|
|
agent_message = (
|
|
"[Voice input — respond concisely and conversationally, "
|
|
"2-3 sentences max. No code blocks or markdown.] "
|
|
+ message
|
|
)
|
|
|
|
assert agent_message is message
|
|
|
|
def test_history_stays_clean(self):
|
|
"""conversation_history should contain the original message,
|
|
not the prefixed version."""
|
|
voice_mode = True
|
|
message = "Hello there"
|
|
conversation_history = []
|
|
|
|
conversation_history.append({"role": "user", "content": message})
|
|
|
|
agent_message = message
|
|
if voice_mode and isinstance(message, str):
|
|
agent_message = (
|
|
"[Voice input — respond concisely and conversationally, "
|
|
"2-3 sentences max. No code blocks or markdown.] "
|
|
+ message
|
|
)
|
|
|
|
assert conversation_history[-1]["content"] == "Hello there"
|
|
assert agent_message.startswith("[Voice input")
|
|
assert agent_message != conversation_history[-1]["content"]
|
|
|
|
def test_enable_voice_mode_does_not_modify_system_prompt(self):
|
|
"""_enable_voice_mode should NOT modify self.system_prompt or
|
|
agent.ephemeral_system_prompt -- the system prompt must stay
|
|
stable to preserve prompt cache."""
|
|
cli = SimpleNamespace(
|
|
_voice_mode=False,
|
|
_voice_tts=False,
|
|
_voice_lock=threading.Lock(),
|
|
system_prompt="You are helpful",
|
|
agent=SimpleNamespace(ephemeral_system_prompt="You are helpful"),
|
|
)
|
|
|
|
original_system = cli.system_prompt
|
|
original_ephemeral = cli.agent.ephemeral_system_prompt
|
|
|
|
cli._voice_mode = True
|
|
|
|
assert cli.system_prompt == original_system
|
|
assert cli.agent.ephemeral_system_prompt == original_ephemeral
|
|
|
|
|
|
# ============================================================================
|
|
# _vprint force parameter (Minor fix)
|
|
# ============================================================================
|
|
|
|
class TestVprintForceParameter:
|
|
"""_vprint should suppress output during streaming TTS unless force=True."""
|
|
|
|
def _make_agent_with_stream(self, stream_active: bool):
|
|
"""Create a minimal agent-like object with _vprint."""
|
|
agent = SimpleNamespace(
|
|
_stream_callback=MagicMock() if stream_active else None,
|
|
)
|
|
|
|
def _vprint(*args, force=False, **kwargs):
|
|
if not force and getattr(agent, "_stream_callback", None) is not None:
|
|
return
|
|
print(*args, **kwargs)
|
|
|
|
agent._vprint = _vprint
|
|
return agent
|
|
|
|
def test_suppressed_during_streaming(self, capsys):
|
|
"""Normal _vprint output is suppressed when streaming TTS is active."""
|
|
agent = self._make_agent_with_stream(stream_active=True)
|
|
agent._vprint("should be hidden")
|
|
captured = capsys.readouterr()
|
|
assert captured.out == ""
|
|
|
|
def test_shown_when_not_streaming(self, capsys):
|
|
"""Normal _vprint output is shown when streaming is not active."""
|
|
agent = self._make_agent_with_stream(stream_active=False)
|
|
agent._vprint("should be shown")
|
|
captured = capsys.readouterr()
|
|
assert "should be shown" in captured.out
|
|
|
|
def test_force_shown_during_streaming(self, capsys):
|
|
"""force=True bypasses the streaming suppression."""
|
|
agent = self._make_agent_with_stream(stream_active=True)
|
|
agent._vprint("critical error!", force=True)
|
|
captured = capsys.readouterr()
|
|
assert "critical error!" in captured.out
|
|
|
|
def test_force_shown_when_not_streaming(self, capsys):
|
|
"""force=True works normally when not streaming (no regression)."""
|
|
agent = self._make_agent_with_stream(stream_active=False)
|
|
agent._vprint("normal message", force=True)
|
|
captured = capsys.readouterr()
|
|
assert "normal message" in captured.out
|
|
|
|
def test_error_messages_use_force_in_run_agent(self):
|
|
"""Verify that critical error _vprint calls in run_agent.py
|
|
include force=True."""
|
|
with open("run_agent.py", "r") as f:
|
|
source = f.read()
|
|
|
|
tree = ast.parse(source)
|
|
|
|
forced_error_count = 0
|
|
unforced_error_count = 0
|
|
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.Call):
|
|
continue
|
|
func = node.func
|
|
if not (isinstance(func, ast.Attribute) and func.attr == "_vprint"):
|
|
continue
|
|
has_fatal = False
|
|
for arg in node.args:
|
|
if isinstance(arg, ast.JoinedStr):
|
|
for val in arg.values:
|
|
if isinstance(val, ast.Constant) and isinstance(val.value, str):
|
|
if "\u274c" in val.value:
|
|
has_fatal = True
|
|
break
|
|
|
|
if not has_fatal:
|
|
continue
|
|
|
|
has_force = any(
|
|
kw.arg == "force"
|
|
and isinstance(kw.value, ast.Constant)
|
|
and kw.value.value is True
|
|
for kw in node.keywords
|
|
)
|
|
|
|
if has_force:
|
|
forced_error_count += 1
|
|
else:
|
|
unforced_error_count += 1
|
|
|
|
assert forced_error_count > 0, \
|
|
"Expected at least one _vprint with force=True for error messages"
|
|
assert unforced_error_count == 0, \
|
|
f"Found {unforced_error_count} critical error _vprint calls without force=True"
|
|
|
|
|
|
# ============================================================================
|
|
# Bug fix regression tests
|
|
# ============================================================================
|
|
|
|
class TestEdgeTTSLazyImport:
|
|
"""Bug #3: _generate_edge_tts must use lazy import, not bare module name."""
|
|
|
|
def test_generate_edge_tts_calls_lazy_import(self):
|
|
"""AST check: _generate_edge_tts must call _import_edge_tts(), not
|
|
reference bare 'edge_tts' module name."""
|
|
import ast as _ast
|
|
|
|
with open("tools/tts_tool.py") as f:
|
|
tree = _ast.parse(f.read())
|
|
|
|
for node in _ast.walk(tree):
|
|
if isinstance(node, _ast.AsyncFunctionDef) and node.name == "_generate_edge_tts":
|
|
# Collect all Name references (bare identifiers)
|
|
bare_refs = [
|
|
n.id for n in _ast.walk(node)
|
|
if isinstance(n, _ast.Name) and n.id == "edge_tts"
|
|
]
|
|
assert bare_refs == [], (
|
|
f"_generate_edge_tts uses bare 'edge_tts' name — "
|
|
f"should use _import_edge_tts() lazy helper"
|
|
)
|
|
|
|
# Must have a call to _import_edge_tts
|
|
lazy_calls = [
|
|
n for n in _ast.walk(node)
|
|
if isinstance(n, _ast.Call)
|
|
and isinstance(n.func, _ast.Name)
|
|
and n.func.id == "_import_edge_tts"
|
|
]
|
|
assert len(lazy_calls) >= 1, (
|
|
"_generate_edge_tts must call _import_edge_tts()"
|
|
)
|
|
break
|
|
else:
|
|
pytest.fail("_generate_edge_tts not found in tts_tool.py")
|
|
|
|
|
|
class TestStreamingTTSOutputStreamCleanup:
|
|
"""Bug #7: output_stream must be closed in finally block."""
|
|
|
|
def test_output_stream_closed_in_finally(self):
|
|
"""AST check: stream_tts_to_speaker's finally block must close
|
|
output_stream even on exception."""
|
|
import ast as _ast
|
|
|
|
with open("tools/tts_tool.py") as f:
|
|
tree = _ast.parse(f.read())
|
|
|
|
for node in _ast.walk(tree):
|
|
if isinstance(node, _ast.FunctionDef) and node.name == "stream_tts_to_speaker":
|
|
# Find the outermost try that has a finally with tts_done_event.set()
|
|
for child in _ast.walk(node):
|
|
if isinstance(child, _ast.Try) and child.finalbody:
|
|
finally_text = "\n".join(
|
|
_ast.dump(n) for n in child.finalbody
|
|
)
|
|
if "tts_done_event" in finally_text:
|
|
assert "output_stream" in finally_text, (
|
|
"finally block must close output_stream"
|
|
)
|
|
return
|
|
pytest.fail("No finally block with tts_done_event found")
|
|
|
|
|
|
class TestCtrlCResetsContinuousMode:
|
|
"""Bug #4: Ctrl+C cancel must reset _voice_continuous."""
|
|
|
|
def test_ctrl_c_handler_resets_voice_continuous(self):
|
|
"""Source check: Ctrl+C voice cancel block must set
|
|
_voice_continuous = False."""
|
|
with open("cli.py") as f:
|
|
source = f.read()
|
|
|
|
# Find the Ctrl+C handler's voice cancel block
|
|
lines = source.split("\n")
|
|
in_cancel_block = False
|
|
found_continuous_reset = False
|
|
for i, line in enumerate(lines):
|
|
if "Cancel active voice recording" in line:
|
|
in_cancel_block = True
|
|
if in_cancel_block:
|
|
if "_voice_continuous = False" in line:
|
|
found_continuous_reset = True
|
|
break
|
|
# Block ends at next comment section or return
|
|
if "return" in line and in_cancel_block:
|
|
break
|
|
|
|
assert found_continuous_reset, (
|
|
"Ctrl+C voice cancel block must set _voice_continuous = False"
|
|
)
|
|
|
|
|
|
class TestDisableVoiceModeStopsTTS:
|
|
"""Bug #5: _disable_voice_mode must stop active TTS playback."""
|
|
|
|
def test_disable_voice_mode_calls_stop_playback(self):
|
|
"""Source check: _disable_voice_mode must call stop_playback()."""
|
|
import inspect
|
|
from cli import HermesCLI
|
|
|
|
source = inspect.getsource(HermesCLI._disable_voice_mode)
|
|
assert "stop_playback" in source, (
|
|
"_disable_voice_mode must call stop_playback()"
|
|
)
|
|
assert "_voice_tts_done.set()" in source, (
|
|
"_disable_voice_mode must set _voice_tts_done"
|
|
)
|
|
|
|
|
|
class TestVoiceStatusUsesConfigKey:
|
|
"""Bug #8: _show_voice_status must read record key from config."""
|
|
|
|
def test_show_voice_status_not_hardcoded(self):
|
|
"""Source check: _show_voice_status must not hardcode Ctrl+B."""
|
|
with open("cli.py") as f:
|
|
source = f.read()
|
|
|
|
lines = source.split("\n")
|
|
in_method = False
|
|
for line in lines:
|
|
if "def _show_voice_status" in line:
|
|
in_method = True
|
|
elif in_method and line.strip().startswith("def "):
|
|
break
|
|
elif in_method:
|
|
assert 'Record key: Ctrl+B"' not in line, (
|
|
"_show_voice_status hardcodes 'Ctrl+B' — "
|
|
"should read from config"
|
|
)
|
|
|
|
def test_show_voice_status_reads_config(self):
|
|
"""Source check: _show_voice_status must use load_config()."""
|
|
with open("cli.py") as f:
|
|
source = f.read()
|
|
|
|
lines = source.split("\n")
|
|
in_method = False
|
|
method_lines = []
|
|
for line in lines:
|
|
if "def _show_voice_status" in line:
|
|
in_method = True
|
|
elif in_method and line.strip().startswith("def "):
|
|
break
|
|
elif in_method:
|
|
method_lines.append(line)
|
|
|
|
method_body = "\n".join(method_lines)
|
|
assert "load_config" in method_body or "record_key" in method_body, (
|
|
"_show_voice_status should read record_key from config"
|
|
)
|
|
|
|
|
|
class TestChatTTSCleanupOnException:
|
|
"""Bug #2: chat() must clean up streaming TTS resources on exception."""
|
|
|
|
def test_chat_has_finally_for_tts_cleanup(self):
|
|
"""AST check: chat() method must have a finally block that cleans up
|
|
text_queue, stop_event, and tts_thread."""
|
|
import ast as _ast
|
|
|
|
with open("cli.py") as f:
|
|
tree = _ast.parse(f.read())
|
|
|
|
for node in _ast.walk(tree):
|
|
if isinstance(node, _ast.FunctionDef) and node.name == "chat":
|
|
# Find Try nodes with finally blocks
|
|
for child in _ast.walk(node):
|
|
if isinstance(child, _ast.Try) and child.finalbody:
|
|
finally_text = "\n".join(
|
|
_ast.dump(n) for n in child.finalbody
|
|
)
|
|
if "text_queue" in finally_text:
|
|
assert "stop_event" in finally_text, (
|
|
"finally must also handle stop_event"
|
|
)
|
|
assert "tts_thread" in finally_text, (
|
|
"finally must also handle tts_thread"
|
|
)
|
|
return
|
|
pytest.fail(
|
|
"chat() must have a finally block cleaning up "
|
|
"text_queue/stop_event/tts_thread"
|
|
)
|
|
|
|
|
|
class TestBrowserToolSignalHandlerRemoved:
|
|
"""browser_tool.py must NOT register SIGINT/SIGTERM handlers that call
|
|
sys.exit() — this conflicts with prompt_toolkit's event loop and causes
|
|
the process to become unkillable during voice mode."""
|
|
|
|
def test_no_signal_handler_registration(self):
|
|
"""Source check: browser_tool.py must not call signal.signal()
|
|
for SIGINT or SIGTERM."""
|
|
with open("tools/browser_tool.py") as f:
|
|
source = f.read()
|
|
|
|
lines = source.split("\n")
|
|
for i, line in enumerate(lines, 1):
|
|
stripped = line.strip()
|
|
# Skip comments
|
|
if stripped.startswith("#"):
|
|
continue
|
|
assert "signal.signal(signal.SIGINT" not in stripped, (
|
|
f"browser_tool.py:{i} registers SIGINT handler — "
|
|
f"use atexit instead to avoid prompt_toolkit conflicts"
|
|
)
|
|
assert "signal.signal(signal.SIGTERM" not in stripped, (
|
|
f"browser_tool.py:{i} registers SIGTERM handler — "
|
|
f"use atexit instead to avoid prompt_toolkit conflicts"
|
|
)
|
|
|
|
|
|
class TestKeyHandlerNeverBlocks:
|
|
"""The Ctrl+B key handler runs in prompt_toolkit's event-loop thread.
|
|
Any blocking call freezes the entire UI. Verify that:
|
|
1. _voice_start_recording is NOT called directly (must be in daemon thread)
|
|
2. _voice_processing guard prevents starting while stop/transcribe runs
|
|
3. _voice_processing is set atomically with _voice_recording in stop_and_transcribe
|
|
"""
|
|
|
|
def test_start_recording_not_called_directly_in_handler(self):
|
|
"""AST check: handle_voice_record must NOT call _voice_start_recording()
|
|
directly — it must wrap it in a Thread to avoid blocking the UI."""
|
|
import ast as _ast
|
|
|
|
with open("cli.py") as f:
|
|
tree = _ast.parse(f.read())
|
|
|
|
for node in _ast.walk(tree):
|
|
if isinstance(node, _ast.FunctionDef) and node.name == "handle_voice_record":
|
|
# Collect all direct calls to _voice_start_recording in this function.
|
|
# They should ONLY appear inside a nested def (the _start_recording wrapper).
|
|
for child in _ast.iter_child_nodes(node):
|
|
# Direct statements in the handler body (not nested defs)
|
|
if isinstance(child, _ast.Expr) and isinstance(child.value, _ast.Call):
|
|
call_src = _ast.dump(child.value)
|
|
assert "_voice_start_recording" not in call_src, (
|
|
"handle_voice_record calls _voice_start_recording directly "
|
|
"— must dispatch to a daemon thread"
|
|
)
|
|
break
|
|
|
|
def test_processing_guard_in_start_path(self):
|
|
"""Source check: key handler must check _voice_processing before
|
|
starting a new recording."""
|
|
with open("cli.py") as f:
|
|
source = f.read()
|
|
|
|
lines = source.split("\n")
|
|
in_handler = False
|
|
in_else = False
|
|
found_guard = False
|
|
for line in lines:
|
|
if "def handle_voice_record" in line:
|
|
in_handler = True
|
|
elif in_handler and line.strip().startswith("def ") and "_start_recording" not in line:
|
|
break
|
|
elif in_handler and "else:" in line:
|
|
in_else = True
|
|
elif in_else and "_voice_processing" in line:
|
|
found_guard = True
|
|
break
|
|
|
|
assert found_guard, (
|
|
"Key handler START path must guard against _voice_processing "
|
|
"to prevent blocking on AudioRecorder._lock"
|
|
)
|
|
|
|
def test_processing_set_atomically_with_recording_false(self):
|
|
"""Source check: _voice_stop_and_transcribe must set _voice_processing = True
|
|
in the same lock block where it sets _voice_recording = False."""
|
|
with open("cli.py") as f:
|
|
source = f.read()
|
|
|
|
lines = source.split("\n")
|
|
in_method = False
|
|
in_first_lock = False
|
|
found_recording_false = False
|
|
found_processing_true = False
|
|
for line in lines:
|
|
if "def _voice_stop_and_transcribe" in line:
|
|
in_method = True
|
|
elif in_method and "with self._voice_lock:" in line and not in_first_lock:
|
|
in_first_lock = True
|
|
elif in_first_lock:
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
if "_voice_recording = False" in stripped:
|
|
found_recording_false = True
|
|
if "_voice_processing = True" in stripped:
|
|
found_processing_true = True
|
|
# End of with block (dedent)
|
|
if stripped and not line.startswith(" ") and not line.startswith("\t\t\t"):
|
|
break
|
|
|
|
assert found_recording_false and found_processing_true, (
|
|
"_voice_stop_and_transcribe must set _voice_processing = True "
|
|
"atomically (same lock block) with _voice_recording = False"
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Real behavior tests — CLI voice methods via _make_voice_cli()
|
|
# ============================================================================
|
|
|
|
class TestHandleVoiceCommandReal:
|
|
"""Tests _handle_voice_command routing with real CLI instance."""
|
|
|
|
def _cli(self):
|
|
cli = _make_voice_cli()
|
|
cli._enable_voice_mode = MagicMock()
|
|
cli._disable_voice_mode = MagicMock()
|
|
cli._toggle_voice_tts = MagicMock()
|
|
cli._show_voice_status = MagicMock()
|
|
return cli
|
|
|
|
@patch("cli._cprint")
|
|
def test_on_calls_enable(self, _cp):
|
|
cli = self._cli()
|
|
cli._handle_voice_command("/voice on")
|
|
cli._enable_voice_mode.assert_called_once()
|
|
|
|
@patch("cli._cprint")
|
|
def test_off_calls_disable(self, _cp):
|
|
cli = self._cli()
|
|
cli._handle_voice_command("/voice off")
|
|
cli._disable_voice_mode.assert_called_once()
|
|
|
|
@patch("cli._cprint")
|
|
def test_tts_calls_toggle(self, _cp):
|
|
cli = self._cli()
|
|
cli._handle_voice_command("/voice tts")
|
|
cli._toggle_voice_tts.assert_called_once()
|
|
|
|
@patch("cli._cprint")
|
|
def test_status_calls_show(self, _cp):
|
|
cli = self._cli()
|
|
cli._handle_voice_command("/voice status")
|
|
cli._show_voice_status.assert_called_once()
|
|
|
|
@patch("cli._cprint")
|
|
def test_toggle_off_when_enabled(self, _cp):
|
|
cli = self._cli()
|
|
cli._voice_mode = True
|
|
cli._handle_voice_command("/voice")
|
|
cli._disable_voice_mode.assert_called_once()
|
|
|
|
@patch("cli._cprint")
|
|
def test_toggle_on_when_disabled(self, _cp):
|
|
cli = self._cli()
|
|
cli._voice_mode = False
|
|
cli._handle_voice_command("/voice")
|
|
cli._enable_voice_mode.assert_called_once()
|
|
|
|
@patch("cli._cprint")
|
|
def test_unknown_subcommand(self, mock_cp):
|
|
cli = self._cli()
|
|
cli._handle_voice_command("/voice foobar")
|
|
cli._enable_voice_mode.assert_not_called()
|
|
cli._disable_voice_mode.assert_not_called()
|
|
# Should print usage via _cprint
|
|
assert any("Unknown" in str(c) or "unknown" in str(c)
|
|
for c in mock_cp.call_args_list)
|
|
|
|
|
|
class TestEnableVoiceModeReal:
|
|
"""Tests _enable_voice_mode with real CLI instance."""
|
|
|
|
@patch("cli._cprint")
|
|
@patch("hermes_cli.config.load_config", return_value={"voice": {}})
|
|
@patch("tools.voice_mode.check_voice_requirements",
|
|
return_value={"available": True, "details": "OK"})
|
|
@patch("tools.voice_mode.detect_audio_environment",
|
|
return_value={"available": True, "warnings": []})
|
|
def test_success_sets_voice_mode(self, _env, _req, _cfg, _cp):
|
|
cli = _make_voice_cli()
|
|
cli._enable_voice_mode()
|
|
assert cli._voice_mode is True
|
|
|
|
@patch("cli._cprint")
|
|
def test_already_enabled_noop(self, _cp):
|
|
cli = _make_voice_cli(_voice_mode=True)
|
|
cli._enable_voice_mode()
|
|
assert cli._voice_mode is True
|
|
|
|
@patch("cli._cprint")
|
|
@patch("tools.voice_mode.detect_audio_environment",
|
|
return_value={"available": False, "warnings": ["SSH session"]})
|
|
def test_env_check_fails(self, _env, _cp):
|
|
cli = _make_voice_cli()
|
|
cli._enable_voice_mode()
|
|
assert cli._voice_mode is False
|
|
|
|
@patch("cli._cprint")
|
|
@patch("tools.voice_mode.check_voice_requirements",
|
|
return_value={"available": False, "details": "Missing",
|
|
"missing_packages": ["sounddevice"]})
|
|
@patch("tools.voice_mode.detect_audio_environment",
|
|
return_value={"available": True, "warnings": []})
|
|
def test_requirements_fail(self, _env, _req, _cp):
|
|
cli = _make_voice_cli()
|
|
cli._enable_voice_mode()
|
|
assert cli._voice_mode is False
|
|
|
|
@patch("cli._cprint")
|
|
@patch("hermes_cli.config.load_config", return_value={"voice": {"auto_tts": True}})
|
|
@patch("tools.voice_mode.check_voice_requirements",
|
|
return_value={"available": True, "details": "OK"})
|
|
@patch("tools.voice_mode.detect_audio_environment",
|
|
return_value={"available": True, "warnings": []})
|
|
def test_auto_tts_from_config(self, _env, _req, _cfg, _cp):
|
|
cli = _make_voice_cli()
|
|
cli._enable_voice_mode()
|
|
assert cli._voice_tts is True
|
|
|
|
@patch("cli._cprint")
|
|
@patch("hermes_cli.config.load_config", return_value={"voice": {}})
|
|
@patch("tools.voice_mode.check_voice_requirements",
|
|
return_value={"available": True, "details": "OK"})
|
|
@patch("tools.voice_mode.detect_audio_environment",
|
|
return_value={"available": True, "warnings": []})
|
|
def test_no_auto_tts_default(self, _env, _req, _cfg, _cp):
|
|
cli = _make_voice_cli()
|
|
cli._enable_voice_mode()
|
|
assert cli._voice_tts is False
|
|
|
|
@patch("cli._cprint")
|
|
@patch("hermes_cli.config.load_config", side_effect=Exception("broken config"))
|
|
@patch("tools.voice_mode.check_voice_requirements",
|
|
return_value={"available": True, "details": "OK"})
|
|
@patch("tools.voice_mode.detect_audio_environment",
|
|
return_value={"available": True, "warnings": []})
|
|
def test_config_exception_still_enables(self, _env, _req, _cfg, _cp):
|
|
cli = _make_voice_cli()
|
|
cli._enable_voice_mode()
|
|
assert cli._voice_mode is True
|
|
|
|
|
|
class TestDisableVoiceModeReal:
|
|
"""Tests _disable_voice_mode with real CLI instance."""
|
|
|
|
@patch("cli._cprint")
|
|
@patch("tools.voice_mode.stop_playback")
|
|
def test_all_flags_reset(self, _sp, _cp):
|
|
cli = _make_voice_cli(_voice_mode=True, _voice_tts=True,
|
|
_voice_continuous=True)
|
|
cli._disable_voice_mode()
|
|
assert cli._voice_mode is False
|
|
assert cli._voice_tts is False
|
|
assert cli._voice_continuous is False
|
|
|
|
@patch("cli._cprint")
|
|
@patch("tools.voice_mode.stop_playback")
|
|
def test_active_recording_cancelled(self, _sp, _cp):
|
|
recorder = MagicMock()
|
|
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)
|
|
cli._disable_voice_mode()
|
|
recorder.cancel.assert_called_once()
|
|
assert cli._voice_recording is False
|
|
|
|
@patch("cli._cprint")
|
|
@patch("tools.voice_mode.stop_playback")
|
|
def test_stop_playback_called(self, mock_sp, _cp):
|
|
cli = _make_voice_cli()
|
|
cli._disable_voice_mode()
|
|
mock_sp.assert_called_once()
|
|
|
|
@patch("cli._cprint")
|
|
@patch("tools.voice_mode.stop_playback")
|
|
def test_tts_done_event_set(self, _sp, _cp):
|
|
cli = _make_voice_cli()
|
|
cli._voice_tts_done.clear()
|
|
cli._disable_voice_mode()
|
|
assert cli._voice_tts_done.is_set()
|
|
|
|
@patch("cli._cprint")
|
|
@patch("tools.voice_mode.stop_playback")
|
|
def test_no_recorder_no_crash(self, _sp, _cp):
|
|
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=None)
|
|
cli._disable_voice_mode()
|
|
assert cli._voice_mode is False
|
|
|
|
@patch("cli._cprint")
|
|
@patch("tools.voice_mode.stop_playback", side_effect=RuntimeError("boom"))
|
|
def test_stop_playback_exception_swallowed(self, _sp, _cp):
|
|
cli = _make_voice_cli(_voice_mode=True)
|
|
cli._disable_voice_mode()
|
|
assert cli._voice_mode is False
|
|
|
|
|
|
class TestVoiceSpeakResponseReal:
|
|
"""Tests _voice_speak_response with real CLI instance."""
|
|
|
|
@patch("cli._cprint")
|
|
def test_early_return_when_tts_off(self, _cp):
|
|
cli = _make_voice_cli(_voice_tts=False)
|
|
with patch("tools.tts_tool.text_to_speech_tool") as mock_tts:
|
|
cli._voice_speak_response("Hello")
|
|
mock_tts.assert_not_called()
|
|
|
|
@patch("cli._cprint")
|
|
@patch("cli.os.unlink")
|
|
@patch("cli.os.path.getsize", return_value=1000)
|
|
@patch("cli.os.path.isfile", return_value=True)
|
|
@patch("cli.os.makedirs")
|
|
@patch("tools.voice_mode.play_audio_file")
|
|
@patch("tools.tts_tool.text_to_speech_tool", return_value='{"success": true}')
|
|
def test_markdown_stripped(self, mock_tts, _play, _mkd, _isf, _gsz, _unl, _cp):
|
|
cli = _make_voice_cli(_voice_tts=True)
|
|
cli._voice_speak_response("## Title\n**bold** and `code`")
|
|
call_text = mock_tts.call_args.kwargs["text"]
|
|
assert "##" not in call_text
|
|
assert "**" not in call_text
|
|
assert "`" not in call_text
|
|
|
|
@patch("cli._cprint")
|
|
@patch("cli.os.makedirs")
|
|
@patch("tools.tts_tool.text_to_speech_tool", return_value='{"success": true}')
|
|
def test_code_blocks_removed(self, mock_tts, _mkd, _cp):
|
|
cli = _make_voice_cli(_voice_tts=True)
|
|
cli._voice_speak_response("```python\nprint('hi')\n```\nSome text")
|
|
call_text = mock_tts.call_args.kwargs["text"]
|
|
assert "print" not in call_text
|
|
assert "```" not in call_text
|
|
assert "Some text" in call_text
|
|
|
|
@patch("cli._cprint")
|
|
@patch("cli.os.makedirs")
|
|
def test_empty_after_strip_returns_early(self, _mkd, _cp):
|
|
cli = _make_voice_cli(_voice_tts=True)
|
|
with patch("tools.tts_tool.text_to_speech_tool") as mock_tts:
|
|
cli._voice_speak_response("```python\nprint('hi')\n```")
|
|
mock_tts.assert_not_called()
|
|
|
|
@patch("cli._cprint")
|
|
@patch("cli.os.makedirs")
|
|
@patch("tools.tts_tool.text_to_speech_tool", return_value='{"success": true}')
|
|
def test_long_text_truncated(self, mock_tts, _mkd, _cp):
|
|
cli = _make_voice_cli(_voice_tts=True)
|
|
cli._voice_speak_response("A" * 5000)
|
|
call_text = mock_tts.call_args.kwargs["text"]
|
|
assert len(call_text) <= 4000
|
|
|
|
@patch("cli._cprint")
|
|
@patch("cli.os.makedirs")
|
|
@patch("tools.tts_tool.text_to_speech_tool", side_effect=RuntimeError("tts fail"))
|
|
def test_exception_sets_done_event(self, _tts, _mkd, _cp):
|
|
cli = _make_voice_cli(_voice_tts=True)
|
|
cli._voice_tts_done.clear()
|
|
cli._voice_speak_response("Hello")
|
|
assert cli._voice_tts_done.is_set()
|
|
|
|
@patch("cli._cprint")
|
|
@patch("cli.os.unlink")
|
|
@patch("cli.os.path.getsize", return_value=1000)
|
|
@patch("cli.os.path.isfile", return_value=True)
|
|
@patch("cli.os.makedirs")
|
|
@patch("tools.voice_mode.play_audio_file")
|
|
@patch("tools.tts_tool.text_to_speech_tool", return_value='{"success": true}')
|
|
def test_play_audio_called(self, _tts, mock_play, _mkd, _isf, _gsz, _unl, _cp):
|
|
cli = _make_voice_cli(_voice_tts=True)
|
|
cli._voice_speak_response("Hello world")
|
|
mock_play.assert_called_once()
|
|
|
|
|
|
class TestVoiceStopAndTranscribeReal:
|
|
"""Tests _voice_stop_and_transcribe with real CLI instance."""
|
|
|
|
@patch("cli._cprint")
|
|
def test_guard_not_recording(self, _cp):
|
|
cli = _make_voice_cli(_voice_recording=False)
|
|
with patch("tools.voice_mode.transcribe_recording") as mock_tr:
|
|
cli._voice_stop_and_transcribe()
|
|
mock_tr.assert_not_called()
|
|
|
|
@patch("cli._cprint")
|
|
def test_no_recorder_returns_early(self, _cp):
|
|
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=None)
|
|
with patch("tools.voice_mode.transcribe_recording") as mock_tr:
|
|
cli._voice_stop_and_transcribe()
|
|
mock_tr.assert_not_called()
|
|
assert cli._voice_recording is False
|
|
|
|
@patch("cli._cprint")
|
|
@patch("tools.voice_mode.play_beep")
|
|
def test_no_speech_detected(self, _beep, _cp):
|
|
recorder = MagicMock()
|
|
recorder.stop.return_value = None
|
|
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)
|
|
cli._voice_stop_and_transcribe()
|
|
assert cli._pending_input.empty()
|
|
|
|
@patch("cli._cprint")
|
|
@patch("cli.os.unlink")
|
|
@patch("cli.os.path.isfile", return_value=True)
|
|
@patch("hermes_cli.config.load_config", return_value={"stt": {}})
|
|
@patch("tools.voice_mode.transcribe_recording",
|
|
return_value={"success": True, "transcript": "hello world"})
|
|
@patch("tools.voice_mode.play_beep")
|
|
def test_successful_transcription_queues_input(
|
|
self, _beep, _tr, _cfg, _isf, _unl, _cp
|
|
):
|
|
recorder = MagicMock()
|
|
recorder.stop.return_value = "/tmp/test.wav"
|
|
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)
|
|
cli._voice_stop_and_transcribe()
|
|
assert cli._pending_input.get_nowait() == "hello world"
|
|
|
|
@patch("cli._cprint")
|
|
@patch("cli.os.unlink")
|
|
@patch("cli.os.path.isfile", return_value=True)
|
|
@patch("hermes_cli.config.load_config", return_value={"stt": {}})
|
|
@patch("tools.voice_mode.transcribe_recording",
|
|
return_value={"success": True, "transcript": ""})
|
|
@patch("tools.voice_mode.play_beep")
|
|
def test_empty_transcript_not_queued(self, _beep, _tr, _cfg, _isf, _unl, _cp):
|
|
recorder = MagicMock()
|
|
recorder.stop.return_value = "/tmp/test.wav"
|
|
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)
|
|
cli._voice_stop_and_transcribe()
|
|
assert cli._pending_input.empty()
|
|
|
|
@patch("cli._cprint")
|
|
@patch("cli.os.unlink")
|
|
@patch("cli.os.path.isfile", return_value=True)
|
|
@patch("hermes_cli.config.load_config", return_value={"stt": {}})
|
|
@patch("tools.voice_mode.transcribe_recording",
|
|
return_value={"success": False, "error": "API timeout"})
|
|
@patch("tools.voice_mode.play_beep")
|
|
def test_transcription_failure(self, _beep, _tr, _cfg, _isf, _unl, _cp):
|
|
recorder = MagicMock()
|
|
recorder.stop.return_value = "/tmp/test.wav"
|
|
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)
|
|
cli._voice_stop_and_transcribe()
|
|
assert cli._pending_input.empty()
|
|
|
|
@patch("cli._cprint")
|
|
@patch("cli.os.unlink")
|
|
@patch("cli.os.path.isfile", return_value=True)
|
|
@patch("hermes_cli.config.load_config", return_value={"stt": {}})
|
|
@patch("tools.voice_mode.transcribe_recording",
|
|
side_effect=ConnectionError("network"))
|
|
@patch("tools.voice_mode.play_beep")
|
|
def test_exception_caught(self, _beep, _tr, _cfg, _isf, _unl, _cp):
|
|
recorder = MagicMock()
|
|
recorder.stop.return_value = "/tmp/test.wav"
|
|
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)
|
|
cli._voice_stop_and_transcribe() # Should not raise
|
|
|
|
@patch("cli._cprint")
|
|
@patch("tools.voice_mode.play_beep")
|
|
def test_processing_flag_cleared(self, _beep, _cp):
|
|
recorder = MagicMock()
|
|
recorder.stop.return_value = None
|
|
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)
|
|
cli._voice_stop_and_transcribe()
|
|
assert cli._voice_processing is False
|
|
|
|
@patch("cli._cprint")
|
|
@patch("tools.voice_mode.play_beep")
|
|
def test_continuous_restarts_on_no_speech(self, _beep, _cp):
|
|
recorder = MagicMock()
|
|
recorder.stop.return_value = None
|
|
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder,
|
|
_voice_continuous=True)
|
|
cli._voice_start_recording = MagicMock()
|
|
cli._voice_stop_and_transcribe()
|
|
cli._voice_start_recording.assert_called_once()
|
|
|
|
@patch("cli._cprint")
|
|
@patch("cli.os.unlink")
|
|
@patch("cli.os.path.isfile", return_value=True)
|
|
@patch("hermes_cli.config.load_config", return_value={"stt": {}})
|
|
@patch("tools.voice_mode.transcribe_recording",
|
|
return_value={"success": True, "transcript": "hello"})
|
|
@patch("tools.voice_mode.play_beep")
|
|
def test_continuous_no_restart_on_success(
|
|
self, _beep, _tr, _cfg, _isf, _unl, _cp
|
|
):
|
|
recorder = MagicMock()
|
|
recorder.stop.return_value = "/tmp/test.wav"
|
|
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder,
|
|
_voice_continuous=True)
|
|
cli._voice_start_recording = MagicMock()
|
|
cli._voice_stop_and_transcribe()
|
|
cli._voice_start_recording.assert_not_called()
|
|
|
|
@patch("cli._cprint")
|
|
@patch("cli.os.unlink")
|
|
@patch("cli.os.path.isfile", return_value=True)
|
|
@patch("hermes_cli.config.load_config", return_value={"stt": {"model": "whisper-large-v3"}})
|
|
@patch("tools.voice_mode.transcribe_recording",
|
|
return_value={"success": True, "transcript": "hi"})
|
|
@patch("tools.voice_mode.play_beep")
|
|
def test_stt_model_from_config(self, _beep, mock_tr, _cfg, _isf, _unl, _cp):
|
|
recorder = MagicMock()
|
|
recorder.stop.return_value = "/tmp/test.wav"
|
|
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)
|
|
cli._voice_stop_and_transcribe()
|
|
mock_tr.assert_called_once_with("/tmp/test.wav", model="whisper-large-v3")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bugfix: _refresh_level must read _voice_recording under lock
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRefreshLevelLock:
|
|
"""Bug: _refresh_level thread read _voice_recording without lock."""
|
|
|
|
def test_refresh_stops_when_recording_false(self):
|
|
import threading, time
|
|
|
|
lock = threading.Lock()
|
|
recording = True
|
|
iterations = 0
|
|
|
|
def refresh_level():
|
|
nonlocal iterations
|
|
while True:
|
|
with lock:
|
|
still = recording
|
|
if not still:
|
|
break
|
|
iterations += 1
|
|
time.sleep(0.01)
|
|
|
|
t = threading.Thread(target=refresh_level, daemon=True)
|
|
t.start()
|
|
|
|
time.sleep(0.05)
|
|
with lock:
|
|
recording = False
|
|
|
|
t.join(timeout=1)
|
|
assert not t.is_alive(), "Refresh thread did not stop"
|
|
assert iterations > 0, "Refresh thread never ran"
|