test_assistant_integration.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865
  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. cfg.brief.dir = str(tmp_path / "brief")
  334. cfg.brief.filename = "b.md"
  335. rc, _, _, made_services = _run_runner_main(monkeypatch, cfg)
  336. assert rc == 0
  337. brief_file = made_services[0].kwargs["brief_file"]
  338. assert isinstance(brief_file, BriefFile)
  339. assert brief_file.write_enabled is True
  340. brief_file.write("latest brief")
  341. path = os.path.join(cfg.brief.dir, "b.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. cfg.brief.dir = str(tmp_path / "brief")
  350. rc, _, _, made_services = _run_runner_main(monkeypatch, cfg)
  351. assert rc == 0
  352. brief_file = made_services[0].kwargs["brief_file"]
  353. assert isinstance(brief_file, BriefFile)
  354. assert brief_file.write_enabled is False
  355. brief_file.write("nope")
  356. assert not os.path.exists(os.path.join(cfg.brief.dir, cfg.brief.filename))
  357. # ---------------------------------------------------------------------------
  358. # End-to-end dual-engine coverage (chat + triage on ONE inference queue).
  359. #
  360. # These tests build the REAL `AssistantService` plus the real `build_triage`
  361. # wiring (real `TriageAnalyzer`, real `ConversationEngine` brief engine, real
  362. # `InferenceQueue`, real `BriefFile`) and only fake the LLM client and the bus.
  363. # Laya results are delivered as fake payloads through the service's documented
  364. # `_on_triage_result` seam; the queued jobs are then driven for real via
  365. # `job.run()` (the queue's worker body).
  366. # ---------------------------------------------------------------------------
  367. class _Clock:
  368. """Deterministic clock for the integration harness."""
  369. def __init__(self, start: float = 1000.0) -> None:
  370. self.t = float(start)
  371. def __call__(self) -> float:
  372. return self.t
  373. def advance(self, dt: float) -> None:
  374. self.t += float(dt)
  375. class _SilentSubscriber:
  376. def events(self):
  377. return iter(())
  378. def close(self) -> None:
  379. pass
  380. class _EngineClient:
  381. """Recording LLM client: brief replies differ from chat replies.
  382. Accepts `slot_id` like the real client and always returns a `finish_reason`
  383. (the brief engine reads it). `brief_error` makes the brief call raise, which
  384. the real `_run_brief_update` must absorb.
  385. """
  386. def __init__(
  387. self, *, brief_reply: str = "BRIEF", brief_error: bool = False,
  388. chat_reply: str = "chat reply",
  389. ) -> None:
  390. self.calls: list[dict] = []
  391. self.brief_reply = brief_reply
  392. self.brief_error = brief_error
  393. self.chat_reply = chat_reply
  394. self.brief_system: str | None = None
  395. def chat(self, messages, tools=None, max_tokens=None, timeout_s=None, slot_id=None):
  396. system = messages[0].get("content") if messages else ""
  397. is_brief = self.brief_system is not None and system == self.brief_system
  398. self.calls.append(
  399. {
  400. "is_brief": is_brief,
  401. "system": system,
  402. "user": messages[-1].get("content") if messages else "",
  403. "slot_id": slot_id,
  404. "max_tokens": max_tokens,
  405. "timeout_s": timeout_s,
  406. }
  407. )
  408. if is_brief and self.brief_error:
  409. raise RuntimeError("llm down")
  410. content = self.brief_reply if is_brief else self.chat_reply
  411. return SimpleNamespace(content=content, finish_reason="stop")
  412. def brief_calls(self) -> list[dict]:
  413. return [call for call in self.calls if call["is_brief"]]
  414. def chat_calls(self) -> list[dict]:
  415. return [call for call in self.calls if not call["is_brief"]]
  416. def _laya_payload() -> dict:
  417. """A realistic fake Laya result for the `urgent` noul question."""
  418. return {"answers": {"urgent": {"noul": 0.9, "confidence": 0.9, "answer_confidence": 0.9}}}
  419. def _engine_service(
  420. tmp_path,
  421. client,
  422. *,
  423. chat_slot=None,
  424. brief_slot=None,
  425. brief_write: bool = False,
  426. ):
  427. """Build the real service + `build_triage` wiring over a fake client."""
  428. cfg = _enabled_triage_cfg(tmp_path)
  429. cfg.llm.enabled = True
  430. cfg.assistant.turn_gap_s = 0.5
  431. cfg.triage.brief_gate_enabled = False # deterministic: dispatch every turn
  432. cfg.triage.brief_slot_id = brief_slot
  433. cfg.llm.chat_slot_id = chat_slot
  434. cfg.brief.write = brief_write
  435. cfg.brief.dir = str(tmp_path / "brief")
  436. cfg.brief.filename = "brief.md"
  437. client.brief_system = cfg.llm.triage_system_prompt
  438. analyzer, lane, brief_fn, brief_reset = build_triage(cfg, client)
  439. chat_engine = ConversationEngine(
  440. cfg.llm,
  441. client,
  442. system_prompt=cfg.llm.system_prompt_chat,
  443. slot_id=chat_slot,
  444. )
  445. emitted: list[dict] = []
  446. clock = _Clock()
  447. brief_file = BriefFile(cfg.brief.write, cfg.brief.dir, cfg.brief.filename)
  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. cfg=cfg,
  470. client=client,
  471. )
  472. def _transcript(clock, text):
  473. return {"v": 1, "type": events.TRANSCRIPT, "seq": 1, "ts": clock(), "text": text}
  474. def _triage_events(emitted):
  475. return [event for event in emitted if event["type"] == "triage"]
  476. def _stages(emitted):
  477. return [event.get("stage") for event in _triage_events(emitted)]
  478. def _brief_jobs(service):
  479. return [job for job in service._inference._queue if job.name == "brief-update"]
  480. def _chat_jobs(service):
  481. return [job for job in service._inference._queue if job.name == "chat"]
  482. def _run_queued(service, job):
  483. """Pop a queued job (mirroring the worker) and run it."""
  484. service._inference._queue.remove(job)
  485. job.run()
  486. def _settle(service, clock, text):
  487. service.handle_event(_transcript(clock, text))
  488. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  489. def _deliver(service, payload):
  490. service._on_triage_result(service._triage_generation, payload)
  491. def test_two_engines_emit_signals_then_brief_and_chat_works(tmp_path):
  492. """(a) signals then content.brief; (b) chat works while triage is active;
  493. (c) each engine carries id_slot only when set."""
  494. client = _EngineClient(brief_reply="E2E BRIEF")
  495. h = _engine_service(tmp_path, client, chat_slot=7, brief_slot=5)
  496. assert h.service.toggle_chat() is True
  497. _settle(h.service, h.clock, "first thought")
  498. h.service.tick() # dispatches the chat turn through the real queue
  499. _deliver(h.service, _laya_payload())
  500. assert _stages(h.emitted) == ["signals"]
  501. assert h.service.last_triage is not None
  502. assert "brief" not in h.service.last_triage
  503. chat_jobs = _chat_jobs(h.service)
  504. assert len(chat_jobs) == 1
  505. _run_queued(h.service, chat_jobs[0]) # chat works while triage is active
  506. assert h.service.last_triage is not None # triage still owns its state
  507. assert any(
  508. event["type"] == "chat_message"
  509. and event.get("role") == "assistant"
  510. and event.get("text") == "chat reply"
  511. for event in h.emitted
  512. )
  513. brief_jobs = _brief_jobs(h.service)
  514. assert len(brief_jobs) == 1
  515. _run_queued(h.service, brief_jobs[0])
  516. assert _stages(h.emitted) == ["signals", "content"]
  517. contents = [event for event in _triage_events(h.emitted) if event.get("stage") == "content"]
  518. assert contents[0]["triage"]["brief"] == "E2E BRIEF"
  519. assert h.service.last_triage["brief"] == "E2E BRIEF"
  520. assert [call["slot_id"] for call in client.chat_calls()] == [7]
  521. assert [call["slot_id"] for call in client.brief_calls()] == [5]
  522. assert client.brief_calls()[0]["user"].startswith("New utterances:")
  523. def test_slotless_engines_omit_id_slot(tmp_path):
  524. """(c) unset slots leave `id_slot` off both engines' calls."""
  525. client = _EngineClient(brief_reply="BRIEF")
  526. h = _engine_service(tmp_path, client, chat_slot=None, brief_slot=None)
  527. assert h.service.toggle_chat() is True
  528. _settle(h.service, h.clock, "hello")
  529. h.service.tick()
  530. _deliver(h.service, _laya_payload())
  531. _run_queued(h.service, _chat_jobs(h.service)[0])
  532. _run_queued(h.service, _brief_jobs(h.service)[0])
  533. assert [call["slot_id"] for call in client.chat_calls()] == [None]
  534. assert [call["slot_id"] for call in client.brief_calls()] == [None]
  535. def test_refused_brief_keeps_deltas_and_next_dispatch_delivers(tmp_path):
  536. """(d) a full queue refuses the brief without losing pending deltas, and the
  537. next dispatch delivers them all."""
  538. client = _EngineClient(brief_reply="BRIEF")
  539. h = _engine_service(tmp_path, client)
  540. service = h.service
  541. service._inference = InferenceQueue(size=2)
  542. service._inference.submit(InferenceJob(name="blocker", run=lambda: None, evictable=False))
  543. _settle(service, h.clock, "first thought")
  544. _deliver(service, _laya_payload())
  545. # Refused: no brief job admitted, but the delta is buffered and the flag clear.
  546. assert _brief_jobs(service) == []
  547. assert service._pending_brief == ["Unknown: first thought"]
  548. assert service._brief_outstanding is False
  549. assert _stages(h.emitted) == ["signals"]
  550. # Free room; the next settled turn re-dispatches and consumes both deltas.
  551. service._inference._queue.clear()
  552. _settle(service, h.clock, "second thought")
  553. _deliver(service, _laya_payload())
  554. jobs = _brief_jobs(service)
  555. assert len(jobs) == 1
  556. assert service._pending_brief == ["Unknown: first thought", "Unknown: second thought"]
  557. _run_queued(service, jobs[0])
  558. assert _stages(h.emitted) == ["signals", "signals", "content"]
  559. prompt = client.brief_calls()[-1]["user"]
  560. assert "- Unknown: first thought" in prompt
  561. assert "- Unknown: second thought" in prompt
  562. assert service._pending_brief == []
  563. def test_empty_brief_requeues_deltas_and_clears_flag(tmp_path):
  564. """(e) a `""` brief re-queues the deltas and clears the outstanding flag."""
  565. client = _EngineClient(brief_reply="")
  566. h = _engine_service(tmp_path, client)
  567. service = h.service
  568. _settle(service, h.clock, "first thought")
  569. _deliver(service, _laya_payload())
  570. _run_queued(service, _brief_jobs(service)[0])
  571. assert _stages(h.emitted) == ["signals"] # no content emitted
  572. assert service._pending_brief == ["Unknown: first thought"]
  573. assert service._brief_outstanding is False
  574. assert _brief_jobs(service) == [] # requeued, not flushed
  575. def test_raising_brief_requeues_deltas_and_clears_flag(tmp_path):
  576. """(e) a raising brief leaves no stranded flag and re-buffers the deltas."""
  577. client = _EngineClient(brief_error=True)
  578. h = _engine_service(tmp_path, client)
  579. service = h.service
  580. _settle(service, h.clock, "first thought")
  581. _deliver(service, _laya_payload())
  582. _run_queued(service, _brief_jobs(service)[0])
  583. assert _stages(h.emitted) == ["signals"]
  584. assert service._pending_brief == ["Unknown: first thought"]
  585. assert service._brief_outstanding is False
  586. assert _brief_jobs(service) == []
  587. def test_none_laya_payload_does_not_dispatch(tmp_path):
  588. """(f) a None Laya payload stores nothing and never dispatches the brief."""
  589. client = _EngineClient()
  590. h = _engine_service(tmp_path, client)
  591. service = h.service
  592. _settle(service, h.clock, "first thought")
  593. _deliver(service, None)
  594. assert _triage_events(h.emitted) == []
  595. assert service.last_triage is None
  596. assert _brief_jobs(service) == []
  597. assert service._pending_brief == ["Unknown: first thought"]
  598. assert client.calls == []
  599. def test_clear_resets_both_engines_and_stale_job_cannot_clobber(tmp_path):
  600. """(g) clear resets chat + triage, suppresses stale emits, and a stale job
  601. cannot clear a newer dispatch's flag."""
  602. client = _EngineClient()
  603. h = _engine_service(tmp_path, client)
  604. service = h.service
  605. assert service.toggle_chat() is True
  606. _settle(service, h.clock, "hello")
  607. service.tick()
  608. _run_queued(service, _chat_jobs(service)[0])
  609. assert h.chat_engine.history != []
  610. _settle(service, h.clock, "first thought")
  611. _deliver(service, _laya_payload())
  612. stale_job = _brief_jobs(service)[0]
  613. assert service._brief_outstanding is True
  614. service.clear()
  615. # Both engines reset.
  616. assert h.chat_engine.history == []
  617. assert service.chat_history == []
  618. assert service._triage_generation == 1
  619. assert service.last_triage is None
  620. assert service.triage_history == []
  621. assert service._pending_brief == []
  622. assert service._brief_outstanding is False
  623. assert service._last_brief_signals is None
  624. assert _brief_jobs(service) == []
  625. assert "cleared" in _stages(h.emitted)
  626. assert {"type": "cleared"} in h.emitted
  627. h.emitted.clear()
  628. stale_job.run() # clear() already dropped it from the queue
  629. assert _triage_events(h.emitted) == []
  630. assert service._pending_brief == []
  631. assert service.last_triage is None
  632. # A newer dispatch after clear owns the flag; the stale job cannot clear it.
  633. _settle(service, h.clock, "fresh")
  634. _deliver(service, _laya_payload())
  635. newer_job = _brief_jobs(service)[0]
  636. newer_token = service._brief_token
  637. pending_after = list(service._pending_brief)
  638. triage_after = service.last_triage
  639. assert service._brief_outstanding is True
  640. h.emitted.clear()
  641. stale_job.run()
  642. assert _triage_events(h.emitted) == []
  643. assert service._brief_outstanding is True
  644. assert service._brief_token == newer_token
  645. assert service._pending_brief == pending_after
  646. assert service.last_triage is triage_after
  647. _run_queued(service, newer_job)
  648. assert _stages(h.emitted) == ["content"]
  649. assert service._brief_outstanding is False
  650. def test_durable_brief_file_holds_latest_and_clears(tmp_path):
  651. """(h) with `[brief].write=true` the file holds the exact latest brief and
  652. is empty after `clear`."""
  653. client = _EngineClient(brief_reply="FIRST BRIEF")
  654. h = _engine_service(tmp_path, client, brief_write=True)
  655. service = h.service
  656. path = os.path.join(h.cfg.brief.dir, h.cfg.brief.filename)
  657. _settle(service, h.clock, "first thought")
  658. _deliver(service, _laya_payload())
  659. _run_queued(service, _brief_jobs(service)[0])
  660. with open(path, encoding="utf-8") as handle:
  661. assert handle.read() == "FIRST BRIEF"
  662. client.brief_reply = "LATEST BRIEF"
  663. _settle(service, h.clock, "second thought")
  664. _deliver(service, _laya_payload())
  665. _run_queued(service, _brief_jobs(service)[0])
  666. with open(path, encoding="utf-8") as handle:
  667. assert handle.read() == "LATEST BRIEF"
  668. assert service.last_triage["brief"] == "LATEST BRIEF"
  669. service.clear()
  670. assert os.path.exists(path)
  671. with open(path, encoding="utf-8") as handle:
  672. assert handle.read() == ""
  673. def test_disabled_brief_file_absent_but_panel_updates(tmp_path):
  674. """(h) with `[brief].write=false` no file appears while the panel brief still
  675. updates."""
  676. client = _EngineClient(brief_reply="PANEL BRIEF")
  677. h = _engine_service(tmp_path, client, brief_write=False)
  678. service = h.service
  679. path = os.path.join(h.cfg.brief.dir, h.cfg.brief.filename)
  680. _settle(service, h.clock, "first thought")
  681. _deliver(service, _laya_payload())
  682. _run_queued(service, _brief_jobs(service)[0])
  683. assert h.brief_file.write_enabled is False
  684. assert not os.path.exists(path)
  685. assert service.last_triage["brief"] == "PANEL BRIEF"