test_assistant_service.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. """Tests for the assistant service (Phase 2) using synthetic event streams."""
  2. import itertools
  3. from stt_daemon import events
  4. from stt_daemon.assistant.service import AssistantService
  5. from stt_daemon.config import Config
  6. _sequence = itertools.count(1)
  7. class Clock:
  8. def __init__(self, start=1000.0):
  9. self.t = float(start)
  10. def __call__(self):
  11. return self.t
  12. def advance(self, dt):
  13. self.t += float(dt)
  14. class FakeSubscriber:
  15. def events(self):
  16. return iter(())
  17. def close(self):
  18. pass
  19. def _service():
  20. cfg = Config()
  21. clock = Clock()
  22. emitted: list[dict] = []
  23. def emit(type_, **fields):
  24. emitted.append({"type": type_, **fields})
  25. service = AssistantService(cfg, FakeSubscriber(), emit=emit, time_fn=clock)
  26. return service, emitted, clock
  27. def _ev(type_, clock, **fields):
  28. event = {"v": 1, "type": type_, "seq": next(_sequence), "ts": clock()}
  29. event.update(fields)
  30. return event
  31. def _transcript(clock, text, ts=None):
  32. return {
  33. "v": 1,
  34. "type": events.TRANSCRIPT,
  35. "seq": next(_sequence),
  36. "ts": clock() if ts is None else ts,
  37. "text": text,
  38. }
  39. def test_ready_requires_matching_segment_done():
  40. service, _, clock = _service()
  41. service.handle_event(_ev(events.SPEECH_START, clock))
  42. assert service.ready() is False
  43. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  44. assert service.ready() is False # awaiting segment 1
  45. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  46. assert service.ready() is True
  47. def test_stale_segment_done_does_not_satisfy_later_turn():
  48. service, _, clock = _service()
  49. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  50. service.handle_event(_ev(events.SPEECH_START, clock))
  51. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=2))
  52. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  53. assert service.ready() is False # still awaiting segment 2
  54. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=2, emitted=True))
  55. assert service.ready() is True
  56. def test_gated_segment_done_clears_readiness():
  57. service, _, clock = _service()
  58. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=3))
  59. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=3, emitted=False))
  60. assert service.ready() is True
  61. def test_speech_stop_without_seg_id_clears_readiness():
  62. """A discarded turn closes with speech_stop but no seg_id; readiness must recover."""
  63. service, _, clock = _service()
  64. service.handle_event(_ev(events.SPEECH_START, clock))
  65. assert service.ready() is False
  66. service.handle_event(_ev(events.SPEECH_STOP, clock)) # discarded: no seg_id
  67. assert service.speech_open is False
  68. assert service.awaited_seg_id is None
  69. assert service.ready() is True
  70. def test_status_resets_stale_speech_state_on_reconnect():
  71. service, _, clock = _service()
  72. service.handle_event(_ev(events.SPEECH_START, clock))
  73. assert service.speech_open is True
  74. service.handle_event(_ev(events.STATUS, clock, capture="x", segments_done=0))
  75. assert service.speech_open is False
  76. assert service.awaited_seg_id is None
  77. assert service.ready() is True
  78. def test_command_buffer_uses_arm_timestamp():
  79. service, _, clock = _service()
  80. clock.advance(10)
  81. assert service.arm() is True
  82. arm_ts = service.arm_ts
  83. assert arm_ts is not None
  84. # Replayed transcript from before arming is ignored.
  85. service.handle_event(_transcript(clock, "old", ts=arm_ts - 5))
  86. assert service.command_text == ""
  87. service.handle_event(_transcript(clock, "what time is it"))
  88. assert service.command_text == "what time is it"
  89. def test_mutual_exclusion_between_command_and_chat():
  90. service, _, _ = _service()
  91. assert service.toggle_chat() is True
  92. assert service.arm() is False # cannot arm in chat
  93. assert service.toggle_chat() is True # back to idle
  94. assert service.arm() is True
  95. assert service.toggle_chat() is False # cannot chat while armed
  96. def test_arm_timeout_disarms_and_emits_error():
  97. service, emitted, clock = _service()
  98. service.arm()
  99. clock.advance(service.cfg.assistant.command_arm_timeout_s + 0.1)
  100. service.tick()
  101. assert service.modes.mode.value == "idle"
  102. assert service.command_text == ""
  103. assert any(e["type"] == "error" and e["code"] == "arm_timeout" for e in emitted)
  104. def test_long_speech_does_not_disarm():
  105. service, emitted, clock = _service()
  106. service.arm()
  107. service.handle_event(_ev(events.SPEECH_START, clock))
  108. clock.advance(service.cfg.assistant.command_arm_timeout_s + 30.0)
  109. service.tick()
  110. assert service.modes.mode.value == "command_armed"
  111. assert not any(e["type"] == "error" for e in emitted)
  112. def test_captured_command_survives_arm_timeout():
  113. service, emitted, clock = _service()
  114. service.arm()
  115. service.handle_event(_transcript(clock, "what time is it"))
  116. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  117. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  118. clock.advance(service.cfg.assistant.command_arm_timeout_s + 30.0)
  119. service.tick()
  120. assert service.modes.mode.value == "command_armed"
  121. assert service.command_text == "what time is it"
  122. assert not any(e["type"] == "error" for e in emitted)
  123. def test_stt_stall_cancels_armed_command():
  124. service, emitted, clock = _service()
  125. service.handle_event(_ev(events.HEARTBEAT, clock, worker_stall_s=0.0))
  126. service.arm()
  127. clock.advance(service.cfg.assistant.stall_timeout_ms / 1000.0 + 0.5)
  128. service.tick()
  129. assert service.modes.mode.value == "idle"
  130. assert any(e["type"] == "error" and e["code"] == "stt_stall" for e in emitted)
  131. def test_worker_stall_marks_stt_degraded():
  132. service, _, clock = _service()
  133. long_stall = service.cfg.assistant.worker_stall_ms / 1000.0 + 1.0
  134. service.handle_event(_ev(events.HEARTBEAT, clock, worker_stall_s=long_stall))
  135. assert service.stt_ok() is False
  136. assert service.ready() is False
  137. def test_chat_idle_timeout_exits_chat():
  138. service, _, clock = _service()
  139. assert service.toggle_chat() is True
  140. clock.advance(service.cfg.assistant.chat_idle_timeout_s + 0.1)
  141. service.tick()
  142. assert service.modes.mode.value == "idle"
  143. def test_chat_exit_phrase_exits_chat():
  144. service, _, clock = _service()
  145. assert service.toggle_chat() is True
  146. service.handle_event(_transcript(clock, "That's all."))
  147. assert service.modes.mode.value == "idle"
  148. assert service.chat_history == []
  149. def test_chat_accumulates_turns():
  150. service, _, clock = _service()
  151. service.toggle_chat()
  152. for text in ("hello there", "how are you"):
  153. service.handle_event(_transcript(clock, text))
  154. assert service.chat_history == ["hello there", "how are you"]
  155. def test_execute_requires_readiness():
  156. service, _, clock = _service()
  157. service.arm()
  158. service.handle_event(_transcript(clock, "do it"))
  159. service.handle_event(_ev(events.SPEECH_START, clock))
  160. assert service.execute() is None # speech still open
  161. assert service.modes.mode.value == "command_armed"
  162. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  163. assert service.execute() is None # awaiting segment 1
  164. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  165. assert service.execute() == "do it"
  166. assert service.modes.mode.value == "executing"
  167. assert service.finish() is True
  168. assert service.modes.mode.value == "idle"
  169. def test_turn_ended_requires_quiet_gap():
  170. service, _, clock = _service()
  171. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  172. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  173. assert service.ready() is True
  174. assert service.turn_ended() is False
  175. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  176. assert service.turn_ended() is True
  177. def test_emits_mode_and_command_text():
  178. service, emitted, clock = _service()
  179. service.arm()
  180. assert {"type": "mode", "mode": "command_armed"} in emitted
  181. service.handle_event(_transcript(clock, "hi"))
  182. assert any(e["type"] == "command_text" and e["text"] == "hi" for e in emitted)
  183. def test_cancel_clears_command_buffer():
  184. service, _, clock = _service()
  185. service.arm()
  186. service.handle_event(_transcript(clock, "hi"))
  187. assert service.cancel() is True
  188. assert service.modes.mode.value == "idle"
  189. assert service.command_text == ""
  190. assert service.arm_ts is None