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.
222 lines
7.6 KiB
Python
222 lines
7.6 KiB
Python
"""Tests for the Google Gemini TTS provider in tools/tts_tool.py."""
|
|
|
|
import base64
|
|
import struct
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_env(monkeypatch):
|
|
for key in (
|
|
"GEMINI_API_KEY",
|
|
"GOOGLE_API_KEY",
|
|
"GEMINI_BASE_URL",
|
|
"HERMES_SESSION_PLATFORM",
|
|
):
|
|
monkeypatch.delenv(key, raising=False)
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_pcm_bytes():
|
|
# 0.1s of silence at 24kHz mono 16-bit = 4800 bytes
|
|
return b"\x00" * 4800
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_gemini_response(fake_pcm_bytes):
|
|
"""A successful Gemini generateContent response."""
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.json.return_value = {
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"parts": [
|
|
{
|
|
"inlineData": {
|
|
"mimeType": "audio/L16;codec=pcm;rate=24000",
|
|
"data": base64.b64encode(fake_pcm_bytes).decode(),
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
return resp
|
|
|
|
|
|
class TestWrapPcmAsWav:
|
|
def test_riff_header_structure(self):
|
|
from tools.tts_tool import _wrap_pcm_as_wav
|
|
|
|
pcm = b"\x01\x02\x03\x04" * 10
|
|
wav = _wrap_pcm_as_wav(pcm, sample_rate=24000, channels=1, sample_width=2)
|
|
|
|
assert wav[:4] == b"RIFF"
|
|
assert wav[8:12] == b"WAVE"
|
|
assert wav[12:16] == b"fmt "
|
|
# Audio format (PCM=1)
|
|
assert struct.unpack("<H", wav[20:22])[0] == 1
|
|
# Channels
|
|
assert struct.unpack("<H", wav[22:24])[0] == 1
|
|
# Sample rate
|
|
assert struct.unpack("<I", wav[24:28])[0] == 24000
|
|
# Bits per sample
|
|
assert struct.unpack("<H", wav[34:36])[0] == 16
|
|
assert wav[36:40] == b"data"
|
|
assert wav[44:] == pcm
|
|
|
|
def test_header_size_is_44(self):
|
|
from tools.tts_tool import _wrap_pcm_as_wav
|
|
|
|
pcm = b"\xff" * 100
|
|
wav = _wrap_pcm_as_wav(pcm)
|
|
assert len(wav) == 44 + len(pcm)
|
|
|
|
|
|
class TestGenerateGeminiTts:
|
|
def test_missing_api_key_raises_value_error(self, tmp_path):
|
|
from tools.tts_tool import _generate_gemini_tts
|
|
|
|
output_path = str(tmp_path / "test.wav")
|
|
with pytest.raises(ValueError, match="GEMINI_API_KEY"):
|
|
_generate_gemini_tts("Hello", output_path, {})
|
|
|
|
def test_google_api_key_fallback(self, tmp_path, monkeypatch, mock_gemini_response):
|
|
from tools.tts_tool import _generate_gemini_tts
|
|
|
|
monkeypatch.setenv("GOOGLE_API_KEY", "from-google-env")
|
|
output_path = str(tmp_path / "test.wav")
|
|
|
|
with patch("requests.post", return_value=mock_gemini_response) as mock_post:
|
|
_generate_gemini_tts("Hi", output_path, {})
|
|
|
|
# Confirm it used the GOOGLE_API_KEY as the query parameter
|
|
_, kwargs = mock_post.call_args
|
|
assert kwargs["params"]["key"] == "from-google-env"
|
|
|
|
def test_wav_output_fast_path(self, tmp_path, monkeypatch, mock_gemini_response, fake_pcm_bytes):
|
|
from tools.tts_tool import _generate_gemini_tts
|
|
|
|
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
|
output_path = str(tmp_path / "test.wav")
|
|
|
|
with patch("requests.post", return_value=mock_gemini_response):
|
|
result = _generate_gemini_tts("Hi", output_path, {})
|
|
|
|
assert result == output_path
|
|
data = (tmp_path / "test.wav").read_bytes()
|
|
assert data[:4] == b"RIFF"
|
|
assert data[8:12] == b"WAVE"
|
|
# Audio payload should match the PCM we put in
|
|
assert data[44:] == fake_pcm_bytes
|
|
|
|
def test_x_goog_api_client_header_is_set(self, tmp_path, monkeypatch, mock_gemini_response):
|
|
"""Gemini TTS requests should include Hermes client context."""
|
|
from hermes_cli import __version__
|
|
from tools.tts_tool import _generate_gemini_tts
|
|
|
|
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
|
|
|
with patch("requests.post", return_value=mock_gemini_response) as mock_post:
|
|
_generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {})
|
|
|
|
headers = mock_post.call_args[1]["headers"]
|
|
assert headers["X-Goog-Api-Client"] == f"hermes-agent/{__version__}"
|
|
|
|
def test_default_voice_and_model(self, tmp_path, monkeypatch, mock_gemini_response):
|
|
from tools.tts_tool import (
|
|
DEFAULT_GEMINI_TTS_MODEL,
|
|
DEFAULT_GEMINI_TTS_VOICE,
|
|
_generate_gemini_tts,
|
|
)
|
|
|
|
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
|
|
|
with patch("requests.post", return_value=mock_gemini_response) as mock_post:
|
|
_generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {})
|
|
|
|
args, kwargs = mock_post.call_args
|
|
assert DEFAULT_GEMINI_TTS_MODEL in args[0]
|
|
payload = kwargs["json"]
|
|
voice = (
|
|
payload["generationConfig"]["speechConfig"]["voiceConfig"]
|
|
["prebuiltVoiceConfig"]["voiceName"]
|
|
)
|
|
assert voice == DEFAULT_GEMINI_TTS_VOICE
|
|
|
|
def test_custom_voice(self, tmp_path, monkeypatch, mock_gemini_response):
|
|
from tools.tts_tool import _generate_gemini_tts
|
|
|
|
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
|
config = {"gemini": {"voice": "Puck"}}
|
|
|
|
with patch("requests.post", return_value=mock_gemini_response) as mock_post:
|
|
_generate_gemini_tts("Hi", str(tmp_path / "test.wav"), config)
|
|
|
|
payload = mock_post.call_args[1]["json"]
|
|
voice = (
|
|
payload["generationConfig"]["speechConfig"]["voiceConfig"]
|
|
["prebuiltVoiceConfig"]["voiceName"]
|
|
)
|
|
assert voice == "Puck"
|
|
|
|
|
|
def test_audio_tag_rewrite_failure_falls_back_to_original_text(
|
|
self, tmp_path, monkeypatch, mock_gemini_response, caplog
|
|
):
|
|
from tools.tts_tool import _generate_gemini_tts
|
|
|
|
config = {
|
|
"gemini": {
|
|
"model": "gemini-3.1-flash-tts-preview",
|
|
"audio_tags": True,
|
|
}
|
|
}
|
|
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
|
|
|
with patch("agent.auxiliary_client.call_llm", side_effect=RuntimeError("boom")), \
|
|
patch("requests.post", return_value=mock_gemini_response) as mock_post:
|
|
_generate_gemini_tts("Hi there.", str(tmp_path / "test.wav"), config)
|
|
|
|
prompt_text = mock_post.call_args[1]["json"]["contents"][0]["parts"][0]["text"]
|
|
assert prompt_text == "Hi there."
|
|
assert "audio tag rewrite failed" in caplog.text
|
|
|
|
|
|
class TestGeminiInCheckRequirements:
|
|
def test_gemini_api_key_satisfies_requirements(self, monkeypatch):
|
|
from tools.tts_tool import check_tts_requirements
|
|
|
|
# Strip everything else
|
|
for key in (
|
|
"ELEVENLABS_API_KEY",
|
|
"OPENAI_API_KEY",
|
|
"VOICE_TOOLS_OPENAI_KEY",
|
|
"MINIMAX_API_KEY",
|
|
"XAI_API_KEY",
|
|
"MISTRAL_API_KEY",
|
|
"GOOGLE_API_KEY",
|
|
):
|
|
monkeypatch.delenv(key, raising=False)
|
|
monkeypatch.setenv("GEMINI_API_KEY", "k")
|
|
|
|
# Force edge_tts import to fail so we actually hit the gemini check
|
|
import builtins
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def fake_import(name, *args, **kwargs):
|
|
if name == "edge_tts":
|
|
raise ImportError("simulated")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
with patch(
|
|
"tools.tts_tool._load_tts_config",
|
|
return_value={"provider": "gemini"},
|
|
), patch("builtins.__import__", side_effect=fake_import):
|
|
assert check_tts_requirements() is True
|