test_assistant_service.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. """Tests for the assistant service (Phase 2) using synthetic event streams."""
  2. import itertools
  3. import threading
  4. import time
  5. from stt_daemon import events
  6. from stt_daemon.assistant.inference import InferenceQueue
  7. from stt_daemon.assistant.service import EXECUTE_BUSY, AssistantService
  8. from stt_daemon.config import Config
  9. _sequence = itertools.count(1)
  10. class Clock:
  11. def __init__(self, start=1000.0):
  12. self.t = float(start)
  13. def __call__(self):
  14. return self.t
  15. def advance(self, dt):
  16. self.t += float(dt)
  17. class FakeSubscriber:
  18. def events(self):
  19. return iter(())
  20. def close(self):
  21. pass
  22. def _service():
  23. cfg = Config()
  24. clock = Clock()
  25. emitted: list[dict] = []
  26. def emit(type_, **fields):
  27. emitted.append({"type": type_, **fields})
  28. service = AssistantService(cfg, FakeSubscriber(), emit=emit, time_fn=clock)
  29. return service, emitted, clock
  30. def _ev(type_, clock, **fields):
  31. event = {"v": 1, "type": type_, "seq": next(_sequence), "ts": clock()}
  32. event.update(fields)
  33. return event
  34. def _transcript(clock, text, ts=None):
  35. return {
  36. "v": 1,
  37. "type": events.TRANSCRIPT,
  38. "seq": next(_sequence),
  39. "ts": clock() if ts is None else ts,
  40. "text": text,
  41. }
  42. def test_ready_requires_matching_segment_done():
  43. service, _, clock = _service()
  44. service.handle_event(_ev(events.SPEECH_START, clock))
  45. assert service.ready() is False
  46. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  47. assert service.ready() is False # awaiting segment 1
  48. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  49. assert service.ready() is True
  50. def test_stale_segment_done_does_not_satisfy_later_turn():
  51. service, _, clock = _service()
  52. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  53. service.handle_event(_ev(events.SPEECH_START, clock))
  54. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=2))
  55. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  56. assert service.ready() is False # still awaiting segment 2
  57. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=2, emitted=True))
  58. assert service.ready() is True
  59. def test_gated_segment_done_clears_readiness():
  60. service, _, clock = _service()
  61. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=3))
  62. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=3, emitted=False))
  63. assert service.ready() is True
  64. def test_speech_stop_without_seg_id_clears_readiness():
  65. """A discarded turn closes with speech_stop but no seg_id; readiness must recover."""
  66. service, _, clock = _service()
  67. service.handle_event(_ev(events.SPEECH_START, clock))
  68. assert service.ready() is False
  69. service.handle_event(_ev(events.SPEECH_STOP, clock)) # discarded: no seg_id
  70. assert service.speech_open is False
  71. assert service.awaited_seg_id is None
  72. assert service.ready() is True
  73. def test_status_resets_stale_speech_state_on_reconnect():
  74. service, _, clock = _service()
  75. service.handle_event(_ev(events.SPEECH_START, clock))
  76. assert service.speech_open is True
  77. service.handle_event(_ev(events.STATUS, clock, capture="x", segments_done=0))
  78. assert service.speech_open is False
  79. assert service.awaited_seg_id is None
  80. assert service.ready() is True
  81. def test_command_buffer_uses_arm_timestamp():
  82. service, _, clock = _service()
  83. clock.advance(10)
  84. assert service.arm() is True
  85. arm_ts = service.arm_ts
  86. assert arm_ts is not None
  87. # Replayed transcript from before arming is ignored.
  88. service.handle_event(_transcript(clock, "old", ts=arm_ts - 5))
  89. assert service.command_text == ""
  90. service.handle_event(_transcript(clock, "what time is it"))
  91. assert service.command_text == "what time is it"
  92. def test_mutual_exclusion_between_command_and_chat():
  93. service, _, _ = _service()
  94. assert service.toggle_chat() is True
  95. assert service.arm() is False # cannot arm in chat
  96. assert service.toggle_chat() is True # back to idle
  97. assert service.arm() is True
  98. assert service.toggle_chat() is False # cannot chat while armed
  99. def test_arm_timeout_disarms_and_emits_error():
  100. service, emitted, clock = _service()
  101. service.arm()
  102. clock.advance(service.cfg.assistant.command_arm_timeout_s + 0.1)
  103. service.tick()
  104. assert service.modes.mode.value == "idle"
  105. assert service.command_text == ""
  106. assert any(e["type"] == "error" and e["code"] == "arm_timeout" for e in emitted)
  107. def test_long_speech_does_not_disarm():
  108. service, emitted, clock = _service()
  109. service.arm()
  110. service.handle_event(_ev(events.SPEECH_START, clock))
  111. clock.advance(service.cfg.assistant.command_arm_timeout_s + 30.0)
  112. service.tick()
  113. assert service.modes.mode.value == "command_armed"
  114. assert not any(e["type"] == "error" for e in emitted)
  115. def test_captured_command_survives_arm_timeout():
  116. service, emitted, clock = _service()
  117. service.arm()
  118. service.handle_event(_transcript(clock, "what time is it"))
  119. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  120. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  121. clock.advance(service.cfg.assistant.command_arm_timeout_s + 30.0)
  122. service.tick()
  123. assert service.modes.mode.value == "command_armed"
  124. assert service.command_text == "what time is it"
  125. assert not any(e["type"] == "error" for e in emitted)
  126. def test_stt_stall_cancels_armed_command():
  127. service, emitted, clock = _service()
  128. service.handle_event(_ev(events.HEARTBEAT, clock, worker_stall_s=0.0))
  129. service.arm()
  130. clock.advance(service.cfg.assistant.stall_timeout_ms / 1000.0 + 0.5)
  131. service.tick()
  132. assert service.modes.mode.value == "idle"
  133. assert any(e["type"] == "error" and e["code"] == "stt_stall" for e in emitted)
  134. def test_worker_stall_marks_stt_degraded():
  135. service, _, clock = _service()
  136. long_stall = service.cfg.assistant.worker_stall_ms / 1000.0 + 1.0
  137. service.handle_event(_ev(events.HEARTBEAT, clock, worker_stall_s=long_stall))
  138. assert service.stt_ok() is False
  139. assert service.ready() is False
  140. def test_chat_idle_timeout_exits_chat():
  141. service, _, clock = _service()
  142. assert service.toggle_chat() is True
  143. clock.advance(service.cfg.assistant.chat_idle_timeout_s + 0.1)
  144. service.tick()
  145. assert service.modes.mode.value == "idle"
  146. def test_chat_exit_phrase_exits_chat():
  147. service, _, clock = _service()
  148. assert service.toggle_chat() is True
  149. service.handle_event(_transcript(clock, "That's all."))
  150. assert service.modes.mode.value == "idle"
  151. assert service.chat_history == []
  152. def test_viewer_detached_exits_chat_and_forgets_history():
  153. service, _, clock = _service()
  154. assert service.toggle_chat() is True
  155. service.handle_event(_transcript(clock, "hello"))
  156. assert service.viewer_detached() is True
  157. assert service.modes.mode.value == "idle"
  158. assert service.chat_history == []
  159. def test_viewer_detached_is_noop_outside_chat():
  160. service, _, _ = _service()
  161. assert service.viewer_detached() is False
  162. assert service.modes.mode.value == "idle"
  163. def test_exit_chat_invokes_chat_reset():
  164. cfg = Config()
  165. resets: list[int] = []
  166. service = AssistantService(
  167. cfg, FakeSubscriber(), time_fn=Clock(), chat_reset=lambda: resets.append(1)
  168. )
  169. service.toggle_chat()
  170. service.viewer_detached()
  171. assert resets == [1]
  172. def test_chat_accumulates_turns():
  173. service, _, clock = _service()
  174. service.toggle_chat()
  175. for text in ("hello there", "how are you"):
  176. service.handle_event(_transcript(clock, text))
  177. assert service.chat_history == ["hello there", "how are you"]
  178. def test_execute_requires_readiness():
  179. service, _, clock = _service()
  180. service.arm()
  181. service.handle_event(_transcript(clock, "do it"))
  182. service.handle_event(_ev(events.SPEECH_START, clock))
  183. assert service.execute() is None # speech still open
  184. assert service.modes.mode.value == "command_armed"
  185. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  186. assert service.execute() is None # awaiting segment 1
  187. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  188. assert service.execute() == "do it"
  189. assert service.modes.mode.value == "executing"
  190. assert service.finish() is True
  191. assert service.modes.mode.value == "idle"
  192. def test_execute_reports_busy_when_queue_rejects():
  193. service, emitted, clock = _service()
  194. # A zero-capacity lane rejects the priority command job on submit.
  195. service._inference = InferenceQueue(size=0)
  196. service.executor = object()
  197. service.arm()
  198. service.handle_event(_transcript(clock, "do it"))
  199. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  200. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  201. assert service.execute() is EXECUTE_BUSY
  202. assert service.modes.mode.value == "idle"
  203. assert not any(e.get("code") == "busy" for e in emitted)
  204. def test_turn_ended_requires_quiet_gap():
  205. service, _, clock = _service()
  206. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  207. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  208. assert service.ready() is True
  209. assert service.turn_ended() is False
  210. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  211. assert service.turn_ended() is True
  212. def test_emits_mode_and_command_text():
  213. service, emitted, clock = _service()
  214. service.arm()
  215. assert {"type": "mode", "mode": "command_armed"} in emitted
  216. service.handle_event(_transcript(clock, "hi"))
  217. assert any(e["type"] == "command_text" and e["text"] == "hi" for e in emitted)
  218. def test_cancel_clears_command_buffer():
  219. service, _, clock = _service()
  220. service.arm()
  221. service.handle_event(_transcript(clock, "hi"))
  222. assert service.cancel() is True
  223. assert service.modes.mode.value == "idle"
  224. assert service.command_text == ""
  225. assert service.arm_ts is None
  226. def test_pending_chat_is_not_lost_across_threads():
  227. """Transcripts appended on the reader thread while the ticker dispatches
  228. must all reach the model: the join-and-clear in _dispatch_chat is atomic."""
  229. cfg = Config()
  230. cfg.assistant.turn_gap_s = 0.01
  231. dispatched: list[str] = []
  232. def emit(type_, **fields):
  233. if type_ == "chat_message" and fields.get("role") == "user":
  234. dispatched.append(fields["text"])
  235. service = AssistantService(cfg, FakeSubscriber(), emit=emit, chat_fn=lambda text: "ok")
  236. service.toggle_chat()
  237. total = 200
  238. producer = threading.Thread(
  239. target=lambda: [
  240. service.handle_event(
  241. {
  242. "v": 1,
  243. "type": events.TRANSCRIPT,
  244. "seq": i,
  245. "ts": time.time(),
  246. "text": f"w{i}",
  247. }
  248. )
  249. for i in range(total)
  250. ],
  251. daemon=True,
  252. )
  253. producer.start()
  254. deadline = time.time() + 10.0
  255. while time.time() < deadline:
  256. service.tick()
  257. words = sum(len(text.split()) for text in dispatched)
  258. if not producer.is_alive() and words == total:
  259. break
  260. time.sleep(0.002)
  261. producer.join(timeout=5)
  262. assert sum(len(text.split()) for text in dispatched) == total
  263. def test_chat_notify_emits_compaction_note():
  264. service, emitted, _ = _service()
  265. service.chat_notify("compacted", turns=4)
  266. assert {
  267. "type": "chat_message",
  268. "role": "note",
  269. "text": "Compacted 4 earlier turns into a summary.",
  270. } in emitted
  271. def test_chat_notify_emits_truncated_error():
  272. service, emitted, _ = _service()
  273. service.chat_notify("truncated", message="cut off")
  274. assert {"type": "error", "code": "truncated", "message": "cut off"} in emitted
  275. def test_clear_empties_chat_history():
  276. service, emitted, clock = _service()
  277. service.toggle_chat()
  278. for text in ("hello", "how are you"):
  279. service.handle_event(_transcript(clock, text))
  280. assert service.chat_history == ["hello", "how are you"]
  281. assert len(service._pending_chat) == 2
  282. assert service.clear() is True
  283. assert service.chat_history == []
  284. assert service._pending_chat == []
  285. assert {"type": "cleared"} in emitted
  286. def test_clear_invokes_chat_reset():
  287. cfg = Config()
  288. resets: list[int] = []
  289. service = AssistantService(
  290. cfg, FakeSubscriber(), time_fn=Clock(), chat_reset=lambda: resets.append(1)
  291. )
  292. service.toggle_chat()
  293. service.clear()
  294. assert resets == [1]
  295. def test_clear_does_not_change_mode():
  296. service, _, clock = _service()
  297. service.toggle_chat()
  298. assert service.modes.mode.value == "chat"
  299. service.clear()
  300. assert service.modes.mode.value == "chat"
  301. def test_clear_always_returns_true():
  302. service, _, _ = _service()
  303. assert service.clear() is True
  304. service.toggle_chat()
  305. service.handle_event(_ev(events.TRANSCRIPT, Clock(), text="test"))
  306. assert service.clear() is True
  307. def test_heartbeat_emits_inference_depth_and_in_flight():
  308. """Calling tick() emits heartbeat with inference_depth (int) and inference_in_flight (bool)."""
  309. service, emitted, clock = _service()
  310. service.tick()
  311. heartbeats = [e for e in emitted if e["type"] == "heartbeat"]
  312. assert len(heartbeats) == 1
  313. heartbeat = heartbeats[0]
  314. assert "inference_depth" in heartbeat
  315. assert isinstance(heartbeat["inference_depth"], int)
  316. assert "inference_in_flight" in heartbeat
  317. assert isinstance(heartbeat["inference_in_flight"], bool)
  318. def test_status_contains_inference_depth_and_in_flight():
  319. """status() snapshot contains inference_depth (int) and inference_in_flight (bool)."""
  320. service, _, _ = _service()
  321. status = service.status()
  322. assert "inference_depth" in status
  323. assert isinstance(status["inference_depth"], int)
  324. assert "inference_in_flight" in status
  325. assert isinstance(status["inference_in_flight"], bool)