mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
290 lines
11 KiB
Python
290 lines
11 KiB
Python
"""Tests for transcription_tools.py — local (faster-whisper) and OpenAI providers.
|
|
|
|
Tests cover provider selection, config loading, validation, and transcription
|
|
dispatch. All external dependencies (faster_whisper, openai) are mocked.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
def _fake_faster_whisper_module(mock_model):
|
|
return SimpleNamespace(WhisperModel=MagicMock(return_value=mock_model))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Provider selection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
pytestmark = pytest.mark.usefixtures("disable_lazy_stt_install")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_openai_env(monkeypatch):
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
|
|
|
|
class TestGetProvider:
|
|
"""_get_provider() picks the right backend based on config + availability."""
|
|
|
|
def test_local_when_available(self):
|
|
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True):
|
|
from tools.transcription_tools import _get_provider
|
|
assert _get_provider({"provider": "local"}) == "local"
|
|
|
|
def test_explicit_local_no_cloud_fallback(self, monkeypatch):
|
|
"""Explicit local provider must not silently fall back to cloud."""
|
|
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
|
|
monkeypatch.delenv("GROQ_API_KEY", raising=False)
|
|
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
|
|
patch("tools.transcription_tools._HAS_OPENAI", True), \
|
|
patch("tools.transcription_tools._has_local_command", return_value=False):
|
|
from tools.transcription_tools import _get_provider
|
|
assert _get_provider({"provider": "local"}) == "none"
|
|
|
|
|
|
def test_disabled_config_returns_none(self):
|
|
from tools.transcription_tools import _get_provider
|
|
assert _get_provider({"enabled": False, "provider": "openai"}) == "none"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# File validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateAudioFile:
|
|
|
|
def test_missing_file(self, tmp_path):
|
|
from tools.transcription_tools import _validate_audio_file
|
|
result = _validate_audio_file(str(tmp_path / "nope.ogg"))
|
|
assert result is not None
|
|
assert "not found" in result["error"]
|
|
|
|
|
|
def test_too_large(self, tmp_path):
|
|
f = tmp_path / "big.ogg"
|
|
f.write_bytes(b"x")
|
|
from tools.transcription_tools import _validate_audio_file, MAX_FILE_SIZE
|
|
real_stat = f.stat()
|
|
with patch.object(type(f), "stat", return_value=os.stat_result((
|
|
real_stat.st_mode, real_stat.st_ino, real_stat.st_dev,
|
|
real_stat.st_nlink, real_stat.st_uid, real_stat.st_gid,
|
|
MAX_FILE_SIZE + 1, # st_size
|
|
real_stat.st_atime, real_stat.st_mtime, real_stat.st_ctime,
|
|
))):
|
|
result = _validate_audio_file(str(f))
|
|
assert result is not None
|
|
assert "too large" in result["error"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoadSttConfig:
|
|
|
|
def test_merges_default_local_initial_prompt(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
(tmp_path / "config.yaml").write_text(
|
|
"stt:\n local:\n model: small\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
from tools.transcription_tools import _load_stt_config
|
|
local_config = _load_stt_config()["local"]
|
|
|
|
assert local_config["model"] == "small"
|
|
assert local_config["initial_prompt"] == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Local transcription
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTranscribeLocal:
|
|
|
|
def test_successful_transcription(self, tmp_path):
|
|
audio_file = tmp_path / "test.ogg"
|
|
audio_file.write_bytes(b"fake audio")
|
|
|
|
mock_segment = MagicMock()
|
|
mock_segment.text = "Hello world"
|
|
mock_info = MagicMock()
|
|
mock_info.language = "en"
|
|
mock_info.duration = 2.5
|
|
|
|
mock_model = MagicMock()
|
|
mock_model.transcribe.return_value = ([mock_segment], mock_info)
|
|
|
|
fake_fw = _fake_faster_whisper_module(mock_model)
|
|
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \
|
|
patch.dict("sys.modules", {"faster_whisper": fake_fw}), \
|
|
patch("tools.transcription_tools._local_model", None):
|
|
from tools.transcription_tools import _transcribe_local
|
|
result = _transcribe_local(str(audio_file), "base")
|
|
|
|
assert result["success"] is True
|
|
assert result["transcript"] == "Hello world"
|
|
|
|
|
|
def test_not_installed(self):
|
|
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False):
|
|
from tools.transcription_tools import _transcribe_local
|
|
result = _transcribe_local("/tmp/test.ogg", "base")
|
|
assert result["success"] is False
|
|
assert "not installed" in result["error"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OpenAI transcription
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTranscribeOpenAI:
|
|
|
|
def test_no_key(self, monkeypatch):
|
|
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
|
from tools.transcription_tools import _transcribe_openai
|
|
result = _transcribe_openai("/tmp/test.ogg", "whisper-1")
|
|
assert result["success"] is False
|
|
assert "VOICE_TOOLS_OPENAI_KEY" in result["error"]
|
|
|
|
|
|
def test_unset_language_omits_argument(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
|
|
audio_file = tmp_path / "test.ogg"
|
|
audio_file.write_bytes(b"fake audio")
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.audio.transcriptions.create.return_value = "Hello"
|
|
|
|
with patch("tools.transcription_tools._HAS_OPENAI", True), \
|
|
patch("tools.transcription_tools._load_stt_config", return_value={
|
|
"openai": {"language": ""},
|
|
}), \
|
|
patch("openai.OpenAI", return_value=mock_client):
|
|
from tools.transcription_tools import _transcribe_openai
|
|
result = _transcribe_openai(str(audio_file), "whisper-1")
|
|
|
|
assert result["success"] is True
|
|
assert "language" not in mock_client.audio.transcriptions.create.call_args.kwargs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main transcribe_audio() dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTranscribeAudio:
|
|
|
|
def test_dispatches_to_local(self, tmp_path):
|
|
audio_file = tmp_path / "test.ogg"
|
|
audio_file.write_bytes(b"fake audio")
|
|
|
|
with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "local"}), \
|
|
patch("tools.transcription_tools._get_provider", return_value="local"), \
|
|
patch("tools.transcription_tools._transcribe_local", return_value={"success": True, "transcript": "hi"}) as mock_local:
|
|
from tools.transcription_tools import transcribe_audio
|
|
result = transcribe_audio(str(audio_file))
|
|
|
|
assert result["success"] is True
|
|
mock_local.assert_called_once()
|
|
|
|
|
|
def test_invalid_file_returns_error(self):
|
|
from tools.transcription_tools import transcribe_audio
|
|
result = transcribe_audio("/nonexistent/file.ogg")
|
|
assert result["success"] is False
|
|
assert "not found" in result["error"]
|
|
|
|
|
|
class TestLocalFallback:
|
|
|
|
def test_uses_installed_faster_whisper_without_changing_provider(self, tmp_path):
|
|
audio_file = tmp_path / "test.ogg"
|
|
audio_file.write_bytes(b"fake audio")
|
|
|
|
with patch(
|
|
"tools.transcription_tools._load_stt_config",
|
|
return_value={"provider": "openai", "local": {"model": "small"}},
|
|
), patch(
|
|
"tools.transcription_tools._HAS_FASTER_WHISPER",
|
|
True,
|
|
), patch(
|
|
"tools.transcription_tools._transcribe_local",
|
|
return_value={"success": True, "transcript": "local result"},
|
|
) as mock_local:
|
|
from tools.transcription_tools import transcribe_audio_local_fallback
|
|
|
|
result = transcribe_audio_local_fallback(str(audio_file))
|
|
|
|
assert result["transcript"] == "local result"
|
|
mock_local.assert_called_once_with(str(audio_file), "small")
|
|
|
|
def test_does_not_install_when_no_local_backend_exists(self, tmp_path):
|
|
audio_file = tmp_path / "test.ogg"
|
|
audio_file.write_bytes(b"fake audio")
|
|
|
|
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), patch(
|
|
"tools.transcription_tools._has_local_command", return_value=False
|
|
):
|
|
from tools.transcription_tools import transcribe_audio_local_fallback
|
|
|
|
result = transcribe_audio_local_fallback(str(audio_file))
|
|
|
|
assert result["success"] is False
|
|
assert "installed local STT" in result["error"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Model name normalisation for local providers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNormalizeLocalModel:
|
|
"""_normalize_local_model() maps cloud-only names to the local default."""
|
|
|
|
def test_openai_model_name_maps_to_default(self):
|
|
from tools.transcription_tools import _normalize_local_model, DEFAULT_LOCAL_MODEL
|
|
assert _normalize_local_model("whisper-1") == DEFAULT_LOCAL_MODEL
|
|
|
|
|
|
def test_local_transcribe_normalises_model(self):
|
|
"""transcribe_audio with local provider must not pass 'whisper-1' to WhisperModel."""
|
|
import os
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
|
|
f.write(b"x")
|
|
audio_file = f.name
|
|
try:
|
|
mock_model = MagicMock()
|
|
mock_model.transcribe.return_value = (iter([]), MagicMock(language="en", duration=1.0))
|
|
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \
|
|
patch("tools.transcription_tools._load_stt_config", return_value={
|
|
"enabled": True,
|
|
"provider": "local",
|
|
"local": {"model": "whisper-1"},
|
|
}), \
|
|
patch("tools.transcription_tools._local_model", None), \
|
|
patch("tools.transcription_tools._local_model_name", None), \
|
|
patch.dict("sys.modules", {"faster_whisper": _fake_faster_whisper_module(mock_model)}):
|
|
mock_cls = __import__("faster_whisper").WhisperModel
|
|
from tools.transcription_tools import transcribe_audio
|
|
transcribe_audio(audio_file)
|
|
# WhisperModel must NOT have been called with "whisper-1"
|
|
call_args = mock_cls.call_args
|
|
assert call_args is not None
|
|
assert call_args[0][0] != "whisper-1", (
|
|
"WhisperModel was called with the cloud-only name 'whisper-1'"
|
|
)
|
|
finally:
|
|
os.unlink(audio_file)
|