atlas-iac/testing/tests/test_hermes_handsfree_stt.py
jenkins d041f1d1ee 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

239 lines
7.4 KiB
Python

"""Browser-to-ffmpeg contracts for Hermes hands-free transcription uploads."""
from __future__ import annotations
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import pytest
ROOT = Path(__file__).resolve().parents[2]
VOICE_JS = ROOT / "dockerfiles/hermes-webui-atlas-voice.js"
STT_PATCHER = ROOT / "dockerfiles/hermes-webui-stt-patch.py"
AGENT_FIXTURE = ROOT / "testing/fixtures/hermes-agent-9de9c25f"
MEDIA_FIXTURE = ROOT / "testing/fixtures/mediarecorder/chromium-webm-opus.json"
SAFE_CONVERSION_ERROR = (
"Audio conversion failed: upload is invalid, incomplete, or uses an "
"unsupported codec"
)
def _media_chunks() -> tuple[dict[str, object], list[bytes]]:
fixture = json.loads(MEDIA_FIXTURE.read_text(encoding="utf-8"))
payload = MEDIA_FIXTURE.with_name(str(fixture["payload_file"])).read_bytes()
chunks = []
offset = 0
for size in fixture["chunk_sizes"]:
end = offset + int(size)
chunks.append(payload[offset:end])
offset = end
assert offset == len(payload)
return fixture, chunks
def _late_speech_upload(*, preserve_header: bool) -> bytes:
fixture, chunks = _media_chunks()
count = int(fixture["pre_speech_chunk_count"])
retained = chunks[count - 3 :]
if preserve_header:
retained.insert(0, chunks[0])
return b"".join(retained)
def _patched_transcription_module(tmp_path: Path):
target = tmp_path / "hermes-agent"
shutil.copytree(AGENT_FIXTURE, target)
env = os.environ.copy()
env["HERMES_AGENT_PATCH_ROOT"] = str(target)
subprocess.run(
[sys.executable, str(STT_PATCHER)],
cwd=ROOT,
env=env,
check=True,
capture_output=True,
text=True,
)
module_path = target / "tools/transcription_tools.py"
spec = importlib.util.spec_from_file_location(
"pinned_transcription_tools", module_path
)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _convert(module, tmp_path: Path, payload: bytes, suffix: str):
source = tmp_path / f"voice-input{suffix}"
output_dir = tmp_path / f"converted-{suffix.removeprefix('.')}"
output_dir.mkdir()
source.write_bytes(payload)
return module._prepare_local_audio(str(source), str(output_dir))
def test_fixture_reproduces_the_current_ebml_header_failure(tmp_path: Path):
"""The red control is browser data after the old sliding pre-roll dropped chunk 0."""
broken = tmp_path / "current-late-speech.webm"
broken.write_bytes(_late_speech_upload(preserve_header=False))
result = subprocess.run(
["ffmpeg", "-v", "error", "-y", "-i", str(broken), str(tmp_path / "bad.wav")],
check=False,
capture_output=True,
text=True,
timeout=15,
)
assert result.returncode != 0
assert "EBML header" in result.stderr
def test_complete_browser_recording_reaches_real_conversion_boundary(
tmp_path: Path,
):
module = _patched_transcription_module(tmp_path)
_fixture, chunks = _media_chunks()
prepared, error = _convert(
module,
tmp_path,
b"".join(chunks),
".webm",
)
assert error is None
assert prepared is not None
output = Path(prepared)
assert output.is_file()
assert output.read_bytes().startswith(b"RIFF")
def test_invalid_browser_upload_returns_bounded_error_without_ffmpeg_spam(
tmp_path: Path,
):
module = _patched_transcription_module(tmp_path)
prepared, error = _convert(
module,
tmp_path,
_late_speech_upload(preserve_header=False),
".webm",
)
assert prepared is None
assert error == SAFE_CONVERSION_ERROR
assert len(error) < 128
lowered = error.lower()
assert "ffmpeg version" not in lowered
assert "configuration:" not in lowered
assert "/tmp/" not in lowered
def test_local_conversion_accepts_browser_fallback_containers(tmp_path: Path):
module = _patched_transcription_module(tmp_path)
formats = ((".ogg", "libopus"), (".mp4", "aac"))
# This test synthesizes .ogg/.mp4 sources itself, so it needs ffmpeg WITH
# the opus/aac encoders. Runners without them (the CI pod) skip; runners
# with the binaries keep full enforcement.
if shutil.which("ffmpeg") is None:
pytest.skip("ffmpeg is unavailable on this runner")
for _suffix, codec in formats:
encoder_probe = subprocess.run(
[
"ffmpeg",
"-v",
"error",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=0.1",
"-c:a",
codec,
"-f",
"null",
"-",
],
check=False,
capture_output=True,
text=True,
timeout=15,
)
if encoder_probe.returncode != 0:
pytest.skip(f"ffmpeg lacks the {codec} encoder on this runner")
for suffix, codec in formats:
source = tmp_path / f"source{suffix}"
subprocess.run(
[
"ffmpeg",
"-v",
"error",
"-y",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=0.4",
"-c:a",
codec,
str(source),
],
check=True,
capture_output=True,
text=True,
timeout=15,
)
prepared, error = _convert(module, tmp_path, source.read_bytes(), suffix)
assert error is None
assert prepared is not None
assert Path(prepared).read_bytes().startswith(b"RIFF")
def test_browser_uses_actual_recorder_mime_and_supported_extensions():
source = VOICE_JS.read_text(encoding="utf-8")
assert "recorder.start();" in source
assert "recorder.start(250)" not in source
assert "chunks.push(event.data)" in source
assert "initialChunk" not in source
assert "preRoll" not in source
assert "let recordedMime=recorder.mimeType||mime||''" in source
assert "if(event.data.type) recordedMime=event.data.type" in source
assert "audio/mp4;codecs=mp4a.40.2" in source
assert "if(normalized.indexOf('ogg')>=0) return 'ogg'" in source
assert "if(normalized.indexOf('mp4')>=0) return 'mp4'" in source
def test_stt_patch_is_built_fail_closed_into_the_webui_image(tmp_path: Path):
dockerfile = (ROOT / "dockerfiles/Dockerfile.hermes-webui").read_text(
encoding="utf-8"
)
assert "COPY dockerfiles/hermes-webui-stt-patch.py" in dockerfile
assert "python /tmp/hermes-webui-stt-patch.py" in dockerfile
assert "/opt/hermes/tools/transcription_tools.py" in dockerfile
drifted = tmp_path / "drifted-agent"
shutil.copytree(AGENT_FIXTURE, drifted)
transcription = drifted / "tools/transcription_tools.py"
source = transcription.read_text(encoding="utf-8")
transcription.write_text(
source.replace("Failed to convert audio for local STT", "upstream drift", 1),
encoding="utf-8",
)
env = os.environ.copy()
env["HERMES_AGENT_PATCH_ROOT"] = str(drifted)
result = subprocess.run(
[sys.executable, str(STT_PATCHER)],
cwd=ROOT,
env=env,
check=False,
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "patch context changed" in result.stderr