test_assistant_integration.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883
  1. """Integration: the assistant service consuming a real publish-only bus."""
  2. import json
  3. import os
  4. import shutil
  5. import signal
  6. import sys
  7. import tempfile
  8. import threading
  9. import time
  10. from types import SimpleNamespace
  11. import pytest
  12. from stt_daemon import events
  13. from stt_daemon.assistant.brief_file import BriefFile
  14. from stt_daemon.assistant.inference import InferenceJob, InferenceQueue
  15. from stt_daemon.assistant.llm import ConversationEngine
  16. from stt_daemon.assistant.runner import build_triage
  17. from stt_daemon.assistant.service import AssistantService
  18. from stt_daemon.bus import EventPublisher, EventSubscriber
  19. from stt_daemon.config import Config
  20. @pytest.fixture
  21. def sock_path():
  22. directory = tempfile.mkdtemp(prefix="sttb-")
  23. try:
  24. yield os.path.join(directory, "s.sock")
  25. finally:
  26. shutil.rmtree(directory, ignore_errors=True)
  27. def _wait(predicate, timeout=5.0):
  28. deadline = time.time() + timeout
  29. while time.time() < deadline:
  30. if predicate():
  31. return True
  32. time.sleep(0.01)
  33. return predicate()
  34. def _heartbeat_fields():
  35. return {
  36. "in_flight": False,
  37. "segments_done": 0,
  38. "worker_stall_s": 0.0,
  39. "queue_depth": 0,
  40. "frames_since_last": 1,
  41. "rms": 0.0,
  42. "capture": "test",
  43. "device_ok": True,
  44. "model_loaded": True,
  45. "uptime_s": 1.0,
  46. "last_transcript_age_s": 0.0,
  47. }
  48. def test_service_consumes_real_bus(sock_path):
  49. cfg = Config()
  50. publisher = EventPublisher(sock_path, ring_size=10)
  51. publisher.start()
  52. subscriber = EventSubscriber(sock_path, reconnect_delay=0.05)
  53. emitted: list[dict] = []
  54. service = AssistantService(
  55. cfg,
  56. subscriber,
  57. emit=lambda type_, **fields: emitted.append({"type": type_, **fields}),
  58. )
  59. service.start()
  60. try:
  61. publisher.emit(events.HEARTBEAT, **_heartbeat_fields())
  62. assert _wait(lambda: service.stt_ok() is True)
  63. assert service.arm() is True
  64. assert _wait(lambda: any(e.get("mode") == "command_armed" for e in emitted))
  65. publisher.emit(events.SPEECH_START)
  66. assert _wait(lambda: service.speech_open is True)
  67. publisher.emit(events.SPEECH_STOP, seg_id=5)
  68. assert _wait(lambda: service.awaited_seg_id == 5)
  69. assert service.ready() is False
  70. publisher.emit(events.SEGMENT_DONE, seg_id=5, emitted=True)
  71. assert _wait(lambda: service.ready() is True)
  72. publisher.emit(events.TRANSCRIPT, seg_id=5, text="hello", lang="en", lang_prob=0.9)
  73. assert _wait(lambda: service.command_text == "hello")
  74. assert service.execute() == "hello"
  75. assert service.finish() is True
  76. finally:
  77. service.stop()
  78. publisher.close()
  79. def _enabled_triage_cfg(tmp_path):
  80. cfg = Config()
  81. cfg.audit.enabled = False
  82. cfg.llm.enabled = False
  83. cfg.triage.enabled = True
  84. path = os.path.join(str(tmp_path), "questions.json")
  85. with open(path, "w", encoding="utf-8") as handle:
  86. json.dump({"urgent": {"type": "noul", "instructions": "is it urgent"}}, handle)
  87. cfg.triage.questions_file = path
  88. return cfg
  89. class _FakeChatClient:
  90. def __init__(self):
  91. self.calls = []
  92. def chat(
  93. self, messages, tools=None, max_tokens=None, timeout_s=None,
  94. temperature=None, slot_id=None,
  95. ):
  96. self.calls.append(
  97. {
  98. "messages": messages,
  99. "tools": tools,
  100. "max_tokens": max_tokens,
  101. "timeout_s": timeout_s,
  102. "temperature": temperature,
  103. "slot_id": slot_id,
  104. }
  105. )
  106. return SimpleNamespace(content="EXTRACTED", finish_reason="stop")
  107. def test_build_triage_disabled_returns_nones():
  108. cfg = Config()
  109. assert cfg.triage.enabled is False
  110. assert build_triage(cfg, None) == (None, None, None, None)
  111. assert build_triage(cfg, _FakeChatClient()) == (None, None, None, None)
  112. assert "laya" not in sys.modules
  113. service = AssistantService(cfg, subscriber=object())
  114. assert service.status()["triage_state"] == "disabled"
  115. def test_build_triage_enabled_constructs_without_client(tmp_path):
  116. cfg = _enabled_triage_cfg(tmp_path)
  117. before = len(threading.enumerate())
  118. analyzer, lane, brief_fn, brief_reset = build_triage(cfg, None)
  119. assert analyzer is not None
  120. assert lane is not None
  121. assert brief_fn is None
  122. assert brief_reset is None
  123. assert analyzer._cfg is cfg.triage
  124. assert lane._cfg is cfg.triage
  125. assert analyzer.state() == ("loading", "")
  126. assert len(threading.enumerate()) == before
  127. assert "laya" not in sys.modules
  128. def test_build_triage_brief_fn_calls_client(tmp_path):
  129. cfg = _enabled_triage_cfg(tmp_path)
  130. client = _FakeChatClient()
  131. _, _, brief_fn, brief_reset = build_triage(cfg, client)
  132. assert brief_fn is not None
  133. assert brief_reset is not None
  134. assert callable(brief_fn)
  135. assert callable(brief_reset)
  136. assert brief_fn("PROMPT") == "EXTRACTED"
  137. assert len(client.calls) == 1
  138. call = client.calls[0]
  139. assert call["messages"] == [
  140. {"role": "system", "content": cfg.llm.triage_system_prompt},
  141. {"role": "user", "content": "PROMPT"},
  142. ]
  143. assert call["max_tokens"] == cfg.triage.extract_max_tokens
  144. assert call["timeout_s"] == cfg.triage.extract_timeout_s
  145. assert call["slot_id"] is None
  146. def test_build_triage_brief_fn_slot_only_when_configured(tmp_path):
  147. cfg = _enabled_triage_cfg(tmp_path)
  148. cfg.triage.brief_slot_id = 2
  149. client = _FakeChatClient()
  150. _, _, brief_fn, _ = build_triage(cfg, client)
  151. assert brief_fn is not None
  152. brief_fn("PROMPT")
  153. assert client.calls[0]["slot_id"] == 2
  154. def test_build_triage_on_state_refreshes_analyzer(tmp_path):
  155. from stt_daemon.assistant.laya_lane import LAYA_MISSING_HINT
  156. cfg = _enabled_triage_cfg(tmp_path)
  157. sentinel = object()
  158. analyzer, lane, _, _ = build_triage(cfg, None, on_result=sentinel)
  159. assert analyzer is not None
  160. assert lane is not None
  161. assert lane._on_result is sentinel
  162. assert callable(lane._on_state)
  163. lane._on_state("ok", "")
  164. assert analyzer.state() == ("ok", "")
  165. lane._on_state("laya_missing", LAYA_MISSING_HINT)
  166. assert analyzer.state() == ("laya_missing", LAYA_MISSING_HINT)
  167. def _run_runner_main(monkeypatch, cfg):
  168. from stt_daemon.assistant import runner as runner_mod
  169. import stt_daemon.assistant.laya_lane as lane_mod
  170. calls = []
  171. made_lanes = []
  172. made_services = []
  173. class FakeLane:
  174. def __init__(self, triage_cfg, agent_factory=None, on_result=None, on_state=None):
  175. made_lanes.append(self)
  176. self.cfg = triage_cfg
  177. self.on_state = on_state
  178. self.on_result = on_result
  179. calls.append("lane.construct")
  180. def start(self):
  181. calls.append("lane.start")
  182. def stop(self):
  183. calls.append("lane.stop")
  184. class FakeSubscriber:
  185. def close(self):
  186. calls.append("subscriber.close")
  187. class FakeService:
  188. def __init__(self, cfg, subscriber, **kwargs):
  189. made_services.append(self)
  190. self.kwargs = kwargs
  191. calls.append("service.construct")
  192. def set_sinks(self, **kwargs):
  193. pass
  194. def chat_notify(self, *args, **kwargs):
  195. return None
  196. def _on_triage_result(self, generation, payload):
  197. calls.append("triage.result")
  198. def start(self):
  199. calls.append("service.start")
  200. def stop(self):
  201. calls.append("service.stop")
  202. class FakeControl:
  203. def __init__(self, cfg, service):
  204. self.service = service
  205. calls.append("control.construct")
  206. path = "/tmp/fake.sock"
  207. def start(self):
  208. calls.append("control.start")
  209. def broadcast(self, *args, **kwargs):
  210. pass
  211. def relay(self, event):
  212. pass
  213. def close(self):
  214. calls.append("control.close")
  215. class FakeEvent:
  216. def set(self):
  217. pass
  218. def wait(self, timeout=None):
  219. return True
  220. monkeypatch.setattr(lane_mod, "LayaLane", FakeLane)
  221. monkeypatch.setattr(runner_mod, "EventSubscriber", lambda *a, **k: FakeSubscriber())
  222. monkeypatch.setattr(runner_mod, "ControlServer", FakeControl)
  223. monkeypatch.setattr(runner_mod, "AssistantService", FakeService)
  224. monkeypatch.setattr(runner_mod, "load_config", lambda path=None: cfg)
  225. monkeypatch.setattr(threading, "Event", FakeEvent)
  226. monkeypatch.setattr(signal, "signal", lambda *a, **k: None)
  227. monkeypatch.setattr(sys, "argv", ["stt-assistant"])
  228. rc = runner_mod.main()
  229. return rc, calls, made_lanes, made_services
  230. def test_runner_main_shutdown_order_lane_before_control(tmp_path, monkeypatch):
  231. cfg = _enabled_triage_cfg(tmp_path)
  232. rc, calls, made_lanes, made_services = _run_runner_main(monkeypatch, cfg)
  233. assert rc == 0
  234. assert len(made_lanes) == 1
  235. assert made_lanes[0].cfg is cfg.triage
  236. assert made_services[0].kwargs["laya_lane"] is made_lanes[0]
  237. assert made_services[0].kwargs["triage"] is not None
  238. assert made_services[0].kwargs["brief_fn"] is None
  239. assert made_services[0].kwargs["brief_reset"] is None
  240. assert calls.index("control.start") < calls.index("service.start")
  241. assert calls.index("service.start") < calls.index("lane.start")
  242. assert calls.index("service.stop") < calls.index("lane.stop")
  243. assert calls.index("lane.stop") < calls.index("control.close")
  244. made_lanes[0].on_result(3, {"answers": {}})
  245. assert "triage.result" in calls
  246. assert "laya" not in sys.modules
  247. def test_runner_main_disabled_builds_no_lane(monkeypatch):
  248. cfg = Config()
  249. cfg.audit.enabled = False
  250. cfg.llm.enabled = False
  251. rc, calls, made_lanes, made_services = _run_runner_main(monkeypatch, cfg)
  252. assert rc == 0
  253. assert made_lanes == []
  254. assert "lane.construct" not in calls
  255. assert "lane.start" not in calls
  256. assert "lane.stop" not in calls
  257. assert made_services[0].kwargs["triage"] is None
  258. assert made_services[0].kwargs["laya_lane"] is None
  259. assert made_services[0].kwargs["brief_fn"] is None
  260. assert made_services[0].kwargs["brief_reset"] is None
  261. assert not [t for t in threading.enumerate() if t.name.startswith("laya-")]
  262. def _patch_llm_client(monkeypatch, calls):
  263. from stt_daemon.assistant import runner as runner_mod
  264. class FakeLlmClient:
  265. def __init__(self, *args, **kwargs):
  266. pass
  267. def chat(self, messages, tools=None, max_tokens=None, timeout_s=None, **kwargs):
  268. calls.append({"messages": messages, "kwargs": kwargs})
  269. return SimpleNamespace(content="hi there", finish_reason="stop")
  270. monkeypatch.setattr(runner_mod, "LlmClient", FakeLlmClient)
  271. def test_runner_wires_chat_engine_and_command_slot(monkeypatch):
  272. """Runner builds the chat conversation from ConversationEngine with the chat
  273. slot, and gives the command executor the configured command slot."""
  274. cfg = Config()
  275. cfg.audit.enabled = False
  276. cfg.llm.enabled = True
  277. cfg.llm.chat_slot_id = 7
  278. cfg.llm.command_slot_id = 3
  279. calls: list[dict] = []
  280. _patch_llm_client(monkeypatch, calls)
  281. rc, _, _, made_services = _run_runner_main(monkeypatch, cfg)
  282. assert rc == 0
  283. kwargs = made_services[0].kwargs
  284. engine = kwargs["chat_fn"].__self__
  285. assert engine.system == cfg.llm.system_prompt_chat
  286. assert engine._slot_id == 7
  287. assert kwargs["executor"].slot_id == 3
  288. assert kwargs["chat_fn"]("hello") == "hi there"
  289. assert calls[0]["kwargs"] == {"slot_id": 7}
  290. def test_runner_wires_slotless_engine_when_slots_unset(monkeypatch):
  291. """Unset slot config leaves both lanes slot-free (getattr fallback)."""
  292. cfg = Config()
  293. cfg.audit.enabled = False
  294. cfg.llm.enabled = True
  295. assert cfg.llm.chat_slot_id is None
  296. assert cfg.llm.command_slot_id is None
  297. calls: list[dict] = []
  298. _patch_llm_client(monkeypatch, calls)
  299. rc, _, _, made_services = _run_runner_main(monkeypatch, cfg)
  300. assert rc == 0
  301. kwargs = made_services[0].kwargs
  302. assert kwargs["executor"].slot_id is None
  303. assert kwargs["chat_fn"].__self__._slot_id is None
  304. assert kwargs["chat_fn"]("hello") == "hi there"
  305. assert calls[0]["kwargs"] == {}
  306. def test_runner_wires_brief_engine_slot_when_configured(monkeypatch, tmp_path):
  307. """The triage brief engine sends `id_slot` only when `brief_slot_id` is set."""
  308. cfg = _enabled_triage_cfg(tmp_path)
  309. cfg.llm.enabled = True
  310. cfg.triage.brief_slot_id = 5
  311. calls: list[dict] = []
  312. _patch_llm_client(monkeypatch, calls)
  313. rc, _, _, made_services = _run_runner_main(monkeypatch, cfg)
  314. assert rc == 0
  315. brief_fn = made_services[0].kwargs["brief_fn"]
  316. assert brief_fn is not None
  317. assert brief_fn("BRIEF-PROMPT") == "hi there"
  318. assert calls[0]["kwargs"] == {"temperature": 0.2, "slot_id": 5}
  319. def test_runner_wires_slotless_brief_engine_when_slot_unset(monkeypatch, tmp_path):
  320. """An unset `brief_slot_id` leaves the brief engine slot-free (`id_slot` absent)."""
  321. cfg = _enabled_triage_cfg(tmp_path)
  322. cfg.llm.enabled = True
  323. assert cfg.triage.brief_slot_id is None
  324. calls: list[dict] = []
  325. _patch_llm_client(monkeypatch, calls)
  326. rc, _, _, made_services = _run_runner_main(monkeypatch, cfg)
  327. assert rc == 0
  328. brief_fn = made_services[0].kwargs["brief_fn"]
  329. assert brief_fn is not None
  330. assert brief_fn("BRIEF-PROMPT") == "hi there"
  331. assert calls[0]["kwargs"] == {"temperature": 0.2}
  332. def test_runner_brief_file_writes_when_enabled(monkeypatch, tmp_path):
  333. """`[brief].write=true` makes the runner pass a live, durable writer."""
  334. from stt_daemon.assistant.brief_file import BriefFile
  335. cfg = _enabled_triage_cfg(tmp_path)
  336. cfg.brief.write = True
  337. rc, _, _, made_services = _run_runner_main(monkeypatch, cfg)
  338. assert rc == 0
  339. brief_file = made_services[0].kwargs["brief_file"]
  340. assert isinstance(brief_file, BriefFile)
  341. assert brief_file.write_enabled is True
  342. session_dir = str(tmp_path / "transcripts" / "s1")
  343. brief_file.set_directory(session_dir)
  344. brief_file.write("latest brief")
  345. path = os.path.join(session_dir, "brief.md")
  346. with open(path, encoding="utf-8") as handle:
  347. assert handle.read() == "latest brief"
  348. def test_runner_brief_file_disabled_writes_nothing(monkeypatch, tmp_path):
  349. """The default (`[brief].write=false`) writer is present but creates no file."""
  350. from stt_daemon.assistant.brief_file import BriefFile
  351. cfg = _enabled_triage_cfg(tmp_path)
  352. cfg.brief.write = False
  353. rc, _, _, made_services = _run_runner_main(monkeypatch, cfg)
  354. assert rc == 0
  355. brief_file = made_services[0].kwargs["brief_file"]
  356. assert isinstance(brief_file, BriefFile)
  357. assert brief_file.write_enabled is False
  358. session_dir = str(tmp_path / "transcripts" / "s1")
  359. brief_file.set_directory(session_dir)
  360. brief_file.write("nope")
  361. assert not os.path.exists(os.path.join(session_dir, "brief.md"))
  362. # ---------------------------------------------------------------------------
  363. # End-to-end dual-engine coverage (chat + triage on ONE inference queue).
  364. #
  365. # These tests build the REAL `AssistantService` plus the real `build_triage`
  366. # wiring (real `TriageAnalyzer`, real `ConversationEngine` brief engine, real
  367. # `InferenceQueue`, real `BriefFile`) and only fake the LLM client and the bus.
  368. # Laya results are delivered as fake payloads through the service's documented
  369. # `_on_triage_result` seam; the queued jobs are then driven for real via
  370. # `job.run()` (the queue's worker body).
  371. # ---------------------------------------------------------------------------
  372. class _Clock:
  373. """Deterministic clock for the integration harness."""
  374. def __init__(self, start: float = 1000.0) -> None:
  375. self.t = float(start)
  376. def __call__(self) -> float:
  377. return self.t
  378. def advance(self, dt: float) -> None:
  379. self.t += float(dt)
  380. class _SilentSubscriber:
  381. def events(self):
  382. return iter(())
  383. def close(self) -> None:
  384. pass
  385. class _EngineClient:
  386. """Recording LLM client: brief replies differ from chat replies.
  387. Accepts `slot_id` like the real client and always returns a `finish_reason`
  388. (the brief engine reads it). `brief_error` makes the brief call raise, which
  389. the real `_run_brief_update` must absorb.
  390. """
  391. def __init__(
  392. self, *, brief_reply: str = "NEW FACTS:\nnone\n\nBRIEF:\nE2E BRIEF",
  393. brief_error: bool = False,
  394. chat_reply: str = "chat reply",
  395. ) -> None:
  396. self.calls: list[dict] = []
  397. self.brief_reply = brief_reply
  398. self.brief_error = brief_error
  399. self.chat_reply = chat_reply
  400. self.brief_system: str | None = None
  401. def chat(
  402. self, messages, tools=None, max_tokens=None, timeout_s=None,
  403. temperature=None, slot_id=None,
  404. ):
  405. system = messages[0].get("content") if messages else ""
  406. is_brief = self.brief_system is not None and system == self.brief_system
  407. self.calls.append(
  408. {
  409. "is_brief": is_brief,
  410. "system": system,
  411. "user": messages[-1].get("content") if messages else "",
  412. "slot_id": slot_id,
  413. "max_tokens": max_tokens,
  414. "timeout_s": timeout_s,
  415. "temperature": temperature,
  416. }
  417. )
  418. if is_brief and self.brief_error:
  419. raise RuntimeError("llm down")
  420. content = self.brief_reply if is_brief else self.chat_reply
  421. if is_brief and content and "BRIEF:" not in content and "NEW FACTS:" not in content:
  422. content = f"NEW FACTS:\nnone\n\nBRIEF:\n{content}"
  423. return SimpleNamespace(content=content, finish_reason="stop")
  424. def brief_calls(self) -> list[dict]:
  425. return [call for call in self.calls if call["is_brief"]]
  426. def chat_calls(self) -> list[dict]:
  427. return [call for call in self.calls if not call["is_brief"]]
  428. def _laya_payload() -> dict:
  429. """A realistic fake Laya result for the `urgent` noul question."""
  430. return {"answers": {"urgent": {"noul": 0.9, "confidence": 0.9, "answer_confidence": 0.9}}}
  431. def _engine_service(
  432. tmp_path,
  433. client,
  434. *,
  435. chat_slot=None,
  436. brief_slot=None,
  437. brief_write: bool = False,
  438. ):
  439. """Build the real service + `build_triage` wiring over a fake client."""
  440. cfg = _enabled_triage_cfg(tmp_path)
  441. cfg.llm.enabled = True
  442. cfg.assistant.turn_gap_s = 0.5
  443. cfg.triage.brief_gate_enabled = False # deterministic: dispatch every turn
  444. cfg.triage.brief_slot_id = brief_slot
  445. cfg.llm.chat_slot_id = chat_slot
  446. cfg.brief.write = brief_write
  447. client.brief_system = cfg.llm.triage_system_prompt
  448. analyzer, lane, brief_fn, brief_reset = build_triage(cfg, client)
  449. chat_engine = ConversationEngine(
  450. cfg.llm,
  451. client,
  452. system_prompt=cfg.llm.system_prompt_chat,
  453. slot_id=chat_slot,
  454. )
  455. emitted: list[dict] = []
  456. clock = _Clock()
  457. session_dir = str(tmp_path / "transcripts" / "s1")
  458. brief_file = BriefFile(cfg.brief.write)
  459. service = AssistantService(
  460. cfg,
  461. _SilentSubscriber(),
  462. emit=lambda type_, **fields: emitted.append({"type": type_, **fields}),
  463. time_fn=clock,
  464. triage=analyzer,
  465. laya_lane=lane,
  466. brief_fn=brief_fn,
  467. brief_reset=brief_reset,
  468. brief_file=brief_file,
  469. chat_fn=chat_engine.send,
  470. chat_reset=chat_engine.reset,
  471. )
  472. return SimpleNamespace(
  473. service=service,
  474. emitted=emitted,
  475. clock=clock,
  476. analyzer=analyzer,
  477. lane=lane,
  478. chat_engine=chat_engine,
  479. brief_file=brief_file,
  480. session_dir=session_dir,
  481. cfg=cfg,
  482. client=client,
  483. )
  484. def _transcript(clock, text, session_dir=None):
  485. event = {"v": 1, "type": events.TRANSCRIPT, "seq": 1, "ts": clock(), "text": text}
  486. if session_dir is not None:
  487. event["session_dir"] = session_dir
  488. return event
  489. def _triage_events(emitted):
  490. return [event for event in emitted if event["type"] == "triage"]
  491. def _stages(emitted):
  492. return [event.get("stage") for event in _triage_events(emitted)]
  493. def _brief_jobs(service):
  494. return [job for job in service._inference._queue if job.name == "brief-update"]
  495. def _chat_jobs(service):
  496. return [job for job in service._inference._queue if job.name == "chat"]
  497. def _run_queued(service, job):
  498. """Pop a queued job (mirroring the worker) and run it."""
  499. service._inference._queue.remove(job)
  500. job.run()
  501. def _settle(service, clock, text, session_dir=None):
  502. service.handle_event(_transcript(clock, text, session_dir))
  503. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  504. def _deliver(service, payload):
  505. service._on_triage_result(service._triage_generation, payload)
  506. def test_two_engines_emit_signals_then_brief_and_chat_works(tmp_path):
  507. """(a) signals then content.brief; (b) chat works while triage is active;
  508. (c) each engine carries id_slot only when set."""
  509. client = _EngineClient(brief_reply="E2E BRIEF")
  510. h = _engine_service(tmp_path, client, chat_slot=7, brief_slot=5)
  511. assert h.service.toggle_chat() is True
  512. h.service.set_chat_capture(True)
  513. _settle(h.service, h.clock, "first thought")
  514. h.service.tick() # dispatches the chat turn through the real queue
  515. _deliver(h.service, _laya_payload())
  516. assert _stages(h.emitted) == ["signals"]
  517. assert h.service.last_triage is not None
  518. assert "brief" not in h.service.last_triage
  519. chat_jobs = _chat_jobs(h.service)
  520. assert len(chat_jobs) == 1
  521. _run_queued(h.service, chat_jobs[0]) # chat works while triage is active
  522. assert h.service.last_triage is not None # triage still owns its state
  523. assert any(
  524. event["type"] == "chat_message"
  525. and event.get("role") == "assistant"
  526. and event.get("text") == "chat reply"
  527. for event in h.emitted
  528. )
  529. brief_jobs = _brief_jobs(h.service)
  530. assert len(brief_jobs) == 1
  531. _run_queued(h.service, brief_jobs[0])
  532. assert _stages(h.emitted) == ["signals", "content"]
  533. contents = [event for event in _triage_events(h.emitted) if event.get("stage") == "content"]
  534. assert contents[0]["triage"]["brief"] == "E2E BRIEF"
  535. assert h.service.last_triage["brief"] == "E2E BRIEF"
  536. assert [call["slot_id"] for call in client.chat_calls()] == [7]
  537. assert [call["slot_id"] for call in client.brief_calls()] == [5]
  538. assert client.brief_calls()[0]["user"].startswith("CURRENT LEDGER")
  539. def test_slotless_engines_omit_id_slot(tmp_path):
  540. """(c) unset slots leave `id_slot` off both engines' calls."""
  541. client = _EngineClient(brief_reply="BRIEF")
  542. h = _engine_service(tmp_path, client, chat_slot=None, brief_slot=None)
  543. assert h.service.toggle_chat() is True
  544. h.service.set_chat_capture(True)
  545. _settle(h.service, h.clock, "hello")
  546. h.service.tick()
  547. _deliver(h.service, _laya_payload())
  548. _run_queued(h.service, _chat_jobs(h.service)[0])
  549. _run_queued(h.service, _brief_jobs(h.service)[0])
  550. assert [call["slot_id"] for call in client.chat_calls()] == [None]
  551. assert [call["slot_id"] for call in client.brief_calls()] == [None]
  552. def test_refused_brief_keeps_deltas_and_next_dispatch_delivers(tmp_path):
  553. """(d) a full queue refuses the brief without losing pending deltas, and the
  554. next dispatch delivers them all."""
  555. client = _EngineClient(brief_reply="BRIEF")
  556. h = _engine_service(tmp_path, client)
  557. service = h.service
  558. service._inference = InferenceQueue(size=2)
  559. service._inference.submit(InferenceJob(name="blocker", run=lambda: None, evictable=False))
  560. _settle(service, h.clock, "first thought")
  561. _deliver(service, _laya_payload())
  562. # Refused: no brief job admitted, but the delta is buffered and the flag clear.
  563. assert _brief_jobs(service) == []
  564. assert service._pending_brief == ["Unknown: first thought"]
  565. assert service._brief_outstanding is False
  566. assert _stages(h.emitted) == ["signals"]
  567. # Free room; the next settled turn re-dispatches and consumes both deltas.
  568. service._inference._queue.clear()
  569. _settle(service, h.clock, "second thought")
  570. _deliver(service, _laya_payload())
  571. jobs = _brief_jobs(service)
  572. assert len(jobs) == 1
  573. assert service._pending_brief == ["Unknown: first thought", "Unknown: second thought"]
  574. _run_queued(service, jobs[0])
  575. assert _stages(h.emitted) == ["signals", "signals", "content"]
  576. prompt = client.brief_calls()[-1]["user"]
  577. assert "- Unknown: first thought" in prompt
  578. assert "- Unknown: second thought" in prompt
  579. assert service._pending_brief == []
  580. def test_empty_brief_requeues_deltas_and_clears_flag(tmp_path):
  581. """(e) a `""` brief re-queues the deltas and clears the outstanding flag."""
  582. client = _EngineClient(brief_reply="")
  583. h = _engine_service(tmp_path, client)
  584. service = h.service
  585. _settle(service, h.clock, "first thought")
  586. _deliver(service, _laya_payload())
  587. _run_queued(service, _brief_jobs(service)[0])
  588. assert _stages(h.emitted) == ["signals"] # no content emitted
  589. assert service._pending_brief == ["Unknown: first thought"]
  590. assert service._brief_outstanding is False
  591. assert _brief_jobs(service) == [] # requeued, not flushed
  592. def test_raising_brief_requeues_deltas_and_clears_flag(tmp_path):
  593. """(e) a raising brief leaves no stranded flag and re-buffers the deltas."""
  594. client = _EngineClient(brief_error=True)
  595. h = _engine_service(tmp_path, client)
  596. service = h.service
  597. _settle(service, h.clock, "first thought")
  598. _deliver(service, _laya_payload())
  599. _run_queued(service, _brief_jobs(service)[0])
  600. assert _stages(h.emitted) == ["signals"]
  601. assert service._pending_brief == ["Unknown: first thought"]
  602. assert service._brief_outstanding is False
  603. assert _brief_jobs(service) == []
  604. def test_none_laya_payload_does_not_dispatch(tmp_path):
  605. """(f) a None Laya payload stores nothing and never dispatches the brief."""
  606. client = _EngineClient()
  607. h = _engine_service(tmp_path, client)
  608. service = h.service
  609. _settle(service, h.clock, "first thought")
  610. _deliver(service, None)
  611. assert _triage_events(h.emitted) == []
  612. assert service.last_triage is None
  613. assert _brief_jobs(service) == []
  614. assert service._pending_brief == ["Unknown: first thought"]
  615. assert client.calls == []
  616. def test_clear_resets_both_engines_and_stale_job_cannot_clobber(tmp_path):
  617. """(g) clear resets chat + triage, suppresses stale emits, and a stale job
  618. cannot clear a newer dispatch's flag."""
  619. client = _EngineClient()
  620. h = _engine_service(tmp_path, client)
  621. service = h.service
  622. assert service.toggle_chat() is True
  623. service.set_chat_capture(True)
  624. _settle(service, h.clock, "hello")
  625. service.tick()
  626. _run_queued(service, _chat_jobs(service)[0])
  627. assert h.chat_engine.history != []
  628. _settle(service, h.clock, "first thought")
  629. _deliver(service, _laya_payload())
  630. stale_job = _brief_jobs(service)[0]
  631. assert service._brief_outstanding is True
  632. service.clear()
  633. # Both engines reset.
  634. assert h.chat_engine.history == []
  635. assert service.chat_history == []
  636. assert service._triage_generation == 1
  637. assert service.last_triage is None
  638. assert service.triage_history == []
  639. assert service._pending_brief == []
  640. assert service._brief_outstanding is False
  641. assert service._last_brief_signals is None
  642. assert _brief_jobs(service) == []
  643. assert "cleared" in _stages(h.emitted)
  644. assert {"type": "cleared"} in h.emitted
  645. h.emitted.clear()
  646. stale_job.run() # clear() already dropped it from the queue
  647. assert _triage_events(h.emitted) == []
  648. assert service._pending_brief == []
  649. assert service.last_triage is None
  650. # A newer dispatch after clear owns the flag; the stale job cannot clear it.
  651. _settle(service, h.clock, "fresh")
  652. _deliver(service, _laya_payload())
  653. newer_job = _brief_jobs(service)[0]
  654. newer_token = service._brief_token
  655. pending_after = list(service._pending_brief)
  656. triage_after = service.last_triage
  657. assert service._brief_outstanding is True
  658. h.emitted.clear()
  659. stale_job.run()
  660. assert _triage_events(h.emitted) == []
  661. assert service._brief_outstanding is True
  662. assert service._brief_token == newer_token
  663. assert service._pending_brief == pending_after
  664. assert service.last_triage is triage_after
  665. _run_queued(service, newer_job)
  666. assert _stages(h.emitted) == ["content"]
  667. assert service._brief_outstanding is False
  668. def test_durable_brief_file_holds_latest_and_clears(tmp_path):
  669. """(h) with `[brief].write=true` the file holds the exact latest brief and
  670. is empty after `clear`."""
  671. client = _EngineClient(brief_reply="FIRST BRIEF")
  672. h = _engine_service(tmp_path, client, brief_write=True)
  673. service = h.service
  674. path = os.path.join(h.session_dir, "brief.md")
  675. _settle(service, h.clock, "first thought", h.session_dir)
  676. _deliver(service, _laya_payload())
  677. _run_queued(service, _brief_jobs(service)[0])
  678. with open(path, encoding="utf-8") as handle:
  679. assert handle.read() == "FIRST BRIEF"
  680. client.brief_reply = "LATEST BRIEF"
  681. _settle(service, h.clock, "second thought", h.session_dir)
  682. _deliver(service, _laya_payload())
  683. _run_queued(service, _brief_jobs(service)[0])
  684. with open(path, encoding="utf-8") as handle:
  685. assert handle.read() == "LATEST BRIEF"
  686. assert service.last_triage["brief"] == "LATEST BRIEF"
  687. service.clear()
  688. assert os.path.exists(path)
  689. with open(path, encoding="utf-8") as handle:
  690. assert handle.read() == ""
  691. def test_disabled_brief_file_absent_but_panel_updates(tmp_path):
  692. """(h) with `[brief].write=false` no file appears while the panel brief still
  693. updates."""
  694. client = _EngineClient(brief_reply="PANEL BRIEF")
  695. h = _engine_service(tmp_path, client, brief_write=False)
  696. service = h.service
  697. path = os.path.join(h.session_dir, "brief.md")
  698. _settle(service, h.clock, "first thought", h.session_dir)
  699. _deliver(service, _laya_payload())
  700. _run_queued(service, _brief_jobs(service)[0])
  701. assert h.brief_file.write_enabled is False
  702. assert not os.path.exists(path)
  703. assert service.last_triage["brief"] == "PANEL BRIEF"