Merge branch 'main' into hermes/delegation-readiness-doctor-clean

This commit is contained in:
NplusM420 2026-04-23 19:46:36 -05:00 committed by GitHub
commit cb855b84a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 4915 additions and 218 deletions

View file

@ -463,7 +463,7 @@ class TestPlatformToolsetConsistency:
gateway_includes = set(TOOLSETS["hermes-gateway"]["includes"])
# Exclude non-messaging platforms from the check
non_messaging = {"cli", "api_server"}
non_messaging = {"cli", "api_server", "cron"}
for platform, meta in PLATFORMS.items():
if platform in non_messaging:
continue

View file

@ -0,0 +1,255 @@
"""Tests for ``hermes_cli.voice`` — the TUI gateway's voice wrapper.
The module is imported *lazily* by ``tui_gateway/server.py`` so that a
box with missing audio deps fails at call time (returning a clean RPC
error) rather than at gateway startup. These tests therefore only
assert the public contract the gateway depends on: the three symbols
exist, ``stop_and_transcribe`` is a no-op when nothing is recording,
and ``speak_text`` tolerates empty input without touching the provider
stack.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
class TestPublicAPI:
def test_gateway_symbols_importable(self):
"""Match the exact import shape tui_gateway/server.py uses."""
from hermes_cli.voice import (
speak_text,
start_recording,
stop_and_transcribe,
)
assert callable(start_recording)
assert callable(stop_and_transcribe)
assert callable(speak_text)
class TestStopWithoutStart:
def test_returns_none_when_no_recording_active(self, monkeypatch):
"""Idempotent no-op: stop before start must not raise or touch state."""
import hermes_cli.voice as voice
monkeypatch.setattr(voice, "_recorder", None)
assert voice.stop_and_transcribe() is None
class TestSpeakTextGuards:
@pytest.mark.parametrize("text", ["", " ", "\n\t "])
def test_empty_text_is_noop(self, text):
"""Empty / whitespace-only text must return without importing tts_tool
(the gateway spawns a thread per call, so a no-op on empty input
keeps the thread pool from churning on trivial inputs)."""
from hermes_cli.voice import speak_text
# Should simply return None without raising.
assert speak_text(text) is None
class TestContinuousAPI:
"""Continuous (VAD) mode API — CLI-parity loop entry points."""
def test_continuous_exports(self):
from hermes_cli.voice import (
is_continuous_active,
start_continuous,
stop_continuous,
)
assert callable(start_continuous)
assert callable(stop_continuous)
assert callable(is_continuous_active)
def test_not_active_by_default(self, monkeypatch):
import hermes_cli.voice as voice
# Isolate from any state left behind by other tests in the session.
monkeypatch.setattr(voice, "_continuous_active", False)
monkeypatch.setattr(voice, "_continuous_recorder", None)
assert voice.is_continuous_active() is False
def test_stop_continuous_idempotent_when_inactive(self, monkeypatch):
"""stop_continuous must not raise when no loop is active — the
gateway's voice.toggle off path calls it unconditionally."""
import hermes_cli.voice as voice
monkeypatch.setattr(voice, "_continuous_active", False)
monkeypatch.setattr(voice, "_continuous_recorder", None)
# Should return cleanly without exceptions
assert voice.stop_continuous() is None
assert voice.is_continuous_active() is False
def test_double_start_is_idempotent(self, monkeypatch):
"""A second start_continuous while already active is a no-op — prevents
two overlapping capture threads fighting over the microphone when the
UI double-fires (e.g. both /voice on and Ctrl+B within the same tick)."""
import hermes_cli.voice as voice
monkeypatch.setattr(voice, "_continuous_active", True)
called = {"n": 0}
class FakeRecorder:
def start(self, on_silence_stop=None):
called["n"] += 1
def cancel(self):
pass
monkeypatch.setattr(voice, "_continuous_recorder", FakeRecorder())
voice.start_continuous(on_transcript=lambda _t: None)
# The guard inside start_continuous short-circuits before rec.start()
assert called["n"] == 0
class TestContinuousLoopSimulation:
"""End-to-end simulation of the VAD loop with a fake recorder.
Proves auto-restart works: the silence callback must trigger transcribe
on_transcript re-call rec.start(on_silence_stop=same_cb). Also covers
the 3-strikes no-speech halt.
"""
@pytest.fixture
def fake_recorder(self, monkeypatch):
import hermes_cli.voice as voice
# Reset module state between tests.
monkeypatch.setattr(voice, "_continuous_active", False)
monkeypatch.setattr(voice, "_continuous_recorder", None)
monkeypatch.setattr(voice, "_continuous_no_speech_count", 0)
monkeypatch.setattr(voice, "_continuous_on_transcript", None)
monkeypatch.setattr(voice, "_continuous_on_status", None)
monkeypatch.setattr(voice, "_continuous_on_silent_limit", None)
class FakeRecorder:
_silence_threshold = 200
_silence_duration = 3.0
is_recording = False
def __init__(self):
self.start_calls = 0
self.last_callback = None
self.stopped = 0
self.cancelled = 0
# Preset WAV path returned by stop()
self.next_stop_wav = "/tmp/fake.wav"
def start(self, on_silence_stop=None):
self.start_calls += 1
self.last_callback = on_silence_stop
self.is_recording = True
def stop(self):
self.stopped += 1
self.is_recording = False
return self.next_stop_wav
def cancel(self):
self.cancelled += 1
self.is_recording = False
rec = FakeRecorder()
monkeypatch.setattr(voice, "create_audio_recorder", lambda: rec)
# Skip real file ops in the silence callback.
monkeypatch.setattr(voice.os.path, "isfile", lambda _p: False)
return rec
def test_loop_auto_restarts_after_transcript(self, fake_recorder, monkeypatch):
import hermes_cli.voice as voice
monkeypatch.setattr(
voice,
"transcribe_recording",
lambda _p: {"success": True, "transcript": "hello world"},
)
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)
transcripts = []
statuses = []
voice.start_continuous(
on_transcript=lambda t: transcripts.append(t),
on_status=lambda s: statuses.append(s),
)
assert fake_recorder.start_calls == 1
assert statuses == ["listening"]
# Simulate AudioRecorder's silence detector firing.
fake_recorder.last_callback()
assert transcripts == ["hello world"]
assert fake_recorder.start_calls == 2 # auto-restarted
assert statuses == ["listening", "transcribing", "listening"]
assert voice.is_continuous_active() is True
voice.stop_continuous()
def test_silent_limit_halts_loop_after_three_strikes(self, fake_recorder, monkeypatch):
import hermes_cli.voice as voice
# Transcription returns no speech — fake_recorder.stop() returns the
# path, but transcribe returns empty text, counting as silence.
monkeypatch.setattr(
voice,
"transcribe_recording",
lambda _p: {"success": True, "transcript": ""},
)
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)
transcripts = []
silent_limit_fired = []
voice.start_continuous(
on_transcript=lambda t: transcripts.append(t),
on_silent_limit=lambda: silent_limit_fired.append(True),
)
# Fire silence callback 3 times
for _ in range(3):
fake_recorder.last_callback()
assert transcripts == []
assert silent_limit_fired == [True]
assert voice.is_continuous_active() is False
assert fake_recorder.cancelled >= 1
def test_stop_during_transcription_discards_restart(self, fake_recorder, monkeypatch):
"""User hits Ctrl+B mid-transcription: the in-flight transcript must
still fire (it's a real utterance), but the loop must NOT restart."""
import hermes_cli.voice as voice
stop_triggered = {"flag": False}
def late_transcribe(_p):
# Simulate stop_continuous arriving while we're inside transcribe
voice.stop_continuous()
stop_triggered["flag"] = True
return {"success": True, "transcript": "final word"}
monkeypatch.setattr(voice, "transcribe_recording", late_transcribe)
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)
transcripts = []
voice.start_continuous(on_transcript=lambda t: transcripts.append(t))
initial_starts = fake_recorder.start_calls # 1
fake_recorder.last_callback()
assert stop_triggered["flag"] is True
# Loop is stopped — no auto-restart
assert fake_recorder.start_calls == initial_starts
# The in-flight transcript was suppressed because we stopped mid-flight
assert transcripts == []
assert voice.is_continuous_active() is False

