diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 788e1043bac2d..bf504a1ecab8c 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -16,6 +16,24 @@ from hermes_cli.active_sessions import active_session_registry_snapshot from hermes_cli.browser_connect import ChromeDebugLaunch from tools import async_delegation as ad from tui_gateway import server +from tui_gateway.transport import bind_transport, reset_transport + + +def _dispatch_sync(req: dict, transport=None) -> dict | None: + """Run one RPC to completion synchronously, regardless of pool routing. + + Voice RPCs (voice.toggle/record/tts) are in ``_LONG_HANDLERS`` so they run + on the RPC pool and ``server.dispatch`` returns None — the pool worker + writes the response via the bound transport. These tests exercise the + handler's business logic, not the routing, so they drive the handler + inline while preserving the transport-binding semantics ``dispatch`` + applies around a real request. + """ + token = bind_transport(transport) + try: + return server.handle_request(req) + finally: + reset_transport(token) @pytest.fixture(autouse=True) @@ -1234,10 +1252,10 @@ def test_voice_toggle_returns_configured_record_key(monkeypatch): # review on #19835). monkeypatch.setenv("HERMES_VOICE", "0") - on_resp = server.dispatch( + on_resp = _dispatch_sync( {"id": "voice-on", "method": "voice.toggle", "params": {"action": "on"}} ) - status_resp = server.dispatch( + status_resp = _dispatch_sync( {"id": "voice-status", "method": "voice.toggle", "params": {"action": "status"}} ) @@ -1260,7 +1278,7 @@ def test_voice_toggle_on_carries_stop_hint(monkeypatch): ) monkeypatch.setenv("HERMES_VOICE", "0") - on_resp = server.dispatch( + on_resp = _dispatch_sync( {"id": "voice-on", "method": "voice.toggle", "params": {"action": "on"}} ) assert on_resp["result"]["stop_hint"] == 'Say "halt" to end the voice chat.' @@ -1274,13 +1292,13 @@ def test_voice_toggle_on_carries_stop_hint(monkeypatch): voice_stop_hint=lambda: "", ), ) - on_resp = server.dispatch( + on_resp = _dispatch_sync( {"id": "voice-on2", "method": "voice.toggle", "params": {"action": "on"}} ) assert on_resp["result"]["stop_hint"] == "" # off carries no hint text (mode is ending). - off_resp = server.dispatch( + off_resp = _dispatch_sync( {"id": "voice-off", "method": "voice.toggle", "params": {"action": "off"}} ) assert off_resp["result"]["stop_hint"] == "" @@ -1306,7 +1324,7 @@ def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch): for bad in (True, "cmd+b", None, 42, ["ctrl+b"]): monkeypatch.setattr(server, "_load_cfg", lambda b=bad: {"voice": b}) - status_resp = server.dispatch( + status_resp = _dispatch_sync( { "id": "voice-status", "method": "voice.toggle", @@ -1325,7 +1343,7 @@ def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch): for bad_root in (True, None, [], "ctrl+b", 42): monkeypatch.setattr(server, "_load_cfg", lambda r=bad_root: r) - status_resp = server.dispatch( + status_resp = _dispatch_sync( { "id": "voice-status-root", "method": "voice.toggle", @@ -1366,7 +1384,7 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch): captured.clear() monkeypatch.setattr(server, "_load_cfg", lambda b=bad: {"voice": b}) - resp = server.dispatch( + resp = _dispatch_sync( { "id": "voice-record", "method": "voice.record", @@ -1395,7 +1413,7 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch): captured.clear() monkeypatch.setattr(server, "_load_cfg", lambda c=bad_bool_cfg: {"voice": c}) - resp = server.dispatch( + resp = _dispatch_sync( { "id": "voice-record-bool", "method": "voice.record", @@ -1609,7 +1627,7 @@ def test_wake_owner_is_sticky_and_routes_detection_to_first_transport(monkeypatc "method": "wake.stop", "params": {}, }, transport=second) - denied_voice_stop = server.dispatch({ + denied_voice_stop = _dispatch_sync({ "id": "voice-stop-2", "method": "voice.record", "params": {"action": "stop"}, @@ -1640,7 +1658,7 @@ def test_wake_owner_is_sticky_and_routes_detection_to_first_transport(monkeypatc )] assert state["paused"] is True - voice_started = server.dispatch({ + voice_started = _dispatch_sync({ "id": "voice-start-1", "method": "voice.record", "params": {"action": "start", "session_id": "first-session"}, @@ -1863,7 +1881,7 @@ def test_voice_record_start_forwards_max_recording_seconds(monkeypatch): captured.clear() monkeypatch.setattr(server, "_load_cfg", lambda c=cfg: {"voice": c}) - resp = server.dispatch( + resp = _dispatch_sync( { "id": "voice-record-cap", "method": "voice.record", @@ -1893,7 +1911,7 @@ def test_voice_record_stop_forces_transcription(monkeypatch): ), ) - resp = server.dispatch( + resp = _dispatch_sync( { "id": "voice-record-stop", "method": "voice.record", @@ -1916,7 +1934,7 @@ def test_voice_record_stop_updates_event_session_id(monkeypatch): ) monkeypatch.setattr(server, "_voice_event_sid", "old-session") - resp = server.dispatch( + resp = _dispatch_sync( { "id": "voice-record-stop-session", "method": "voice.record", @@ -1940,7 +1958,7 @@ def test_voice_record_start_reports_busy_when_stop_is_in_progress(monkeypatch): monkeypatch.setenv("HERMES_VOICE", "1") monkeypatch.setattr(server, "_load_cfg", lambda: {"voice": {}}) - resp = server.dispatch( + resp = _dispatch_sync( { "id": "voice-record-busy", "method": "voice.record", @@ -1978,7 +1996,7 @@ def test_voice_toggle_tts_branch_also_carries_record_key(monkeypatch): # later test in the file (which now spins up the streaming TTS pipeline). monkeypatch.setenv("HERMES_VOICE_TTS", "0") - tts_resp = server.dispatch( + tts_resp = _dispatch_sync( {"id": "voice-tts", "method": "voice.toggle", "params": {"action": "tts"}} ) diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 2265da9074b9d..f22f65fa7d01f 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -658,6 +658,20 @@ def test_completion_handlers_are_pool_routed(completion_method, server): assert completion_method in server._LONG_HANDLERS +@pytest.mark.parametrize("voice_method", ["voice.toggle", "voice.record", "voice.tts"]) +def test_voice_handlers_are_pool_routed(voice_method, server): + """Voice RPCs must run on the pool, never the WS reader thread. + + Regression: voice.toggle (status) triggers check_voice_requirements() → + STT provider auto-detect → a SYNCHRONOUS faster-whisper lazy install (uv/pip + subprocess, up to a 300s timeout). Inline on the WS reader loop it blocked + prompt.submit / session.list frames queued behind it — the desktop showed + sent messages that never reached the agent. Same bug class as #21123 / + #50005: anything that can stall for seconds must stay off the reader thread. + """ + assert voice_method in server._LONG_HANDLERS + + def test_skin_live_switch_end_to_end(server, tmp_path, monkeypatch): """Real config + skin files: activating a skin (as `hermes config set` does) makes the per-tool reconcile broadcast skin.changed with the resolved palette. diff --git a/tui_gateway/server.py b/tui_gateway/server.py index cbfaf3c94812e..b9649de3c0f45 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -274,6 +274,17 @@ _LONG_HANDLERS = frozenset( # the WS read loop and causing false "needs setup" (#50005 family). "setup.runtime_check", "setup.status", + # Voice RPCs can trigger check_voice_requirements() → STT provider + # auto-detect → a SYNCHRONOUS faster-whisper lazy install (uv/pip + # subprocess with a 300s timeout). Inline they stall the WS reader + # loop (handle_ws awaits dispatch before reading the next frame), so + # prompt.submit / session.list queued behind a voice.toggle sit + # unread and the desktop "send message" appears dead for minutes + # (reproduced: voice.toggle → session.list 40s+ timeout). Route them + # to the pool so a slow lazy install can't block message handling. + "voice.toggle", + "voice.record", + "voice.tts", # Desktop also polls the in-memory live-session registry every 15s. # The handler is normally cheap, but under heavy agent GIL pressure it # can still stall for tens of seconds. Keep it off the WS reader thread