test_assistant_service.py 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345
  1. """Tests for the assistant service (Phase 2) using synthetic event streams."""
  2. import itertools
  3. import threading
  4. import time
  5. from typing import Any
  6. from stt_daemon import events
  7. from stt_daemon.assistant.inference import InferenceQueue
  8. from stt_daemon.assistant.service import EXECUTE_BUSY, AssistantService
  9. from stt_daemon.config import Config
  10. _sequence = itertools.count(1)
  11. class Clock:
  12. def __init__(self, start=1000.0):
  13. self.t = float(start)
  14. def __call__(self):
  15. return self.t
  16. def advance(self, dt):
  17. self.t += float(dt)
  18. class FakeSubscriber:
  19. def events(self):
  20. return iter(())
  21. def close(self):
  22. pass
  23. def _service():
  24. cfg = Config()
  25. clock = Clock()
  26. emitted: list[dict] = []
  27. def emit(type_, **fields):
  28. emitted.append({"type": type_, **fields})
  29. service = AssistantService(cfg, FakeSubscriber(), emit=emit, time_fn=clock)
  30. return service, emitted, clock
  31. def _ev(type_, clock, **fields):
  32. event = {"v": 1, "type": type_, "seq": next(_sequence), "ts": clock()}
  33. event.update(fields)
  34. return event
  35. def _transcript(clock, text, ts=None):
  36. return {
  37. "v": 1,
  38. "type": events.TRANSCRIPT,
  39. "seq": next(_sequence),
  40. "ts": clock() if ts is None else ts,
  41. "text": text,
  42. }
  43. def test_ready_requires_matching_segment_done():
  44. service, _, clock = _service()
  45. service.handle_event(_ev(events.SPEECH_START, clock))
  46. assert service.ready() is False
  47. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  48. assert service.ready() is False # awaiting segment 1
  49. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  50. assert service.ready() is True
  51. def test_stale_segment_done_does_not_satisfy_later_turn():
  52. service, _, clock = _service()
  53. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  54. service.handle_event(_ev(events.SPEECH_START, clock))
  55. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=2))
  56. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  57. assert service.ready() is False # still awaiting segment 2
  58. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=2, emitted=True))
  59. assert service.ready() is True
  60. def test_gated_segment_done_clears_readiness():
  61. service, _, clock = _service()
  62. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=3))
  63. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=3, emitted=False))
  64. assert service.ready() is True
  65. def test_speech_stop_without_seg_id_clears_readiness():
  66. """A discarded turn closes with speech_stop but no seg_id; readiness must recover."""
  67. service, _, clock = _service()
  68. service.handle_event(_ev(events.SPEECH_START, clock))
  69. assert service.ready() is False
  70. service.handle_event(_ev(events.SPEECH_STOP, clock)) # discarded: no seg_id
  71. assert service.speech_open is False
  72. assert service.awaited_seg_id is None
  73. assert service.ready() is True
  74. def test_status_resets_stale_speech_state_on_reconnect():
  75. service, _, clock = _service()
  76. service.handle_event(_ev(events.SPEECH_START, clock))
  77. assert service.speech_open is True
  78. service.handle_event(_ev(events.STATUS, clock, capture="x", segments_done=0))
  79. assert service.speech_open is False
  80. assert service.awaited_seg_id is None
  81. assert service.ready() is True
  82. def test_command_buffer_uses_arm_timestamp():
  83. service, _, clock = _service()
  84. clock.advance(10)
  85. assert service.arm() is True
  86. arm_ts = service.arm_ts
  87. assert arm_ts is not None
  88. # Replayed transcript from before arming is ignored.
  89. service.handle_event(_transcript(clock, "old", ts=arm_ts - 5))
  90. assert service.command_text == ""
  91. service.handle_event(_transcript(clock, "what time is it"))
  92. assert service.command_text == "what time is it"
  93. def test_mutual_exclusion_between_command_and_chat():
  94. service, _, _ = _service()
  95. assert service.toggle_chat() is True
  96. assert service.arm() is False # cannot arm in chat
  97. assert service.toggle_chat() is True # back to idle
  98. assert service.arm() is True
  99. assert service.toggle_chat() is False # cannot chat while armed
  100. def test_arm_timeout_disarms_and_emits_error():
  101. service, emitted, clock = _service()
  102. service.arm()
  103. clock.advance(service.cfg.assistant.command_arm_timeout_s + 0.1)
  104. service.tick()
  105. assert service.modes.mode.value == "idle"
  106. assert service.command_text == ""
  107. assert any(e["type"] == "error" and e["code"] == "arm_timeout" for e in emitted)
  108. def test_long_speech_does_not_disarm():
  109. service, emitted, clock = _service()
  110. service.arm()
  111. service.handle_event(_ev(events.SPEECH_START, clock))
  112. clock.advance(service.cfg.assistant.command_arm_timeout_s + 30.0)
  113. service.tick()
  114. assert service.modes.mode.value == "command_armed"
  115. assert not any(e["type"] == "error" for e in emitted)
  116. def test_captured_command_survives_arm_timeout():
  117. service, emitted, clock = _service()
  118. service.arm()
  119. service.handle_event(_transcript(clock, "what time is it"))
  120. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  121. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  122. clock.advance(service.cfg.assistant.command_arm_timeout_s + 30.0)
  123. service.tick()
  124. assert service.modes.mode.value == "command_armed"
  125. assert service.command_text == "what time is it"
  126. assert not any(e["type"] == "error" for e in emitted)
  127. def test_stt_stall_cancels_armed_command():
  128. service, emitted, clock = _service()
  129. service.handle_event(_ev(events.HEARTBEAT, clock, worker_stall_s=0.0))
  130. service.arm()
  131. clock.advance(service.cfg.assistant.stall_timeout_ms / 1000.0 + 0.5)
  132. service.tick()
  133. assert service.modes.mode.value == "idle"
  134. assert any(e["type"] == "error" and e["code"] == "stt_stall" for e in emitted)
  135. def test_worker_stall_marks_stt_degraded():
  136. service, _, clock = _service()
  137. long_stall = service.cfg.assistant.worker_stall_ms / 1000.0 + 1.0
  138. service.handle_event(_ev(events.HEARTBEAT, clock, worker_stall_s=long_stall))
  139. assert service.stt_ok() is False
  140. assert service.ready() is False
  141. def test_chat_idle_timeout_exits_chat():
  142. service, _, clock = _service()
  143. assert service.toggle_chat() is True
  144. clock.advance(service.cfg.assistant.chat_idle_timeout_s + 0.1)
  145. service.tick()
  146. assert service.modes.mode.value == "idle"
  147. def test_chat_exit_phrase_exits_chat():
  148. service, _, clock = _service()
  149. assert service.toggle_chat() is True
  150. service.handle_event(_transcript(clock, "That's all."))
  151. assert service.modes.mode.value == "idle"
  152. assert service.chat_history == []
  153. def test_viewer_detached_exits_chat_and_forgets_history():
  154. service, _, clock = _service()
  155. assert service.toggle_chat() is True
  156. service.handle_event(_transcript(clock, "hello"))
  157. assert service.viewer_detached() is True
  158. assert service.modes.mode.value == "idle"
  159. assert service.chat_history == []
  160. def test_viewer_detached_is_noop_outside_chat():
  161. service, _, _ = _service()
  162. assert service.viewer_detached() is False
  163. assert service.modes.mode.value == "idle"
  164. def test_exit_chat_invokes_chat_reset():
  165. cfg = Config()
  166. resets: list[int] = []
  167. service = AssistantService(
  168. cfg, FakeSubscriber(), time_fn=Clock(), chat_reset=lambda: resets.append(1)
  169. )
  170. service.toggle_chat()
  171. service.viewer_detached()
  172. assert resets == [1]
  173. def test_chat_accumulates_turns():
  174. service, _, clock = _service()
  175. service.toggle_chat()
  176. for text in ("hello there", "how are you"):
  177. service.handle_event(_transcript(clock, text))
  178. assert service.chat_history == ["Unknown: hello there", "Unknown: how are you"]
  179. def test_execute_requires_readiness():
  180. service, _, clock = _service()
  181. service.arm()
  182. service.handle_event(_transcript(clock, "do it"))
  183. service.handle_event(_ev(events.SPEECH_START, clock))
  184. assert service.execute() is None # speech still open
  185. assert service.modes.mode.value == "command_armed"
  186. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  187. assert service.execute() is None # awaiting segment 1
  188. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  189. assert service.execute() == "do it"
  190. assert service.modes.mode.value == "executing"
  191. assert service.finish() is True
  192. assert service.modes.mode.value == "idle"
  193. def test_execute_reports_busy_when_queue_rejects():
  194. service, emitted, clock = _service()
  195. # A zero-capacity lane rejects the priority command job on submit.
  196. service._inference = InferenceQueue(size=0)
  197. service.executor = object()
  198. service.arm()
  199. service.handle_event(_transcript(clock, "do it"))
  200. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  201. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  202. assert service.execute() is EXECUTE_BUSY
  203. assert service.modes.mode.value == "idle"
  204. assert not any(e.get("code") == "busy" for e in emitted)
  205. def test_turn_ended_requires_quiet_gap():
  206. service, _, clock = _service()
  207. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  208. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  209. assert service.ready() is True
  210. assert service.turn_ended() is False
  211. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  212. assert service.turn_ended() is True
  213. def test_emits_mode_and_command_text():
  214. service, emitted, clock = _service()
  215. service.arm()
  216. assert {"type": "mode", "mode": "command_armed"} in emitted
  217. service.handle_event(_transcript(clock, "hi"))
  218. assert any(e["type"] == "command_text" and e["text"] == "hi" for e in emitted)
  219. def test_cancel_clears_command_buffer():
  220. service, _, clock = _service()
  221. service.arm()
  222. service.handle_event(_transcript(clock, "hi"))
  223. assert service.cancel() is True
  224. assert service.modes.mode.value == "idle"
  225. assert service.command_text == ""
  226. assert service.arm_ts is None
  227. def test_pending_chat_is_not_lost_across_threads():
  228. """Transcripts appended on the reader thread while the ticker dispatches
  229. must all reach the model: the join-and-clear in _dispatch_chat is atomic."""
  230. cfg = Config()
  231. cfg.assistant.turn_gap_s = 0.01
  232. dispatched: list[str] = []
  233. def emit(type_, **fields):
  234. if type_ == "chat_message" and fields.get("role") == "user":
  235. dispatched.append(fields["text"])
  236. service = AssistantService(cfg, FakeSubscriber(), emit=emit, chat_fn=lambda text: "ok")
  237. service.toggle_chat()
  238. total = 200
  239. producer = threading.Thread(
  240. target=lambda: [
  241. service.handle_event(
  242. {
  243. "v": 1,
  244. "type": events.TRANSCRIPT,
  245. "seq": i,
  246. "ts": time.time(),
  247. "text": f"w{i}",
  248. }
  249. )
  250. for i in range(total)
  251. ],
  252. daemon=True,
  253. )
  254. producer.start()
  255. deadline = time.time() + 10.0
  256. while time.time() < deadline:
  257. service.tick()
  258. words = sum(len(text.split()) for text in dispatched)
  259. if not producer.is_alive() and words == 2 * total:
  260. break
  261. time.sleep(0.002)
  262. producer.join(timeout=5)
  263. assert sum(len(text.split()) for text in dispatched) == 2 * total
  264. def test_chat_notify_emits_compaction_note():
  265. service, emitted, _ = _service()
  266. service.chat_notify("compacted", turns=4)
  267. assert {
  268. "type": "chat_message",
  269. "role": "note",
  270. "text": "Compacted 4 earlier turns into a summary.",
  271. } in emitted
  272. def test_chat_notify_emits_truncated_error():
  273. service, emitted, _ = _service()
  274. service.chat_notify("truncated", message="cut off")
  275. assert {"type": "error", "code": "truncated", "message": "cut off"} in emitted
  276. def test_clear_empties_chat_history():
  277. service, emitted, clock = _service()
  278. service.toggle_chat()
  279. for text in ("hello", "how are you"):
  280. service.handle_event(_transcript(clock, text))
  281. assert service.chat_history == ["Unknown: hello", "Unknown: how are you"]
  282. assert len(service._pending_chat) == 2
  283. assert service.clear() is True
  284. assert service.chat_history == []
  285. assert service._pending_chat == []
  286. assert {"type": "cleared"} in emitted
  287. def test_clear_invokes_chat_reset():
  288. cfg = Config()
  289. resets: list[int] = []
  290. service = AssistantService(
  291. cfg, FakeSubscriber(), time_fn=Clock(), chat_reset=lambda: resets.append(1)
  292. )
  293. service.toggle_chat()
  294. service.clear()
  295. assert resets == [1]
  296. def test_clear_does_not_change_mode():
  297. service, _, clock = _service()
  298. service.toggle_chat()
  299. assert service.modes.mode.value == "chat"
  300. service.clear()
  301. assert service.modes.mode.value == "chat"
  302. def test_clear_always_returns_true():
  303. service, _, _ = _service()
  304. assert service.clear() is True
  305. service.toggle_chat()
  306. service.handle_event(_ev(events.TRANSCRIPT, Clock(), text="test"))
  307. assert service.clear() is True
  308. def test_heartbeat_emits_inference_depth_and_in_flight():
  309. """Calling tick() emits heartbeat with inference_depth (int) and inference_in_flight (bool)."""
  310. service, emitted, clock = _service()
  311. service.tick()
  312. heartbeats = [e for e in emitted if e["type"] == "heartbeat"]
  313. assert len(heartbeats) == 1
  314. heartbeat = heartbeats[0]
  315. assert "inference_depth" in heartbeat
  316. assert isinstance(heartbeat["inference_depth"], int)
  317. assert "inference_in_flight" in heartbeat
  318. assert isinstance(heartbeat["inference_in_flight"], bool)
  319. def test_status_contains_inference_depth_and_in_flight():
  320. """status() snapshot contains inference_depth (int) and inference_in_flight (bool)."""
  321. service, _, _ = _service()
  322. status = service.status()
  323. assert "inference_depth" in status
  324. assert isinstance(status["inference_depth"], int)
  325. assert "inference_in_flight" in status
  326. assert isinstance(status["inference_in_flight"], bool)
  327. _AUTO = object()
  328. class FakeTriage:
  329. def __init__(
  330. self,
  331. questions=_AUTO,
  332. state_pair=("ok", ""),
  333. escalate=False,
  334. parsed=_AUTO,
  335. normalized=_AUTO,
  336. ):
  337. if questions is _AUTO:
  338. questions = {"urgency": {"type": "noul", "instructions": "how urgent"}}
  339. self._questions = questions
  340. self._state_pair = state_pair
  341. self._escalate = escalate
  342. self._parsed = parsed
  343. self._normalized = normalized
  344. self.build_state_calls: list[tuple] = []
  345. self.normalize_calls: list = []
  346. self.prompt_calls: list[tuple] = []
  347. @property
  348. def questions(self):
  349. return self._questions
  350. def state(self):
  351. return self._state_pair
  352. def build_state(self, window):
  353. self.build_state_calls.append(tuple(window))
  354. return "\n".join("- " + str(item) for item in (window or []))
  355. def normalize(self, payload):
  356. self.normalize_calls.append(payload)
  357. if self._normalized is not _AUTO:
  358. return self._normalized
  359. if not isinstance(payload, dict):
  360. return {}
  361. signals = {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
  362. signals.update(payload)
  363. if self._escalate:
  364. signals["escalated"] = True
  365. return signals
  366. def build_extraction_prompt(self, window, signals):
  367. self.prompt_calls.append(
  368. (tuple(window), dict(signals) if isinstance(signals, dict) else signals)
  369. )
  370. return "EXTRACT"
  371. def parse_extraction(self, text):
  372. if self._parsed is _AUTO:
  373. return {
  374. "summary": "s",
  375. "opportunities": [],
  376. "doubts": [],
  377. "action_items": [],
  378. "risks": [],
  379. }
  380. return self._parsed
  381. class FakeLane:
  382. def __init__(self, ready=True):
  383. self._ready = ready
  384. self.submits: list[tuple] = []
  385. self.drops = 0
  386. self.predict_gate = threading.Event()
  387. def ready(self):
  388. return self._ready
  389. def state(self):
  390. return ("ok", "") if self._ready else ("loading", "")
  391. def submit(self, state, questions, lang, generation):
  392. self.submits.append((state, questions, lang, generation))
  393. return True
  394. def drop_pending(self):
  395. self.drops += 1
  396. def _ok_reply(text):
  397. return "ok"
  398. def _triage_service(triage=None, lane=None, extract_fn=None, chat_fn: Any = _AUTO):
  399. cfg = Config()
  400. cfg.assistant.turn_gap_s = 0.5
  401. clock = Clock()
  402. emitted: list[dict] = []
  403. def emit(type_, **fields):
  404. emitted.append({"type": type_, **fields})
  405. if triage is None:
  406. triage = FakeTriage()
  407. if lane is None:
  408. lane = FakeLane()
  409. if chat_fn is _AUTO:
  410. chat_fn = _ok_reply
  411. service = AssistantService(
  412. cfg,
  413. FakeSubscriber(),
  414. emit=emit,
  415. time_fn=clock,
  416. triage=triage,
  417. laya_lane=lane,
  418. triage_extract_fn=extract_fn,
  419. chat_fn=chat_fn,
  420. )
  421. return service, emitted, clock, triage, lane
  422. def _triage_events(emitted):
  423. return [e for e in emitted if e["type"] == "triage"]
  424. def _escalated_payload():
  425. return {"order": [], "items": {}, "escalated": True, "input_tokens": 3}
  426. def test_triage_status_disabled_when_no_analyzer():
  427. service, _, _ = _service()
  428. status = service.status()
  429. assert status["triage_state"] == "disabled"
  430. assert status["triage_reason"] == ""
  431. assert status["triage"] is None
  432. def test_triage_status_reflects_analyzer():
  433. service, _, _, _, _ = _triage_service(
  434. triage=FakeTriage(state_pair=("loading", "warming up"))
  435. )
  436. status = service.status()
  437. assert status["triage_state"] == "loading"
  438. assert status["triage_reason"] == "warming up"
  439. assert status["triage"] is None
  440. def test_triage_captures_transcript_lang():
  441. service, _, clock, _, _ = _triage_service()
  442. assert service._last_transcript_lang is None
  443. event = _transcript(clock, "hola")
  444. event["lang"] = "es"
  445. service.handle_event(event)
  446. assert service._last_transcript_lang == "es"
  447. service.handle_event(_transcript(clock, "hello"))
  448. assert service._last_transcript_lang is None
  449. def test_maybe_triage_passes_lang_to_lane():
  450. service, _, clock, _, lane = _triage_service()
  451. event = _transcript(clock, "hola")
  452. event["lang"] = "es"
  453. service.handle_event(event)
  454. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  455. service.tick()
  456. assert len(lane.submits) == 1
  457. _, _, lang, _ = lane.submits[0]
  458. assert lang == "es"
  459. def test_maybe_triage_runs_in_idle_mode():
  460. service, _, clock, triage, lane = _triage_service()
  461. service.handle_event(_transcript(clock, "hello"))
  462. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  463. service.tick()
  464. assert len(lane.submits) == 1
  465. state, questions, lang, gen = lane.submits[0]
  466. assert state == "- hello"
  467. assert questions is triage.questions
  468. assert lang is None
  469. assert gen == 0
  470. assert triage.build_state_calls == [("hello",)]
  471. assert service.triage_history == ["hello"]
  472. def test_maybe_triage_uses_transcript_buffer_not_chat_history():
  473. service, _, clock, triage, lane = _triage_service(chat_fn=None)
  474. service.chat_history = ["stale chat only"]
  475. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  476. service.tick()
  477. assert lane.submits == []
  478. assert triage.build_state_calls == []
  479. service.handle_event(_transcript(clock, "hello"))
  480. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  481. service.tick()
  482. assert len(lane.submits) == 1
  483. assert triage.build_state_calls == [("hello",)]
  484. def test_maybe_triage_requires_settled_turn():
  485. service, _, clock, _, lane = _triage_service()
  486. service.handle_event(_transcript(clock, "hello"))
  487. service.tick()
  488. assert lane.submits == []
  489. def test_maybe_triage_skips_unready_lane():
  490. service, _, clock, _, lane = _triage_service(lane=FakeLane(ready=False))
  491. service.handle_event(_transcript(clock, "hello"))
  492. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  493. service.tick()
  494. assert lane.submits == []
  495. def test_maybe_triage_skips_without_questions():
  496. service, _, clock, triage, lane = _triage_service(
  497. triage=FakeTriage(questions=None)
  498. )
  499. service.handle_event(_transcript(clock, "hello"))
  500. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  501. service.tick()
  502. assert lane.submits == []
  503. assert triage.build_state_calls == []
  504. def test_maybe_triage_dedupes_identical_window():
  505. service, _, clock, triage, lane = _triage_service()
  506. service.handle_event(_transcript(clock, "hello"))
  507. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  508. service.tick()
  509. assert len(lane.submits) == 1
  510. state, questions, lang, gen = lane.submits[0]
  511. assert state == "- hello"
  512. assert questions is triage.questions
  513. assert lang is None
  514. assert gen == 0
  515. service.tick()
  516. service.tick()
  517. assert len(lane.submits) == 1
  518. service.handle_event(_transcript(clock, "second"))
  519. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  520. service.tick()
  521. assert len(lane.submits) == 2
  522. assert lane.submits[1][0] == "- hello\n- second"
  523. def test_second_transcript_produces_second_submit():
  524. service, _, clock, _, lane = _triage_service()
  525. service.handle_event(_transcript(clock, "first"))
  526. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  527. service.tick()
  528. assert len(lane.submits) == 1
  529. service.handle_event(_transcript(clock, "second"))
  530. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  531. service.tick()
  532. assert len(lane.submits) == 2
  533. assert lane.submits[1][0] == "- first\n- second"
  534. def test_tick_under_50ms_with_blocked_lane():
  535. service, _, clock, _, lane = _triage_service()
  536. assert not lane.predict_gate.is_set()
  537. service.toggle_chat()
  538. service.handle_event(_transcript(clock, "hello"))
  539. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  540. start = time.perf_counter()
  541. service.tick()
  542. elapsed = time.perf_counter() - start
  543. assert len(lane.submits) == 1
  544. assert elapsed < 0.05
  545. def test_on_triage_result_emits_signals():
  546. service, emitted, clock, _, lane = _triage_service()
  547. service.toggle_chat()
  548. service.handle_event(_transcript(clock, "hello"))
  549. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  550. service.tick()
  551. _, _, _, gen = lane.submits[0]
  552. payload = {
  553. "order": ["urgency"],
  554. "items": {"urgency": {"kind": "noul"}},
  555. "escalated": False,
  556. "input_tokens": 12,
  557. }
  558. service._on_triage_result(gen, payload)
  559. staged = _triage_events(emitted)
  560. assert len(staged) == 1
  561. assert staged[0]["stage"] == "signals"
  562. assert staged[0]["triage"] == service.last_triage
  563. assert staged[0]["triage"]["escalated"] is False
  564. assert staged[0]["triage"]["input_tokens"] == 12
  565. assert service.status()["triage"] == service.last_triage
  566. def test_triage_events_carry_the_server_timestamp():
  567. service, emitted, clock, _, lane = _triage_service(extract_fn=lambda prompt: "RAW")
  568. service.toggle_chat()
  569. service.handle_event(_transcript(clock, "hello"))
  570. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  571. service.tick()
  572. _, _, _, gen = lane.submits[0]
  573. expected_ts = clock()
  574. service._on_triage_result(gen, _escalated_payload())
  575. signals = [e for e in _triage_events(emitted) if e.get("stage") == "signals"]
  576. assert len(signals) == 1
  577. assert isinstance(signals[0]["ts"], (int, float))
  578. assert signals[0]["ts"] == expected_ts
  579. job = [j for j in service._inference._queue if j.name == "triage-extract"][0]
  580. job.run()
  581. contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
  582. assert len(contents) == 1
  583. assert isinstance(contents[0]["ts"], (int, float))
  584. assert contents[0]["ts"] == expected_ts # content reuses the signals timestamp
  585. def test_on_triage_result_suppresses_stale_generation():
  586. service, emitted, clock, _, lane = _triage_service()
  587. service.toggle_chat()
  588. service.handle_event(_transcript(clock, "hello"))
  589. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  590. service.tick()
  591. _, _, _, gen = lane.submits[0]
  592. service.clear()
  593. service._on_triage_result(gen, _escalated_payload())
  594. assert service.last_triage is None
  595. assert [e for e in _triage_events(emitted) if e.get("stage") == "signals"] == []
  596. service.handle_event(_transcript(clock, "hello"))
  597. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  598. service.tick()
  599. assert len(lane.submits) == 2
  600. def test_on_triage_result_accepts_non_chat_mode():
  601. service, emitted, clock, _, lane = _triage_service()
  602. service.handle_event(_transcript(clock, "hello"))
  603. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  604. service.tick()
  605. _, _, _, gen = lane.submits[0]
  606. window = service._last_triage_window
  607. service._on_triage_result(gen, _escalated_payload())
  608. assert service.last_triage is not None
  609. assert service._last_triage_window == window
  610. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  611. def test_on_triage_result_ignores_none_payload():
  612. service, emitted, clock, _, lane = _triage_service()
  613. service.toggle_chat()
  614. service.handle_event(_transcript(clock, "hello"))
  615. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  616. service.tick()
  617. _, _, _, gen = lane.submits[0]
  618. service._on_triage_result(gen, None)
  619. assert service.last_triage is None
  620. assert _triage_events(emitted) == []
  621. def test_none_payload_enqueues_state_only_extract():
  622. service, emitted, clock, triage, lane = _triage_service(
  623. extract_fn=lambda prompt: "RAW"
  624. )
  625. service.toggle_chat()
  626. service.handle_event(_transcript(clock, "hello"))
  627. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  628. service.tick()
  629. _, _, _, gen = lane.submits[0]
  630. service._on_triage_result(gen, None)
  631. assert service.last_triage is None
  632. assert [e.get("stage") for e in _triage_events(emitted)] == []
  633. assert [j.name for j in service._inference._queue] == ["chat", "triage-extract"]
  634. job = [j for j in service._inference._queue if j.name == "triage-extract"][0]
  635. job.run()
  636. contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
  637. assert len(contents) == 1
  638. assert contents[0]["triage"] == {"content": triage.parse_extraction("RAW")}
  639. assert contents[0]["ts"] == clock()
  640. assert service.last_triage == contents[0]["triage"]
  641. assert triage.prompt_calls == [(service._last_triage_window, None)]
  642. def test_none_payload_without_extract_fn_enqueues_nothing():
  643. service, emitted, clock, _, lane = _triage_service()
  644. service.toggle_chat()
  645. service.handle_event(_transcript(clock, "hello"))
  646. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  647. service.tick()
  648. _, _, _, gen = lane.submits[0]
  649. service._on_triage_result(gen, None)
  650. assert service.last_triage is None
  651. assert _triage_events(emitted) == []
  652. assert [j.name for j in service._inference._queue] == ["chat"]
  653. def test_on_triage_result_ignores_empty_signals():
  654. service, emitted, clock, _, lane = _triage_service(
  655. triage=FakeTriage(normalized={})
  656. )
  657. service.toggle_chat()
  658. service.handle_event(_transcript(clock, "hello"))
  659. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  660. service.tick()
  661. _, _, _, gen = lane.submits[0]
  662. service._on_triage_result(gen, _escalated_payload())
  663. assert service.last_triage is None
  664. assert _triage_events(emitted) == []
  665. def test_escalation_enqueues_extract_job():
  666. service, emitted, clock, _, lane = _triage_service(
  667. triage=FakeTriage(escalate=True), extract_fn=lambda prompt: "RAW"
  668. )
  669. service.toggle_chat()
  670. service.handle_event(_transcript(clock, "we might churn"))
  671. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  672. service.tick()
  673. _, _, _, gen = lane.submits[0]
  674. service._on_triage_result(
  675. gen, {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
  676. )
  677. assert service.last_triage is not None
  678. assert service.last_triage["escalated"] is True
  679. assert service._inference.depth() == 2
  680. assert [j.name for j in service._inference._queue] == ["chat", "triage-extract"]
  681. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  682. def test_extract_runs_without_escalation():
  683. service, _, clock, _, lane = _triage_service(extract_fn=lambda prompt: "RAW")
  684. service.toggle_chat()
  685. service.handle_event(_transcript(clock, "all calm"))
  686. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  687. service.tick()
  688. _, _, _, gen = lane.submits[0]
  689. service._on_triage_result(
  690. gen, {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
  691. )
  692. assert service.last_triage is not None
  693. assert service._inference.depth() == 2
  694. assert [j.name for j in service._inference._queue] == ["chat", "triage-extract"]
  695. def test_no_extract_when_disabled_in_config():
  696. service, _, clock, _, lane = _triage_service(
  697. triage=FakeTriage(escalate=True), extract_fn=lambda prompt: "RAW"
  698. )
  699. service.cfg.triage.extract_enabled = False
  700. service.toggle_chat()
  701. service.handle_event(_transcript(clock, "churn risk"))
  702. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  703. service.tick()
  704. _, _, _, gen = lane.submits[0]
  705. service._on_triage_result(gen, _escalated_payload())
  706. assert service.last_triage is not None
  707. assert service.last_triage["escalated"] is True
  708. assert [j.name for j in service._inference._queue] == ["chat"]
  709. def test_extract_refused_when_queue_full():
  710. service, emitted, clock, _, lane = _triage_service(
  711. triage=FakeTriage(escalate=True), extract_fn=lambda prompt: "RAW"
  712. )
  713. service._inference = InferenceQueue(size=2)
  714. service.toggle_chat()
  715. service.handle_event(_transcript(clock, "churn risk"))
  716. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  717. service.tick()
  718. assert service._inference.depth() == 1
  719. _, _, _, gen = lane.submits[0]
  720. service._on_triage_result(gen, _escalated_payload())
  721. assert service._inference.depth() == 1
  722. assert service._inference._queue[0].name == "chat"
  723. assert service.last_triage is not None
  724. assert service.last_triage["escalated"] is True
  725. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  726. def test_extract_merges_content():
  727. service, emitted, _, triage, _ = _triage_service(extract_fn=lambda prompt: "RAW")
  728. service.toggle_chat()
  729. window = ("hello",)
  730. signals = _escalated_payload()
  731. service._run_triage_extract(0, window, signals)
  732. contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
  733. assert len(contents) == 1
  734. assert contents[0]["triage"] == {**signals, "content": triage.parse_extraction("RAW")}
  735. assert service.last_triage == contents[0]["triage"]
  736. assert triage.prompt_calls == [(window, signals)]
  737. def test_extract_none_parse_keeps_signals():
  738. service, emitted, _, _, _ = _triage_service(
  739. triage=FakeTriage(parsed=None), extract_fn=lambda prompt: "GARBAGE"
  740. )
  741. service.toggle_chat()
  742. signals = _escalated_payload()
  743. service.last_triage = signals
  744. service._run_triage_extract(0, ("hello",), signals)
  745. assert [e for e in _triage_events(emitted) if e.get("stage") == "content"] == []
  746. assert service.last_triage == signals
  747. assert "content" not in service.last_triage
  748. def test_extract_error_is_swallowed():
  749. def boom(prompt):
  750. raise RuntimeError("llm down")
  751. service, emitted, _, _, _ = _triage_service(extract_fn=boom)
  752. service.toggle_chat()
  753. service._run_triage_extract(0, ("hello",), _escalated_payload())
  754. assert _triage_events(emitted) == []
  755. assert service.last_triage is None
  756. def test_extract_suppressed_after_reset():
  757. service, emitted, _, _, _ = _triage_service(extract_fn=lambda prompt: "RAW")
  758. service.toggle_chat()
  759. service._reset_triage()
  760. emitted.clear()
  761. service._run_triage_extract(0, ("hello",), _escalated_payload())
  762. assert _triage_events(emitted) == []
  763. assert service.last_triage is None
  764. def test_extract_works_after_leaving_chat():
  765. seen = []
  766. service, emitted, _, _, _ = _triage_service(
  767. extract_fn=lambda prompt: seen.append(prompt) or "RAW"
  768. )
  769. service.toggle_chat()
  770. assert service.modes.exit_chat() is True
  771. service._run_triage_extract(0, ("hello",), _escalated_payload())
  772. assert seen != []
  773. assert [e.get("stage") for e in _triage_events(emitted)] == ["content"]
  774. assert service.last_triage is not None
  775. assert "content" in service.last_triage
  776. def test_clear_resets_triage():
  777. service, emitted, clock, _, lane = _triage_service(
  778. triage=FakeTriage(escalate=True), extract_fn=lambda prompt: "RAW"
  779. )
  780. service.toggle_chat()
  781. service.handle_event(_transcript(clock, "churn"))
  782. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  783. service.tick()
  784. _, _, _, gen = lane.submits[0]
  785. service._on_triage_result(gen, _escalated_payload())
  786. assert service._inference.depth() == 2
  787. assert service.clear() is True
  788. assert service._triage_generation == 1
  789. assert service.last_triage is None
  790. assert service._last_triage_window == ()
  791. assert service.triage_history == []
  792. assert lane.drops == 1
  793. assert service._inference.depth() == 0
  794. assert list(service._inference._queue) == []
  795. cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
  796. assert len(cleared) == 1
  797. assert cleared[0]["triage"] is None
  798. assert {"type": "cleared"} in emitted
  799. signals_before = len(
  800. [e for e in _triage_events(emitted) if e.get("stage") == "signals"]
  801. )
  802. service._on_triage_result(gen, _escalated_payload())
  803. assert service.last_triage is None
  804. assert (
  805. len([e for e in _triage_events(emitted) if e.get("stage") == "signals"])
  806. == signals_before
  807. )
  808. submits_before = len(lane.submits)
  809. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  810. service.tick()
  811. assert len(lane.submits) == submits_before
  812. def test_exit_chat_does_not_reset_triage():
  813. service, emitted, clock, _, lane = _triage_service(
  814. extract_fn=lambda prompt: "RAW"
  815. )
  816. service.toggle_chat()
  817. service.handle_event(_transcript(clock, "hello"))
  818. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  819. service.tick()
  820. assert len(lane.submits) == 1
  821. _, _, _, gen = lane.submits[0]
  822. service._on_triage_result(gen, _escalated_payload())
  823. assert service.last_triage is not None
  824. assert service._triage_generation == 0
  825. assert service.toggle_chat() is True
  826. assert service.modes.mode.value == "idle"
  827. assert service._triage_generation == 0
  828. assert service.last_triage is not None
  829. assert service._last_triage_window == ("hello",)
  830. assert service.triage_history == ["hello"]
  831. assert lane.drops == 0
  832. cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
  833. assert cleared == []
  834. def test_clear_without_triage_emits_no_triage_event():
  835. service, emitted, _ = _service()
  836. service.clear()
  837. assert {"type": "cleared"} in emitted
  838. assert _triage_events(emitted) == []
  839. def test_triage_end_to_end_signals_content_cleared():
  840. prompts: list[str] = []
  841. service, emitted, clock, triage, lane = _triage_service(
  842. extract_fn=lambda prompt: prompts.append(prompt) or '{"summary": "s"}'
  843. )
  844. service.toggle_chat()
  845. service.handle_event(_transcript(clock, "first thought"))
  846. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  847. service.tick()
  848. assert len(lane.submits) == 1
  849. _, _, _, gen0 = lane.submits[0]
  850. service._on_triage_result(
  851. gen0, {"order": ["urgency"], "items": {}, "escalated": False, "input_tokens": 1}
  852. )
  853. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  854. assert prompts == []
  855. assert service.last_triage is not None
  856. assert service.last_triage["escalated"] is False
  857. assert [j.name for j in service._inference._queue] == ["chat", "triage-extract"]
  858. triage._escalate = True
  859. service.handle_event(_transcript(clock, "we might churn"))
  860. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  861. service.tick()
  862. assert len(lane.submits) == 2
  863. _, _, _, gen1 = lane.submits[1]
  864. service._on_triage_result(gen1, _escalated_payload())
  865. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals", "signals"]
  866. assert [j.name for j in service._inference._queue] == [
  867. "chat",
  868. "triage-extract",
  869. "chat",
  870. "triage-extract",
  871. ]
  872. for job in [j for j in service._inference._queue if j.name == "triage-extract"]:
  873. job.run()
  874. contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
  875. assert len(contents) == 2
  876. assert len(prompts) == 2
  877. assert contents[0]["triage"]["escalated"] is False
  878. assert contents[1]["triage"]["escalated"] is True
  879. assert contents[1]["triage"]["content"] == triage.parse_extraction("x")
  880. assert service.last_triage == contents[1]["triage"]
  881. assert service.status()["triage"] == contents[1]["triage"]
  882. service.clear()
  883. cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
  884. assert len(cleared) == 1
  885. assert cleared[0]["triage"] is None
  886. assert service.last_triage is None
  887. service._on_triage_result(gen1, _escalated_payload())
  888. assert [e.get("stage") for e in _triage_events(emitted)] == [
  889. "signals",
  890. "signals",
  891. "content",
  892. "content",
  893. "cleared",
  894. ]
  895. assert service.toggle_chat() is True
  896. service.handle_event(_transcript(clock, "idle thought"))
  897. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  898. service.tick()
  899. assert len(lane.submits) == 3
  900. assert lane.submits[2][0].endswith("- idle thought")
  901. def test_tick_emits_status_on_triage_state_change():
  902. service, emitted, _, triage, _ = _triage_service(
  903. triage=FakeTriage(state_pair=("loading", "warming up"))
  904. )
  905. service.tick()
  906. statuses = [e for e in emitted if e["type"] == "status"]
  907. assert len(statuses) == 1
  908. assert statuses[0]["triage_state"] == "loading"
  909. assert statuses[0]["triage_reason"] == "warming up"
  910. assert "triage" in statuses[0]
  911. triage._state_pair = ("ok", "")
  912. service.tick()
  913. statuses = [e for e in emitted if e["type"] == "status"]
  914. assert len(statuses) == 2
  915. assert statuses[1]["triage_state"] == "ok"
  916. assert statuses[1]["triage_reason"] == ""
  917. assert "triage" in statuses[1]
  918. service.tick()
  919. assert len([e for e in emitted if e["type"] == "status"]) == 2
  920. def test_no_status_emitted_when_triage_disabled():
  921. service, emitted, _ = _service()
  922. service.tick()
  923. service.tick()
  924. assert [e for e in emitted if e["type"] == "status"] == []
  925. def test_tick_heals_triage_panel_after_lane_ready():
  926. import time as _time
  927. from pathlib import Path
  928. from stt_daemon.assistant.laya_lane import LayaLane
  929. from stt_daemon.assistant.triage import TriageAnalyzer
  930. from stt_daemon.tui.state import ViewState
  931. class _FakeRouter:
  932. def preload(self, names):
  933. return self
  934. def predict(self, state, questions, model=None, lang=None, **kwargs):
  935. if "_warmup" in questions:
  936. return {"model": model, "answers": {}, "usage": {}, "routing": {}}
  937. return {
  938. "model": model,
  939. "answers": {
  940. "frustration": {
  941. "noul": 0.1,
  942. "confidence": 0.9,
  943. "answer_confidence": 0.9,
  944. },
  945. "urgency": {
  946. "choice": "low",
  947. "confidence": 0.9,
  948. "answer_confidence": 0.9,
  949. },
  950. },
  951. "usage": {"input_tokens": 5},
  952. }
  953. def _factory(**kwargs):
  954. return _FakeRouter()
  955. cfg = Config()
  956. cfg.assistant.turn_gap_s = 0.5
  957. cfg.triage.questions_file = str(
  958. Path(__file__).resolve().parent.parent / "questions.example.json"
  959. )
  960. clock = Clock()
  961. emitted: list[dict] = []
  962. def emit(type_, **fields):
  963. emitted.append({"type": type_, **fields})
  964. analyzer = TriageAnalyzer(cfg.triage)
  965. assert analyzer.questions, "example questions must load"
  966. holder: dict = {}
  967. lane = LayaLane(
  968. cfg.triage,
  969. agent_factory=_factory,
  970. on_result=lambda gen, payload: holder["svc"]._on_triage_result(gen, payload),
  971. on_state=lambda state, reason: analyzer.refresh(state, reason),
  972. )
  973. service = AssistantService(
  974. cfg,
  975. FakeSubscriber(),
  976. emit=emit,
  977. time_fn=clock,
  978. triage=analyzer,
  979. laya_lane=lane,
  980. chat_fn=_ok_reply,
  981. )
  982. holder["svc"] = service
  983. try:
  984. lane.start()
  985. deadline = _time.monotonic() + 5.0
  986. while not lane.ready() and _time.monotonic() < deadline:
  987. _time.sleep(0.005)
  988. assert lane.ready(), "lane did not become ready"
  989. assert analyzer.state() == ("ok", "")
  990. service.tick()
  991. statuses = [e for e in emitted if e["type"] == "status"]
  992. assert statuses, "tick emitted no status after lane ready"
  993. assert statuses[-1]["triage_state"] == "ok"
  994. service.handle_event(_transcript(clock, "hello"))
  995. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  996. service.tick()
  997. deadline = _time.monotonic() + 5.0
  998. while service.last_triage is None and _time.monotonic() < deadline:
  999. _time.sleep(0.005)
  1000. assert service.last_triage is not None, "no triage signals from lane"
  1001. view = ViewState()
  1002. for event in emitted:
  1003. view.ingest(event)
  1004. assert view.triage_state == "ok"
  1005. lines = view.triage_lines()
  1006. assert lines
  1007. assert not any("unavailable" in line for line in lines)
  1008. assert not any("loading" in line for line in lines)
  1009. finally:
  1010. lane.stop()