test_assistant_integration.py 29 KB

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