test_assistant_integration.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. """Integration: the assistant service consuming a real publish-only bus."""
  2. import json
  3. import os
  4. import shutil
  5. import signal
  6. import sys
  7. import tempfile
  8. import threading
  9. import time
  10. from types import SimpleNamespace
  11. import pytest
  12. from stt_daemon import events
  13. from stt_daemon.assistant.runner import build_triage
  14. from stt_daemon.assistant.service import AssistantService
  15. from stt_daemon.bus import EventPublisher, EventSubscriber
  16. from stt_daemon.config import Config
  17. @pytest.fixture
  18. def sock_path():
  19. directory = tempfile.mkdtemp(prefix="sttb-")
  20. try:
  21. yield os.path.join(directory, "s.sock")
  22. finally:
  23. shutil.rmtree(directory, ignore_errors=True)
  24. def _wait(predicate, timeout=5.0):
  25. deadline = time.time() + timeout
  26. while time.time() < deadline:
  27. if predicate():
  28. return True
  29. time.sleep(0.01)
  30. return predicate()
  31. def _heartbeat_fields():
  32. return {
  33. "in_flight": False,
  34. "segments_done": 0,
  35. "worker_stall_s": 0.0,
  36. "queue_depth": 0,
  37. "frames_since_last": 1,
  38. "rms": 0.0,
  39. "capture": "test",
  40. "device_ok": True,
  41. "model_loaded": True,
  42. "uptime_s": 1.0,
  43. "last_transcript_age_s": 0.0,
  44. }
  45. def test_service_consumes_real_bus(sock_path):
  46. cfg = Config()
  47. publisher = EventPublisher(sock_path, ring_size=10)
  48. publisher.start()
  49. subscriber = EventSubscriber(sock_path, reconnect_delay=0.05)
  50. emitted: list[dict] = []
  51. service = AssistantService(
  52. cfg,
  53. subscriber,
  54. emit=lambda type_, **fields: emitted.append({"type": type_, **fields}),
  55. )
  56. service.start()
  57. try:
  58. publisher.emit(events.HEARTBEAT, **_heartbeat_fields())
  59. assert _wait(lambda: service.stt_ok() is True)
  60. assert service.arm() is True
  61. assert _wait(lambda: any(e.get("mode") == "command_armed" for e in emitted))
  62. publisher.emit(events.SPEECH_START)
  63. assert _wait(lambda: service.speech_open is True)
  64. publisher.emit(events.SPEECH_STOP, seg_id=5)
  65. assert _wait(lambda: service.awaited_seg_id == 5)
  66. assert service.ready() is False
  67. publisher.emit(events.SEGMENT_DONE, seg_id=5, emitted=True)
  68. assert _wait(lambda: service.ready() is True)
  69. publisher.emit(events.TRANSCRIPT, seg_id=5, text="hello", lang="en", lang_prob=0.9)
  70. assert _wait(lambda: service.command_text == "hello")
  71. assert service.execute() == "hello"
  72. assert service.finish() is True
  73. finally:
  74. service.stop()
  75. publisher.close()
  76. def _enabled_triage_cfg(tmp_path):
  77. cfg = Config()
  78. cfg.audit.enabled = False
  79. cfg.llm.enabled = False
  80. cfg.triage.enabled = True
  81. path = os.path.join(str(tmp_path), "questions.json")
  82. with open(path, "w", encoding="utf-8") as handle:
  83. json.dump({"urgent": {"type": "noul", "instructions": "is it urgent"}}, handle)
  84. cfg.triage.questions_file = path
  85. return cfg
  86. class _FakeChatClient:
  87. def __init__(self):
  88. self.calls = []
  89. def chat(self, messages, tools=None, max_tokens=None, timeout_s=None):
  90. self.calls.append(
  91. {
  92. "messages": messages,
  93. "tools": tools,
  94. "max_tokens": max_tokens,
  95. "timeout_s": timeout_s,
  96. }
  97. )
  98. return SimpleNamespace(content="EXTRACTED")
  99. def test_build_triage_disabled_returns_nones():
  100. cfg = Config()
  101. assert cfg.triage.enabled is False
  102. assert build_triage(cfg, None) == (None, None, None)
  103. assert build_triage(cfg, _FakeChatClient()) == (None, None, None)
  104. assert "laya" not in sys.modules
  105. service = AssistantService(cfg, subscriber=object())
  106. assert service.status()["triage_state"] == "disabled"
  107. def test_build_triage_enabled_constructs_without_client(tmp_path):
  108. cfg = _enabled_triage_cfg(tmp_path)
  109. before = len(threading.enumerate())
  110. analyzer, lane, extract_fn = build_triage(cfg, None)
  111. assert analyzer is not None
  112. assert lane is not None
  113. assert extract_fn is None
  114. assert analyzer._cfg is cfg.triage
  115. assert lane._cfg is cfg.triage
  116. assert analyzer.state() == ("loading", "")
  117. assert len(threading.enumerate()) == before
  118. assert "laya" not in sys.modules
  119. def test_build_triage_extract_fn_calls_client(tmp_path):
  120. cfg = _enabled_triage_cfg(tmp_path)
  121. client = _FakeChatClient()
  122. _, _, extract_fn = build_triage(cfg, client)
  123. assert extract_fn is not None
  124. assert extract_fn("PROMPT") == "EXTRACTED"
  125. assert len(client.calls) == 1
  126. call = client.calls[0]
  127. assert call["messages"] == [
  128. {"role": "system", "content": cfg.llm.triage_system_prompt},
  129. {"role": "user", "content": "PROMPT"},
  130. ]
  131. assert call["max_tokens"] == cfg.triage.extract_max_tokens
  132. assert call["timeout_s"] == cfg.triage.extract_timeout_s
  133. def test_build_triage_on_state_refreshes_analyzer(tmp_path):
  134. from stt_daemon.assistant.laya_lane import LAYA_MISSING_HINT
  135. cfg = _enabled_triage_cfg(tmp_path)
  136. sentinel = object()
  137. analyzer, lane, _ = build_triage(cfg, None, on_result=sentinel)
  138. assert analyzer is not None
  139. assert lane is not None
  140. assert lane._on_result is sentinel
  141. assert callable(lane._on_state)
  142. lane._on_state("ok", "")
  143. assert analyzer.state() == ("ok", "")
  144. lane._on_state("laya_missing", LAYA_MISSING_HINT)
  145. assert analyzer.state() == ("laya_missing", LAYA_MISSING_HINT)
  146. def _run_runner_main(monkeypatch, cfg):
  147. from stt_daemon.assistant import runner as runner_mod
  148. import stt_daemon.assistant.laya_lane as lane_mod
  149. calls = []
  150. made_lanes = []
  151. made_services = []
  152. class FakeLane:
  153. def __init__(self, triage_cfg, agent_factory=None, on_result=None, on_state=None):
  154. made_lanes.append(self)
  155. self.cfg = triage_cfg
  156. self.on_state = on_state
  157. self.on_result = on_result
  158. calls.append("lane.construct")
  159. def start(self):
  160. calls.append("lane.start")
  161. def stop(self):
  162. calls.append("lane.stop")
  163. class FakeSubscriber:
  164. def close(self):
  165. calls.append("subscriber.close")
  166. class FakeService:
  167. def __init__(self, cfg, subscriber, **kwargs):
  168. made_services.append(self)
  169. self.kwargs = kwargs
  170. calls.append("service.construct")
  171. def set_sinks(self, **kwargs):
  172. pass
  173. def chat_notify(self, *args, **kwargs):
  174. return None
  175. def _on_triage_result(self, generation, payload):
  176. calls.append("triage.result")
  177. def start(self):
  178. calls.append("service.start")
  179. def stop(self):
  180. calls.append("service.stop")
  181. class FakeControl:
  182. def __init__(self, cfg, service):
  183. self.service = service
  184. calls.append("control.construct")
  185. path = "/tmp/fake.sock"
  186. def start(self):
  187. calls.append("control.start")
  188. def broadcast(self, *args, **kwargs):
  189. pass
  190. def relay(self, event):
  191. pass
  192. def close(self):
  193. calls.append("control.close")
  194. class FakeEvent:
  195. def set(self):
  196. pass
  197. def wait(self, timeout=None):
  198. return True
  199. monkeypatch.setattr(lane_mod, "LayaLane", FakeLane)
  200. monkeypatch.setattr(runner_mod, "EventSubscriber", lambda *a, **k: FakeSubscriber())
  201. monkeypatch.setattr(runner_mod, "ControlServer", FakeControl)
  202. monkeypatch.setattr(runner_mod, "AssistantService", FakeService)
  203. monkeypatch.setattr(runner_mod, "load_config", lambda path=None: cfg)
  204. monkeypatch.setattr(threading, "Event", FakeEvent)
  205. monkeypatch.setattr(signal, "signal", lambda *a, **k: None)
  206. monkeypatch.setattr(sys, "argv", ["stt-assistant"])
  207. rc = runner_mod.main()
  208. return rc, calls, made_lanes, made_services
  209. def test_runner_main_shutdown_order_lane_before_control(tmp_path, monkeypatch):
  210. cfg = _enabled_triage_cfg(tmp_path)
  211. rc, calls, made_lanes, made_services = _run_runner_main(monkeypatch, cfg)
  212. assert rc == 0
  213. assert len(made_lanes) == 1
  214. assert made_lanes[0].cfg is cfg.triage
  215. assert made_services[0].kwargs["laya_lane"] is made_lanes[0]
  216. assert made_services[0].kwargs["triage"] is not None
  217. assert made_services[0].kwargs["triage_extract_fn"] is None
  218. assert calls.index("control.start") < calls.index("service.start")
  219. assert calls.index("service.start") < calls.index("lane.start")
  220. assert calls.index("service.stop") < calls.index("lane.stop")
  221. assert calls.index("lane.stop") < calls.index("control.close")
  222. made_lanes[0].on_result(3, {"answers": {}})
  223. assert "triage.result" in calls
  224. assert "laya" not in sys.modules
  225. def test_runner_main_disabled_builds_no_lane(monkeypatch):
  226. cfg = Config()
  227. cfg.audit.enabled = False
  228. cfg.llm.enabled = False
  229. rc, calls, made_lanes, made_services = _run_runner_main(monkeypatch, cfg)
  230. assert rc == 0
  231. assert made_lanes == []
  232. assert "lane.construct" not in calls
  233. assert "lane.start" not in calls
  234. assert "lane.stop" not in calls
  235. assert made_services[0].kwargs["triage"] is None
  236. assert made_services[0].kwargs["laya_lane"] is None
  237. assert made_services[0].kwargs["triage_extract_fn"] is None
  238. assert not [t for t in threading.enumerate() if t.name.startswith("laya-")]