test_assistant_service.py 102 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024
  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 InferenceJob, InferenceQueue
  10. from stt_daemon.assistant.service import EXECUTE_BUSY, AssistantService
  11. from stt_daemon.assistant.triage import TriageAnalyzer
  12. from stt_daemon.assistant.triage_dump import TriageDumper
  13. from stt_daemon.config import Config
  14. _sequence = itertools.count(1)
  15. # A real analyzer (pure, stdlib-only helpers) backing the fake's retention
  16. # guard so guard behaviour is exercised end to end.
  17. _real_analyzer = TriageAnalyzer(Config().triage)
  18. class Clock:
  19. def __init__(self, start=1000.0):
  20. self.t = float(start)
  21. def __call__(self):
  22. return self.t
  23. def advance(self, dt):
  24. self.t += float(dt)
  25. class FakeSubscriber:
  26. def events(self):
  27. return iter(())
  28. def close(self):
  29. pass
  30. def _service():
  31. cfg = Config()
  32. clock = Clock()
  33. emitted: list[dict] = []
  34. def emit(type_, **fields):
  35. emitted.append({"type": type_, **fields})
  36. service = AssistantService(cfg, FakeSubscriber(), emit=emit, time_fn=clock)
  37. return service, emitted, clock
  38. def _ev(type_, clock, **fields):
  39. event = {"v": 1, "type": type_, "seq": next(_sequence), "ts": clock()}
  40. event.update(fields)
  41. return event
  42. def _transcript(clock, text, ts=None, session_dir=None):
  43. event = {
  44. "v": 1,
  45. "type": events.TRANSCRIPT,
  46. "seq": next(_sequence),
  47. "ts": clock() if ts is None else ts,
  48. "text": text,
  49. }
  50. if session_dir is not None:
  51. event["session_dir"] = session_dir
  52. return event
  53. def test_ready_requires_matching_segment_done():
  54. service, _, clock = _service()
  55. service.handle_event(_ev(events.SPEECH_START, clock))
  56. assert service.ready() is False
  57. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  58. assert service.ready() is False # awaiting segment 1
  59. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  60. assert service.ready() is True
  61. def test_stale_segment_done_does_not_satisfy_later_turn():
  62. service, _, clock = _service()
  63. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  64. service.handle_event(_ev(events.SPEECH_START, clock))
  65. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=2))
  66. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  67. assert service.ready() is False # still awaiting segment 2
  68. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=2, emitted=True))
  69. assert service.ready() is True
  70. def test_gated_segment_done_clears_readiness():
  71. service, _, clock = _service()
  72. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=3))
  73. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=3, emitted=False))
  74. assert service.ready() is True
  75. def test_speech_stop_without_seg_id_clears_readiness():
  76. """A discarded turn closes with speech_stop but no seg_id; readiness must recover."""
  77. service, _, clock = _service()
  78. service.handle_event(_ev(events.SPEECH_START, clock))
  79. assert service.ready() is False
  80. service.handle_event(_ev(events.SPEECH_STOP, clock)) # discarded: no seg_id
  81. assert service.speech_open is False
  82. assert service.awaited_seg_id is None
  83. assert service.ready() is True
  84. def test_status_resets_stale_speech_state_on_reconnect():
  85. service, _, clock = _service()
  86. service.handle_event(_ev(events.SPEECH_START, clock))
  87. assert service.speech_open is True
  88. service.handle_event(_ev(events.STATUS, clock, capture="x", segments_done=0))
  89. assert service.speech_open is False
  90. assert service.awaited_seg_id is None
  91. assert service.ready() is True
  92. def test_command_buffer_uses_arm_timestamp():
  93. service, _, clock = _service()
  94. clock.advance(10)
  95. assert service.arm() is True
  96. arm_ts = service.arm_ts
  97. assert arm_ts is not None
  98. # Replayed transcript from before arming is ignored.
  99. service.handle_event(_transcript(clock, "old", ts=arm_ts - 5))
  100. assert service.command_text == ""
  101. service.handle_event(_transcript(clock, "what time is it"))
  102. assert service.command_text == "what time is it"
  103. def test_mutual_exclusion_between_command_and_chat():
  104. service, _, _ = _service()
  105. assert service.toggle_chat() is True
  106. assert service.arm() is False # cannot arm in chat
  107. assert service.toggle_chat() is True # back to idle
  108. assert service.arm() is True
  109. assert service.toggle_chat() is False # cannot chat while armed
  110. def test_arm_timeout_disarms_and_emits_error():
  111. service, emitted, clock = _service()
  112. service.arm()
  113. clock.advance(service.cfg.assistant.command_arm_timeout_s + 0.1)
  114. service.tick()
  115. assert service.modes.mode.value == "idle"
  116. assert service.command_text == ""
  117. assert any(e["type"] == "error" and e["code"] == "arm_timeout" for e in emitted)
  118. def test_long_speech_does_not_disarm():
  119. service, emitted, clock = _service()
  120. service.arm()
  121. service.handle_event(_ev(events.SPEECH_START, clock))
  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 not any(e["type"] == "error" for e in emitted)
  126. def test_captured_command_survives_arm_timeout():
  127. service, emitted, clock = _service()
  128. service.arm()
  129. service.handle_event(_transcript(clock, "what time is it"))
  130. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  131. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  132. clock.advance(service.cfg.assistant.command_arm_timeout_s + 30.0)
  133. service.tick()
  134. assert service.modes.mode.value == "command_armed"
  135. assert service.command_text == "what time is it"
  136. assert not any(e["type"] == "error" for e in emitted)
  137. def test_stt_stall_cancels_armed_command():
  138. service, emitted, clock = _service()
  139. service.handle_event(_ev(events.HEARTBEAT, clock, worker_stall_s=0.0))
  140. service.arm()
  141. clock.advance(service.cfg.assistant.stall_timeout_ms / 1000.0 + 0.5)
  142. service.tick()
  143. assert service.modes.mode.value == "idle"
  144. assert any(e["type"] == "error" and e["code"] == "stt_stall" for e in emitted)
  145. def test_worker_stall_marks_stt_degraded():
  146. service, _, clock = _service()
  147. long_stall = service.cfg.assistant.worker_stall_ms / 1000.0 + 1.0
  148. service.handle_event(_ev(events.HEARTBEAT, clock, worker_stall_s=long_stall))
  149. assert service.stt_ok() is False
  150. assert service.ready() is False
  151. def test_chat_idle_timeout_exits_chat():
  152. service, _, clock = _service()
  153. assert service.toggle_chat() is True
  154. clock.advance(service.cfg.assistant.chat_idle_timeout_s + 0.1)
  155. service.tick()
  156. assert service.modes.mode.value == "idle"
  157. def test_chat_exit_phrase_exits_chat():
  158. service, _, clock = _service()
  159. assert service.toggle_chat() is True
  160. service.set_chat_capture(True)
  161. service.handle_event(_transcript(clock, "That's all."))
  162. assert service.modes.mode.value == "idle"
  163. def test_exit_phrase_preserves_chat_history():
  164. service, _, clock = _service()
  165. service.toggle_chat()
  166. service.set_chat_capture(True)
  167. service.handle_event(_transcript(clock, "hello"))
  168. service.handle_event(_transcript(clock, "That's all."))
  169. assert service.modes.mode.value == "idle"
  170. assert service.chat_history == ["Unknown: hello", "Unknown: That's all."]
  171. def test_viewer_detached_exits_chat_and_keeps_history():
  172. service, _, clock = _service()
  173. assert service.toggle_chat() is True
  174. service.set_chat_capture(True)
  175. service.handle_event(_transcript(clock, "hello"))
  176. assert service.viewer_detached() is True
  177. assert service.modes.mode.value == "idle"
  178. assert service.chat_history == ["Unknown: hello"]
  179. def test_viewer_detached_is_noop_outside_chat():
  180. service, _, _ = _service()
  181. assert service.viewer_detached() is False
  182. assert service.modes.mode.value == "idle"
  183. def test_exit_chat_preserves_history_and_does_not_reset():
  184. cfg = Config()
  185. resets: list[int] = []
  186. service = AssistantService(
  187. cfg, FakeSubscriber(), time_fn=Clock(), chat_reset=lambda: resets.append(1)
  188. )
  189. service.toggle_chat()
  190. service.set_chat_capture(True)
  191. service.handle_event(_transcript(Clock(), "hello"))
  192. service.viewer_detached()
  193. service.toggle_chat()
  194. assert resets == []
  195. assert service.chat_history == ["Unknown: hello"]
  196. def test_chat_accumulates_turns():
  197. service, _, clock = _service()
  198. service.toggle_chat()
  199. service.set_chat_capture(True)
  200. for text in ("hello there", "how are you"):
  201. service.handle_event(_transcript(clock, text))
  202. assert service.chat_history == ["Unknown: hello there", "Unknown: how are you"]
  203. def test_chat_capture_off_skips_spoken_transcripts_and_exit_phrase():
  204. service, _, clock = _service()
  205. service.toggle_chat()
  206. assert service.set_chat_capture(False) is True
  207. service.handle_event(_transcript(clock, "hello"))
  208. service.handle_event(_transcript(clock, "That's all."))
  209. assert service.chat_history == []
  210. assert service._pending_chat == []
  211. assert service.modes.mode.value == "chat"
  212. def test_chat_capture_off_still_allows_typed_turns_and_triage():
  213. service, _, clock = _service()
  214. service.toggle_chat()
  215. service.set_chat_capture(False)
  216. service.handle_event(_transcript(clock, "spoken"))
  217. assert service.submit_chat_text("typed") is True
  218. assert service.chat_history == ["typed"]
  219. assert service.triage_history == ["Unknown: spoken"]
  220. def test_set_chat_capture_emits_once_and_reports_status():
  221. service, emitted, _ = _service()
  222. assert service.status()["chat_capture"] is False
  223. assert service.set_chat_capture(True) is True
  224. assert [e for e in emitted if e["type"] == "chat_capture"] == [
  225. {"type": "chat_capture", "on": True}
  226. ]
  227. assert service.status()["chat_capture"] is True
  228. emitted.clear()
  229. service.set_chat_capture(True)
  230. assert emitted == []
  231. def test_triage_chat_off_ignores_chat_turns():
  232. service, _, _, _, lane = _triage_service()
  233. service._run_chat("typed")
  234. assert lane.submits == []
  235. assert service.triage_history == []
  236. def test_triage_chat_sends_reply_only_when_capture_on():
  237. service, _, _, _, lane = _triage_service()
  238. service.toggle_chat()
  239. service.set_chat_capture(True)
  240. service.set_triage_chat(True)
  241. service._run_chat("spoken, already in triage")
  242. assert service.triage_history == ["Assistant: ok"]
  243. assert lane.submits[0][0] == "- Assistant: ok"
  244. def test_triage_chat_sends_user_and_reply_when_capture_off():
  245. service, _, _, _, lane = _triage_service()
  246. service.toggle_chat()
  247. service.set_chat_capture(False)
  248. service.set_triage_chat(True)
  249. service._run_chat("typed")
  250. assert service.triage_history == ["You: typed", "Assistant: ok"]
  251. assert lane.submits[0][0] == "- You: typed\n- Assistant: ok"
  252. def test_set_triage_chat_emits_once_and_reports_status():
  253. service, emitted, _, _, _ = _triage_service()
  254. assert service.status()["triage_chat"] is False
  255. assert service.set_triage_chat(True) is True
  256. assert [e for e in emitted if e["type"] == "triage_chat"] == [
  257. {"type": "triage_chat", "on": True}
  258. ]
  259. assert service.status()["triage_chat"] is True
  260. emitted.clear()
  261. service.set_triage_chat(True)
  262. assert emitted == []
  263. def test_submit_chat_text_requires_chat_and_dispatches_immediately():
  264. cfg = Config()
  265. cfg.assistant.turn_gap_s = 999.0
  266. replies: list[str] = []
  267. emitted: list[dict] = []
  268. def emit(type_, **fields):
  269. emitted.append({"type": type_, **fields})
  270. service = AssistantService(
  271. cfg,
  272. FakeSubscriber(),
  273. emit=emit,
  274. chat_fn=lambda text: replies.append(text) or "ok",
  275. time_fn=Clock(),
  276. )
  277. assert service.submit_chat_text("hi") is False
  278. assert service.submit_chat_text(" ") is False
  279. service.toggle_chat()
  280. service.start()
  281. try:
  282. assert service.submit_chat_text("hi") is True
  283. deadline = time.time() + 2.0
  284. while time.time() < deadline and not replies:
  285. time.sleep(0.01)
  286. finally:
  287. service.stop()
  288. assert replies == ["hi"]
  289. assert service.chat_history == ["hi"]
  290. assert service._pending_chat == []
  291. assert any(
  292. e["type"] == "chat_message" and e.get("role") == "user" and e.get("text") == "hi"
  293. for e in emitted
  294. )
  295. def test_execute_clears_command_buffer_immediately():
  296. service, emitted, clock = _service()
  297. service.arm()
  298. service.handle_event(_transcript(clock, "do it"))
  299. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  300. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  301. assert service.execute() == "do it"
  302. assert service.modes.mode.value == "executing"
  303. assert service.command_text == ""
  304. assert service.command_edited is False
  305. assert service.arm_ts is None
  306. assert any(e["type"] == "command_text" and e["text"] == "" for e in emitted)
  307. def test_execute_requires_readiness():
  308. service, _, clock = _service()
  309. service.arm()
  310. service.handle_event(_transcript(clock, "do it"))
  311. service.handle_event(_ev(events.SPEECH_START, clock))
  312. assert service.execute() is None # speech still open
  313. assert service.modes.mode.value == "command_armed"
  314. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  315. assert service.execute() is None # awaiting segment 1
  316. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  317. assert service.execute() == "do it"
  318. assert service.modes.mode.value == "executing"
  319. assert service.finish() is True
  320. assert service.modes.mode.value == "idle"
  321. def test_execute_reports_busy_when_queue_rejects():
  322. service, emitted, clock = _service()
  323. # A zero-capacity lane rejects the priority command job on submit.
  324. service._inference = InferenceQueue(size=0)
  325. service.executor = object()
  326. service.arm()
  327. service.handle_event(_transcript(clock, "do it"))
  328. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  329. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  330. assert service.execute() is EXECUTE_BUSY
  331. assert service.modes.mode.value == "idle"
  332. assert not any(e.get("code") == "busy" for e in emitted)
  333. def test_turn_ended_requires_quiet_gap():
  334. service, _, clock = _service()
  335. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  336. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  337. assert service.ready() is True
  338. assert service.turn_ended() is False
  339. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  340. assert service.turn_ended() is True
  341. def test_emits_mode_and_command_text():
  342. service, emitted, clock = _service()
  343. service.arm()
  344. assert {"type": "mode", "mode": "command_armed"} in emitted
  345. service.handle_event(_transcript(clock, "hi"))
  346. assert any(e["type"] == "command_text" and e["text"] == "hi" for e in emitted)
  347. def test_cancel_clears_command_buffer():
  348. service, _, clock = _service()
  349. service.arm()
  350. service.handle_event(_transcript(clock, "hi"))
  351. assert service.cancel() is True
  352. assert service.modes.mode.value == "idle"
  353. assert service.command_text == ""
  354. assert service.arm_ts is None
  355. def test_pending_chat_is_not_lost_across_threads():
  356. """Transcripts appended on the reader thread while the ticker dispatches
  357. must all reach the model: the join-and-clear in _dispatch_chat is atomic,
  358. and the user bubble is emitted by the worker in _run_chat."""
  359. cfg = Config()
  360. cfg.assistant.turn_gap_s = 0.01
  361. dispatched: list[str] = []
  362. lock = threading.Lock()
  363. def emit(type_, **fields):
  364. if type_ == "chat_message" and fields.get("role") == "user":
  365. with lock:
  366. dispatched.append(fields["text"])
  367. service = AssistantService(cfg, FakeSubscriber(), emit=emit, chat_fn=lambda text: "ok")
  368. service.toggle_chat()
  369. service.set_chat_capture(True)
  370. service.start()
  371. try:
  372. total = 200
  373. producer = threading.Thread(
  374. target=lambda: [
  375. service.handle_event(
  376. {
  377. "v": 1,
  378. "type": events.TRANSCRIPT,
  379. "seq": i,
  380. "ts": time.time(),
  381. "text": f"w{i}",
  382. }
  383. )
  384. for i in range(total)
  385. ],
  386. daemon=True,
  387. )
  388. producer.start()
  389. deadline = time.time() + 10.0
  390. while time.time() < deadline:
  391. service.tick()
  392. with lock:
  393. words = sum(len(text.split()) for text in dispatched)
  394. done = words == 2 * total
  395. if not producer.is_alive() and done:
  396. break
  397. time.sleep(0.002)
  398. producer.join(timeout=5)
  399. with lock:
  400. words = sum(len(text.split()) for text in dispatched)
  401. assert words == 2 * total
  402. finally:
  403. service.stop()
  404. def _full_queue_service():
  405. service, emitted, clock = _service()
  406. service.toggle_chat()
  407. service._inference = InferenceQueue(size=1)
  408. service._inference.submit(
  409. InferenceJob(name="blocker", run=lambda: None, evictable=False)
  410. )
  411. return service, emitted, clock
  412. def test_chat_refusal_rebuffers_and_emits_queue_full_once():
  413. """A refused chat submit re-buffers under lock and emits queue_full on flip."""
  414. service, emitted, _ = _full_queue_service()
  415. service._pending_chat = ["hello world"]
  416. service._dispatch_chat()
  417. assert service._pending_chat == ["hello world"]
  418. assert service._chat_queue_full is True
  419. errors = [e for e in emitted if e["type"] == "error" and e.get("code") == "queue_full"]
  420. assert len(errors) == 1
  421. assert [e for e in emitted if e["type"] == "chat_message"] == []
  422. service._dispatch_chat()
  423. assert service._pending_chat == ["hello world"]
  424. errors = [e for e in emitted if e["type"] == "error" and e.get("code") == "queue_full"]
  425. assert len(errors) == 1
  426. def test_chat_dispatch_success_clears_latch_without_user_bubble():
  427. """Success clears the latch; the user bubble comes from _run_chat, not dispatch."""
  428. service, emitted, _ = _full_queue_service()
  429. service._pending_chat = ["hello world"]
  430. service._chat_queue_full = True
  431. service._inference.drop(lambda j: True)
  432. service._dispatch_chat()
  433. assert service._pending_chat == []
  434. assert service._chat_queue_full is False
  435. assert [e for e in emitted if e["type"] == "chat_message"] == []
  436. def test_run_chat_emits_user_first_then_reply():
  437. service, emitted, _ = _service()
  438. service.chat_fn = lambda text: "reply!"
  439. service._run_chat("hello")
  440. assert emitted == [
  441. {"type": "chat_message", "role": "user", "text": "hello"},
  442. {"type": "chat_message", "role": "assistant", "text": "reply!"},
  443. ]
  444. def test_chat_notify_emits_compaction_note():
  445. service, emitted, _ = _service()
  446. service.chat_notify("compacted", turns=4)
  447. assert {
  448. "type": "chat_message",
  449. "role": "note",
  450. "text": "Compacted 4 earlier turns into a summary.",
  451. } in emitted
  452. def test_chat_notify_emits_truncated_error():
  453. service, emitted, _ = _service()
  454. service.chat_notify("truncated", message="cut off")
  455. assert {"type": "error", "code": "truncated", "message": "cut off"} in emitted
  456. def test_clear_empties_chat_history():
  457. service, emitted, clock = _service()
  458. service.toggle_chat()
  459. service.set_chat_capture(True)
  460. for text in ("hello", "how are you"):
  461. service.handle_event(_transcript(clock, text))
  462. assert service.chat_history == ["Unknown: hello", "Unknown: how are you"]
  463. assert len(service._pending_chat) == 2
  464. assert service.clear() is True
  465. assert service.chat_history == []
  466. assert service._pending_chat == []
  467. assert {"type": "cleared"} in emitted
  468. def test_clear_invokes_chat_reset():
  469. cfg = Config()
  470. resets: list[int] = []
  471. service = AssistantService(
  472. cfg, FakeSubscriber(), time_fn=Clock(), chat_reset=lambda: resets.append(1)
  473. )
  474. service.toggle_chat()
  475. service.clear()
  476. assert resets == [1]
  477. def test_clear_does_not_change_mode():
  478. service, _, clock = _service()
  479. service.toggle_chat()
  480. assert service.modes.mode.value == "chat"
  481. service.clear()
  482. assert service.modes.mode.value == "chat"
  483. def test_clear_always_returns_true():
  484. service, _, _ = _service()
  485. assert service.clear() is True
  486. service.toggle_chat()
  487. service.handle_event(_ev(events.TRANSCRIPT, Clock(), text="test"))
  488. assert service.clear() is True
  489. def test_heartbeat_emits_inference_depth_and_in_flight():
  490. """Calling tick() emits heartbeat with inference_depth (int) and inference_in_flight (bool)."""
  491. service, emitted, clock = _service()
  492. service.tick()
  493. heartbeats = [e for e in emitted if e["type"] == "heartbeat"]
  494. assert len(heartbeats) == 1
  495. heartbeat = heartbeats[0]
  496. assert "inference_depth" in heartbeat
  497. assert isinstance(heartbeat["inference_depth"], int)
  498. assert "inference_in_flight" in heartbeat
  499. assert isinstance(heartbeat["inference_in_flight"], bool)
  500. def test_status_contains_inference_depth_and_in_flight():
  501. """status() snapshot contains inference_depth (int) and inference_in_flight (bool)."""
  502. service, _, _ = _service()
  503. status = service.status()
  504. assert "inference_depth" in status
  505. assert isinstance(status["inference_depth"], int)
  506. assert "inference_in_flight" in status
  507. assert isinstance(status["inference_in_flight"], bool)
  508. _AUTO = object()
  509. class FakeTriage:
  510. def __init__(
  511. self,
  512. questions=_AUTO,
  513. state_pair=("ok", ""),
  514. escalate=False,
  515. parsed=_AUTO,
  516. normalized=_AUTO,
  517. gate=True,
  518. ):
  519. if questions is _AUTO:
  520. questions = {"urgency": {"type": "noul", "instructions": "how urgent"}}
  521. self._questions = questions
  522. self._state_pair = state_pair
  523. self._escalate = escalate
  524. self._parsed = parsed
  525. self._normalized = normalized
  526. self._gate = gate
  527. self.build_state_calls: list[tuple] = []
  528. self.normalize_calls: list = []
  529. self.prompt_calls: list[tuple] = []
  530. self.gate_calls: list = []
  531. @property
  532. def questions(self):
  533. return self._questions
  534. def state(self):
  535. return self._state_pair
  536. def build_state(self, window):
  537. self.build_state_calls.append(tuple(window))
  538. return "\n".join("- " + str(item) for item in (window or []))
  539. def normalize(self, payload):
  540. self.normalize_calls.append(payload)
  541. if self._normalized is not _AUTO:
  542. return self._normalized
  543. if not isinstance(payload, dict):
  544. return {}
  545. signals = {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
  546. signals.update(payload)
  547. if self._escalate:
  548. signals["escalated"] = True
  549. return signals
  550. def build_brief_prompt(self, deltas, signals):
  551. self.prompt_calls.append(
  552. (tuple(deltas), dict(signals) if isinstance(signals, dict) else signals)
  553. )
  554. return "BRIEF-PROMPT"
  555. def build_ledger_prompt(self, narrative, ledger, deltas, signals):
  556. self.prompt_calls.append(
  557. (tuple(deltas), dict(signals) if isinstance(signals, dict) else signals)
  558. )
  559. ledger_text = "\n".join("- " + f for f in ledger) or "(none yet)"
  560. body = "\n".join("- " + d for d in deltas)
  561. return (
  562. f"CURRENT LEDGER:\n{ledger_text}\n\n"
  563. f"CURRENT BRIEF:\n{narrative or '(none yet)'}\n\n"
  564. f"NEW UTTERANCES:\n{body}"
  565. )
  566. def parse_ledger_reply(self, text, truncated=False):
  567. return _real_analyzer.parse_ledger_reply(text, truncated=truncated)
  568. def is_regression(self, prior_brief, new_brief):
  569. return _real_analyzer.is_regression(prior_brief, new_brief)
  570. def should_escalate(self, signals):
  571. return _real_analyzer.should_escalate(signals)
  572. def parse_facts(self, block):
  573. return _real_analyzer.parse_facts(block)
  574. def parse_brief(self, text):
  575. if self._parsed is _AUTO:
  576. return text if text else None
  577. return self._parsed
  578. def should_update_brief(self, signals, prior=None):
  579. self.gate_calls.append(signals)
  580. if self._gate is _AUTO:
  581. return _real_analyzer.should_update_brief(signals, prior)
  582. return self._gate
  583. class FakeBriefFile:
  584. """Recording stand-in for BriefFile (write/clear/set_directory never raise)."""
  585. def __init__(self):
  586. self.writes: list[str] = []
  587. self.clears = 0
  588. self.directories: list[str] = []
  589. def set_directory(self, directory):
  590. self.directories.append(directory)
  591. def write(self, text):
  592. self.writes.append(text)
  593. def clear(self):
  594. self.clears += 1
  595. class FakeLane:
  596. def __init__(self, ready=True):
  597. self._ready = ready
  598. self.submits: list[tuple] = []
  599. self.drops = 0
  600. self.predict_gate = threading.Event()
  601. def ready(self):
  602. return self._ready
  603. def state(self):
  604. return ("ok", "") if self._ready else ("loading", "")
  605. def submit(self, state, questions, lang, generation):
  606. self.submits.append((state, questions, lang, generation))
  607. return True
  608. def drop_pending(self):
  609. self.drops += 1
  610. class FakeDebugSink:
  611. def __init__(self):
  612. self.events: list[tuple] = []
  613. def emit(self, type_, **fields):
  614. self.events.append((type_, fields))
  615. def _ok_reply(text):
  616. return "ok"
  617. def _brief_reply(narrative="", facts=None):
  618. """Build a ledger-format reply the analyzer's parser can consume."""
  619. body = "\n".join("- " + fact for fact in (facts or [])) or "none"
  620. return f"NEW FACTS:\n{body}\n\nBRIEF:\n{narrative}"
  621. def _triage_service(
  622. triage=None,
  623. lane=None,
  624. brief_fn=None,
  625. chat_fn: Any = _AUTO,
  626. brief_file=None,
  627. brief_reset=None,
  628. chat_reset=None,
  629. debug_sink=None,
  630. brief_finish: Any = "stop",
  631. ):
  632. cfg = Config()
  633. cfg.assistant.turn_gap_s = 0.5
  634. clock = Clock()
  635. emitted: list[dict] = []
  636. def emit(type_, **fields):
  637. emitted.append({"type": type_, **fields})
  638. if triage is None:
  639. triage = FakeTriage()
  640. if lane is None:
  641. lane = FakeLane()
  642. if chat_fn is _AUTO:
  643. chat_fn = _ok_reply
  644. factory = (lambda: debug_sink) if debug_sink is not None else None
  645. service = AssistantService(
  646. cfg,
  647. FakeSubscriber(),
  648. emit=emit,
  649. time_fn=clock,
  650. triage=triage,
  651. laya_lane=lane,
  652. brief_fn=_wrap_brief_fn(brief_fn, brief_finish),
  653. brief_reset=brief_reset,
  654. brief_file=brief_file,
  655. chat_fn=chat_fn,
  656. chat_reset=chat_reset,
  657. debug_sink_factory=factory,
  658. )
  659. return service, emitted, clock, triage, lane
  660. def _wrap_brief_fn(brief_fn, finish_reason="stop"):
  661. """Adapt a simple `prompt -> text` stub to the engine's call signature.
  662. A plain string return is treated as the volatile narrative half of a
  663. ledger reply, so existing stubs keep reading naturally. ``finish_reason``
  664. may be a callable taking the reply so a test can mark one reply truncated.
  665. """
  666. if brief_fn is None:
  667. return None
  668. def wrapped(prompt, on_io=None, finalize=None):
  669. kwargs = {}
  670. if on_io is not None:
  671. kwargs["on_io"] = on_io
  672. try:
  673. result = brief_fn(prompt, **kwargs)
  674. except TypeError:
  675. result = brief_fn(prompt)
  676. if finalize is not None:
  677. reason = finish_reason(result) if callable(finish_reason) else finish_reason
  678. result = finalize(result, reason)
  679. is_ledger = "BRIEF:" in result or "NEW FACTS:" in result
  680. if isinstance(result, str) and result and not is_ledger:
  681. result = _brief_reply(narrative=result)
  682. return result
  683. return wrapped
  684. def _triage_events(emitted):
  685. return [e for e in emitted if e["type"] == "triage"]
  686. def _escalated_payload():
  687. return {"order": [], "items": {}, "escalated": True, "input_tokens": 3}
  688. def test_triage_status_disabled_when_no_analyzer():
  689. service, _, _ = _service()
  690. status = service.status()
  691. assert status["triage_state"] == "disabled"
  692. assert status["triage_reason"] == ""
  693. assert status["triage"] is None
  694. def test_triage_status_reflects_analyzer():
  695. service, _, _, _, _ = _triage_service(
  696. triage=FakeTriage(state_pair=("loading", "warming up"))
  697. )
  698. status = service.status()
  699. assert status["triage_state"] == "loading"
  700. assert status["triage_reason"] == "warming up"
  701. assert status["triage"] is None
  702. def test_status_contains_questions_from_analyzer():
  703. questions = {
  704. "urgency": {"type": "noul", "instructions": "how urgent"},
  705. "sentiment": {"type": "score", "instructions": "tone", "criteria": ["low", "high"]},
  706. }
  707. service, _, _, triage, _ = _triage_service(triage=FakeTriage(questions=questions))
  708. status = service.status()
  709. assert status["questions"] == questions
  710. assert status["questions"] is triage.questions
  711. def test_status_questions_none_without_analyzer():
  712. service, _, _ = _service()
  713. assert service.status()["questions"] is None
  714. def test_status_event_carries_questions():
  715. service, emitted, _, triage, _ = _triage_service()
  716. service.tick()
  717. statuses = [e for e in emitted if e["type"] == "status"]
  718. assert len(statuses) == 1
  719. assert statuses[0]["questions"] is triage.questions
  720. def test_triage_captures_transcript_lang():
  721. service, _, clock, _, _ = _triage_service()
  722. assert service._last_transcript_lang is None
  723. event = _transcript(clock, "hola")
  724. event["lang"] = "es"
  725. service.handle_event(event)
  726. assert service._last_transcript_lang == "es"
  727. service.handle_event(_transcript(clock, "hello"))
  728. assert service._last_transcript_lang is None
  729. def test_maybe_triage_passes_lang_to_lane():
  730. service, _, clock, _, lane = _triage_service()
  731. event = _transcript(clock, "hola")
  732. event["lang"] = "es"
  733. service.handle_event(event)
  734. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  735. service.tick()
  736. assert len(lane.submits) == 1
  737. _, _, lang, _ = lane.submits[0]
  738. assert lang == "es"
  739. def test_maybe_triage_runs_in_idle_mode():
  740. service, _, clock, triage, lane = _triage_service()
  741. service.handle_event(_transcript(clock, "hello"))
  742. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  743. service.tick()
  744. assert len(lane.submits) == 1
  745. state, questions, lang, gen = lane.submits[0]
  746. assert state == "- Unknown: hello"
  747. assert questions is triage.questions
  748. assert lang is None
  749. assert gen == 0
  750. assert triage.build_state_calls == [("Unknown: hello",)]
  751. assert service.triage_history == ["Unknown: hello"]
  752. def test_maybe_triage_uses_transcript_buffer_not_chat_history():
  753. service, _, clock, triage, lane = _triage_service(chat_fn=None)
  754. service.chat_history = ["stale chat only"]
  755. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  756. service.tick()
  757. assert lane.submits == []
  758. assert triage.build_state_calls == []
  759. service.handle_event(_transcript(clock, "hello"))
  760. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  761. service.tick()
  762. assert len(lane.submits) == 1
  763. assert triage.build_state_calls == [("Unknown: hello",)]
  764. def test_maybe_triage_requires_settled_turn():
  765. service, _, clock, _, lane = _triage_service()
  766. service.handle_event(_transcript(clock, "hello"))
  767. service.tick()
  768. assert lane.submits == []
  769. def test_maybe_triage_periodic_fires_before_turn_settles():
  770. service, _, clock, _, lane = _triage_service()
  771. service.cfg.triage.periodic_s = 0.2
  772. service.handle_event(_transcript(clock, "hello"))
  773. assert service.turn_ended() is False
  774. clock.advance(0.25) # due (0.2) yet still quieter than turn_gap_s (0.5)
  775. service.tick()
  776. assert service.turn_ended() is False
  777. assert len(lane.submits) == 1
  778. assert lane.submits[0][0] == "- Unknown: hello"
  779. def test_maybe_triage_periodic_disabled_ignores_accumulating_speech():
  780. service, _, clock, _, lane = _triage_service()
  781. service.cfg.triage.periodic_s = 0.0
  782. service.handle_event(_transcript(clock, "hello"))
  783. clock.advance(0.25)
  784. service.tick()
  785. assert service.turn_ended() is False
  786. assert lane.submits == []
  787. def test_maybe_triage_periodic_rearms_after_submit():
  788. service, _, clock, _, lane = _triage_service()
  789. service.cfg.triage.periodic_s = 0.2
  790. service.handle_event(_transcript(clock, "first"))
  791. clock.advance(0.25)
  792. service.tick()
  793. assert len(lane.submits) == 1
  794. service.tick()
  795. service.tick()
  796. assert len(lane.submits) == 1 # identical window, no new speech
  797. service.handle_event(_transcript(clock, "second"))
  798. clock.advance(0.25)
  799. service.tick()
  800. assert len(lane.submits) == 2
  801. assert lane.submits[1][0] == "- Unknown: first\n- Unknown: second"
  802. def test_triage_debug_emits_submit_and_result():
  803. sink = FakeDebugSink()
  804. service, _, clock, _, lane = _triage_service(debug_sink=sink)
  805. service.handle_event(_transcript(clock, "hello"))
  806. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  807. service.tick()
  808. submits = [event for event in sink.events if event[0] == "triage-submit"]
  809. assert submits == [
  810. ("triage-submit", {"trigger": "settled", "window": 1, "generation": 0})
  811. ]
  812. _, _, _, gen = lane.submits[0]
  813. service._on_triage_result(gen, _escalated_payload())
  814. results = [event for event in sink.events if event[0] == "triage-result"]
  815. assert len(results) == 1
  816. assert results[0][1]["escalated"] is True
  817. assert results[0][1]["generation"] == 0
  818. def test_triage_debug_marks_periodic_trigger():
  819. sink = FakeDebugSink()
  820. service, _, clock, _, lane = _triage_service(debug_sink=sink)
  821. service.cfg.triage.periodic_s = 0.2
  822. service.handle_event(_transcript(clock, "hello"))
  823. clock.advance(0.25)
  824. service.tick()
  825. submits = [event for event in sink.events if event[0] == "triage-submit"]
  826. assert submits[0][1]["trigger"] == "periodic"
  827. def test_triage_debug_emits_brief_dispatch():
  828. sink = FakeDebugSink()
  829. service, _, clock, _, lane = _triage_service(
  830. brief_fn=lambda prompt: "RAW", debug_sink=sink
  831. )
  832. service.handle_event(_transcript(clock, "hello"))
  833. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  834. service.tick()
  835. _, _, _, gen = lane.submits[0]
  836. service._on_triage_result(gen, _escalated_payload())
  837. brief = [event for event in sink.events if event[0] == "triage-brief"]
  838. assert brief == [("triage-brief", {"action": "dispatch", "generation": 0})]
  839. def test_triage_debug_emits_brief_gate_skip():
  840. sink = FakeDebugSink()
  841. service, _, clock, _, lane = _triage_service(
  842. triage=FakeTriage(gate=False), brief_fn=lambda prompt: "RAW", debug_sink=sink
  843. )
  844. service.handle_event(_transcript(clock, "hello"))
  845. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  846. service.tick()
  847. _, _, _, gen = lane.submits[0]
  848. service._on_triage_result(gen, _escalated_payload())
  849. brief = [event for event in sink.events if event[0] == "triage-brief"]
  850. assert brief == [
  851. ("triage-brief", {"action": "skip", "reason": "gate", "generation": 0})
  852. ]
  853. def test_maybe_triage_skips_unready_lane():
  854. service, _, clock, _, lane = _triage_service(lane=FakeLane(ready=False))
  855. service.handle_event(_transcript(clock, "hello"))
  856. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  857. service.tick()
  858. assert lane.submits == []
  859. def test_maybe_triage_skips_without_questions():
  860. service, _, clock, triage, lane = _triage_service(
  861. triage=FakeTriage(questions=None)
  862. )
  863. service.handle_event(_transcript(clock, "hello"))
  864. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  865. service.tick()
  866. assert lane.submits == []
  867. assert triage.build_state_calls == []
  868. def test_maybe_triage_dedupes_identical_window():
  869. service, _, clock, triage, lane = _triage_service()
  870. service.handle_event(_transcript(clock, "hello"))
  871. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  872. service.tick()
  873. assert len(lane.submits) == 1
  874. state, questions, lang, gen = lane.submits[0]
  875. assert state == "- Unknown: hello"
  876. assert questions is triage.questions
  877. assert lang is None
  878. assert gen == 0
  879. service.tick()
  880. service.tick()
  881. assert len(lane.submits) == 1
  882. service.handle_event(_transcript(clock, "second"))
  883. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  884. service.tick()
  885. assert len(lane.submits) == 2
  886. assert lane.submits[1][0] == "- Unknown: hello\n- Unknown: second"
  887. def test_second_transcript_produces_second_submit():
  888. service, _, clock, _, lane = _triage_service()
  889. service.handle_event(_transcript(clock, "first"))
  890. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  891. service.tick()
  892. assert len(lane.submits) == 1
  893. service.handle_event(_transcript(clock, "second"))
  894. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  895. service.tick()
  896. assert len(lane.submits) == 2
  897. assert lane.submits[1][0] == "- Unknown: first\n- Unknown: second"
  898. def test_tick_under_50ms_with_blocked_lane():
  899. service, _, clock, _, lane = _triage_service()
  900. assert not lane.predict_gate.is_set()
  901. service.toggle_chat()
  902. service.handle_event(_transcript(clock, "hello"))
  903. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  904. start = time.perf_counter()
  905. service.tick()
  906. elapsed = time.perf_counter() - start
  907. assert len(lane.submits) == 1
  908. assert elapsed < 0.05
  909. def test_on_triage_result_emits_signals():
  910. service, emitted, clock, _, lane = _triage_service()
  911. service.toggle_chat()
  912. service.handle_event(_transcript(clock, "hello"))
  913. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  914. service.tick()
  915. _, _, _, gen = lane.submits[0]
  916. payload = {
  917. "order": ["urgency"],
  918. "items": {"urgency": {"kind": "noul"}},
  919. "escalated": False,
  920. "input_tokens": 12,
  921. }
  922. service._on_triage_result(gen, payload)
  923. staged = _triage_events(emitted)
  924. assert len(staged) == 1
  925. assert staged[0]["stage"] == "signals"
  926. assert staged[0]["triage"] == service.last_triage
  927. assert staged[0]["triage"]["escalated"] is False
  928. assert staged[0]["triage"]["input_tokens"] == 12
  929. assert service.status()["triage"] == service.last_triage
  930. def test_triage_events_carry_the_server_timestamp():
  931. service, emitted, clock, _, lane = _triage_service(brief_fn=lambda prompt: "RAW")
  932. service.toggle_chat()
  933. service.handle_event(_transcript(clock, "hello"))
  934. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  935. service.tick()
  936. _, _, _, gen = lane.submits[0]
  937. expected_ts = clock()
  938. service._on_triage_result(gen, _escalated_payload())
  939. signals = [e for e in _triage_events(emitted) if e.get("stage") == "signals"]
  940. assert len(signals) == 1
  941. assert isinstance(signals[0]["ts"], (int, float))
  942. assert signals[0]["ts"] == expected_ts
  943. job = [j for j in service._inference._queue if j.name == "brief-update"][0]
  944. job.run()
  945. contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
  946. assert len(contents) == 1
  947. assert isinstance(contents[0]["ts"], (int, float))
  948. assert contents[0]["ts"] == expected_ts # content reuses the signals timestamp
  949. def test_on_triage_result_suppresses_stale_generation():
  950. service, emitted, clock, _, lane = _triage_service()
  951. service.toggle_chat()
  952. service.handle_event(_transcript(clock, "hello"))
  953. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  954. service.tick()
  955. _, _, _, gen = lane.submits[0]
  956. service.clear()
  957. service._on_triage_result(gen, _escalated_payload())
  958. assert service.last_triage is None
  959. assert [e for e in _triage_events(emitted) if e.get("stage") == "signals"] == []
  960. service.handle_event(_transcript(clock, "hello"))
  961. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  962. service.tick()
  963. assert len(lane.submits) == 2
  964. def test_pending_brief_capped_by_char_budget():
  965. """The buffer keeps the newest deltas within budget, dropping the oldest."""
  966. service, _, clock, _, _ = _triage_service(brief_fn=lambda prompt: "RAW")
  967. service.cfg.triage.brief_pending_char_budget = 30
  968. service.handle_event(_transcript(clock, "aaa"))
  969. service.handle_event(_transcript(clock, "bbbb"))
  970. assert service._pending_brief == ["Unknown: aaa", "Unknown: bbbb"]
  971. service.handle_event(_transcript(clock, "ccccc"))
  972. assert service._pending_brief == ["Unknown: bbbb", "Unknown: ccccc"]
  973. assert service.triage_history == ["Unknown: aaa", "Unknown: bbbb", "Unknown: ccccc"]
  974. def test_pending_brief_untouched_without_triage():
  975. service, _, clock = _service()
  976. service.handle_event(_transcript(clock, "hello"))
  977. assert service._pending_brief == []
  978. assert service.triage_history == ["Unknown: hello"]
  979. def test_on_triage_result_accepts_non_chat_mode():
  980. service, emitted, clock, _, lane = _triage_service()
  981. service.handle_event(_transcript(clock, "hello"))
  982. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  983. service.tick()
  984. _, _, _, gen = lane.submits[0]
  985. window = service._last_triage_window
  986. service._on_triage_result(gen, _escalated_payload())
  987. assert service.last_triage is not None
  988. assert service._last_triage_window == window
  989. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  990. def test_on_triage_result_ignores_none_payload():
  991. service, emitted, clock, _, lane = _triage_service()
  992. service.toggle_chat()
  993. service.handle_event(_transcript(clock, "hello"))
  994. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  995. service.tick()
  996. _, _, _, gen = lane.submits[0]
  997. service._on_triage_result(gen, None)
  998. assert service.last_triage is None
  999. assert _triage_events(emitted) == []
  1000. def test_none_payload_dispatches_nothing_and_keeps_deltas():
  1001. """A Laya predict failure never dispatches: no job, no events, deltas stay."""
  1002. calls: list[str] = []
  1003. service, emitted, clock, _, lane = _triage_service(
  1004. brief_fn=lambda prompt: calls.append(prompt) or "RAW"
  1005. )
  1006. service.toggle_chat()
  1007. service.set_chat_capture(True)
  1008. service.handle_event(_transcript(clock, "hello"))
  1009. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1010. service.tick()
  1011. _, _, _, gen = lane.submits[0]
  1012. assert service._pending_brief == ["Unknown: hello"]
  1013. service._on_triage_result(gen, None)
  1014. assert service.last_triage is None
  1015. assert [e.get("stage") for e in _triage_events(emitted)] == []
  1016. assert [j.name for j in service._inference._queue] == ["chat"]
  1017. assert calls == []
  1018. assert service._pending_brief == ["Unknown: hello"]
  1019. assert service._brief_outstanding is False
  1020. def test_none_payload_without_brief_fn_enqueues_nothing():
  1021. service, emitted, clock, _, lane = _triage_service()
  1022. service.toggle_chat()
  1023. service.set_chat_capture(True)
  1024. service.handle_event(_transcript(clock, "hello"))
  1025. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1026. service.tick()
  1027. _, _, _, gen = lane.submits[0]
  1028. service._on_triage_result(gen, None)
  1029. assert service.last_triage is None
  1030. assert _triage_events(emitted) == []
  1031. assert [j.name for j in service._inference._queue] == ["chat"]
  1032. assert service._pending_brief == ["Unknown: hello"]
  1033. def test_on_triage_result_ignores_empty_signals():
  1034. service, emitted, clock, _, lane = _triage_service(
  1035. triage=FakeTriage(normalized={})
  1036. )
  1037. service.toggle_chat()
  1038. service.handle_event(_transcript(clock, "hello"))
  1039. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1040. service.tick()
  1041. _, _, _, gen = lane.submits[0]
  1042. service._on_triage_result(gen, _escalated_payload())
  1043. assert service.last_triage is None
  1044. assert _triage_events(emitted) == []
  1045. def test_substantive_turn_dispatches_one_brief_job():
  1046. """A substantive settled turn calls the gate once and queues one brief job."""
  1047. service, emitted, clock, triage, lane = _triage_service(
  1048. triage=FakeTriage(escalate=True), brief_fn=lambda prompt: "RAW"
  1049. )
  1050. service.toggle_chat()
  1051. service.set_chat_capture(True)
  1052. service.handle_event(_transcript(clock, "we might churn"))
  1053. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1054. service.tick()
  1055. _, _, _, gen = lane.submits[0]
  1056. service._on_triage_result(
  1057. gen, {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
  1058. )
  1059. assert service.last_triage is not None
  1060. assert service.last_triage["escalated"] is True
  1061. assert service._last_brief_signals == service.last_triage
  1062. assert service._inference.depth() == 2
  1063. assert [j.name for j in service._inference._queue] == ["chat", "brief-update"]
  1064. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  1065. assert len(triage.gate_calls) == 1
  1066. assert service._brief_outstanding is True
  1067. assert service._brief_token == 1
  1068. def test_brief_dispatches_without_escalation():
  1069. service, _, clock, _, lane = _triage_service(brief_fn=lambda prompt: "RAW")
  1070. service.toggle_chat()
  1071. service.set_chat_capture(True)
  1072. service.handle_event(_transcript(clock, "all calm"))
  1073. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1074. service.tick()
  1075. _, _, _, gen = lane.submits[0]
  1076. service._on_triage_result(
  1077. gen, {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
  1078. )
  1079. assert service.last_triage is not None
  1080. assert service._inference.depth() == 2
  1081. assert [j.name for j in service._inference._queue] == ["chat", "brief-update"]
  1082. def test_gated_out_turn_skips_brief():
  1083. """A gated-out turn stores signals but never calls brief_fn."""
  1084. calls: list[str] = []
  1085. service, emitted, clock, triage, lane = _triage_service(
  1086. triage=FakeTriage(gate=False),
  1087. brief_fn=lambda prompt: calls.append(prompt) or "RAW",
  1088. )
  1089. service.toggle_chat()
  1090. service.set_chat_capture(True)
  1091. service.handle_event(_transcript(clock, "small talk"))
  1092. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1093. service.tick()
  1094. _, _, _, gen = lane.submits[0]
  1095. service._on_triage_result(gen, _escalated_payload())
  1096. assert service.last_triage is not None
  1097. assert "brief" not in service.last_triage # no prior brief to retain
  1098. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  1099. assert calls == []
  1100. assert [j.name for j in service._inference._queue] == ["chat"]
  1101. assert service._brief_outstanding is False
  1102. assert len(triage.gate_calls) == 1
  1103. def test_gated_out_turn_retains_previous_brief():
  1104. """A gated-out settled turn keeps the last accepted brief in the panel."""
  1105. service, emitted, clock, triage, lane = _triage_service(
  1106. triage=FakeTriage(gate=False), brief_fn=lambda prompt: "RAW"
  1107. )
  1108. service.toggle_chat()
  1109. signals = _escalated_payload()
  1110. service._last_brief_signals = signals
  1111. token = _seed_brief(service, "Unknown: first")
  1112. service._run_brief_update(0, signals, service._now(), token)
  1113. assert service.last_triage is not None
  1114. assert service.last_triage["brief"] == "RAW"
  1115. emitted.clear()
  1116. service.handle_event(_transcript(clock, "small talk"))
  1117. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1118. service.tick()
  1119. _, _, _, gen = lane.submits[0]
  1120. service._on_triage_result(gen, _escalated_payload())
  1121. assert service.last_triage is not None
  1122. assert service.last_triage["brief"] == "RAW"
  1123. assert "brief" not in service._last_brief_signals # raw signals stay raw
  1124. staged = [e for e in _triage_events(emitted) if e.get("stage") == "signals"]
  1125. assert len(staged) == 1
  1126. assert staged[0]["triage"] == service.last_triage
  1127. assert staged[0]["triage"]["brief"] == "RAW"
  1128. assert len(triage.gate_calls) == 1
  1129. def test_gated_out_turn_retains_brief_when_extract_disabled():
  1130. """`extract_enabled = false` skips the dispatch but still retains the brief."""
  1131. service, emitted, clock, _, lane = _triage_service(
  1132. triage=FakeTriage(gate=False), brief_fn=lambda prompt: "RAW"
  1133. )
  1134. service.toggle_chat()
  1135. service.set_chat_capture(True)
  1136. signals = _escalated_payload()
  1137. service._last_brief_signals = signals
  1138. token = _seed_brief(service, "Unknown: first")
  1139. service._run_brief_update(0, signals, service._now(), token)
  1140. assert service.last_triage is not None
  1141. assert service.last_triage["brief"] == "RAW"
  1142. service.cfg.triage.extract_enabled = False
  1143. emitted.clear()
  1144. service.handle_event(_transcript(clock, "small talk"))
  1145. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1146. service.tick()
  1147. _, _, _, gen = lane.submits[0]
  1148. service._on_triage_result(gen, _escalated_payload())
  1149. assert service.last_triage is not None
  1150. assert service.last_triage["brief"] == "RAW"
  1151. assert [j.name for j in service._inference._queue] == ["chat"]
  1152. staged = [e for e in _triage_events(emitted) if e.get("stage") == "signals"]
  1153. assert staged[0]["triage"]["brief"] == "RAW"
  1154. def test_gated_out_turn_retains_brief_without_brief_fn():
  1155. """No brief_fn means no dispatch, but a retained brief is still carried."""
  1156. service, emitted, clock, _, lane = _triage_service(triage=FakeTriage(gate=False))
  1157. service.toggle_chat()
  1158. service.last_triage = {**_escalated_payload(), "brief": "KEEP"}
  1159. emitted.clear()
  1160. service.handle_event(_transcript(clock, "small talk"))
  1161. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1162. service.tick()
  1163. _, _, _, gen = lane.submits[0]
  1164. service._on_triage_result(gen, _escalated_payload())
  1165. assert service.last_triage["brief"] == "KEEP"
  1166. staged = [e for e in _triage_events(emitted) if e.get("stage") == "signals"]
  1167. assert staged[0]["triage"]["brief"] == "KEEP"
  1168. def test_later_accepted_update_replaces_retained_brief():
  1169. """A later accepted update overwrites the retained brief text."""
  1170. texts = iter(["OLD", "NEW"])
  1171. service, emitted, _, _, _ = _triage_service(brief_fn=lambda prompt: next(texts))
  1172. service.toggle_chat()
  1173. signals = _escalated_payload()
  1174. service._last_brief_signals = signals
  1175. token = _seed_brief(service, "Unknown: first")
  1176. service._run_brief_update(0, signals, service._now(), token)
  1177. assert service.last_triage is not None
  1178. assert service.last_triage["brief"] == "OLD"
  1179. token = _seed_brief(service, "Unknown: second")
  1180. service._run_brief_update(0, signals, service._now(), token)
  1181. assert service.last_triage is not None
  1182. assert service.last_triage["brief"] == "NEW"
  1183. contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
  1184. assert contents[-1]["triage"]["brief"] == "NEW"
  1185. service.clear()
  1186. assert service.last_triage is None
  1187. def test_no_brief_when_disabled_in_config():
  1188. calls: list[str] = []
  1189. service, _, clock, _, lane = _triage_service(
  1190. triage=FakeTriage(escalate=True),
  1191. brief_fn=lambda prompt: calls.append(prompt) or "RAW",
  1192. )
  1193. service.cfg.triage.extract_enabled = False
  1194. service.toggle_chat()
  1195. service.set_chat_capture(True)
  1196. service.handle_event(_transcript(clock, "churn risk"))
  1197. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1198. service.tick()
  1199. _, _, _, gen = lane.submits[0]
  1200. service._on_triage_result(gen, _escalated_payload())
  1201. assert service.last_triage is not None
  1202. assert service.last_triage["escalated"] is True
  1203. assert calls == []
  1204. assert [j.name for j in service._inference._queue] == ["chat"]
  1205. def test_no_brief_without_brief_fn():
  1206. service, _, clock, _, lane = _triage_service(triage=FakeTriage(escalate=True))
  1207. service.toggle_chat()
  1208. service.set_chat_capture(True)
  1209. service.handle_event(_transcript(clock, "churn risk"))
  1210. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1211. service.tick()
  1212. _, _, _, gen = lane.submits[0]
  1213. service._on_triage_result(gen, _escalated_payload())
  1214. assert service.last_triage is not None
  1215. assert [j.name for j in service._inference._queue] == ["chat"]
  1216. assert service._brief_outstanding is False
  1217. def test_brief_refused_when_queue_full():
  1218. """A refused submit leaves deltas buffered and the outstanding flag clear."""
  1219. service, emitted, clock, _, lane = _triage_service(
  1220. triage=FakeTriage(escalate=True), brief_fn=lambda prompt: "RAW"
  1221. )
  1222. service._inference = InferenceQueue(size=2)
  1223. service.toggle_chat()
  1224. service.set_chat_capture(True)
  1225. service.handle_event(_transcript(clock, "churn risk"))
  1226. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1227. service.tick()
  1228. assert service._inference.depth() == 1
  1229. _, _, _, gen = lane.submits[0]
  1230. service._on_triage_result(gen, _escalated_payload())
  1231. assert service._inference.depth() == 1
  1232. assert service._inference._queue[0].name == "chat"
  1233. assert service.last_triage is not None
  1234. assert service.last_triage["escalated"] is True
  1235. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  1236. assert service._pending_brief == ["Unknown: churn risk"]
  1237. assert service._brief_outstanding is False
  1238. def test_second_result_while_outstanding_is_refused():
  1239. """At most one brief job is outstanding: the second turn only stores signals."""
  1240. service, emitted, clock, _, lane = _triage_service(brief_fn=lambda prompt: "RAW")
  1241. service.toggle_chat()
  1242. service.set_chat_capture(True)
  1243. service.handle_event(_transcript(clock, "first"))
  1244. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1245. service.tick()
  1246. _, _, _, gen0 = lane.submits[0]
  1247. service._on_triage_result(gen0, _escalated_payload())
  1248. assert [j.name for j in service._inference._queue] == ["chat", "brief-update"]
  1249. service.handle_event(_transcript(clock, "second"))
  1250. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1251. service.tick()
  1252. _, _, _, gen1 = lane.submits[1]
  1253. service._on_triage_result(gen1, _escalated_payload())
  1254. jobs = [j for j in service._inference._queue if j.name == "brief-update"]
  1255. assert len(jobs) == 1
  1256. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals", "signals"]
  1257. assert service._brief_outstanding is True
  1258. assert service._pending_brief == ["Unknown: first", "Unknown: second"]
  1259. def _seed_brief(service, *deltas):
  1260. service._pending_brief = list(deltas)
  1261. service._brief_outstanding = True
  1262. return service._brief_token
  1263. def test_ledger_records_fact_and_survives_model_reemit():
  1264. """A recorded fact persists even when a later reply omits it."""
  1265. first = _brief_reply("Summary: rolling", ["The budget was $305 million."])
  1266. second = _brief_reply("Summary: newer", [])
  1267. replies = iter([first, second])
  1268. service, _, _, _, _ = _triage_service(brief_fn=lambda prompt: next(replies))
  1269. service._pending_brief = ["Unknown: one"]
  1270. service._brief_outstanding = True
  1271. service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
  1272. service._pending_brief = ["Unknown: two"]
  1273. service._brief_outstanding = True
  1274. service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
  1275. assert service.last_triage is not None
  1276. brief = service.last_triage["brief"]
  1277. assert "The budget was $305 million." in brief
  1278. assert service._fact_ledger.facts() == ["The budget was $305 million."]
  1279. def test_ledger_dedupes_repeated_fact():
  1280. """The same fact proposed twice is recorded once."""
  1281. reply = _brief_reply("Summary: s", ["Sense could find prey.", "sense could find prey"])
  1282. service, _, _, _, _ = _triage_service(brief_fn=lambda prompt: reply)
  1283. service._pending_brief = ["Unknown: one"]
  1284. service._brief_outstanding = True
  1285. service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
  1286. assert service._fact_ledger.facts() == ["Sense could find prey."]
  1287. def test_truncated_brief_reply_keeps_prior_narrative_and_facts():
  1288. """A reply cut at the token limit must not replace the accepted narrative."""
  1289. sink = FakeDebugSink()
  1290. replies = iter(
  1291. [
  1292. _brief_reply("Summary: good\n\nOpen Questions:\n- who owns it?"),
  1293. _brief_reply("Summary: cut mid-wor", ["fact one durable", "fact two"]),
  1294. ]
  1295. )
  1296. service, _, _, _, _ = _triage_service(
  1297. brief_fn=lambda prompt: next(replies),
  1298. brief_finish=lambda text: "length" if "cut mid-wor" in text else "stop",
  1299. debug_sink=sink,
  1300. )
  1301. service._pending_brief = ["Unknown: one"]
  1302. service._brief_outstanding = True
  1303. service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
  1304. service._pending_brief = ["Unknown: two"]
  1305. service._brief_outstanding = True
  1306. service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
  1307. assert service._last_brief_narrative.startswith("Summary: good")
  1308. assert "cut mid-wor" not in service.last_triage["brief"]
  1309. assert service._fact_ledger.facts() == ["fact one durable"]
  1310. rejects = [
  1311. event
  1312. for event in sink.events
  1313. if event[0] == "triage-brief" and event[1].get("action") == "reject"
  1314. ]
  1315. assert rejects
  1316. assert rejects[-1][1]["reason"] == "truncated"
  1317. def test_brief_rewrite_that_drops_a_section_is_rejected():
  1318. """Dropping a canonical section keeps the prior, more complete brief."""
  1319. replies = iter(
  1320. [
  1321. _brief_reply("Summary: full\n\nOpen Questions:\n- who owns it?"),
  1322. _brief_reply("Summary: only"),
  1323. ]
  1324. )
  1325. service, _, _, _, _ = _triage_service(brief_fn=lambda prompt: next(replies))
  1326. service._pending_brief = ["Unknown: one"]
  1327. service._brief_outstanding = True
  1328. service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
  1329. service._pending_brief = ["Unknown: two"]
  1330. service._brief_outstanding = True
  1331. service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
  1332. assert "Open Questions:" in service.last_triage["brief"]
  1333. assert "Summary: only" not in service.last_triage["brief"]
  1334. def test_ledger_prompt_carries_ledger_and_narrative():
  1335. """The brief request hands the model the ledger, narrative, and new deltas."""
  1336. seen: list[str] = []
  1337. service, _, _, _, _ = _triage_service(
  1338. brief_fn=lambda prompt: seen.append(prompt) or _brief_reply("UPDATED")
  1339. )
  1340. service._fact_ledger.merge(["PRIOR FACT"])
  1341. service._last_brief_narrative = "PRIOR NARRATIVE"
  1342. service._pending_brief = ["Unknown: hello there"]
  1343. service._brief_outstanding = True
  1344. service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
  1345. assert len(seen) == 1
  1346. assert "CURRENT LEDGER:" in seen[0]
  1347. assert "- PRIOR FACT" in seen[0]
  1348. assert "CURRENT BRIEF:\nPRIOR NARRATIVE" in seen[0]
  1349. assert "NEW UTTERANCES:\n- Unknown: hello there" in seen[0]
  1350. def test_brief_merges_content_and_writes_file():
  1351. brief_file = FakeBriefFile()
  1352. service, emitted, _, triage, _ = _triage_service(
  1353. brief_fn=lambda prompt: "the brief", brief_file=brief_file
  1354. )
  1355. service.toggle_chat()
  1356. signals = _escalated_payload()
  1357. service._last_brief_signals = signals
  1358. token = _seed_brief(service, "Unknown: hello")
  1359. ts = service._now()
  1360. service._run_brief_update(0, signals, ts, token)
  1361. contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
  1362. assert len(contents) == 1
  1363. assert contents[0]["triage"] == {**signals, "brief": "the brief"}
  1364. assert service.last_triage == contents[0]["triage"]
  1365. assert triage.prompt_calls == [(("Unknown: hello",), signals)]
  1366. assert brief_file.writes == ["the brief"]
  1367. assert service._pending_brief == []
  1368. assert service._brief_outstanding is False
  1369. def test_empty_brief_requeues_deltas():
  1370. """A `""` reply keeps signals, re-buffers the deltas, and clears the flag."""
  1371. service, emitted, _, _, _ = _triage_service(brief_fn=lambda prompt: "")
  1372. service.toggle_chat()
  1373. signals = _escalated_payload()
  1374. service.last_triage = signals
  1375. service._last_brief_signals = signals
  1376. token = _seed_brief(service, "Unknown: hello")
  1377. ts = service._now()
  1378. service._run_brief_update(0, signals, ts, token)
  1379. assert [e for e in _triage_events(emitted) if e.get("stage") == "content"] == []
  1380. assert service.last_triage == signals
  1381. assert "brief" not in service.last_triage
  1382. assert service._pending_brief == ["Unknown: hello"]
  1383. assert service._brief_outstanding is False
  1384. def test_brief_error_requeues_and_clears_flag():
  1385. """A raising brief_fn leaves the flag clear and the deltas buffered."""
  1386. def boom(prompt):
  1387. raise RuntimeError("llm down")
  1388. service, emitted, _, _, _ = _triage_service(brief_fn=boom)
  1389. service.toggle_chat()
  1390. token = _seed_brief(service, "Unknown: hello")
  1391. ts = service._now()
  1392. service._run_brief_update(0, _escalated_payload(), ts, token)
  1393. assert _triage_events(emitted) == []
  1394. assert service.last_triage is None
  1395. assert service._pending_brief == ["Unknown: hello"]
  1396. assert service._brief_outstanding is False
  1397. def test_brief_empty_buffer_returns_early():
  1398. """An empty buffer returns early: no call, no event, flag cleared."""
  1399. calls: list[str] = []
  1400. service, emitted, _, _, _ = _triage_service(
  1401. brief_fn=lambda prompt: calls.append(prompt) or "RAW"
  1402. )
  1403. service.toggle_chat()
  1404. service._brief_outstanding = True
  1405. token = service._brief_token
  1406. ts = service._now()
  1407. assert service._pending_brief == []
  1408. service._run_brief_update(0, _escalated_payload(), ts, token)
  1409. assert calls == []
  1410. assert _triage_events(emitted) == []
  1411. assert service._brief_outstanding is False
  1412. def test_brief_suppressed_after_reset():
  1413. service, emitted, _, _, _ = _triage_service(brief_fn=lambda prompt: "RAW")
  1414. service.toggle_chat()
  1415. service._pending_brief = ["Unknown: hello"]
  1416. service._reset_triage()
  1417. emitted.clear()
  1418. service._run_brief_update(0, _escalated_payload(), service._now(), 0)
  1419. assert _triage_events(emitted) == []
  1420. assert service.last_triage is None
  1421. assert service._pending_brief == ["Unknown: hello"]
  1422. def test_brief_works_after_leaving_chat():
  1423. seen = []
  1424. service, emitted, _, _, _ = _triage_service(
  1425. brief_fn=lambda prompt: seen.append(prompt) or "RAW"
  1426. )
  1427. service.toggle_chat()
  1428. assert service.modes.exit_chat() is True
  1429. token = _seed_brief(service, "Unknown: hello")
  1430. signals = _escalated_payload()
  1431. service._last_brief_signals = signals
  1432. service._run_brief_update(0, signals, service._now(), token)
  1433. assert seen != []
  1434. assert [e.get("stage") for e in _triage_events(emitted)] == ["content"]
  1435. assert service.last_triage is not None
  1436. assert service.last_triage["brief"] == "RAW"
  1437. def test_deltas_arriving_during_call_survive_and_flush():
  1438. """Deltas appended mid-call are not consumed; the finally flushes them."""
  1439. service, emitted, clock, _, _ = _triage_service()
  1440. service.toggle_chat()
  1441. signals = _escalated_payload()
  1442. service._last_brief_signals = signals
  1443. token = _seed_brief(service, "Unknown: first")
  1444. appended: list[int] = []
  1445. def _brief(prompt, on_io=None, finalize=None):
  1446. if not appended:
  1447. appended.append(1)
  1448. service.handle_event(_transcript(clock, "during call"))
  1449. return _brief_reply("first brief")
  1450. service._brief_fn = _brief
  1451. service._run_brief_update(0, signals, clock(), token)
  1452. contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
  1453. assert len(contents) == 1
  1454. assert contents[0]["triage"]["brief"] == "first brief"
  1455. flushed = [j for j in service._inference._queue if j.name == "brief-update"]
  1456. assert len(flushed) == 1
  1457. assert service._brief_outstanding is True
  1458. assert service._pending_brief == ["Unknown: during call"]
  1459. flushed[0].run()
  1460. contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
  1461. assert len(contents) == 2
  1462. assert contents[1]["triage"]["brief"] == "first brief"
  1463. assert service._pending_brief == []
  1464. assert service._brief_outstanding is False
  1465. def test_in_flight_brief_after_clear_emits_and_writes_nothing():
  1466. """Stale in-flight job: no event, no file write, no requeue, flag untouched."""
  1467. brief_file = FakeBriefFile()
  1468. service, emitted, clock, _, lane = _triage_service(
  1469. brief_fn=lambda prompt: "RAW", brief_file=brief_file
  1470. )
  1471. service.toggle_chat()
  1472. service.handle_event(_transcript(clock, "hello"))
  1473. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1474. service.tick()
  1475. _, _, _, gen = lane.submits[0]
  1476. service._on_triage_result(gen, _escalated_payload())
  1477. job = [j for j in service._inference._queue if j.name == "brief-update"][0]
  1478. assert service._brief_outstanding is True
  1479. service.clear()
  1480. assert service._brief_outstanding is False
  1481. token_after_clear = service._brief_token
  1482. emitted.clear()
  1483. job.run()
  1484. assert [e for e in _triage_events(emitted)] == []
  1485. assert brief_file.writes == []
  1486. assert service._pending_brief == []
  1487. assert service.last_triage is None
  1488. assert service._brief_token == token_after_clear
  1489. assert service._brief_outstanding is False
  1490. def test_clear_resets_triage():
  1491. resets: list[int] = []
  1492. chats: list[int] = []
  1493. brief_file = FakeBriefFile()
  1494. service, emitted, clock, _, lane = _triage_service(
  1495. triage=FakeTriage(escalate=True),
  1496. brief_fn=lambda prompt: "RAW",
  1497. brief_file=brief_file,
  1498. brief_reset=lambda: resets.append(1),
  1499. chat_reset=lambda: chats.append(1),
  1500. )
  1501. service.toggle_chat()
  1502. service.set_chat_capture(True)
  1503. service.handle_event(_transcript(clock, "churn"))
  1504. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1505. service.tick()
  1506. _, _, _, gen = lane.submits[0]
  1507. service._on_triage_result(gen, _escalated_payload())
  1508. assert service._inference.depth() == 2
  1509. assert service._pending_brief == ["Unknown: churn"]
  1510. assert service._brief_outstanding is True
  1511. token_before = service._brief_token
  1512. assert service.clear() is True
  1513. assert service._triage_generation == 1
  1514. assert service.last_triage is None
  1515. assert service._last_triage_window == ()
  1516. assert service.triage_history == []
  1517. assert service._pending_brief == []
  1518. assert service._brief_token == token_before + 1
  1519. assert service._brief_outstanding is False
  1520. assert service._last_brief_signals is None
  1521. assert resets == [1]
  1522. assert brief_file.clears == 1
  1523. assert chats == [1]
  1524. assert lane.drops == 1
  1525. assert service._inference.depth() == 0
  1526. assert list(service._inference._queue) == []
  1527. cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
  1528. assert len(cleared) == 1
  1529. assert cleared[0]["triage"] is None
  1530. assert {"type": "cleared"} in emitted
  1531. signals_before = len(
  1532. [e for e in _triage_events(emitted) if e.get("stage") == "signals"]
  1533. )
  1534. service._on_triage_result(gen, _escalated_payload())
  1535. assert service.last_triage is None
  1536. assert (
  1537. len([e for e in _triage_events(emitted) if e.get("stage") == "signals"])
  1538. == signals_before
  1539. )
  1540. submits_before = len(lane.submits)
  1541. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1542. service.tick()
  1543. assert len(lane.submits) == submits_before
  1544. def test_exit_chat_does_not_reset_triage():
  1545. chats: list[int] = []
  1546. bresets: list[int] = []
  1547. brief_file = FakeBriefFile()
  1548. service, emitted, clock, _, lane = _triage_service(
  1549. brief_fn=lambda prompt: "RAW",
  1550. chat_reset=lambda: chats.append(1),
  1551. brief_reset=lambda: bresets.append(1),
  1552. brief_file=brief_file,
  1553. )
  1554. service.toggle_chat()
  1555. service.handle_event(_transcript(clock, "hello"))
  1556. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1557. service.tick()
  1558. assert len(lane.submits) == 1
  1559. _, _, _, gen = lane.submits[0]
  1560. service._on_triage_result(gen, _escalated_payload())
  1561. assert service.last_triage is not None
  1562. assert service._triage_generation == 0
  1563. assert service.toggle_chat() is True
  1564. assert service.modes.mode.value == "idle"
  1565. assert service._triage_generation == 0
  1566. assert service.last_triage is not None
  1567. assert service._last_triage_window == ("Unknown: hello",)
  1568. assert service.triage_history == ["Unknown: hello"]
  1569. assert service._pending_brief == ["Unknown: hello"]
  1570. assert service._last_brief_signals is not None
  1571. assert service._brief_outstanding is True
  1572. assert lane.drops == 0
  1573. cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
  1574. assert cleared == []
  1575. assert chats == []
  1576. assert bresets == []
  1577. assert brief_file.clears == 0
  1578. def test_toggle_chat_enter_does_not_touch_triage():
  1579. """Entering chat resets only the chat buffers; the brief engine is independent."""
  1580. bresets: list[int] = []
  1581. brief_file = FakeBriefFile()
  1582. service, emitted, clock, _, lane = _triage_service(
  1583. brief_fn=lambda prompt: "RAW",
  1584. brief_reset=lambda: bresets.append(1),
  1585. brief_file=brief_file,
  1586. )
  1587. service.handle_event(_transcript(clock, "hello"))
  1588. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1589. service.tick()
  1590. _, _, _, gen = lane.submits[0]
  1591. service._on_triage_result(gen, _escalated_payload())
  1592. triage_before = service.last_triage
  1593. pending_before = list(service._pending_brief)
  1594. assert service.toggle_chat() is True
  1595. assert service.modes.mode.value == "chat"
  1596. assert service._triage_generation == 0
  1597. assert service.last_triage is triage_before
  1598. assert service._last_triage_window == ("Unknown: hello",)
  1599. assert service.triage_history == ["Unknown: hello"]
  1600. assert service._pending_brief == pending_before
  1601. assert service._last_brief_signals is not None
  1602. assert service._brief_outstanding is True
  1603. assert lane.drops == 0
  1604. assert bresets == []
  1605. assert brief_file.clears == 0
  1606. cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
  1607. assert cleared == []
  1608. def test_clear_without_triage_emits_no_triage_event():
  1609. service, emitted, _ = _service()
  1610. brief_file = FakeBriefFile()
  1611. bresets: list[int] = []
  1612. service._brief_file = brief_file
  1613. service._brief_reset = lambda: bresets.append(1)
  1614. service.clear()
  1615. assert {"type": "cleared"} in emitted
  1616. assert _triage_events(emitted) == []
  1617. assert brief_file.clears == 1
  1618. assert bresets == []
  1619. def test_stale_brief_after_clear_does_not_clear_newer_dispatch_flag():
  1620. """A stale in-flight job must not clear a newer dispatch's outstanding flag."""
  1621. brief_file = FakeBriefFile()
  1622. service, emitted, clock, _, lane = _triage_service(
  1623. brief_fn=lambda prompt: "RAW", brief_file=brief_file
  1624. )
  1625. service.toggle_chat()
  1626. service.set_chat_capture(True)
  1627. service.handle_event(_transcript(clock, "hello"))
  1628. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1629. service.tick()
  1630. _, _, _, gen = lane.submits[0]
  1631. service._on_triage_result(gen, _escalated_payload())
  1632. stale_job = [j for j in service._inference._queue if j.name == "brief-update"][0]
  1633. assert service._brief_outstanding is True
  1634. service.clear()
  1635. # A fresh turn dispatches a new brief AFTER clear bumped the token.
  1636. service.handle_event(_transcript(clock, "fresh"))
  1637. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1638. service.tick()
  1639. _, _, _, gen2 = lane.submits[1]
  1640. service._on_triage_result(gen2, _escalated_payload())
  1641. assert service._brief_outstanding is True
  1642. newer_token = service._brief_token
  1643. pending_before = list(service._pending_brief)
  1644. triage_before = service.last_triage
  1645. emitted.clear()
  1646. stale_job.run()
  1647. assert _triage_events(emitted) == []
  1648. assert brief_file.writes == []
  1649. assert service._pending_brief == pending_before
  1650. assert service._brief_outstanding is True
  1651. assert service._brief_token == newer_token
  1652. assert service.last_triage is triage_before
  1653. def test_clear_resets_chat_latch():
  1654. service, emitted, _ = _service()
  1655. service.toggle_chat()
  1656. service._chat_queue_full = True
  1657. service._pending_chat = ["stuck text"]
  1658. service.clear()
  1659. assert service._chat_queue_full is False
  1660. assert service._pending_chat == []
  1661. assert {"type": "cleared"} in emitted
  1662. def test_triage_end_to_end_signals_content_cleared():
  1663. """Two settled turns collapse into ONE brief job (single-outstanding guard).
  1664. The second `_on_triage_result` is refused as `"outstanding"`, so the queue
  1665. holds one `brief-update` that consumes both deltas and emits one `content`.
  1666. The panel keeps the FIRST turn's signals until the next settled turn (lag).
  1667. """
  1668. prompts: list[str] = []
  1669. service, emitted, clock, triage, lane = _triage_service(
  1670. brief_fn=lambda prompt: prompts.append(prompt) or "the brief"
  1671. )
  1672. service.toggle_chat()
  1673. service.set_chat_capture(True)
  1674. service.handle_event(_transcript(clock, "first thought"))
  1675. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1676. service.tick()
  1677. assert len(lane.submits) == 1
  1678. _, _, _, gen0 = lane.submits[0]
  1679. service._on_triage_result(
  1680. gen0, {"order": ["urgency"], "items": {}, "escalated": False, "input_tokens": 1}
  1681. )
  1682. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  1683. assert prompts == []
  1684. assert service.last_triage is not None
  1685. assert service.last_triage["escalated"] is False
  1686. assert [j.name for j in service._inference._queue] == ["chat", "brief-update"]
  1687. triage._escalate = True
  1688. service.handle_event(_transcript(clock, "we might churn"))
  1689. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1690. service.tick()
  1691. assert len(lane.submits) == 2
  1692. _, _, _, gen1 = lane.submits[1]
  1693. service._on_triage_result(gen1, _escalated_payload())
  1694. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals", "signals"]
  1695. assert [j.name for j in service._inference._queue] == [
  1696. "chat",
  1697. "brief-update",
  1698. "chat",
  1699. ]
  1700. for job in [j for j in service._inference._queue if j.name == "brief-update"]:
  1701. job.run()
  1702. contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
  1703. assert len(contents) == 1
  1704. assert len(prompts) == 1
  1705. deltas, _ = triage.prompt_calls[0]
  1706. assert deltas == ("Unknown: first thought", "Unknown: we might churn")
  1707. assert contents[0]["triage"]["escalated"] is False
  1708. assert contents[0]["triage"]["brief"] == "the brief"
  1709. assert service.last_triage == contents[0]["triage"]
  1710. assert service.status()["triage"] == contents[0]["triage"]
  1711. service.clear()
  1712. cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
  1713. assert len(cleared) == 1
  1714. assert cleared[0]["triage"] is None
  1715. assert service.last_triage is None
  1716. service._on_triage_result(gen1, _escalated_payload())
  1717. assert [e.get("stage") for e in _triage_events(emitted)] == [
  1718. "signals",
  1719. "signals",
  1720. "content",
  1721. "cleared",
  1722. ]
  1723. assert service.toggle_chat() is True
  1724. service.handle_event(_transcript(clock, "idle thought"))
  1725. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1726. service.tick()
  1727. assert len(lane.submits) == 3
  1728. assert lane.submits[2][0].endswith("- Unknown: idle thought")
  1729. def test_tick_emits_status_on_triage_state_change():
  1730. service, emitted, _, triage, _ = _triage_service(
  1731. triage=FakeTriage(state_pair=("loading", "warming up"))
  1732. )
  1733. service.tick()
  1734. statuses = [e for e in emitted if e["type"] == "status"]
  1735. assert len(statuses) == 1
  1736. assert statuses[0]["triage_state"] == "loading"
  1737. assert statuses[0]["triage_reason"] == "warming up"
  1738. assert "triage" in statuses[0]
  1739. triage._state_pair = ("ok", "")
  1740. service.tick()
  1741. statuses = [e for e in emitted if e["type"] == "status"]
  1742. assert len(statuses) == 2
  1743. assert statuses[1]["triage_state"] == "ok"
  1744. assert statuses[1]["triage_reason"] == ""
  1745. assert "triage" in statuses[1]
  1746. service.tick()
  1747. assert len([e for e in emitted if e["type"] == "status"]) == 2
  1748. def test_no_status_emitted_when_triage_disabled():
  1749. service, emitted, _ = _service()
  1750. service.tick()
  1751. service.tick()
  1752. assert [e for e in emitted if e["type"] == "status"] == []
  1753. def test_tick_heals_triage_panel_after_lane_ready():
  1754. import time as _time
  1755. from pathlib import Path
  1756. from stt_daemon.assistant.laya_lane import LayaLane
  1757. from stt_daemon.assistant.triage import TriageAnalyzer
  1758. from stt_daemon.tui.state import ViewState
  1759. class _FakeRouter:
  1760. def preload(self, names):
  1761. return self
  1762. def predict(self, state, questions, model=None, lang=None, **kwargs):
  1763. if "_warmup" in questions:
  1764. return {"model": model, "answers": {}, "usage": {}, "routing": {}}
  1765. return {
  1766. "model": model,
  1767. "answers": {
  1768. "frustration": {
  1769. "noul": 0.1,
  1770. "confidence": 0.9,
  1771. "answer_confidence": 0.9,
  1772. },
  1773. "urgency": {
  1774. "choice": "low",
  1775. "confidence": 0.9,
  1776. "answer_confidence": 0.9,
  1777. },
  1778. },
  1779. "usage": {"input_tokens": 5},
  1780. }
  1781. def _factory(**kwargs):
  1782. return _FakeRouter()
  1783. cfg = Config()
  1784. cfg.assistant.turn_gap_s = 0.5
  1785. cfg.triage.questions_file = str(
  1786. Path(__file__).resolve().parent.parent / "questions.example.json"
  1787. )
  1788. clock = Clock()
  1789. emitted: list[dict] = []
  1790. def emit(type_, **fields):
  1791. emitted.append({"type": type_, **fields})
  1792. analyzer = TriageAnalyzer(cfg.triage)
  1793. assert analyzer.questions, "example questions must load"
  1794. holder: dict = {}
  1795. lane = LayaLane(
  1796. cfg.triage,
  1797. agent_factory=_factory,
  1798. on_result=lambda gen, payload: holder["svc"]._on_triage_result(gen, payload),
  1799. on_state=lambda state, reason: analyzer.refresh(state, reason),
  1800. )
  1801. service = AssistantService(
  1802. cfg,
  1803. FakeSubscriber(),
  1804. emit=emit,
  1805. time_fn=clock,
  1806. triage=analyzer,
  1807. laya_lane=lane,
  1808. chat_fn=_ok_reply,
  1809. )
  1810. holder["svc"] = service
  1811. try:
  1812. lane.start()
  1813. deadline = _time.monotonic() + 5.0
  1814. while not lane.ready() and _time.monotonic() < deadline:
  1815. _time.sleep(0.005)
  1816. assert lane.ready(), "lane did not become ready"
  1817. assert analyzer.state() == ("ok", "")
  1818. service.tick()
  1819. statuses = [e for e in emitted if e["type"] == "status"]
  1820. assert statuses, "tick emitted no status after lane ready"
  1821. assert statuses[-1]["triage_state"] == "ok"
  1822. service.handle_event(_transcript(clock, "hello"))
  1823. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1824. service.tick()
  1825. deadline = _time.monotonic() + 5.0
  1826. while service.last_triage is None and _time.monotonic() < deadline:
  1827. _time.sleep(0.005)
  1828. assert service.last_triage is not None, "no triage signals from lane"
  1829. view = ViewState()
  1830. for event in emitted:
  1831. view.ingest(event)
  1832. assert view.triage_state == "ok"
  1833. lines = view.triage_lines()
  1834. assert lines
  1835. assert not any("unavailable" in line for line in lines)
  1836. assert not any("loading" in line for line in lines)
  1837. finally:
  1838. lane.stop()
  1839. def _dump_brief(prompt, on_io=None, finalize=None):
  1840. """Brief fake that mimics LlmClient.chat's on_io phases."""
  1841. if on_io is not None:
  1842. on_io({"phase": "request", "url": "http://llm/v1", "body": {"prompt": prompt}})
  1843. on_io(
  1844. {
  1845. "phase": "response",
  1846. "payload": {"content": "parsed"},
  1847. "error": None,
  1848. "duration_ms": 250.0,
  1849. }
  1850. )
  1851. return _brief_reply("BRIEF")
  1852. def _run_triage_flow(service, emitted, clock, lane, payload):
  1853. service.toggle_chat()
  1854. service.set_chat_capture(True)
  1855. service.handle_event(_transcript(clock, "hello"))
  1856. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1857. service.tick()
  1858. _, _, _, gen = lane.submits[0]
  1859. service._on_triage_result(gen, payload)
  1860. return gen
  1861. def _dump_files(tmp_path):
  1862. return list(tmp_path.rglob("*.json"))
  1863. def test_dump_writes_full_laya_and_llm_io(tmp_path):
  1864. service, emitted, clock, triage, lane = _triage_service(brief_fn=_dump_brief)
  1865. service._triage_dump = TriageDumper(True, directory=str(tmp_path))
  1866. service.cfg.triage.dump_io = True
  1867. _run_triage_flow(service, emitted, clock, lane, _escalated_payload())
  1868. jobs = [j for j in service._inference._queue if j.name == "brief-update"]
  1869. assert len(jobs) == 1
  1870. jobs[0].run()
  1871. files = _dump_files(tmp_path)
  1872. assert len(files) == 1
  1873. assert re.fullmatch(r"\d{6}-\d{3}\.json", files[0].name)
  1874. data = json.loads(files[0].read_text(encoding="utf-8"))
  1875. assert data["ts"] == clock()
  1876. assert isinstance(data["iso"], str) and data["iso"]
  1877. assert data["laya"]["request"]["state"] == "- Unknown: hello"
  1878. assert data["laya"]["request"]["questions"] == triage.questions
  1879. assert data["laya"]["request"]["model"] == "multilingual"
  1880. assert data["laya"]["request"]["max_len"] == 1024
  1881. assert data["laya"]["response"] == _escalated_payload()
  1882. assert data["laya"]["error"] is None
  1883. assert data["llm"]["request"]["url"] == "http://llm/v1"
  1884. assert data["llm"]["request"]["body"] == {
  1885. "prompt": "CURRENT LEDGER:\n(none yet)\n\nCURRENT BRIEF:\n(none yet)\n\n"
  1886. "NEW UTTERANCES:\n- Unknown: hello"
  1887. }
  1888. assert data["llm"]["response"] == {"content": "parsed"}
  1889. assert data["llm"]["error"] is None
  1890. assert data["llm"]["duration_ms"] == 250.0
  1891. assert service._triage_dump._pending == {}
  1892. def test_dump_laya_error_writes_laya_only_and_keeps_deltas(tmp_path):
  1893. """A Laya predict failure finalizes the Laya half with `llm: null`.
  1894. Nothing is dispatched, so no LLM half is ever recorded and the buffered
  1895. deltas survive for the next settled turn.
  1896. """
  1897. service, emitted, clock, _, lane = _triage_service(brief_fn=_dump_brief)
  1898. service._triage_dump = TriageDumper(True, directory=str(tmp_path))
  1899. service.cfg.triage.dump_io = True
  1900. _run_triage_flow(service, emitted, clock, lane, None)
  1901. assert [j.name for j in service._inference._queue] == ["chat"]
  1902. assert service._pending_brief == ["Unknown: hello"]
  1903. files = _dump_files(tmp_path)
  1904. assert len(files) == 1
  1905. data = json.loads(files[0].read_text(encoding="utf-8"))
  1906. assert data["laya"]["response"] is None
  1907. assert data["laya"]["error"] == "predict failed"
  1908. assert data["llm"] is None
  1909. assert service._triage_dump._pending == {}
  1910. def test_dump_without_brief_has_laya_only(tmp_path):
  1911. service, emitted, clock, _, lane = _triage_service(brief_fn=_dump_brief)
  1912. service._triage_dump = TriageDumper(True, directory=str(tmp_path))
  1913. service.cfg.triage.dump_io = True
  1914. service.cfg.triage.extract_enabled = False
  1915. _run_triage_flow(service, emitted, clock, lane, _escalated_payload())
  1916. files = _dump_files(tmp_path)
  1917. assert len(files) == 1
  1918. data = json.loads(files[0].read_text(encoding="utf-8"))
  1919. assert data["laya"]["response"] == _escalated_payload()
  1920. assert data["llm"] is None
  1921. assert service._triage_dump._pending == {}
  1922. def test_dump_queue_full_has_laya_only(tmp_path):
  1923. service, emitted, clock, _, lane = _triage_service(brief_fn=_dump_brief)
  1924. service._triage_dump = TriageDumper(True, directory=str(tmp_path))
  1925. service.cfg.triage.dump_io = True
  1926. service._inference = InferenceQueue(size=2)
  1927. _run_triage_flow(service, emitted, clock, lane, _escalated_payload())
  1928. assert service._inference.depth() == 1
  1929. assert [j.name for j in service._inference._queue] == ["chat"]
  1930. assert service._pending_brief == ["Unknown: hello"]
  1931. files = _dump_files(tmp_path)
  1932. assert len(files) == 1
  1933. data = json.loads(files[0].read_text(encoding="utf-8"))
  1934. assert data["laya"]["response"] == _escalated_payload()
  1935. assert data["llm"] is None
  1936. assert service._triage_dump._pending == {}
  1937. def test_dump_disabled_writes_nothing(tmp_path):
  1938. service, emitted, clock, _, lane = _triage_service(
  1939. brief_fn=lambda prompt: "RAW"
  1940. )
  1941. assert service._triage_dump.enabled is False
  1942. service._triage_dump = TriageDumper(False, directory=str(tmp_path))
  1943. _run_triage_flow(service, emitted, clock, lane, _escalated_payload())
  1944. jobs = [j for j in service._inference._queue if j.name == "brief-update"]
  1945. assert len(jobs) == 1
  1946. jobs[0].run()
  1947. assert _dump_files(tmp_path) == []
  1948. def test_dump_stale_result_discards_without_file(tmp_path):
  1949. service, emitted, clock, triage, lane = _triage_service(brief_fn=_dump_brief)
  1950. service._triage_dump = TriageDumper(True, directory=str(tmp_path))
  1951. service.cfg.triage.dump_io = True
  1952. service.toggle_chat()
  1953. service.handle_event(_transcript(clock, "hello"))
  1954. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  1955. service.tick()
  1956. _, _, _, gen = lane.submits[0]
  1957. real_normalize = triage.normalize
  1958. def stale_normalize(payload):
  1959. # Simulate a clear landing between the first check/begin and the re-check.
  1960. service._triage_generation += 1
  1961. return real_normalize(payload)
  1962. triage.normalize = stale_normalize
  1963. service._on_triage_result(gen, _escalated_payload())
  1964. assert _dump_files(tmp_path) == []
  1965. assert service._triage_dump._pending == {}
  1966. def test_dump_stale_brief_discards_without_file(tmp_path):
  1967. service, _, clock, _, _ = _triage_service(brief_fn=_dump_brief)
  1968. service._triage_dump = TriageDumper(True, directory=str(tmp_path))
  1969. service.cfg.triage.dump_io = True
  1970. ts = clock()
  1971. service._triage_dump.begin(ts, laya={"request": {}, "response": {}, "error": None})
  1972. service._pending_brief = ["Unknown: hello"]
  1973. service._triage_generation += 1 # stale before the brief even starts
  1974. service._run_brief_update(0, _escalated_payload(), ts, service._brief_token)
  1975. assert _dump_files(tmp_path) == []
  1976. assert service._triage_dump._pending == {}
  1977. assert service._pending_brief == ["Unknown: hello"]
  1978. def test_dump_brief_cleared_mid_flight_writes_nothing(tmp_path):
  1979. service, _, clock, _, _ = _triage_service(brief_fn=_dump_brief)
  1980. service._triage_dump = TriageDumper(True, directory=str(tmp_path))
  1981. service.cfg.triage.dump_io = True
  1982. def stale_brief(prompt, on_io=None, finalize=None):
  1983. # Simulate a clear landing mid-LLM-call (after the request phase fired).
  1984. service._triage_generation += 1
  1985. if on_io is not None:
  1986. on_io({"phase": "request", "url": "http://llm/v1", "body": {}})
  1987. on_io({"phase": "response", "payload": {"c": 1}, "error": None})
  1988. return _brief_reply("RAW")
  1989. service._brief_fn = stale_brief
  1990. service._pending_brief = ["Unknown: hello"]
  1991. service._brief_outstanding = True
  1992. token = service._brief_token
  1993. ts = clock()
  1994. service._triage_dump.begin(ts, laya={"request": {}, "response": {}, "error": None})
  1995. service._run_brief_update(0, _escalated_payload(), ts, token)
  1996. assert _dump_files(tmp_path) == []
  1997. assert service._triage_dump._pending == {}
  1998. assert service._pending_brief == []
  1999. assert service._brief_outstanding is False
  2000. def test_dump_gated_out_writes_laya_only_and_no_pending(tmp_path):
  2001. """A turn the gate leaves out writes `llm: null` and leaks no pending record."""
  2002. service, emitted, clock, triage, lane = _triage_service(
  2003. triage=FakeTriage(escalate=True, gate=False), brief_fn=_dump_brief
  2004. )
  2005. service._triage_dump = TriageDumper(True, directory=str(tmp_path))
  2006. service.cfg.triage.dump_io = True
  2007. _run_triage_flow(service, emitted, clock, lane, _escalated_payload())
  2008. assert [j.name for j in service._inference._queue] == ["chat"]
  2009. assert len(triage.gate_calls) == 1
  2010. files = _dump_files(tmp_path)
  2011. assert len(files) == 1
  2012. data = json.loads(files[0].read_text(encoding="utf-8"))
  2013. assert data["laya"]["response"] == _escalated_payload()
  2014. assert data["llm"] is None
  2015. assert service._triage_dump._pending == {}
  2016. def test_dump_coalesced_update_writes_laya_only_then_llm_only_flush(tmp_path):
  2017. """A coalesced turn writes a Laya-only record; the later flush a separate llm-only one."""
  2018. service, emitted, clock, _, lane = _triage_service(brief_fn=_dump_brief)
  2019. service._triage_dump = TriageDumper(True, directory=str(tmp_path))
  2020. service.cfg.triage.dump_io = True
  2021. service.toggle_chat()
  2022. service.handle_event(_transcript(clock, "first"))
  2023. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  2024. service.tick()
  2025. _, _, _, gen0 = lane.submits[0]
  2026. service._on_triage_result(gen0, _escalated_payload())
  2027. service.handle_event(_transcript(clock, "second"))
  2028. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  2029. service.tick()
  2030. _, _, _, gen1 = lane.submits[1]
  2031. service._on_triage_result(gen1, _escalated_payload())
  2032. assert service._brief_outstanding is True
  2033. appended: list[int] = []
  2034. def _brief(prompt, on_io=None, finalize=None):
  2035. if not appended:
  2036. appended.append(1)
  2037. service.handle_event(_transcript(clock, "during call"))
  2038. if on_io is not None:
  2039. on_io({"phase": "request", "url": "http://llm/v1", "body": {"prompt": prompt}})
  2040. on_io({"phase": "response", "payload": {"content": "parsed"}, "error": None})
  2041. return _brief_reply("BRIEF")
  2042. service._brief_fn = _brief
  2043. clock.advance(1.0)
  2044. job = [j for j in service._inference._queue if j.name == "brief-update"][0]
  2045. service._inference._queue.remove(job)
  2046. job.run()
  2047. flushed = [j for j in service._inference._queue if j.name == "brief-update"]
  2048. assert len(flushed) == 1
  2049. flushed[0].run()
  2050. assert service._triage_dump._pending == {}
  2051. records = [
  2052. json.loads(path.read_text(encoding="utf-8")) for path in _dump_files(tmp_path)
  2053. ]
  2054. assert len(records) == 3
  2055. coalesced = [r for r in records if r["laya"] is not None and r["llm"] is None]
  2056. flushed_records = [r for r in records if r["laya"] is None and r["llm"] is not None]
  2057. both = [r for r in records if r["laya"] is not None and r["llm"] is not None]
  2058. assert len(coalesced) == 1
  2059. assert len(flushed_records) == 1
  2060. assert len(both) == 1
  2061. assert coalesced[0]["ts"] != flushed_records[0]["ts"]
  2062. assert flushed_records[0]["llm"]["request"]["body"] == {
  2063. "prompt": "CURRENT LEDGER:\n(none yet)\n\nCURRENT BRIEF:\nBRIEF\n\n"
  2064. "NEW UTTERANCES:\n- Unknown: during call"
  2065. }
  2066. def test_chat_client_failure_emits_llm_error_and_service_survives():
  2067. """Adversarial (a): a chat client raising on `chat` surfaces as an `llm_error`
  2068. event and the service keeps functioning afterwards."""
  2069. from stt_daemon.assistant.llm import ConversationEngine, LlmError
  2070. class BoomClient:
  2071. def chat(self, messages, max_tokens=None, timeout_s=None, **kwargs):
  2072. raise LlmError("boom")
  2073. engine = ConversationEngine(Config().llm, BoomClient(), system_prompt="SYS")
  2074. service, emitted, clock = _service()
  2075. service.chat_fn = engine.send
  2076. assert service.toggle_chat() is True
  2077. service._run_chat("hello")
  2078. errors = [e for e in emitted if e["type"] == "error" and e.get("code") == "llm_error"]
  2079. assert errors and "boom" in errors[-1]["message"]
  2080. assert service.toggle_chat() is True
  2081. assert service.modes.mode.value == "idle"
  2082. # ---------------------------------------------------------------------------
  2083. # Conversation session adoption (SESSION/TRANSCRIPT session_dir).
  2084. # ---------------------------------------------------------------------------
  2085. def _session_event(clock, session_dir):
  2086. event = _ev(events.SESSION, clock)
  2087. event["session_id"] = session_dir.rsplit("/", 1)[-1]
  2088. event["session_dir"] = session_dir
  2089. return event
  2090. def _ring(service, clock, *items):
  2091. """Feed a daemon-seeded replay window: status -> items -> ring_end."""
  2092. service.handle_event(_ev(events.STATUS, clock))
  2093. for item in items:
  2094. service.handle_event(item)
  2095. service.handle_event(_ev(events.RING_END, clock))
  2096. def _cleared(emitted):
  2097. return [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
  2098. def test_status_includes_session_dir_without_brief_file():
  2099. service, _, clock = _service()
  2100. assert service.status()["session_dir"] is None
  2101. service.handle_event(_session_event(clock, "transcripts/2026-09-30-12-47-29"))
  2102. assert service.status()["session_dir"] == "transcripts/2026-09-30-12-47-29"
  2103. def test_live_session_event_sets_session_and_records_directory():
  2104. brief_file = FakeBriefFile()
  2105. service, _, clock, _, _ = _triage_service(brief_file=brief_file)
  2106. session = "transcripts/2026-09-30-12-47-29"
  2107. service.handle_event(_session_event(clock, session))
  2108. assert service.status()["session_dir"] == session
  2109. assert brief_file.directories == [session]
  2110. assert service._triage_generation == 0
  2111. def test_live_transcript_session_sets_session_without_wiping_utterance():
  2112. brief_file = FakeBriefFile()
  2113. service, _, clock, _, _ = _triage_service(brief_file=brief_file)
  2114. session = "transcripts/s1"
  2115. service.handle_event(_transcript(clock, "hello", session_dir=session))
  2116. assert service.status()["session_dir"] == session
  2117. assert brief_file.directories == [session]
  2118. assert service.triage_history == ["Unknown: hello"]
  2119. assert service._pending_brief == ["Unknown: hello"]
  2120. assert service._triage_generation == 0
  2121. def test_brief_write_lands_under_session_dir(tmp_path):
  2122. from stt_daemon.assistant.brief_file import BriefFile
  2123. brief_file = BriefFile(True)
  2124. service, _, clock, _, _ = _triage_service(brief_file=brief_file)
  2125. session = str(tmp_path / "transcripts" / "s1")
  2126. service.handle_event(_transcript(clock, "hello", session_dir=session))
  2127. brief_file.write("the brief")
  2128. path = tmp_path / "transcripts" / "s1" / "brief.md"
  2129. assert path.read_text(encoding="utf-8") == "the brief"
  2130. def test_live_session_change_resets_triage_and_keeps_chat():
  2131. brief_file = FakeBriefFile()
  2132. resets: list[int] = []
  2133. service, emitted, clock, _, lane = _triage_service(
  2134. brief_file=brief_file, brief_reset=lambda: resets.append(1)
  2135. )
  2136. service.toggle_chat()
  2137. service.set_chat_capture(True)
  2138. service.handle_event(_transcript(clock, "hello"))
  2139. service.handle_event(_session_event(clock, "transcripts/a"))
  2140. service.last_triage = {"signals": 1}
  2141. service._pending_brief = ["stale"]
  2142. emitted.clear()
  2143. service.handle_event(_session_event(clock, "transcripts/b"))
  2144. assert service.status()["session_dir"] == "transcripts/b"
  2145. assert service.last_triage is None
  2146. assert service._pending_brief == []
  2147. assert service._triage_generation == 1
  2148. assert service.chat_history == ["Unknown: hello"]
  2149. assert resets == [1]
  2150. assert lane.drops == 1
  2151. assert brief_file.directories == ["transcripts/a", "transcripts/b"]
  2152. assert len(_cleared(emitted)) == 1
  2153. def test_sessionless_transcript_does_not_reset():
  2154. service, _, clock, _, _ = _triage_service()
  2155. service.handle_event(_session_event(clock, "transcripts/a"))
  2156. service.last_triage = {"signals": 1}
  2157. service.handle_event(_transcript(clock, "hello"))
  2158. assert service._triage_generation == 0
  2159. assert service.last_triage == {"signals": 1}
  2160. assert service.status()["session_dir"] == "transcripts/a"
  2161. def test_same_dir_readoption_does_not_reset():
  2162. brief_file = FakeBriefFile()
  2163. service, _, clock, _, _ = _triage_service(brief_file=brief_file)
  2164. session = "transcripts/a"
  2165. service.handle_event(_session_event(clock, session))
  2166. service.last_triage = {"signals": 1}
  2167. service.handle_event(_transcript(clock, "again", session_dir=session))
  2168. assert service._triage_generation == 0
  2169. assert service.last_triage == {"signals": 1}
  2170. assert brief_file.directories == [session]
  2171. def test_session_change_without_brief_file_does_not_raise():
  2172. service, _, clock, _, _ = _triage_service(brief_file=None)
  2173. service.handle_event(_session_event(clock, "transcripts/a"))
  2174. service.handle_event(_session_event(clock, "transcripts/b"))
  2175. assert service.status()["session_dir"] == "transcripts/b"
  2176. assert service._triage_generation == 1
  2177. def test_replay_single_session_adopts_without_reset():
  2178. brief_file = FakeBriefFile()
  2179. service, emitted, clock, _, _ = _triage_service(brief_file=brief_file)
  2180. session = "transcripts/a"
  2181. _ring(service, clock, _transcript(clock, "hello", session_dir=session))
  2182. assert service.status()["session_dir"] == session
  2183. assert brief_file.directories == [session]
  2184. assert service._triage_generation == 0
  2185. assert _cleared(emitted) == []
  2186. assert service.triage_history == ["Unknown: hello"]
  2187. def test_replay_two_sessions_commits_newest_and_resets_once():
  2188. brief_file = FakeBriefFile()
  2189. service, emitted, clock, _, _ = _triage_service(brief_file=brief_file)
  2190. a, b = "transcripts/a", "transcripts/b"
  2191. _ring(
  2192. service,
  2193. clock,
  2194. _session_event(clock, a),
  2195. _transcript(clock, "old turn", session_dir=a),
  2196. _session_event(clock, b),
  2197. _transcript(clock, "new turn", session_dir=b),
  2198. )
  2199. assert service.status()["session_dir"] == b
  2200. assert brief_file.directories == [b]
  2201. assert service._triage_generation == 1
  2202. assert service.last_triage is None
  2203. assert service._pending_brief == []
  2204. assert len(_cleared(emitted)) == 1
  2205. def test_reconnect_tailing_current_session_is_noop():
  2206. brief_file = FakeBriefFile()
  2207. service, emitted, clock, _, _ = _triage_service(brief_file=brief_file)
  2208. session = "transcripts/a"
  2209. service.handle_event(_session_event(clock, session))
  2210. service.last_triage = {"signals": 1}
  2211. emitted.clear()
  2212. _ring(service, clock, _transcript(clock, "replayed", session_dir=session))
  2213. assert service.status()["session_dir"] == session
  2214. assert brief_file.directories == [session]
  2215. assert service._triage_generation == 0
  2216. assert service.last_triage == {"signals": 1}
  2217. assert _cleared(emitted) == []
  2218. def test_reconnect_older_to_current_resets_once_without_rewind():
  2219. brief_file = FakeBriefFile()
  2220. service, emitted, clock, _, _ = _triage_service(brief_file=brief_file)
  2221. a, b = "transcripts/a", "transcripts/b"
  2222. service.handle_event(_session_event(clock, a))
  2223. service.last_triage = {"signals": 1}
  2224. emitted.clear()
  2225. _ring(
  2226. service,
  2227. clock,
  2228. _session_event(clock, a),
  2229. _transcript(clock, "old", session_dir=a),
  2230. _session_event(clock, b),
  2231. _transcript(clock, "new", session_dir=b),
  2232. )
  2233. assert service.status()["session_dir"] == b
  2234. assert brief_file.directories == [a, b]
  2235. assert service._triage_generation == 1
  2236. assert service.last_triage is None
  2237. assert len(_cleared(emitted)) == 1
  2238. def test_reconnect_only_newer_session_resets_once():
  2239. brief_file = FakeBriefFile()
  2240. service, _, clock, _, _ = _triage_service(brief_file=brief_file)
  2241. a, b = "transcripts/a", "transcripts/b"
  2242. service.handle_event(_session_event(clock, a))
  2243. service.last_triage = {"signals": 1}
  2244. _ring(service, clock, _transcript(clock, "new", session_dir=b))
  2245. assert service.status()["session_dir"] == b
  2246. assert service._triage_generation == 1
  2247. assert service.last_triage is None
  2248. assert brief_file.directories == [a, b]
  2249. def test_reconnect_span_older_current_clears_buffered_older_turns():
  2250. """A RING_END whose ring spanned an older session resets even when the
  2251. newest session already matches the adopted directory (no re-set_directory)."""
  2252. brief_file = FakeBriefFile()
  2253. service, emitted, clock, _, _ = _triage_service(brief_file=brief_file)
  2254. a, b = "transcripts/a", "transcripts/b"
  2255. _ring(service, clock, _session_event(clock, a))
  2256. assert service._triage_generation == 0
  2257. service.handle_event(_transcript(clock, "old turn", session_dir=a))
  2258. assert service._pending_brief == ["Unknown: old turn"]
  2259. service.handle_event(_session_event(clock, b))
  2260. assert service._triage_generation == 1
  2261. service.handle_event(_transcript(clock, "new turn", session_dir=b))
  2262. assert service._pending_brief == ["Unknown: new turn"]
  2263. emitted.clear()
  2264. _ring(
  2265. service,
  2266. clock,
  2267. _session_event(clock, a),
  2268. _transcript(clock, "old turn", session_dir=a),
  2269. _session_event(clock, b),
  2270. _transcript(clock, "new turn", session_dir=b),
  2271. )
  2272. assert service.status()["session_dir"] == b
  2273. assert brief_file.directories == [a, b]
  2274. assert service._pending_brief == []
  2275. assert service._triage_generation == 2
  2276. assert len(_cleared(emitted)) == 1
  2277. def test_daemon_restart_new_session_resets_once():
  2278. brief_file = FakeBriefFile()
  2279. service, _, clock, _, _ = _triage_service(brief_file=brief_file)
  2280. service.handle_event(_session_event(clock, "transcripts/a"))
  2281. service.last_triage = {"signals": 1}
  2282. service.handle_event(_session_event(clock, "transcripts/b"))
  2283. assert service.status()["session_dir"] == "transcripts/b"
  2284. assert service._triage_generation == 1
  2285. assert service.last_triage is None
  2286. assert brief_file.directories == ["transcripts/a", "transcripts/b"]
  2287. def _dwell_service(confirm):
  2288. service, emitted, clock, _, lane = _triage_service(
  2289. triage=FakeTriage(questions={"blocker": {"type": "noul", "instructions": "x"}})
  2290. )
  2291. service.cfg.triage.confirm_windows = confirm
  2292. return service, emitted, clock, lane
  2293. def _blocker_payload(p):
  2294. return {
  2295. "order": ["blocker"],
  2296. "items": {
  2297. "blocker": {
  2298. "kind": "noul",
  2299. "p": p,
  2300. "threshold": 0.5,
  2301. "flagged": p >= 0.5,
  2302. }
  2303. },
  2304. "escalated": p >= 0.5,
  2305. "input_tokens": 0,
  2306. }
  2307. def test_dwell_holds_a_single_window_spike_back():
  2308. service, emitted, _, _ = _dwell_service(2)
  2309. service._on_triage_result(0, _blocker_payload(0.95))
  2310. assert service.last_triage["items"]["blocker"]["flagged"] is False
  2311. service._on_triage_result(0, _blocker_payload(0.95))
  2312. assert service.last_triage["items"]["blocker"]["flagged"] is True
  2313. def test_dwell_resets_when_the_signal_drops():
  2314. service, _, _, _ = _dwell_service(2)
  2315. service._on_triage_result(0, _blocker_payload(0.95))
  2316. service._on_triage_result(0, _blocker_payload(0.10))
  2317. service._on_triage_result(0, _blocker_payload(0.95))
  2318. assert service.last_triage["items"]["blocker"]["flagged"] is False
  2319. service._on_triage_result(0, _blocker_payload(0.95))
  2320. assert service.last_triage["items"]["blocker"]["flagged"] is True
  2321. def test_dwell_disabled_with_one_confirm_window():
  2322. service, _, _, _ = _dwell_service(1)
  2323. service._on_triage_result(0, _blocker_payload(0.95))
  2324. assert service.last_triage["items"]["blocker"]["flagged"] is True
  2325. def test_clear_resets_dwell_streaks():
  2326. service, _, _, _ = _dwell_service(2)
  2327. service._on_triage_result(0, _blocker_payload(0.95))
  2328. assert service._signal_streaks["blocker"] == 1
  2329. service.clear()
  2330. assert service._signal_streaks == {}