test_control.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. """Tests for the assistant control socket (Phase 3)."""
  2. import json
  3. import os
  4. import shutil
  5. import socket
  6. import tempfile
  7. import time
  8. from pathlib import Path
  9. from types import SimpleNamespace
  10. import pytest
  11. from stt_daemon.assistant.audit import AuditLog
  12. from stt_daemon.assistant.control import ControlServer
  13. from stt_daemon.assistant.executor import ExecutionResult
  14. from stt_daemon.assistant.service import AssistantService
  15. from stt_daemon.config import Config
  16. @pytest.fixture
  17. def sock_path():
  18. directory = tempfile.mkdtemp(prefix="sttc-")
  19. try:
  20. yield os.path.join(directory, "c.sock")
  21. finally:
  22. shutil.rmtree(directory, ignore_errors=True)
  23. class FakeSubscriber:
  24. def events(self):
  25. return iter(())
  26. def close(self):
  27. pass
  28. class FakeExecutor:
  29. def __init__(self):
  30. self.calls = []
  31. def run(self, command_text, emit):
  32. self.calls.append(command_text)
  33. emit("tool_call", tool="get_datetime", args={}, requires_confirmation=False)
  34. return ExecutionResult(ok=True, result="2026-09-20T12:00:00", tool="get_datetime")
  35. class Client:
  36. def __init__(self, path):
  37. self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
  38. self.sock.connect(path)
  39. self.sock.settimeout(0.2)
  40. self.buffer = bytearray()
  41. self.messages: list[dict] = []
  42. def send(self, **message):
  43. self.sock.sendall((json.dumps(message) + "\n").encode("utf-8"))
  44. def read(self, timeout=0.2):
  45. deadline = time.time() + timeout
  46. while time.time() < deadline:
  47. newline = self.buffer.find(b"\n")
  48. if newline != -1:
  49. line = bytes(self.buffer[:newline])
  50. del self.buffer[: newline + 1]
  51. return json.loads(line.decode("utf-8"))
  52. try:
  53. chunk = self.sock.recv(65536)
  54. except socket.timeout:
  55. continue
  56. if not chunk:
  57. return None
  58. self.buffer.extend(chunk)
  59. return None
  60. def wait_for(self, predicate, timeout=5.0):
  61. """Return the first message matching `predicate`, accumulating the rest."""
  62. deadline = time.time() + timeout
  63. while time.time() < deadline:
  64. match = next((m for m in self.messages if predicate(m)), None)
  65. if match is not None:
  66. return match
  67. message = self.read()
  68. if message is not None:
  69. self.messages.append(message)
  70. return next((m for m in self.messages if predicate(m)), None)
  71. def close(self):
  72. try:
  73. self.sock.close()
  74. except OSError:
  75. pass
  76. @pytest.fixture
  77. def harness(sock_path, tmp_path):
  78. cfg = Config()
  79. cfg.assistant.control_socket = sock_path
  80. cfg.assistant.require_viewer = True
  81. audit_dir = tmp_path / "audit"
  82. audit = AuditLog(enabled=True, directory=str(audit_dir))
  83. executor = FakeExecutor()
  84. service = AssistantService(cfg, FakeSubscriber(), audit=audit, executor=executor)
  85. server = ControlServer(cfg, service, path=sock_path)
  86. service.set_sinks(emit=server.broadcast, relay=server.relay)
  87. server.start()
  88. service.start()
  89. yield SimpleNamespace(
  90. cfg=cfg,
  91. service=service,
  92. server=server,
  93. audit_dir=audit_dir,
  94. path=sock_path,
  95. executor=executor,
  96. )
  97. server.close()
  98. service.stop()
  99. def _transcript(text, ts=None):
  100. return {
  101. "v": 1,
  102. "type": "transcript",
  103. "seq": 99,
  104. "ts": time.time() if ts is None else ts,
  105. "text": text,
  106. }
  107. def test_subscribe_returns_status_and_counts_viewer(harness):
  108. client = Client(harness.path)
  109. client.send(type="subscribe", viewer=True)
  110. status = client.wait_for(lambda m: m["type"] == "status")
  111. assert status is not None
  112. assert status["mode"] == "idle"
  113. assert status["ready"] is True
  114. assert harness.server.viewer_count() == 1
  115. client.close()
  116. def test_command_start_flips_mode_and_broadcasts(harness):
  117. client = Client(harness.path)
  118. client.send(type="subscribe", viewer=True)
  119. assert client.wait_for(lambda m: m["type"] == "status") is not None
  120. client.send(type="command_start")
  121. assert client.wait_for(lambda m: m["type"] == "ack" and m["command"] == "command_start")
  122. assert client.wait_for(lambda m: m["type"] == "mode" and m["mode"] == "command_armed")
  123. assert harness.service.modes.mode.value == "command_armed"
  124. client.close()
  125. def test_command_execute_refused_without_viewer(harness):
  126. client = Client(harness.path) # connected but not subscribed as a viewer
  127. client.send(type="command_execute")
  128. error = client.wait_for(lambda m: m["type"] == "error")
  129. assert error is not None
  130. assert error["code"] == "no_viewer"
  131. assert harness.executor.calls == []
  132. client.close()
  133. def test_command_execute_runs_executor_and_writes_audit(harness):
  134. client = Client(harness.path)
  135. client.send(type="subscribe", viewer=True)
  136. assert client.wait_for(lambda m: m["type"] == "status") is not None
  137. client.send(type="command_start")
  138. assert client.wait_for(lambda m: m["type"] == "ack" and m["command"] == "command_start")
  139. service = harness.service
  140. service.handle_event({"v": 1, "type": "speech_stop", "seq": 1, "ts": time.time(), "seg_id": 1})
  141. service.handle_event(
  142. {"v": 1, "type": "segment_done", "seq": 2, "ts": time.time(), "seg_id": 1, "emitted": True}
  143. )
  144. service.handle_event(_transcript("what time is it"))
  145. assert service.ready() is True
  146. client.send(type="command_execute")
  147. assert client.wait_for(lambda m: m["type"] == "ack" and m["command"] == "command_execute")
  148. result = client.wait_for(lambda m: m["type"] == "tool_result")
  149. assert result is not None
  150. assert result["ok"] is True
  151. assert result["tool"] == "get_datetime"
  152. assert harness.executor.calls == ["what time is it"]
  153. files = list(harness.audit_dir.glob("*.jsonl"))
  154. assert len(files) == 1
  155. record = json.loads(files[0].read_text().splitlines()[0])
  156. assert record["tool"] == "get_datetime"
  157. assert record["ok"] is True
  158. assert record["text"] == "what time is it"
  159. client.close()
  160. def test_command_edit_updates_buffer(harness):
  161. client = Client(harness.path)
  162. client.send(type="subscribe", viewer=True)
  163. assert client.wait_for(lambda m: m["type"] == "status") is not None
  164. client.send(type="command_start")
  165. assert client.wait_for(lambda m: m["type"] == "ack")
  166. client.send(type="command_edit", text="corrected text")
  167. assert client.wait_for(lambda m: m["type"] == "command_text" and m["text"] == "corrected text")
  168. assert harness.service.command_text == "corrected text"
  169. assert harness.service.command_edited is True
  170. client.close()
  171. def test_chat_toggle_broadcasts_mode(harness):
  172. client = Client(harness.path)
  173. client.send(type="subscribe", viewer=True)
  174. assert client.wait_for(lambda m: m["type"] == "status") is not None
  175. client.send(type="chat_toggle")
  176. assert client.wait_for(lambda m: m["type"] == "mode" and m["mode"] == "chat")
  177. client.close()
  178. def test_chat_send_acks_and_broadcasts_user_turn(harness):
  179. client = Client(harness.path)
  180. client.send(type="subscribe", viewer=True)
  181. assert client.wait_for(lambda m: m["type"] == "status") is not None
  182. client.send(type="chat_toggle")
  183. assert client.wait_for(lambda m: m["type"] == "mode" and m["mode"] == "chat")
  184. client.send(type="chat_send", text="hello there")
  185. assert client.wait_for(lambda m: m["type"] == "ack" and m["command"] == "chat_send")
  186. assert client.wait_for(
  187. lambda m: m["type"] == "chat_message"
  188. and m.get("role") == "user"
  189. and m.get("text") == "hello there"
  190. ) is not None
  191. assert harness.service.chat_history == ["hello there"]
  192. client.close()
  193. def test_chat_send_refused_outside_chat(harness):
  194. client = Client(harness.path)
  195. client.send(type="subscribe", viewer=True)
  196. assert client.wait_for(lambda m: m["type"] == "status") is not None
  197. client.send(type="chat_send", text="hello")
  198. error = client.wait_for(lambda m: m["type"] == "error" and m["code"] == "refused")
  199. assert error is not None
  200. assert harness.service.chat_history == []
  201. client.close()
  202. def test_last_viewer_disconnect_ends_chat(harness):
  203. client = Client(harness.path)
  204. client.send(type="subscribe", viewer=True)
  205. assert client.wait_for(lambda m: m["type"] == "status") is not None
  206. client.send(type="chat_toggle")
  207. assert client.wait_for(lambda m: m["type"] == "mode" and m["mode"] == "chat")
  208. assert harness.service.modes.mode.value == "chat"
  209. client.close()
  210. deadline = time.time() + 5.0
  211. while time.time() < deadline and harness.service.modes.mode.value == "chat":
  212. time.sleep(0.02)
  213. assert harness.service.modes.mode.value == "idle"
  214. def test_non_last_viewer_disconnect_keeps_chat(harness):
  215. first = Client(harness.path)
  216. first.send(type="subscribe", viewer=True)
  217. assert first.wait_for(lambda m: m["type"] == "status") is not None
  218. second = Client(harness.path)
  219. second.send(type="subscribe", viewer=True)
  220. assert second.wait_for(lambda m: m["type"] == "status") is not None
  221. first.send(type="chat_toggle")
  222. assert first.wait_for(lambda m: m["type"] == "mode" and m["mode"] == "chat")
  223. second.close()
  224. time.sleep(0.4)
  225. assert harness.service.modes.mode.value == "chat"
  226. first.close()
  227. def test_status_reply_contains_fields(harness):
  228. client = Client(harness.path)
  229. client.send(type="status")
  230. status = client.wait_for(lambda m: m["type"] == "status")
  231. assert status is not None
  232. for field in ("mode", "ready", "stt_ok", "phase", "command_text", "speech_open", "chat_turns"):
  233. assert field in status
  234. client.close()
  235. def test_unknown_command_is_rejected(harness):
  236. client = Client(harness.path)
  237. client.send(type="bogus")
  238. error = client.wait_for(lambda m: m["type"] == "error")
  239. assert error is not None
  240. assert error["code"] == "unknown_command"
  241. client.close()
  242. def test_clear_broadcasts_cleared_event(harness):
  243. client = Client(harness.path)
  244. client.send(type="subscribe", viewer=True)
  245. assert client.wait_for(lambda m: m["type"] == "status") is not None
  246. client.send(type="chat_toggle")
  247. assert client.wait_for(lambda m: m["type"] == "mode" and m["mode"] == "chat")
  248. service = harness.service
  249. service.handle_event(_transcript("hello"))
  250. assert len(service.chat_history) == 1
  251. client.send(type="clear")
  252. assert client.wait_for(lambda m: m["type"] == "ack" and m["command"] == "clear")
  253. assert client.wait_for(lambda m: m["type"] == "cleared") is not None
  254. assert service.chat_history == []
  255. assert service._pending_chat == []
  256. client.close()
  257. def test_control_socket_is_private_and_removed(tmp_path):
  258. cfg = Config()
  259. path = str(Path(tempfile.mkdtemp(prefix="sttc-")) / "c.sock")
  260. service = AssistantService(cfg, FakeSubscriber())
  261. server = ControlServer(cfg, service, path=path)
  262. server.start()
  263. assert os.stat(path).st_mode & 0o777 == 0o600
  264. server.close()
  265. assert not os.path.exists(path)
  266. service.stop()