atlas-iac/testing/tests/test_hermes_voice_language_routing.py

959 lines
33 KiB
Python
Raw Permalink Normal View History

"""Voice-mode language routing: private Whisper STT decides the Piper voice.
Every assertion here runs without a GPU, a microphone or a cluster. The browser
contract is exercised by driving the real ``atlas-voice.js`` inside a stub DOM
(``testing/tests/data/atlas_voice_language_probe.js``), and the two server-side
trust boundaries are exercised by applying the real image patch to fixtures that
carry the exact upstream anchors and then importing the patched result.
"""
from __future__ import annotations
import importlib.util
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from testing.tests.test_hermes_chat_support import HERMES, ROOT
DOCKERFILES = ROOT / "dockerfiles"
ATLAS_PATCH = DOCKERFILES / "hermes-webui-atlas-patch.py"
VOICE_SCRIPT = DOCKERFILES / "hermes-webui-atlas-voice.js"
VOICE_PROBE = ROOT / "testing" / "tests" / "data" / "atlas_voice_language_probe.js"
WEBUI_FIXTURE = ROOT / "testing" / "fixtures" / "hermes-webui-0.52.181"
ATLAS_TTS_URL = "http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech"
# Voices baked by the multilingual Piper work (PR #26): en=amy, ru=irina,
# es=claude. Anything outside this set must resolve to English.
SUPPORTED = ("en", "ru", "es")
# ---------------------------------------------------------------------------
# Module loaders
# ---------------------------------------------------------------------------
def _load_stt_server(monkeypatch):
"""Import the Jetson Whisper service without CUDA, torch or whisper."""
path = DOCKERFILES / "hermes-jetson-stt-server.py"
spec = importlib.util.spec_from_file_location("hermes_jetson_stt_server", path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, "cgi", SimpleNamespace())
monkeypatch.setitem(
sys.modules,
"torch",
SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: False)),
)
monkeypatch.setitem(sys.modules, "whisper", SimpleNamespace())
spec.loader.exec_module(module)
return module
def _load_stt_client():
"""Import the local-command STT client that Hermes shells out to."""
path = HERMES / "scripts" / "hermes_stt_client.py"
spec = importlib.util.spec_from_file_location("hermes_stt_client", path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
# ---------------------------------------------------------------------------
# Patch fixtures — each file carries the exact upstream fragment the image
# patch pins, so importing the patched result exercises the inserted code.
# ---------------------------------------------------------------------------
INDEX_FIXTURE = (
'<select id="settingsTtsEngine">'
'<option value="browser">Browser speech synthesis</option>'
'<option value="edge">Edge TTS (server)</option></select>\n'
'<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>\n'
)
UI_FIXTURE = """function _playEdgeTtsChunked(text, btn){
fetch('/api/tts',{method:'POST',body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})});
}
function readAloud(clean, btn, engine){
if(engine==='edge'){
_playEdgeTtsChunked(clean, btn);
}
}
function autoRead(clean, engine){
if(engine==='edge'){
_playEdgeTtsChunked(clean, null);
}
}
"""
HELPERS_FIXTURE = '''"""Stand-in for the WebUI helper module the patched code imports."""
def bad(handler, message, status=400):
return {"status": status, "error": message}
'''
ROUTES_FIXTURE = '''"""Stand-in carrying the exact upstream anchors the Atlas TTS patch pins."""
import html as _html
import json
import os
import re
from urllib.request import ProxyHandler, Request, build_opener
class _NoRedirectTtsHandler:
"""Placeholder for the upstream no-redirect opener handler."""
class _Logger:
def __init__(self):
self.failures = []
def exception(self, message):
self.failures.append(message)
logger = _Logger()
UPSTREAM_REQUESTS = []
class _Upstream:
def __init__(self, payload):
self._payload = payload
def read(self):
return self._payload
def __enter__(self):
return self
def __exit__(self, *exc_info):
return False
def _buffer_tts_audio_response(response):
return response.read()
def _tts_open(req, *, timeout=30, opener_factory=None):
"""Thin network seam for the TTS upstream fetch so tests can intercept it."""
UPSTREAM_REQUESTS.append(json.loads(req.data.decode("utf-8")))
return _Upstream(b"RIFFsynthetic")
def _handle_tts(handler, data, text, rate_str, engine):
# ── ElevenLabs TTS ──────────────────────────────────────────────────
return None
def handle_get(handler, parsed) -> bool:
"""Handle all GET routes. Returns True if handled, False for 404."""
return False
def handle_post(handler, parsed) -> bool:
"""Pinned POST route anchors for the Atlas voice patch."""
if parsed.path == "/api/transcribe":
return handle_transcribe(handler)
if parsed.path == "/api/tts":
return _handle_tts(handler, parsed)
return False
'''
UPLOAD_FIXTURE = '''"""Stand-in carrying the exact upstream /api/transcribe response anchor."""
def j(handler, payload, status=200):
return {"status": status, "payload": payload}
def handle_transcribe(handler, result):
try:
transcript = str(result.get('transcript') or '').strip()
return j(handler, {'ok': True, 'transcript': transcript})
except ValueError as error:
return j(handler, {'error': str(error)}, status=400)
'''
TRANSCRIPTION_FIXTURE = '''"""Stand-in carrying the exact upstream local-command STT envelope anchor."""
import contextlib
from pathlib import Path
class _Logger:
def info(self, *args):
return None
logger = _Logger()
def _transcribe_local_command(file_path, normalized_model, output_dir):
try:
with contextlib.nullcontext(output_dir):
txt_files = sorted(Path(output_dir).glob("*.txt"))
transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
logger.info(
"Transcribed %s via local STT command (%s, %d chars)",
Path(file_path).name,
normalized_model,
len(transcript_text),
)
return {"success": True, "transcript": transcript_text, "provider": "local_command"}
except OSError as error:
return {"success": False, "transcript": "", "error": str(error)}
'''
def _write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
@pytest.fixture
def patched_webui(tmp_path, monkeypatch):
"""Apply the real Atlas image patch to fixture trees and import the result."""
webui = tmp_path / "hermes-webui"
agent = tmp_path / "hermes"
# Start with the pinned full-surface fixture introduced by PR #39 so this
# test proves the language pipeline composes with its voice-selector removal
# and conversation instrument, not merely with the older #27 anchors.
shutil.copytree(WEBUI_FIXTURE, webui)
_write(webui / "api" / "__init__.py", "")
_write(webui / "api" / "helpers.py", HELPERS_FIXTURE)
_write(webui / "api" / "routes.py", ROUTES_FIXTURE)
_write(webui / "api" / "upload.py", UPLOAD_FIXTURE)
_write(agent / "tools" / "__init__.py", "")
_write(agent / "tools" / "transcription_tools.py", TRANSCRIPTION_FIXTURE)
environment = dict(os.environ)
environment["HERMES_WEBUI_PATCH_ROOT"] = str(webui)
environment["HERMES_AGENT_PATCH_ROOT"] = str(agent)
completed = subprocess.run(
[sys.executable, str(ATLAS_PATCH)],
env=environment,
capture_output=True,
text=True,
)
assert completed.returncode == 0, completed.stderr or completed.stdout
for name in ("api", "api.helpers", "api.routes", "api.upload", "tools",
"tools.transcription_tools"):
sys.modules.pop(name, None)
monkeypatch.syspath_prepend(str(agent))
monkeypatch.syspath_prepend(str(webui))
import api.routes as routes # noqa: PLC0415
import api.upload as upload # noqa: PLC0415
import tools.transcription_tools as transcription # noqa: PLC0415
yield SimpleNamespace(
webui=webui,
agent=agent,
routes=routes,
upload=upload,
transcription=transcription,
)
for name in ("api", "api.helpers", "api.routes", "api.upload", "tools",
"tools.transcription_tools"):
sys.modules.pop(name, None)
class _Handler:
"""Just enough BaseHTTPRequestHandler surface for the Atlas TTS branch."""
def __init__(self):
self.status = None
self.headers_sent = {}
self.wfile = SimpleNamespace(write=self._write)
self.body = b""
def send_response(self, status):
self.status = status
def send_header(self, name, value):
self.headers_sent[name] = value
def end_headers(self):
return None
def _write(self, payload):
self.body += payload
def _atlas_tts(patched, monkeypatch, data):
"""Run the patched Atlas branch and return the JSON it sent to hermes-tts."""
monkeypatch.setenv("HERMES_WEBUI_ATLAS_TTS_URL", ATLAS_TTS_URL)
patched.routes.UPSTREAM_REQUESTS.clear()
handler = _Handler()
result = patched.routes._handle_tts(handler, data, "Some reply.", "", "atlas")
assert result is True, "the Atlas branch must own the response"
assert handler.status == 200
assert len(patched.routes.UPSTREAM_REQUESTS) == 1
return patched.routes.UPSTREAM_REQUESTS[0]
# ---------------------------------------------------------------------------
# 1. Whisper service reports the language it actually decoded with
# ---------------------------------------------------------------------------
def test_stt_response_carries_whisper_detected_language(monkeypatch):
module = _load_stt_server(monkeypatch)
payload = module._transcription_payload(
{
"language": "ru",
"segments": [
{"text": " Как дела?", "no_speech_prob": 0.1, "avg_logprob": -0.2}
],
}
)
assert payload["text"] == "Как дела?"
assert payload["language"] == "ru"
assert payload["model"] == module.MODEL_NAME
@pytest.mark.parametrize(
("raw", "expected"),
[
("en", "en"),
("RU", "ru"),
(" es ", "es"),
("yue", "yue"),
("fr", "fr"),
("en-US", ""),
("en_US", ""),
("e", ""),
("english", ""),
("", ""),
("../en", ""),
("en\x00", ""),
("ru; rm -rf /", ""),
("рус", ""),
(None, ""),
(7, ""),
(["ru"], ""),
({"language": "ru"}, ""),
],
)
def test_stt_language_field_is_shape_validated(monkeypatch, raw, expected):
module = _load_stt_server(monkeypatch)
assert module._detected_language({"language": raw}) == expected
def test_stt_language_absent_when_whisper_omits_it(monkeypatch):
module = _load_stt_server(monkeypatch)
assert module._detected_language({}) == ""
assert module._detected_language("not a result") == ""
assert module._transcription_payload({"text": "hi"})["language"] == ""
# ---------------------------------------------------------------------------
# 2. The local-command client carries the language without breaking the
# .txt contract Hermes reads the transcript from
# ---------------------------------------------------------------------------
def test_stt_client_retries_a_brief_outage_then_fails_cleanly(monkeypatch, tmp_path):
"""A momentary connection refusal (service roll) retries; a persistent
outage raises a plain RuntimeError, never a urllib traceback."""
module = _load_stt_client()
monkeypatch.setattr(module.time, "sleep", lambda *_a, **_k: None)
calls = {"n": 0}
class _Resp:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return b'{"text": "recovered", "language": "en"}'
def flaky(_request, timeout=None):
calls["n"] += 1
if calls["n"] < 3:
raise ConnectionRefusedError(111, "Connection refused")
return _Resp()
monkeypatch.setattr(module, "urlopen", flaky)
req = module.Request("http://stt.invalid/", data=b"", method="POST")
assert module._transcribe_with_retry(req)["text"] == "recovered"
assert calls["n"] == 3
def always_refused(_request, timeout=None):
raise ConnectionRefusedError(111, "Connection refused")
monkeypatch.setattr(module, "urlopen", always_refused)
import pytest as _pytest
with _pytest.raises(RuntimeError, match="temporarily unavailable"):
module._transcribe_with_retry(req)
def test_stt_client_writes_language_sidecar_beside_the_txt_contract(tmp_path):
module = _load_stt_client()
module._write_result(tmp_path, "voice-input", "Как дела?", "ru")
assert (tmp_path / "voice-input.txt").read_text(encoding="utf-8") == "Как дела?"
assert (tmp_path / "voice-input.language").read_text(encoding="utf-8") == "ru"
# Hermes globs *.txt and reads the first match: the sidecar must not join it.
assert sorted(p.name for p in tmp_path.glob("*.txt")) == ["voice-input.txt"]
def test_stt_client_omits_the_sidecar_when_no_language_was_detected(tmp_path):
module = _load_stt_client()
module._write_result(tmp_path, "voice-input", "Hello.", "")
assert (tmp_path / "voice-input.txt").exists()
assert not (tmp_path / "voice-input.language").exists()
@pytest.mark.parametrize(
("raw", "expected"),
[
("en", "en"),
("ES", "es"),
(" ru ", "ru"),
("en-US", ""),
("", ""),
("../../etc/passwd", ""),
("en\n", "en"),
("e", ""),
(None, ""),
(12, ""),
(["en"], ""),
],
)
def test_stt_client_normalises_the_service_language_field(raw, expected):
module = _load_stt_client()
assert module._normalize_language(raw) == expected
# ---------------------------------------------------------------------------
# 3. The patched agent envelope and /api/transcribe response carry it through
# ---------------------------------------------------------------------------
def test_patched_local_command_envelope_carries_the_sidecar_language(patched_webui, tmp_path):
output = tmp_path / "stt-out"
output.mkdir()
(output / "voice-input.txt").write_text("Как дела?", encoding="utf-8")
(output / "voice-input.language").write_text("ru\n", encoding="utf-8")
result = patched_webui.transcription._transcribe_local_command(
"/tmp/voice-input.wav", "small", output
)
assert result == {
"success": True,
"transcript": "Как дела?",
"provider": "local_command",
"language": "ru",
}
def test_patched_local_command_envelope_defaults_to_no_language(patched_webui, tmp_path):
output = tmp_path / "stt-out"
output.mkdir()
(output / "voice-input.txt").write_text("Hello.", encoding="utf-8")
result = patched_webui.transcription._transcribe_local_command(
"/tmp/voice-input.wav", "small", output
)
assert result["transcript"] == "Hello."
assert result["language"] == ""
@pytest.mark.parametrize(
"hostile",
["en-US", "../../en", "en; rm -rf /", "e", "english", "", "\x00en", "e n"],
)
def test_patched_local_command_envelope_rejects_malformed_sidecars(
patched_webui, tmp_path, hostile
):
output = tmp_path / "stt-out"
output.mkdir()
(output / "voice-input.txt").write_text("Hello.", encoding="utf-8")
(output / "voice-input.language").write_text(hostile, encoding="utf-8")
result = patched_webui.transcription._transcribe_local_command(
"/tmp/voice-input.wav", "small", output
)
assert result["language"] == ""
def test_patched_local_command_envelope_survives_an_undecodable_sidecar(
patched_webui, tmp_path
):
"""A corrupt sidecar must cost the language, never the transcript."""
output = tmp_path / "stt-out"
output.mkdir()
(output / "voice-input.txt").write_text("Hello.", encoding="utf-8")
(output / "voice-input.language").write_bytes(b"\xff\xfe\x00ru")
result = patched_webui.transcription._transcribe_local_command(
"/tmp/voice-input.wav", "small", output
)
assert result["success"] is True
assert result["transcript"] == "Hello."
assert result["language"] == ""
def test_patched_transcribe_response_reports_the_language(patched_webui):
response = patched_webui.upload.handle_transcribe(
None, {"success": True, "transcript": " Как дела? ", "language": "ru"}
)
assert response["payload"] == {"ok": True, "transcript": "Как дела?", "language": "ru"}
@pytest.mark.parametrize(
"hostile",
["", None, "en-US", "englishhh", "../en", 5, ["ru"], {"a": "b"}, "e"],
)
def test_patched_transcribe_response_blanks_untrusted_languages(patched_webui, hostile):
response = patched_webui.upload.handle_transcribe(
None, {"success": True, "transcript": "Hello.", "language": hostile}
)
assert response["payload"]["language"] == ""
assert response["payload"]["transcript"] == "Hello."
# ---------------------------------------------------------------------------
# 4. The /api/tts trust boundary: allow-list only, and never `voice`
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("language", SUPPORTED)
def test_atlas_tts_forwards_allow_listed_languages(patched_webui, monkeypatch, language):
body = _atlas_tts(patched_webui, monkeypatch, {"engine": "atlas", "language": language})
assert body["language"] == language
assert body["model"] == "piper"
@pytest.mark.parametrize(
"hostile",
[
"fr",
"de",
"",
None,
"EN-GB",
" RU ",
"ru-RU",
"es_MX",
"../../ru_RU-irina-medium",
"ru; rm -rf /",
"ru\x00",
"ру",
5,
["ru"],
{"language": "ru"},
True,
"x" * 8192,
],
)
def test_atlas_tts_omits_untrusted_languages(
patched_webui, monkeypatch, hostile
):
body = _atlas_tts(patched_webui, monkeypatch, {"engine": "atlas", "language": hostile})
assert "language" not in body
def test_atlas_tts_language_is_absent_when_the_client_sends_none(patched_webui, monkeypatch):
body = _atlas_tts(patched_webui, monkeypatch, {"engine": "atlas"})
assert "language" not in body
def test_atlas_tts_voice_field_cannot_steer_synthesis(patched_webui, monkeypatch):
body = _atlas_tts(
patched_webui,
monkeypatch,
{"engine": "atlas", "voice": "ru_RU-irina-medium", "language": "en"},
)
assert body["language"] == "en"
body = _atlas_tts(
patched_webui,
monkeypatch,
{"engine": "atlas", "voice": "es_MX-claude-high"},
)
assert "language" not in body
def test_atlas_tts_language_helper_only_ever_returns_a_baked_voice_language(patched_webui):
resolve = patched_webui.routes._atlas_tts_language
hostile = [
None, 0, 1, -1, True, False, [], {}, set(), object(), b"ru",
"", " ", "\t\n", "en", "EN", "en-US", "en_us", "ru-RU", "es-MX",
"e", "eng", "english", "ru ru", "ru;es", "../ru", "ru\x00", "ру",
"x" * 65536, "en" * 4096,
]
for value in hostile:
expected = value if value in SUPPORTED else ""
assert resolve({"language": value}) == expected
for value in hostile:
assert resolve(value) == ""
assert resolve({"voice": "ru_RU-irina-medium"}) == ""
def test_manual_tts_button_body_still_carries_no_language(patched_webui):
"""The read-aloud button has no trusted STT signal, so it must stay Amy."""
ui = (patched_webui.webui / "static" / "ui.js").read_text(encoding="utf-8")
assert "engine:engineOverride||'edge'" in ui
assert "language" not in ui
# ---------------------------------------------------------------------------
# 5. Browser contract, driven through the real atlas-voice.js
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def voice_probe():
node = shutil.which("node")
if not node:
pytest.skip("node is required to drive the browser voice-mode contract")
completed = subprocess.run(
[node, str(VOICE_PROBE), str(VOICE_SCRIPT)],
capture_output=True,
text=True,
timeout=180,
)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
@pytest.mark.parametrize(
("scenario", "expected"),
[
("english_turn_speaks_english", "en"),
("russian_turn_speaks_russian", "ru"),
("spanish_turn_speaks_spanish", "es"),
],
)
def test_voice_mode_speaks_the_language_whisper_detected(voice_probe, scenario, expected):
requests = voice_probe[scenario]["tts"]
assert requests, "voice mode never reached /api/tts"
for request in requests:
assert request["engine"] == "atlas"
assert request["language"] == expected
@pytest.mark.parametrize(
"scenario", ["missing_language_falls_back", "unsupported_language_falls_back"]
)
def test_voice_mode_omits_language_without_a_trusted_signal(voice_probe, scenario):
requests = voice_probe[scenario]["tts"]
assert requests, "voice mode never reached /api/tts"
for request in requests:
assert "language" not in request
def test_voice_mode_drops_hostile_language_values(voice_probe):
for case in voice_probe["hostile_language_values_are_dropped"]["results"]:
for request in case["tts"]:
assert "language" not in request, case["sent"]
def test_voice_mode_never_sends_a_voice_field(voice_probe):
for request in voice_probe["voice_field_is_never_sent"]["tts"]:
assert set(request) <= {"text", "engine", "language", "turn_id", "speed"}
assert "voice" not in request
def test_voice_mode_does_not_reuse_a_previous_turn_language(voice_probe):
requests = voice_probe["language_does_not_leak_into_later_turn"]["tts"]
assert len(requests) == 3
assert requests[0]["language"] == "ru"
assert "language" not in requests[1]
assert requests[2]["language"] == "es"
def test_voice_mode_ignores_language_from_an_empty_transcript(voice_probe):
result = voice_probe["empty_transcript_does_not_arm_a_language"]
assert result["sendsAfterBlank"] == []
assert result["sends"] == ["Hello."]
assert result["tts"], "the follow-up turn should still be spoken"
for request in result["tts"]:
assert "language" not in request
def test_voice_mode_discards_language_when_the_session_changes(voice_probe):
result = voice_probe["session_change_discards_language"]
assert result["afterSwitch"] == []
for request in result["tts"]:
assert "language" not in request
def test_voice_mode_discards_language_when_voice_mode_is_turned_off(voice_probe):
result = voice_probe["deactivation_discards_language"]
assert result["afterDeactivate"] == []
for request in result["tts"]:
assert "language" not in request
def test_voice_mode_speaks_nothing_when_transcription_fails(voice_probe):
result = voice_probe["transcribe_error_speaks_nothing"]
assert result["tts"] == []
assert any("Whisper is down" in toast for toast in result["toasts"])
def test_adaptive_chunks_wait_for_sentence_then_change_size(voice_probe):
result = voice_probe["adaptive_chunks_are_sentence_gated"]
assert result["partial"] == []
chunks = result["complete"]
assert 40 <= len(chunks[0]) <= 60
assert all(100 <= len(chunk) <= 140 for chunk in chunks[1:-1])
assert "".join(chunks).replace(" ", "")
def test_spoken_http_urls_are_skipped_without_mangling_prose(voice_probe):
result = voice_probe["spoken_urls_are_skipped_without_damaging_text"]
assert result["sentence"] == "Read this. Then continue."
assert result["wrapped"] == "Open. Next."
assert result["punctuated"] == "Try, or!"
assert result["domains"] == "Keep example.com and sub.example.org exactly as written."
assert result["prose"] == "No links here; keep this sentence exactly as written."
def test_url_elision_happens_before_every_localized_voice_route(voice_probe):
cases = voice_probe["spoken_urls_are_elided_before_all_voice_routes"]["results"]
assert [case["language"] for case in cases] == ["en", "ru", "es"]
for case in cases:
assert case["tts"]
spoken = " ".join(request["text"] for request in case["tts"])
assert "http://" not in spoken
assert "https://" not in spoken
assert "private.example" not in spoken
assert "example.com" in spoken
assert all(request["language"] == case["language"] for request in case["tts"])
def test_canonical_pcm_fallback_wav_is_runtime_valid(voice_probe):
result = voice_probe["canonical_pcm_fallback_builds_a_valid_wav"]
assert result == {
"type": "audio/wav",
"size": 50,
"riff": "RIFF",
"wave": "WAVE",
"format": 1,
"channels": 1,
"rate": 16000,
"bits": 16,
"data": 6,
"pcm": [0, 0, 255, 127, 0, 128],
}
def test_applied_aec_settings_are_runtime_fail_safe(voice_probe):
assert voice_probe["applied_aec_settings_are_fail_safe"] == {
"applied": True,
"rejected": False,
"unknown": True,
"unsupported": True,
}
def test_first_complete_sentence_reaches_tts_before_completion_callback(voice_probe):
result = voice_probe["first_sentence_speaks_before_stream_completion"]
assert result["beforeBoundary"] == 0
assert result["afterBoundary"] == 1
assert len(result["tts"]) >= 2
def test_final_renderer_revision_closes_queue_without_replaying_prefix(voice_probe):
result = voice_probe["final_renderer_revision_closes_speech_queue"]
assert result["beforeRevision"] == 1
assert result["afterRevision"] >= 2
def test_streaming_tts_failure_falls_back_to_complete_wav(voice_probe):
result = voice_probe["streaming_tts_failure_falls_back_to_wav"]
assert len(result["stream"]) == 1
assert len(result["wav"]) == 1
assert result["stream"][0]["turn_id"] == result["wav"][0]["turn_id"]
def test_one_ahead_bound_and_turn_cancellation(voice_probe):
result = voice_probe["one_ahead_is_bounded_and_turn_cancel_stops_audio"]
assert result["beforeFirstEnds"] == 2
assert result["afterCancel"] == 2
assert result["active"] is False
assert result["firstAudioPaused"] is True
def test_authenticated_voice_websocket_uses_rendered_csrf_subprotocol(
patched_webui, monkeypatch
):
routes = patched_webui.routes
monkeypatch.setattr(
routes, "_check_same_origin_browser_request", lambda _handler: True, raising=False
)
monkeypatch.setitem(
sys.modules,
"api.auth",
SimpleNamespace(
csrf_token_for_session=lambda _cookie: "csrf-token",
is_auth_enabled=lambda: True,
parse_cookie=lambda _handler: None,
verify_session=lambda value: value == "session-cookie",
),
)
handler = SimpleNamespace(
_trusted_auth_session_cookie_value="session-cookie",
headers={
"Origin": "https://chat.bstein.dev",
"Sec-WebSocket-Protocol": (
"hermes-voice-v1, hermes-csrf.csrf-token"
),
}
)
assert routes._atlas_ws_authorized(handler) is True
handler.headers["Sec-WebSocket-Protocol"] = "hermes-voice-v1, hermes-csrf.wrong"
assert routes._atlas_ws_authorized(handler) is False
handler.headers["Origin"] = ""
assert routes._atlas_ws_authorized(handler) is False
def test_streaming_tts_payload_is_narrow_and_turn_bound(patched_webui):
payload = patched_webui.routes._atlas_tts_stream_payload(
{
"text": "A safe sentence.",
"language": "ru",
"turn_id": "voice-turn-7",
"voice": "../../untrusted",
"speed": 99,
}
)
assert payload == {
"model": "piper",
"input": "A safe sentence.",
"speed": 2.0,
"language": "ru",
"turn_id": "voice-turn-7",
}
assert patched_webui.routes._atlas_tts_stream_payload(
{"text": "A safe sentence.", "speed": 1.15}
)["speed"] == 1.15
assert patched_webui.routes._atlas_tts_stream_payload(
{"text": "A safe sentence."}
)["speed"] == 1.0
for hostile in ("2", True, None, [1.5], {"speed": 1.5}, float("nan")):
assert patched_webui.routes._atlas_tts_stream_payload(
{"text": "A safe sentence.", "speed": hostile}
)["speed"] == 1.0
def test_streaming_tts_payload_forwards_only_allowlisted_localized_cues(patched_webui):
routes = patched_webui.routes
assert routes._atlas_tts_stream_payload(
{
"text": "client text is ignored by the cue cache",
"language": "es",
"cue_id": "still_working",
"turn_id": "voice-turn-7:thinking-cue:2",
}
) == {
"model": "piper",
"input": "client text is ignored by the cue cache",
"speed": 1.0,
"language": "es",
"cue_id": "still_working",
"turn_id": "voice-turn-7:thinking-cue:2",
}
for hostile in ("invented", "../thinking", 7, None):
with pytest.raises(ValueError, match="invalid thinking cue"):
routes._atlas_tts_stream_payload(
{"text": "ignored", "language": "en", "cue_id": hostile}
)
with pytest.raises(ValueError, match="invalid thinking cue"):
routes._atlas_tts_stream_payload(
{"text": "ignored", "language": "fr", "cue_id": "thinking"}
)
# ---------------------------------------------------------------------------
# 6. Build-time enforcement and documented semantics
# ---------------------------------------------------------------------------
def test_image_build_verifies_every_language_routing_patch():
dockerfile = (DOCKERFILES / "Dockerfile.hermes-webui").read_text(encoding="utf-8")
assert "'language': detected" in dockerfile
assert "def _atlas_tts_language(body):" in dockerfile
assert 'request_payload["language"] = _atlas_language' in dockerfile
assert '"language": detected_language' in dockerfile
assert "takeSttLanguage(token)" in dockerfile
assert "/opt/hermes-webui/api/upload.py" in dockerfile
assert "/opt/hermes/tools/transcription_tools.py" in dockerfile
def test_atlas_patch_roots_are_overridable_for_offline_verification():
patch = ATLAS_PATCH.read_text(encoding="utf-8")
assert 'os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui")' in patch
assert 'os.environ.get("HERMES_AGENT_PATCH_ROOT", "/opt/hermes")' in patch
def test_notes_document_the_stt_driven_voice_selection_and_its_limits():
notes = (HERMES / "NOTES.md").read_text(encoding="utf-8")
assert "STT-detected language" in notes
for marker in ("hands-free", "Typed messages", "en_US-amy-medium"):
assert marker in notes
hermes(voice): round-3 fixes, language routing, session-toast root cause - Captions read message bodies only (the scraper was concatenating avatar, author and worklog chips); multi-segment interim turns are now speakable and drive clean speak-to-thinking-to-speak cycles when playback drains mid-turn. - Dynamic endpointing: complete-looking partials (3+ words or terminal punctuation) endpoint at the base window; the long hold remains only for one-two-word fragments. A stale-busy 10s settle wait on every post-error send is gone. - Both overlay captions are bounded, touch-scrollable regions with follow-tail; caps raised for long turns. - Error envelopes are never spoken or captioned; errored turns run resyncCapture (fresh STT session on the hot mic). - Workspace toggle now lives in the sidebar rail (floating button only below the rail breakpoint). - False 'session unavailable' toast root-caused: the router continuity guard shows it on a 409 that fired when a transient profile-listing failure failed closed into a fake cross-profile mismatch; the patcher now answers from the alias cache and never claims a default-vs-named mismatch while aliases are unconfirmed. - Barge-in sends carry a one-line cut-point marker with the last spoken sentence; visible-history truncation judged infeasible client-side. - Language switching works end-to-end: sticky per-session STT language hint (restarting an unused next session on switch), reply voice from script evidence, STT detection, then stopword heuristic; cues and WAV fallback share the turn language. - Legacy CI guards: node skip for the DOM probe, ffmpeg/codec skips for the container-fallback test. 272 voice-lane tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 13:58:35 -03:00
def test_reply_language_heuristic_routes_detectionless_spanish(voice_probe):
"""A Spanish reply on a detection-less turn still gets the Spanish voice."""
requests = voice_probe["spanish_reply_without_detection_uses_reply_heuristic"]["tts"]
assert requests, "voice mode never reached /api/tts"
for request in requests:
assert request["language"] == "es"
def test_reply_script_evidence_corrects_wrong_detection(voice_probe):
"""Script-level evidence in the reply text overrides a wrong STT hint, so
a Spanish reply is never spoken by the English Amy voice."""
requests = voice_probe["reply_script_evidence_corrects_wrong_detection"]["tts"]
assert requests, "voice mode never reached /api/tts"
for request in requests:
assert request["language"] == "es"
def test_english_reply_full_of_european_names_stays_on_the_english_voice(voice_probe):
"""FIX 4: an English paragraph packed with accented European proper nouns
(Zürich, München, Málaga, café, Kraków) carries no decisive Spanish/Russian
evidence, so it never trips a foreign voice. A single accented place name used
to make strongReplyLanguage return 'es' and outrank a correct 'en' STT the
root cause of the live "English reply about Europe spoken in a foreign voice"
bug."""
r = voice_probe["reply_language_resolution"]
assert r["europeStrong"] == ""
assert r["europeDetectionless"] == "" # English default (Amy) — no language field
assert r["europeWithEnglishStt"] == "en"
def test_reply_language_resolution_routes_cyrillic_and_spanish(voice_probe):
"""FIX 4: decisive script/orthography evidence still routes correctly —
a Cyrillic paragraph to the Russian voice, a clearly-Spanish paragraph to the
Spanish voice."""
r = voice_probe["reply_language_resolution"]
assert r["cyrillicStrong"] == "ru"
assert r["cyrillicDetectionless"] == "ru"
assert r["spanishStrong"] == "es"
assert r["spanishDetectionless"] == "es"
def test_reply_language_resolution_forced_language_always_wins(voice_probe):
"""FIX 4: an explicit conversation-mode force beats every auto signal —
including plain English text and decisive Cyrillic evidence."""
r = voice_probe["reply_language_resolution"]
assert r["forcedOverridesEnglishText"] == "ru"
assert r["forcedOverridesCyrillic"] == "en"