test_assistant_service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  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.service import AssistantService
  7. from stt_daemon.config import Config
  8. _sequence = itertools.count(1)
  9. class Clock:
  10. def __init__(self, start=1000.0):
  11. self.t = float(start)
  12. def __call__(self):
  13. return self.t
  14. def advance(self, dt):
  15. self.t += float(dt)
  16. class FakeSubscriber:
  17. def events(self):
  18. return iter(())
  19. def close(self):
  20. pass
  21. def _service():
  22. cfg = Config()
  23. clock = Clock()
  24. emitted: list[dict] = []
  25. def emit(type_, **fields):
  26. emitted.append({"type": type_, **fields})
  27. service = AssistantService(cfg, FakeSubscriber(), emit=emit, time_fn=clock)
  28. return service, emitted, clock
  29. def _ev(type_, clock, **fields):
  30. event = {"v": 1, "type": type_, "seq": next(_sequence), "ts": clock()}
  31. event.update(fields)
  32. return event
  33. def _transcript(clock, text, ts=None):
  34. return {
  35. "v": 1,
  36. "type": events.TRANSCRIPT,
  37. "seq": next(_sequence),
  38. "ts": clock() if ts is None else ts,
  39. "text": text,
  40. }
  41. def test_ready_requires_matching_segment_done():
  42. service, _, clock = _service()
  43. service.handle_event(_ev(events.SPEECH_START, clock))
  44. assert service.ready() is False
  45. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  46. assert service.ready() is False # awaiting segment 1
  47. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  48. assert service.ready() is True
  49. def test_stale_segment_done_does_not_satisfy_later_turn():
  50. service, _, clock = _service()
  51. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  52. service.handle_event(_ev(events.SPEECH_START, clock))
  53. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=2))
  54. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  55. assert service.ready() is False # still awaiting segment 2
  56. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=2, emitted=True))
  57. assert service.ready() is True
  58. def test_gated_segment_done_clears_readiness():
  59. service, _, clock = _service()
  60. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=3))
  61. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=3, emitted=False))
  62. assert service.ready() is True
  63. def test_speech_stop_without_seg_id_clears_readiness():
  64. """A discarded turn closes with speech_stop but no seg_id; readiness must recover."""
  65. service, _, clock = _service()
  66. service.handle_event(_ev(events.SPEECH_START, clock))
  67. assert service.ready() is False
  68. service.handle_event(_ev(events.SPEECH_STOP, clock)) # discarded: no seg_id
  69. assert service.speech_open is False
  70. assert service.awaited_seg_id is None
  71. assert service.ready() is True
  72. def test_status_resets_stale_speech_state_on_reconnect():
  73. service, _, clock = _service()
  74. service.handle_event(_ev(events.SPEECH_START, clock))
  75. assert service.speech_open is True
  76. service.handle_event(_ev(events.STATUS, clock, capture="x", segments_done=0))
  77. assert service.speech_open is False
  78. assert service.awaited_seg_id is None
  79. assert service.ready() is True
  80. def test_command_buffer_uses_arm_timestamp():
  81. service, _, clock = _service()
  82. clock.advance(10)
  83. assert service.arm() is True
  84. arm_ts = service.arm_ts
  85. assert arm_ts is not None
  86. # Replayed transcript from before arming is ignored.
  87. service.handle_event(_transcript(clock, "old", ts=arm_ts - 5))
  88. assert service.command_text == ""
  89. service.handle_event(_transcript(clock, "what time is it"))
  90. assert service.command_text == "what time is it"
  91. def test_mutual_exclusion_between_command_and_chat():
  92. service, _, _ = _service()
  93. assert service.toggle_chat() is True
  94. assert service.arm() is False # cannot arm in chat
  95. assert service.toggle_chat() is True # back to idle
  96. assert service.arm() is True
  97. assert service.toggle_chat() is False # cannot chat while armed
  98. def test_arm_timeout_disarms_and_emits_error():
  99. service, emitted, clock = _service()
  100. service.arm()
  101. clock.advance(service.cfg.assistant.command_arm_timeout_s + 0.1)
  102. service.tick()
  103. assert service.modes.mode.value == "idle"
  104. assert service.command_text == ""
  105. assert any(e["type"] == "error" and e["code"] == "arm_timeout" for e in emitted)
  106. def test_long_speech_does_not_disarm():
  107. service, emitted, clock = _service()
  108. service.arm()
  109. service.handle_event(_ev(events.SPEECH_START, clock))
  110. clock.advance(service.cfg.assistant.command_arm_timeout_s + 30.0)
  111. service.tick()
  112. assert service.modes.mode.value == "command_armed"
  113. assert not any(e["type"] == "error" for e in emitted)
  114. def test_captured_command_survives_arm_timeout():
  115. service, emitted, clock = _service()
  116. service.arm()
  117. service.handle_event(_transcript(clock, "what time is it"))
  118. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  119. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  120. clock.advance(service.cfg.assistant.command_arm_timeout_s + 30.0)
  121. service.tick()
  122. assert service.modes.mode.value == "command_armed"
  123. assert service.command_text == "what time is it"
  124. assert not any(e["type"] == "error" for e in emitted)
  125. def test_stt_stall_cancels_armed_command():
  126. service, emitted, clock = _service()
  127. service.handle_event(_ev(events.HEARTBEAT, clock, worker_stall_s=0.0))
  128. service.arm()
  129. clock.advance(service.cfg.assistant.stall_timeout_ms / 1000.0 + 0.5)
  130. service.tick()
  131. assert service.modes.mode.value == "idle"
  132. assert any(e["type"] == "error" and e["code"] == "stt_stall" for e in emitted)
  133. def test_worker_stall_marks_stt_degraded():
  134. service, _, clock = _service()
  135. long_stall = service.cfg.assistant.worker_stall_ms / 1000.0 + 1.0
  136. service.handle_event(_ev(events.HEARTBEAT, clock, worker_stall_s=long_stall))
  137. assert service.stt_ok() is False
  138. assert service.ready() is False
  139. def test_chat_idle_timeout_exits_chat():
  140. service, _, clock = _service()
  141. assert service.toggle_chat() is True
  142. clock.advance(service.cfg.assistant.chat_idle_timeout_s + 0.1)
  143. service.tick()
  144. assert service.modes.mode.value == "idle"
  145. def test_chat_exit_phrase_exits_chat():
  146. service, _, clock = _service()
  147. assert service.toggle_chat() is True
  148. service.handle_event(_transcript(clock, "That's all."))
  149. assert service.modes.mode.value == "idle"
  150. assert service.chat_history == []
  151. def test_viewer_detached_exits_chat_and_forgets_history():
  152. service, _, clock = _service()
  153. assert service.toggle_chat() is True
  154. service.handle_event(_transcript(clock, "hello"))
  155. assert service.viewer_detached() is True
  156. assert service.modes.mode.value == "idle"
  157. assert service.chat_history == []
  158. def test_viewer_detached_is_noop_outside_chat():
  159. service, _, _ = _service()
  160. assert service.viewer_detached() is False
  161. assert service.modes.mode.value == "idle"
  162. def test_exit_chat_invokes_chat_reset():
  163. cfg = Config()
  164. resets: list[int] = []
  165. service = AssistantService(
  166. cfg, FakeSubscriber(), time_fn=Clock(), chat_reset=lambda: resets.append(1)
  167. )
  168. service.toggle_chat()
  169. service.viewer_detached()
  170. assert resets == [1]
  171. def test_chat_accumulates_turns():
  172. service, _, clock = _service()
  173. service.toggle_chat()
  174. for text in ("hello there", "how are you"):
  175. service.handle_event(_transcript(clock, text))
  176. assert service.chat_history == ["hello there", "how are you"]
  177. def test_execute_requires_readiness():
  178. service, _, clock = _service()
  179. service.arm()
  180. service.handle_event(_transcript(clock, "do it"))
  181. service.handle_event(_ev(events.SPEECH_START, clock))
  182. assert service.execute() is None # speech still open
  183. assert service.modes.mode.value == "command_armed"
  184. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  185. assert service.execute() is None # awaiting segment 1
  186. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  187. assert service.execute() == "do it"
  188. assert service.modes.mode.value == "executing"
  189. assert service.finish() is True
  190. assert service.modes.mode.value == "idle"
  191. def test_turn_ended_requires_quiet_gap():
  192. service, _, clock = _service()
  193. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  194. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  195. assert service.ready() is True
  196. assert service.turn_ended() is False
  197. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  198. assert service.turn_ended() is True
  199. def test_emits_mode_and_command_text():
  200. service, emitted, clock = _service()
  201. service.arm()
  202. assert {"type": "mode", "mode": "command_armed"} in emitted
  203. service.handle_event(_transcript(clock, "hi"))
  204. assert any(e["type"] == "command_text" and e["text"] == "hi" for e in emitted)
  205. def test_cancel_clears_command_buffer():
  206. service, _, clock = _service()
  207. service.arm()
  208. service.handle_event(_transcript(clock, "hi"))
  209. assert service.cancel() is True
  210. assert service.modes.mode.value == "idle"
  211. assert service.command_text == ""
  212. assert service.arm_ts is None
  213. def test_pending_chat_is_not_lost_across_threads():
  214. """Transcripts appended on the reader thread while the ticker dispatches
  215. must all reach the model: the join-and-clear in _dispatch_chat is atomic."""
  216. cfg = Config()
  217. cfg.assistant.turn_gap_s = 0.01
  218. dispatched: list[str] = []
  219. def emit(type_, **fields):
  220. if type_ == "chat_message" and fields.get("role") == "user":
  221. dispatched.append(fields["text"])
  222. service = AssistantService(cfg, FakeSubscriber(), emit=emit, chat_fn=lambda text: "ok")
  223. service.toggle_chat()
  224. total = 200
  225. producer = threading.Thread(
  226. target=lambda: [
  227. service.handle_event(
  228. {
  229. "v": 1,
  230. "type": events.TRANSCRIPT,
  231. "seq": i,
  232. "ts": time.time(),
  233. "text": f"w{i}",
  234. }
  235. )
  236. for i in range(total)
  237. ],
  238. daemon=True,
  239. )
  240. producer.start()
  241. deadline = time.time() + 10.0
  242. while time.time() < deadline:
  243. service.tick()
  244. words = sum(len(text.split()) for text in dispatched)
  245. if not producer.is_alive() and words == total:
  246. break
  247. time.sleep(0.002)
  248. producer.join(timeout=5)
  249. assert sum(len(text.split()) for text in dispatched) == total