test_assistant_service.py 48 KB

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