refactor(tools): repoint tests to moved symbols; add voice_mode_transcript module
This commit is contained in:
@@ -199,7 +199,7 @@ def test_steer_closed_acceptance_is_refused():
|
||||
|
||||
|
||||
def test_stop_interrupts_owned_child(monkeypatch):
|
||||
import tools.delegate_tool as dt
|
||||
import tools.delegate_tool_registry as dt
|
||||
|
||||
parent = _StubParent()
|
||||
child = _StubChild(parent)
|
||||
@@ -219,7 +219,7 @@ def test_stop_interrupts_owned_child(monkeypatch):
|
||||
|
||||
|
||||
def test_stop_foreign_child_is_refused(monkeypatch):
|
||||
import tools.delegate_tool as dt
|
||||
import tools.delegate_tool_registry as dt
|
||||
|
||||
parent = _StubParent()
|
||||
foreign = _StubChild(_StubParent())
|
||||
|
||||
@@ -319,7 +319,7 @@ class TestSvgNormalization:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_svg_rasterized_when_converter_available(self, tmp_path, monkeypatch):
|
||||
from tools import vision_tools as vt
|
||||
from tools import vision_tools_image_prep as vt
|
||||
isrc = _reload(monkeypatch, tmp_path / "hermes")
|
||||
monkeypatch.setenv("TERMINAL_ENV", "local")
|
||||
svg = tmp_path / "art.svg"
|
||||
@@ -339,7 +339,7 @@ class TestSvgNormalization:
|
||||
path.unlink()
|
||||
|
||||
def test_svg_actionable_error_when_no_converter(self, tmp_path, monkeypatch):
|
||||
from tools import vision_tools as vt
|
||||
from tools import vision_tools_image_prep as vt
|
||||
_reload(monkeypatch, tmp_path / "hermes")
|
||||
svg = tmp_path / "art.svg"
|
||||
svg.write_bytes(b'<svg xmlns="http://www.w3.org/2000/svg"/>')
|
||||
|
||||
@@ -16,7 +16,6 @@ from tools.process_registry import (
|
||||
ProcessSession,
|
||||
FINISHED_TTL_SECONDS,
|
||||
MAX_PROCESSES,
|
||||
MAX_ACTIVE_PROCESS_AGE,
|
||||
)
|
||||
|
||||
|
||||
@@ -2508,11 +2507,11 @@ def _make_delegation_batch_evt(results):
|
||||
def _patch_delegation_config(
|
||||
monkeypatch, model="upstage/solar-pro-4", provider="openrouter", **over
|
||||
):
|
||||
import tools.process_registry as _pr
|
||||
import tools.process_registry_notifications as _prn
|
||||
|
||||
cfg = {"model": model, "provider": provider}
|
||||
cfg.update(over)
|
||||
monkeypatch.setattr(_pr, "_delegation_config", lambda: cfg)
|
||||
monkeypatch.setattr(_prn, "_delegation_config", lambda: cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from tools.session_search_tool import (
|
||||
SESSION_SEARCH_SCHEMA,
|
||||
_format_timestamp,
|
||||
_is_compacted_message,
|
||||
_is_compression_ended,
|
||||
_resolve_to_parent,
|
||||
_session_link,
|
||||
session_search,
|
||||
@@ -900,24 +899,6 @@ class TestRewindExclusion:
|
||||
assert result_rewind["count"] == 0
|
||||
|
||||
|
||||
class TestCompressionEndedHelper:
|
||||
"""Unit tests for _is_compression_ended."""
|
||||
|
||||
def test_compression_ended_session(self, db):
|
||||
db.create_session("s1", source="cli")
|
||||
db.end_session("s1", "compression")
|
||||
assert _is_compression_ended(db, "s1") is True
|
||||
|
||||
def test_delegation_child_not_ended(self, db):
|
||||
"""A delegation child under a compression continuation does NOT have
|
||||
end_reason='compression' itself."""
|
||||
db.create_session("s_parent", source="cli")
|
||||
db.end_session("s_parent", "compression")
|
||||
db.create_session("s_continuation", source="cli", parent_session_id="s_parent")
|
||||
db.create_session("s_delegate_child", source="cli", parent_session_id="s_continuation")
|
||||
assert _is_compression_ended(db, "s_delegate_child") is False
|
||||
|
||||
|
||||
class TestLegacyContinuationPlusDelegation:
|
||||
"""Regression: a delegation child created under a compression continuation
|
||||
must stay excluded — its content is still live to the parent agent.
|
||||
|
||||
@@ -1159,88 +1159,6 @@ class TestQuarantineBundleBinaryAssets:
|
||||
) == "assets/data/sample.wav"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHubSource._download_directory — tree API + fallback (#2940)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDownloadDirectoryViaTree:
|
||||
"""Tests for the Git Trees API path in _download_directory."""
|
||||
|
||||
def _source(self):
|
||||
auth = MagicMock(spec=GitHubAuth)
|
||||
auth.get_headers.return_value = {}
|
||||
return GitHubSource(auth=auth)
|
||||
|
||||
@patch.object(GitHubSource, "_fetch_file_content")
|
||||
@patch("tools.skills_hub.httpx.get")
|
||||
def test_tree_api_downloads_subdirectories(self, mock_get, mock_fetch):
|
||||
"""Tree API returns files from nested subdirectories."""
|
||||
repo_resp = MagicMock(status_code=200, json=lambda: {"default_branch": "main"})
|
||||
tree_resp = MagicMock(status_code=200, json=lambda: {
|
||||
"truncated": False,
|
||||
"tree": [
|
||||
{"type": "blob", "path": "skills/my-skill/SKILL.md"},
|
||||
{"type": "blob", "path": "skills/my-skill/scripts/run.py"},
|
||||
{"type": "blob", "path": "skills/my-skill/references/api.md"},
|
||||
{"type": "tree", "path": "skills/my-skill/scripts"},
|
||||
{"type": "blob", "path": "other/file.txt"},
|
||||
],
|
||||
})
|
||||
mock_get.side_effect = [repo_resp, tree_resp]
|
||||
mock_fetch.side_effect = lambda repo, path: f"content-of-{path}"
|
||||
|
||||
src = self._source()
|
||||
files = src._download_directory("owner/repo", "skills/my-skill")
|
||||
|
||||
assert "SKILL.md" in files
|
||||
assert "scripts/run.py" in files
|
||||
assert "references/api.md" in files
|
||||
assert "other/file.txt" not in files # outside target path
|
||||
assert len(files) == 3
|
||||
|
||||
@patch.object(GitHubSource, "_download_directory_recursive", return_value={"SKILL.md": "# ok"})
|
||||
@patch("tools.skills_hub.httpx.get")
|
||||
def test_falls_back_on_truncated_tree(self, mock_get, mock_fallback):
|
||||
"""When tree is truncated, fall back to recursive Contents API."""
|
||||
repo_resp = MagicMock(status_code=200, json=lambda: {"default_branch": "main"})
|
||||
tree_resp = MagicMock(status_code=200, json=lambda: {"truncated": True, "tree": []})
|
||||
mock_get.side_effect = [repo_resp, tree_resp]
|
||||
|
||||
src = self._source()
|
||||
files = src._download_directory("owner/repo", "skills/my-skill")
|
||||
|
||||
assert files == {"SKILL.md": "# ok"}
|
||||
mock_fallback.assert_called_once_with("owner/repo", "skills/my-skill")
|
||||
|
||||
class TestDownloadDirectoryRecursive:
|
||||
"""Tests for the Contents API fallback path."""
|
||||
|
||||
def _source(self):
|
||||
auth = MagicMock(spec=GitHubAuth)
|
||||
auth.get_headers.return_value = {}
|
||||
return GitHubSource(auth=auth)
|
||||
|
||||
@patch.object(GitHubSource, "_fetch_file_content")
|
||||
@patch("tools.skills_hub.httpx.get")
|
||||
def test_recursive_downloads_subdirectories(self, mock_get, mock_fetch):
|
||||
"""Contents API recursion includes subdirectories."""
|
||||
root_resp = MagicMock(status_code=200, json=lambda: [
|
||||
{"name": "SKILL.md", "type": "file", "path": "skill/SKILL.md"},
|
||||
{"name": "scripts", "type": "dir", "path": "skill/scripts"},
|
||||
])
|
||||
sub_resp = MagicMock(status_code=200, json=lambda: [
|
||||
{"name": "run.py", "type": "file", "path": "skill/scripts/run.py"},
|
||||
])
|
||||
mock_get.side_effect = [root_resp, sub_resp]
|
||||
mock_fetch.side_effect = lambda repo, path: f"content-of-{path}"
|
||||
|
||||
src = self._source()
|
||||
files = src._download_directory_recursive("owner/repo", "skill")
|
||||
|
||||
assert "SKILL.md" in files
|
||||
assert "scripts/run.py" in files
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install-path safety (lock-file → uninstall rmtree boundary)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -765,23 +765,22 @@ class TestOptOutToggleAndRemove:
|
||||
return bundled
|
||||
|
||||
def test_marker_toggle(self, tmp_path):
|
||||
from tools.skills_sync import (
|
||||
set_bundled_skills_opt_out, is_bundled_skills_opt_out,
|
||||
)
|
||||
from tools.skills_sync import set_bundled_skills_opt_out
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
marker = home / ".no-bundled-skills"
|
||||
with patch("tools.skills_sync.HERMES_HOME", home):
|
||||
assert is_bundled_skills_opt_out() is False
|
||||
assert not marker.exists()
|
||||
r = set_bundled_skills_opt_out(True)
|
||||
assert r["ok"] and r["changed"]
|
||||
assert is_bundled_skills_opt_out() is True
|
||||
assert marker.exists()
|
||||
# idempotent
|
||||
r2 = set_bundled_skills_opt_out(True)
|
||||
assert r2["ok"] and r2["changed"] is False
|
||||
# opt back in
|
||||
r3 = set_bundled_skills_opt_out(False)
|
||||
assert r3["ok"] and r3["changed"]
|
||||
assert is_bundled_skills_opt_out() is False
|
||||
assert not marker.exists()
|
||||
|
||||
def test_remove_keeps_user_modified(self, tmp_path):
|
||||
from tools.skills_sync import (
|
||||
|
||||
@@ -327,7 +327,7 @@ class TestDevGate:
|
||||
import hermes_cli.auth as auth_mod
|
||||
monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials",
|
||||
lambda **kw: {"api_key": token, "base_url": "https://x"})
|
||||
assert ssc.dev_gate_open() is False
|
||||
assert ssc.resolve_identity()["nous_admin"] is False
|
||||
|
||||
def test_maybe_push_inert_when_gate_closed(self, monkeypatch):
|
||||
token = _jwt({"sub": "u"})
|
||||
@@ -882,7 +882,6 @@ class TestOrgIdentityGate:
|
||||
lambda **kw: {"api_key": token, "base_url": "https://x"})
|
||||
with pytest.raises(ssc.SyncInertError):
|
||||
ssc.resolve_org_identity()
|
||||
assert ssc.org_sync_available() is False
|
||||
|
||||
def test_org_identity_with_role(self, monkeypatch):
|
||||
token = _jwt({"sub": "u", "org_id": "org-9", "org_role": "MEMBER"})
|
||||
@@ -892,7 +891,6 @@ class TestOrgIdentityGate:
|
||||
ident = ssc.resolve_org_identity()
|
||||
assert ident["org_id"] == "org-9"
|
||||
assert ident["org_role"] == "MEMBER"
|
||||
assert ssc.org_sync_available() is True
|
||||
|
||||
def test_org_mirror_excluded_from_personal_sync(self, tmp_path, monkeypatch):
|
||||
# A skill under _org/<id>/ must never be personal-sync eligible.
|
||||
|
||||
@@ -34,8 +34,6 @@ from tools.transcription_tools import (
|
||||
_get_command_stt_output_format,
|
||||
_get_command_stt_timeout,
|
||||
_get_named_stt_provider_config,
|
||||
_has_any_command_stt_provider,
|
||||
_iter_command_stt_providers,
|
||||
_render_command_stt_template,
|
||||
_resolve_command_stt_provider_config,
|
||||
_transcribe_command_stt,
|
||||
@@ -145,24 +143,6 @@ class TestSTTCommandHelpers:
|
||||
assert DEFAULT_COMMAND_STT_OUTPUT_FORMAT == "txt"
|
||||
|
||||
|
||||
def test_iter_command_providers_yields_only_command_type(self):
|
||||
cfg = {
|
||||
"providers": {
|
||||
"cmd-one": {"type": "command", "command": "x"},
|
||||
"no-cmd": {"type": "command"}, # no command field
|
||||
"wrong-type": {"type": "http", "command": "x"},
|
||||
"cmd-two": {"command": "y"}, # implicit type
|
||||
},
|
||||
}
|
||||
names = {name for name, _ in _iter_command_stt_providers(cfg)}
|
||||
assert names == {"cmd-one", "cmd-two"}
|
||||
|
||||
|
||||
def test_has_any_command_provider_true_when_one_configured(self):
|
||||
cfg = {"providers": {"custom": {"command": "x"}}}
|
||||
assert _has_any_command_stt_provider(cfg) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -141,7 +141,7 @@ class TestBuildAudioDeliveryFiles:
|
||||
out = str(tmp_path / "output.mp3")
|
||||
profile = AudioDeliveryProfile(platform="default", max_file_bytes=10000)
|
||||
|
||||
with patch("tools.tts_tool._concat_audio_files") as mock_concat:
|
||||
with patch("tools.tts_tool_delivery._concat_audio_files") as mock_concat:
|
||||
mock_concat.return_value = out
|
||||
# Copy the first file to output so the size check passes
|
||||
Path(out).write_bytes(b"\x00" * 300)
|
||||
|
||||
@@ -79,7 +79,7 @@ class TestTextToSpeechToolChunking:
|
||||
return output_path
|
||||
|
||||
monkeypatch.setattr("tools.tts_tool._generate_openai_tts", fake_openai)
|
||||
monkeypatch.setattr("tools.tts_tool._concat_audio_files", fake_combine)
|
||||
monkeypatch.setattr("tools.tts_tool_delivery._concat_audio_files", fake_combine)
|
||||
monkeypatch.setattr("tools.tts_tool._load_tts_config",
|
||||
lambda: {"provider": "openai"})
|
||||
|
||||
@@ -134,7 +134,7 @@ class TestTextToSpeechToolChunking:
|
||||
return output_path
|
||||
|
||||
monkeypatch.setattr("tools.tts_tool._generate_openai_tts", fake_openai)
|
||||
monkeypatch.setattr("tools.tts_tool._concat_audio_files", fake_combine)
|
||||
monkeypatch.setattr("tools.tts_tool_delivery._concat_audio_files", fake_combine)
|
||||
monkeypatch.setattr("tools.tts_tool._load_tts_config",
|
||||
lambda: {"provider": "openai",
|
||||
"openai": {"max_text_length": 100}})
|
||||
|
||||
@@ -24,7 +24,7 @@ def test_loads_on_miss_and_serves_from_cache_on_hit():
|
||||
|
||||
|
||||
def test_hit_refreshes_recency_so_eviction_is_lru_not_fifo(monkeypatch):
|
||||
monkeypatch.setattr(tts, "_TTS_MODEL_CACHE_MAX", 2)
|
||||
monkeypatch.setattr("tools.tts_tool_local._TTS_MODEL_CACHE_MAX", 2)
|
||||
cache: dict = {}
|
||||
tts._tts_cache_get_or_load(cache, "a", lambda: "a")
|
||||
tts._tts_cache_get_or_load(cache, "b", lambda: "b")
|
||||
|
||||
@@ -17,7 +17,7 @@ class TestDefaultOutputTimestampResolution:
|
||||
|
||||
from tools import tts_tool
|
||||
|
||||
src = inspect.getsource(tts_tool.text_to_speech_tool)
|
||||
src = inspect.getsource(tts_tool._resolve_output_base)
|
||||
assert "%Y%m%d_%H%M%S_%f" in src, (
|
||||
"default TTS output timestamp lost its microsecond component — "
|
||||
"concurrent calls in the same second would collide again (#43911)"
|
||||
|
||||
@@ -30,7 +30,7 @@ class StreamingResponse:
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def small_tts_body_cap(monkeypatch):
|
||||
monkeypatch.setattr(tts_tool, "TTS_RESPONSE_BODY_LIMIT_BYTES", 8)
|
||||
monkeypatch.setattr("tools.tts_tool_providers.TTS_RESPONSE_BODY_LIMIT_BYTES", 8)
|
||||
|
||||
|
||||
def test_xai_tts_rejects_oversized_audio_response(tmp_path, monkeypatch):
|
||||
|
||||
@@ -12,7 +12,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from tools.vision_tools import (
|
||||
_validate_image_url,
|
||||
_handle_vision_analyze,
|
||||
_determine_mime_type,
|
||||
_image_to_base64_data_url,
|
||||
@@ -41,42 +40,6 @@ VALID_PNG = (
|
||||
_RESOLVES = [(2, 1, 6, "", ("93.184.216.34", 0))]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_image_url — urlparse-based validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateImageUrl:
|
||||
"""Tests for URL validation, including urlparse-based netloc check."""
|
||||
|
||||
def test_accepts_valid_http_and_https_urls(self):
|
||||
with patch("tools.url_safety.socket.getaddrinfo", return_value=_RESOLVES):
|
||||
assert _validate_image_url("https://example.com/image.jpg") is True
|
||||
assert _validate_image_url("http://cdn.example.org/photo.png") is True
|
||||
# CDN endpoints that redirect to images should still pass.
|
||||
assert _validate_image_url("https://cdn.example.com/abcdef123") is True
|
||||
assert _validate_image_url("https://img.example.com/pic?w=200&h=200") is True
|
||||
assert _validate_image_url("http://example.com:8080/image.png") is True
|
||||
assert _validate_image_url("https://example.com/") is True
|
||||
|
||||
def test_localhost_url_blocked_by_ssrf(self):
|
||||
"""localhost URLs are blocked by SSRF protection."""
|
||||
assert _validate_image_url("http://localhost:8080/image.png") is False
|
||||
|
||||
|
||||
def test_rejects_malformed_and_non_string_inputs(self):
|
||||
# http:// alone has no network location — urlparse catches this.
|
||||
assert _validate_image_url("http://") is False
|
||||
assert _validate_image_url("https://") is False
|
||||
assert _validate_image_url("http:") is False
|
||||
assert _validate_image_url("") is False
|
||||
assert _validate_image_url(" ") is False
|
||||
assert _validate_image_url(None) is False
|
||||
assert _validate_image_url(12345) is False
|
||||
assert _validate_image_url(True) is False
|
||||
assert _validate_image_url(["https://example.com"]) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _determine_mime_type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1069,7 +1069,7 @@ class TestStreamLeakOnStartFailure:
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# listen_for_speech — VAD barge-in monitor
|
||||
# full_duplex_listen — VAD barge-in listener
|
||||
# ============================================================================
|
||||
|
||||
class _FakeInputStream:
|
||||
@@ -1092,118 +1092,6 @@ class _FakeInputStream:
|
||||
return self._np.full((frames, 1), level, dtype=self._np.int16), False
|
||||
|
||||
|
||||
class TestListenForSpeech:
|
||||
"""listen_for_speech: calibration → sustained-speech trigger → barge-in."""
|
||||
|
||||
CALIB_BLOCKS = 14 # 400ms / 30ms
|
||||
TRIP_BLOCKS = 10 # 300ms / 30ms
|
||||
|
||||
def _run(self, mock_sd, levels, should_stop=None, **kwargs):
|
||||
np = pytest.importorskip("numpy")
|
||||
stream = _FakeInputStream(np, levels)
|
||||
mock_sd.InputStream.return_value = stream
|
||||
from tools.voice_mode import listen_for_speech
|
||||
stops = iter([False] * 200 + [True] * 10_000)
|
||||
return listen_for_speech(should_stop or (lambda: next(stops)), **kwargs), stream
|
||||
|
||||
def test_sustained_speech_triggers(self, mock_sd):
|
||||
levels = [0] * self.CALIB_BLOCKS + [5000] * 50
|
||||
heard, _ = self._run(mock_sd, levels)
|
||||
assert heard is True
|
||||
|
||||
|
||||
def test_returns_false_when_audio_unavailable(self, monkeypatch):
|
||||
monkeypatch.setattr("tools.voice_mode._import_audio", MagicMock(side_effect=OSError("no audio")))
|
||||
from tools.voice_mode import listen_for_speech
|
||||
assert listen_for_speech(lambda: False) is False
|
||||
|
||||
def test_quiet_then_loud_playback_does_not_trip(self, mock_sd):
|
||||
"""TTS that starts quiet and gets louder must NOT trip barge-in.
|
||||
|
||||
This is the core regression: a one-shot calibration freezes the
|
||||
floor from the quiet opening, then louder TTS exceeds the stale
|
||||
floor and false-triggers. The rolling window keeps the floor
|
||||
current so the louder passage is absorbed into the floor.
|
||||
"""
|
||||
levels = [100] * self.CALIB_BLOCKS + [200] * 30 + [500] * 30 + [1000] * 30
|
||||
heard, _ = self._run(mock_sd, levels)
|
||||
assert heard is False
|
||||
|
||||
def test_silence_calibration_does_not_false_trip_on_tts(self, mock_sd):
|
||||
"""Calibration during an inter-sentence gap must NOT false-trip.
|
||||
|
||||
If the grace period ends during a pause between TTS sentences, the
|
||||
calibration window samples near-silence. Without the min_floor clamp,
|
||||
min_floor locks near zero, the trigger drops to 400 RMS (SILENCE_RMS_THRESHOLD
|
||||
* 2), and the next TTS sentence at 800 RMS exceeds it — those blocks are
|
||||
excluded from the rolling window (rms >= trigger), the floor freezes, and
|
||||
after sustained_ms the VAD false-triggers and cuts playback mid-sentence.
|
||||
|
||||
With the clamp, min_floor stays at SILENCE_RMS_THRESHOLD * 2 = 400, the
|
||||
trigger is max(400, 400 * 8.0) = 3200, and 800-RMS TTS stays below it and
|
||||
feeds the rolling floor. No false trip.
|
||||
"""
|
||||
# calibration_ms=800 → CALIB_BLOCKS = 800/30 ≈ 26 blocks of silence
|
||||
# Then TTS resumes at 800 RMS — must NOT trip (below 3200 trigger).
|
||||
calib = 800 // 30
|
||||
levels = [0] * calib + [800] * 100
|
||||
heard, _ = self._run(
|
||||
mock_sd, levels,
|
||||
sustained_ms=1000,
|
||||
calibration_ms=800,
|
||||
)
|
||||
assert heard is False
|
||||
|
||||
|
||||
class TestListenForSpeechCapture:
|
||||
"""capture=True: the barge monitor records the interruption with pre-roll,
|
||||
so the utterance is complete from its first syllable — nothing is lost
|
||||
between detection and a recorder restart."""
|
||||
|
||||
CALIB_BLOCKS = 14 # 400ms / 30ms
|
||||
LOUD_BLOCKS = 30 # speech: trips after 10, keeps talking
|
||||
BLOCK = 480 # 16000 * 0.03
|
||||
|
||||
def _run(self, mock_sd, monkeypatch, levels, should_stop=None, **kwargs):
|
||||
np = pytest.importorskip("numpy")
|
||||
stream = _FakeInputStream(np, levels)
|
||||
mock_sd.InputStream.return_value = stream
|
||||
written = {}
|
||||
monkeypatch.setattr(
|
||||
"tools.voice_mode.AudioRecorder._write_wav",
|
||||
staticmethod(lambda audio: written.update(audio=audio) or "/tmp/barge.wav"),
|
||||
)
|
||||
from tools.voice_mode import listen_for_speech
|
||||
stops = iter([False] * 200 + [True] * 10_000)
|
||||
path = listen_for_speech(
|
||||
should_stop or (lambda: next(stops)), capture=True, **kwargs
|
||||
)
|
||||
return path, written.get("audio"), stream
|
||||
|
||||
def test_captured_utterance_includes_speech_onset(self, mock_sd, monkeypatch):
|
||||
"""Every loud block — including the ones BEFORE detection tripped —
|
||||
must land in the WAV. That pre-roll is the whole point."""
|
||||
triggered = []
|
||||
levels = [0] * self.CALIB_BLOCKS + [5000] * self.LOUD_BLOCKS + [0] * 500
|
||||
path, audio, _ = self._run(
|
||||
mock_sd, monkeypatch, levels,
|
||||
should_stop=lambda: False,
|
||||
on_trigger=lambda: triggered.append(True),
|
||||
)
|
||||
assert path == "/tmp/barge.wav"
|
||||
assert triggered == [True]
|
||||
assert int((audio == 5000).sum()) == self.LOUD_BLOCKS * self.BLOCK
|
||||
|
||||
def test_no_trip_returns_none(self, mock_sd, monkeypatch):
|
||||
triggered = []
|
||||
path, audio, _ = self._run(
|
||||
mock_sd, monkeypatch, [0] * 500,
|
||||
on_trigger=lambda: triggered.append(True),
|
||||
)
|
||||
assert path is None
|
||||
assert audio is None
|
||||
assert triggered == []
|
||||
|
||||
class TestFullDuplexListen:
|
||||
"""full_duplex_listen: one agent-turn listener spanning generation and
|
||||
playback — pre-playback calibration, phase-aware trigger, grace window."""
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Pure-text helpers for voice mode: Whisper hallucination filter, voice-chat
|
||||
stop phrases, and the TTS self-echo guard. No audio dependencies."""
|
||||
|
||||
import difflib
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _voice_config() -> dict:
|
||||
"""``voice`` section of config.yaml, or ``{}`` when missing, malformed,
|
||||
or the config system can't be imported (broken config mid-install)."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
voice_cfg = load_config().get("voice", {})
|
||||
return voice_cfg if isinstance(voice_cfg, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# Whisper commonly hallucinates these phrases on silent/near-silent audio
|
||||
# (matched with trailing '.'/'!' stripped, so the bare form suffices).
|
||||
WHISPER_HALLUCINATIONS = {
|
||||
"thank you", "thanks for watching", "subscribe to my channel", "like and subscribe",
|
||||
"please subscribe", "thank you for watching", "bye", "you", "the end",
|
||||
# Non-English hallucinations (common on silence)
|
||||
"продолжение следует", "sous-titres", "sous-titres réalisés par la communauté d'amara.org",
|
||||
"sottotitoli creati dalla comunità amara.org", "untertitel von stephanie geiges",
|
||||
"amara.org", "www.mooji.org", "ご視聴ありがとうございました",
|
||||
}
|
||||
|
||||
|
||||
# Repetitive hallucinations (e.g. "Thank you. Thank you. Thank you.")
|
||||
_HALLUCINATION_REPEAT_RE = re.compile(
|
||||
r'^(?:thank you|thanks|bye|you|ok|okay|the end|\.|\s|,|!)+$',
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def is_whisper_hallucination(transcript: str) -> bool:
|
||||
"""Check if a transcript is a known Whisper hallucination on silence."""
|
||||
cleaned = transcript.strip().lower()
|
||||
if not cleaned:
|
||||
return True
|
||||
return (
|
||||
cleaned.rstrip('.!') in WHISPER_HALLUCINATIONS
|
||||
or bool(_HALLUCINATION_REPEAT_RE.match(cleaned))
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_VOICE_STOP_PHRASES = ("stop",)
|
||||
|
||||
|
||||
def _load_voice_stop_phrases() -> tuple:
|
||||
"""Configured ``voice.stop_phrases`` (default ``("stop",)``); an empty tuple
|
||||
disables the feature. Malformed config (dict, list of non-strings) falls
|
||||
back to the default rather than crashing the voice loop."""
|
||||
try:
|
||||
raw = _voice_config().get("stop_phrases", DEFAULT_VOICE_STOP_PHRASES)
|
||||
if isinstance(raw, str):
|
||||
raw = [raw]
|
||||
if isinstance(raw, (list, tuple)):
|
||||
return tuple(
|
||||
str(p).strip().lower() for p in raw
|
||||
if isinstance(p, (str, int, float)) and str(p).strip()
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return DEFAULT_VOICE_STOP_PHRASES
|
||||
|
||||
|
||||
def _configured_stop_phrases() -> tuple:
|
||||
"""Resolve ``_load_voice_stop_phrases`` through ``tools.voice_mode`` so
|
||||
``patch("tools.voice_mode._load_voice_stop_phrases")`` still takes effect."""
|
||||
from tools import voice_mode as _vm
|
||||
return _vm._load_voice_stop_phrases()
|
||||
|
||||
|
||||
def is_voice_stop_phrase(transcript: str, stop_phrases: Optional[tuple] = None) -> bool:
|
||||
"""True when *transcript* is EXACTLY a configured stop phrase.
|
||||
|
||||
Deliberately strict: the whole utterance — lowercased, surrounding
|
||||
punctuation stripped — must equal a phrase, so "stop doing that and try
|
||||
again" still reaches the agent. ``voice.stop_phrases: []`` disables.
|
||||
"""
|
||||
if not transcript:
|
||||
return False
|
||||
cleaned = transcript.strip().lower().strip(".,!?;: \t\n\"'")
|
||||
if not cleaned:
|
||||
return False
|
||||
if stop_phrases is None:
|
||||
stop_phrases = _configured_stop_phrases()
|
||||
return cleaned in stop_phrases
|
||||
|
||||
|
||||
# Similarity ratio (difflib.SequenceMatcher) above which a playback-phase barge
|
||||
# transcript is treated as a self-capture of Hermes' own TTS: the full-duplex
|
||||
# listener has no echo cancellation, so speaker bleed can trip the barge
|
||||
# trigger and get transcribed near-verbatim (a TTS -> STT -> TTS loop).
|
||||
DEFAULT_TTS_ECHO_SIMILARITY_THRESHOLD = 0.6
|
||||
|
||||
|
||||
# Minimum normalized-transcript length before the sliding-window fallback
|
||||
# runs. Below this a genuine one-word barge-in ("yes") landing verbatim inside
|
||||
# a longer reply would score a trivial 1.0 and be misread as self-capture; a
|
||||
# real self-capture spans pre-roll plus time-to-silence, so it is longer.
|
||||
MIN_FRAGMENT_LENGTH_FOR_ECHO = 10
|
||||
|
||||
|
||||
def _normalize_for_echo_compare(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", text).strip().lower()
|
||||
|
||||
|
||||
def is_tts_echo(
|
||||
transcript: str,
|
||||
spoken_text: str,
|
||||
threshold: float = DEFAULT_TTS_ECHO_SIMILARITY_THRESHOLD,
|
||||
) -> bool:
|
||||
"""True when *transcript* looks like a self-capture of *spoken_text*.
|
||||
|
||||
Character-level similarity (language-agnostic, no word tokenization): a
|
||||
genuine user interjection is very unlikely to closely match Hermes' own
|
||||
words, so a high ratio signals speaker-bleed self-capture (fail-closed
|
||||
guard for the playback-phase listener, which has no echo cancellation).
|
||||
|
||||
The playback-phase capture is cut when the trigger fires and only spans
|
||||
pre-roll plus time-to-silence, so for replies longer than a clause the
|
||||
transcript is a short FRAGMENT of `spoken_text` and the whole-string
|
||||
ratio dilutes toward 0. When it misses, a window sized to the transcript
|
||||
slides across `spoken_text` (character-based, so it works without word
|
||||
boundaries). Transcripts shorter than `MIN_FRAGMENT_LENGTH_FOR_ECHO` skip
|
||||
this fallback: a short interjection trivially matches a short window.
|
||||
"""
|
||||
if not transcript or not spoken_text:
|
||||
return False
|
||||
a = _normalize_for_echo_compare(transcript)
|
||||
b = _normalize_for_echo_compare(spoken_text)
|
||||
if not a or not b:
|
||||
return False
|
||||
|
||||
def _similar(x: str, y: str) -> bool:
|
||||
return difflib.SequenceMatcher(None, x, y).ratio() >= threshold
|
||||
|
||||
if _similar(a, b):
|
||||
return True
|
||||
if len(a) < MIN_FRAGMENT_LENGTH_FOR_ECHO or len(a) >= len(b):
|
||||
return False
|
||||
return any(_similar(a, b[start : start + len(a)]) for start in range(0, len(b) - len(a) + 1))
|
||||
|
||||
|
||||
def voice_stop_hint() -> str:
|
||||
"""One-line 'Say "stop" to end the voice chat.' hint for voice-mode start.
|
||||
|
||||
Uses the first ``voice.stop_phrases`` entry so a custom phrase renders
|
||||
correctly; returns "" when stop phrases are disabled so surfaces show no
|
||||
hint. Every surface announcing voice-mode start (CLI, TUI, desktop) uses
|
||||
this one owner instead of hardcoding the wording.
|
||||
"""
|
||||
phrases = _configured_stop_phrases()
|
||||
if not phrases:
|
||||
return ""
|
||||
return f'Say "{phrases[0]}" to end the voice chat.'
|
||||
Reference in New Issue
Block a user