View file

@ -1473,3 +1473,207 @@ class TestDiscoverUserThemes:
assert "ok" in names
assert "bad" not in names # malformed YAML
assert len(results) == 1 # only the valid one
class TestNormaliseThemeExtensions:
"""Tests for the extended normaliser fields (assets, customCSS,
componentStyles, layoutVariant) the surfaces themes use to reskin
the dashboard without shipping code."""
def test_layout_variant_defaults_to_standard(self):
from hermes_cli.web_server import _normalise_theme_definition
result = _normalise_theme_definition({"name": "t"})
assert result["layoutVariant"] == "standard"
def test_layout_variant_accepts_known_values(self):
from hermes_cli.web_server import _normalise_theme_definition
for variant in ("standard", "cockpit", "tiled"):
r = _normalise_theme_definition({"name": "t", "layoutVariant": variant})
assert r["layoutVariant"] == variant
def test_layout_variant_rejects_unknown(self):
from hermes_cli.web_server import _normalise_theme_definition
r = _normalise_theme_definition({"name": "t", "layoutVariant": "warship"})
assert r["layoutVariant"] == "standard"
r2 = _normalise_theme_definition({"name": "t", "layoutVariant": 12})
assert r2["layoutVariant"] == "standard"
def test_assets_named_slots_passthrough(self):
from hermes_cli.web_server import _normalise_theme_definition
r = _normalise_theme_definition({
"name": "t",
"assets": {
"bg": "https://example.com/bg.jpg",
"hero": "linear-gradient(180deg, red, blue)",
"crest": "/ds-assets/crest.svg",
"logo": " ", # whitespace-only — dropped
"notAKnownKey": "ignored",
},
})
assert r["assets"]["bg"] == "https://example.com/bg.jpg"
assert r["assets"]["hero"].startswith("linear-gradient")
assert r["assets"]["crest"] == "/ds-assets/crest.svg"
assert "logo" not in r["assets"] # whitespace-only rejected
assert "notAKnownKey" not in r["assets"] # unknown slot ignored
def test_assets_custom_block(self):
from hermes_cli.web_server import _normalise_theme_definition
r = _normalise_theme_definition({
"name": "t",
"assets": {
"custom": {
"scan-lines": "/img/scan.png",
"my_overlay": "/img/ov.png",
"bad key!": "x", # non-alnum key — rejected
"empty": "", # empty value — rejected
},
},
})
assert r["assets"]["custom"] == {
"scan-lines": "/img/scan.png",
"my_overlay": "/img/ov.png",
}
def test_assets_absent_means_no_field(self):
from hermes_cli.web_server import _normalise_theme_definition
r = _normalise_theme_definition({"name": "t"})
assert "assets" not in r
def test_custom_css_passthrough_and_capped(self):
from hermes_cli.web_server import _normalise_theme_definition
# Small CSS passes through verbatim.
r = _normalise_theme_definition({
"name": "t",
"customCSS": "body { color: red; }",
})
assert r["customCSS"] == "body { color: red; }"
# 40 KiB of CSS gets clipped to the 32 KiB cap.
huge = "/* x */ " * (40 * 1024 // 8 + 10)
r2 = _normalise_theme_definition({"name": "t", "customCSS": huge})
assert len(r2["customCSS"]) <= 32 * 1024
def test_custom_css_empty_dropped(self):
from hermes_cli.web_server import _normalise_theme_definition
for val in ("", " \n\t", None):
r = _normalise_theme_definition({"name": "t", "customCSS": val})
assert "customCSS" not in r
def test_component_styles_per_bucket(self):
from hermes_cli.web_server import _normalise_theme_definition
r = _normalise_theme_definition({
"name": "t",
"componentStyles": {
"card": {
"clipPath": "polygon(0 0, 100% 0, 100% 100%, 0 100%)",
"boxShadow": "inset 0 0 0 1px red",
"bad prop!": "ignored", # non-alnum prop rejected
},
"header": {"background": "linear-gradient(red, blue)"},
"rogueBucket": {"foo": "bar"}, # not a known bucket — rejected
},
})
assert r["componentStyles"]["card"] == {
"clipPath": "polygon(0 0, 100% 0, 100% 100%, 0 100%)",
"boxShadow": "inset 0 0 0 1px red",
}
assert r["componentStyles"]["header"]["background"].startswith("linear-gradient")
assert "rogueBucket" not in r["componentStyles"]
def test_component_styles_empty_buckets_dropped(self):
from hermes_cli.web_server import _normalise_theme_definition
r = _normalise_theme_definition({
"name": "t",
"componentStyles": {
"card": {}, # empty — dropped entirely
"header": {"bad prop!": "ignored"}, # all props rejected — bucket dropped
"footer": {"background": "black"},
},
})
assert "card" not in r.get("componentStyles", {})
assert "header" not in r.get("componentStyles", {})
assert r["componentStyles"]["footer"]["background"] == "black"
def test_component_styles_accepts_numeric_values(self):
"""Numeric values (e.g. opacity: 0.8) are coerced to strings."""
from hermes_cli.web_server import _normalise_theme_definition
r = _normalise_theme_definition({
"name": "t",
"componentStyles": {"card": {"opacity": 0.8, "zIndex": 5}},
})
assert r["componentStyles"]["card"] == {"opacity": "0.8", "zIndex": "5"}
class TestDashboardPluginManifestExtensions:
"""Tests for the extended plugin manifest fields (tab.override,
tab.hidden, slots) read by _discover_dashboard_plugins()."""
def _write_plugin(self, tmp_path, name, manifest):
import json
plug_dir = tmp_path / "plugins" / name / "dashboard"
plug_dir.mkdir(parents=True)
(plug_dir / "manifest.json").write_text(json.dumps(manifest))
return plug_dir
def test_override_and_hidden_carried_through(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
self._write_plugin(tmp_path, "skin-home", {
"name": "skin-home",
"label": "Skin Home",
"tab": {"path": "/skin-home", "override": "/", "hidden": True},
"slots": ["sidebar", "header-left"],
"entry": "dist/index.js",
})
from hermes_cli import web_server
# Bust the process-level cache so the test plugin is picked up.
web_server._dashboard_plugins_cache = None
plugins = web_server._get_dashboard_plugins(force_rescan=True)
entry = next(p for p in plugins if p["name"] == "skin-home")
assert entry["tab"]["override"] == "/"
assert entry["tab"]["hidden"] is True
assert entry["slots"] == ["sidebar", "header-left"]
def test_override_requires_leading_slash(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
self._write_plugin(tmp_path, "bad-override", {
"name": "bad-override",
"label": "Bad",
"tab": {"path": "/bad", "override": "no-leading-slash"},
"entry": "dist/index.js",
})
from hermes_cli import web_server
web_server._dashboard_plugins_cache = None
plugins = web_server._get_dashboard_plugins(force_rescan=True)
entry = next(p for p in plugins if p["name"] == "bad-override")
assert "override" not in entry["tab"]
def test_slots_default_empty(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
self._write_plugin(tmp_path, "no-slots", {
"name": "no-slots",
"label": "No Slots",
"tab": {"path": "/no-slots"},
"entry": "dist/index.js",
})
from hermes_cli import web_server
web_server._dashboard_plugins_cache = None
plugins = web_server._get_dashboard_plugins(force_rescan=True)
entry = next(p for p in plugins if p["name"] == "no-slots")
assert entry["slots"] == []
assert "hidden" not in entry["tab"]
assert "override" not in entry["tab"]
def test_slots_filters_non_string_entries(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
self._write_plugin(tmp_path, "mixed-slots", {
"name": "mixed-slots",
"label": "Mixed",
"tab": {"path": "/mixed-slots"},
"slots": ["sidebar", "", 42, None, "header-right"],
"entry": "dist/index.js",
})
from hermes_cli import web_server
web_server._dashboard_plugins_cache = None
plugins = web_server._get_dashboard_plugins(force_rescan=True)
entry = next(p for p in plugins if p["name"] == "mixed-slots")
assert entry["slots"] == ["sidebar", "header-right"]