test_agentswarm_cli_tui.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. import asyncio
  2. import importlib
  3. import json
  4. import logging
  5. import subprocess
  6. import sys
  7. import tarfile
  8. import threading
  9. from io import BytesIO
  10. from pathlib import Path
  11. import pytest
  12. from agency_swarm import Agency, Agent
  13. agentswarm_cli_demo = importlib.import_module("agency_swarm.ui.demos.agentswarm_cli")
  14. class DummyServer:
  15. def __init__(self, port: int = 43121) -> None:
  16. self.port = port
  17. self.stopped = False
  18. def stop(self) -> None:
  19. self.stopped = True
  20. def build_agency() -> Agency:
  21. return Agency(Agent(name="CEO", instructions="test"), name="My Agency")
  22. def test_agentswarm_cli_tui_launches_agent_swarm_cli(monkeypatch):
  23. agency = build_agency()
  24. server = DummyServer()
  25. calls: dict[str, object] = {}
  26. monkeypatch.delenv(agentswarm_cli_demo._RELOAD_CHILD_ENV, raising=False)
  27. monkeypatch.setattr(agentswarm_cli_demo.os, "getcwd", lambda: "/tmp/project")
  28. monkeypatch.setattr(agentswarm_cli_demo, "_start_server", lambda value, capture=None: server)
  29. monkeypatch.setattr(agentswarm_cli_demo, "_ensure_cli", lambda: Path("/usr/local/bin/agentswarm"))
  30. def fake_run(cmd, cwd, env, check):
  31. calls["cmd"] = cmd
  32. calls["cwd"] = cwd
  33. calls["env"] = env
  34. calls["check"] = check
  35. return subprocess.CompletedProcess(cmd, 0)
  36. monkeypatch.setattr(agentswarm_cli_demo.subprocess, "run", fake_run)
  37. agentswarm_cli_demo.start_tui(agency, reload=False)
  38. assert calls["cmd"] == ["/usr/local/bin/agentswarm", "--model", agentswarm_cli_demo._MODEL]
  39. assert calls["cwd"] == "/tmp/project"
  40. assert calls["check"] is False
  41. config = json.loads(calls["env"]["OPENCODE_CONFIG_CONTENT"])
  42. assert config["model"] == agentswarm_cli_demo._MODEL
  43. assert config["provider"]["agency-swarm"]["options"]["baseURL"] == "http://127.0.0.1:43121"
  44. assert config["provider"]["agency-swarm"]["options"]["agency"] == "My_Agency"
  45. assert server.stopped is True
  46. def test_agentswarm_cli_tui_continues_after_reload(monkeypatch):
  47. agency = build_agency()
  48. server = DummyServer()
  49. calls: list[str] = []
  50. monkeypatch.setenv(agentswarm_cli_demo._RELOAD_CHILD_ENV, "1")
  51. monkeypatch.setattr(agentswarm_cli_demo.os, "getcwd", lambda: "/tmp/project")
  52. monkeypatch.setattr(agentswarm_cli_demo, "_start_server", lambda value, capture=None: server)
  53. monkeypatch.setattr(agentswarm_cli_demo, "_ensure_cli", lambda: Path("/usr/local/bin/agentswarm"))
  54. monkeypatch.setattr(
  55. agentswarm_cli_demo.subprocess,
  56. "run",
  57. lambda cmd, cwd, env, check: calls.extend(cmd) or subprocess.CompletedProcess(cmd, 0),
  58. )
  59. agentswarm_cli_demo.start_tui(agency, reload=False)
  60. assert "--continue" in calls
  61. assert server.stopped is True
  62. def test_agentswarm_cli_tui_installs_agent_swarm_cli(monkeypatch):
  63. monkeypatch.delenv(agentswarm_cli_demo._BIN_ENV, raising=False)
  64. monkeypatch.setattr(agentswarm_cli_demo, "_ensure_cli", lambda: Path("/tmp/cache/agentswarm"))
  65. assert agentswarm_cli_demo._command() == ["/tmp/cache/agentswarm"]
  66. def test_agentswarm_cli_tui_prefers_explicit_cli(monkeypatch):
  67. monkeypatch.setenv(agentswarm_cli_demo._BIN_ENV, "/tmp/agentswarm")
  68. monkeypatch.setattr(agentswarm_cli_demo, "_ensure_cli", lambda: (_ for _ in ()).throw(AssertionError("unused")))
  69. assert agentswarm_cli_demo._command() == ["/tmp/agentswarm"]
  70. def test_agentswarm_cli_tui_rejects_hidden_reasoning():
  71. agency = build_agency()
  72. with pytest.raises(NotImplementedError, match="show_reasoning=False"):
  73. agentswarm_cli_demo.start_tui(agency, show_reasoning=False, reload=False)
  74. def test_agentswarm_cli_tui_raises_when_bridge_fails(monkeypatch):
  75. agency = build_agency()
  76. monkeypatch.setattr(agentswarm_cli_demo, "_ensure_cli", lambda: Path("/usr/local/bin/agentswarm"))
  77. monkeypatch.setattr(
  78. agentswarm_cli_demo,
  79. "_start_server",
  80. lambda value, capture=None: (_ for _ in ()).throw(RuntimeError("boom")),
  81. )
  82. with pytest.raises(RuntimeError, match="bridge failed to start"):
  83. agentswarm_cli_demo.start_tui(agency, reload=False)
  84. def test_agentswarm_cli_tui_raises_when_cli_launch_fails(monkeypatch):
  85. agency = build_agency()
  86. server = DummyServer()
  87. monkeypatch.setattr(agentswarm_cli_demo.os, "getcwd", lambda: "/tmp/project")
  88. monkeypatch.setattr(agentswarm_cli_demo, "_start_server", lambda value, capture=None: server)
  89. monkeypatch.setattr(agentswarm_cli_demo, "_ensure_cli", lambda: Path("/usr/local/bin/agentswarm"))
  90. monkeypatch.setattr(
  91. agentswarm_cli_demo.subprocess,
  92. "run",
  93. lambda *args, **kwargs: (_ for _ in ()).throw(OSError("boom")),
  94. )
  95. with pytest.raises(RuntimeError, match="could not be launched"):
  96. agentswarm_cli_demo.start_tui(agency, reload=False)
  97. assert server.stopped is True
  98. def test_agentswarm_cli_tui_contains_python_prints_while_cli_runs(monkeypatch, capsys, tmp_path):
  99. agency = build_agency()
  100. log = tmp_path / "bridge.log"
  101. worker: threading.Thread | None = None
  102. state: dict[str, DummyServer] = {}
  103. logger = logging.getLogger("test.agentswarm_cli.bridge")
  104. handler = logging.StreamHandler(sys.stderr)
  105. logger.handlers = [handler]
  106. logger.setLevel(logging.WARNING)
  107. logger.propagate = False
  108. monkeypatch.setattr(agentswarm_cli_demo.os, "getcwd", lambda: "/tmp/project")
  109. monkeypatch.setattr(agentswarm_cli_demo, "_ensure_cli", lambda: Path("/usr/local/bin/agentswarm"))
  110. monkeypatch.setattr(agentswarm_cli_demo, "_bridge_log", lambda: log)
  111. monkeypatch.setattr(agentswarm_cli_demo, "_should_contain_bridge_output", lambda: True)
  112. def fake_start_server(value, capture):
  113. nonlocal worker
  114. def target():
  115. with agentswarm_cli_demo._contain_bridge_output(capture):
  116. sys.stdout.write("bridge stdout noise\n")
  117. sys.stderr.write("bridge stderr noise\n")
  118. logger.warning("bridge logger noise")
  119. worker = threading.Thread(target=target)
  120. worker.start()
  121. class LoggingServer(DummyServer):
  122. def stop(self) -> None:
  123. if worker is not None:
  124. worker.join()
  125. super().stop()
  126. state["server"] = LoggingServer()
  127. return state["server"]
  128. monkeypatch.setattr(agentswarm_cli_demo, "_start_server", fake_start_server)
  129. def fake_run(cmd, cwd, env, check):
  130. if worker is not None:
  131. worker.join()
  132. return subprocess.CompletedProcess(cmd, 0)
  133. monkeypatch.setattr(agentswarm_cli_demo.subprocess, "run", fake_run)
  134. log.unlink(missing_ok=True)
  135. try:
  136. agentswarm_cli_demo.start_tui(agency, reload=False)
  137. finally:
  138. logger.handlers = []
  139. captured = capsys.readouterr()
  140. assert captured.out == ""
  141. assert "bridge stdout noise" not in captured.err
  142. assert "bridge stderr noise" not in captured.err
  143. assert "bridge logger noise" not in captured.err
  144. assert str(log) in captured.err
  145. text = log.read_text()
  146. assert "bridge stdout noise" in text
  147. assert "bridge stderr noise" in text
  148. assert "bridge logger noise" in text
  149. assert state["server"].stopped is True
  150. def test_agentswarm_cli_tui_reports_bridge_output_on_failure(monkeypatch, capsys, tmp_path):
  151. agency = build_agency()
  152. log = tmp_path / "bridge.log"
  153. worker: threading.Thread | None = None
  154. state: dict[str, DummyServer] = {}
  155. monkeypatch.setattr(agentswarm_cli_demo.os, "getcwd", lambda: "/tmp/project")
  156. monkeypatch.setattr(agentswarm_cli_demo, "_ensure_cli", lambda: Path("/usr/local/bin/agentswarm"))
  157. monkeypatch.setattr(agentswarm_cli_demo, "_bridge_log", lambda: log)
  158. monkeypatch.setattr(agentswarm_cli_demo, "_should_contain_bridge_output", lambda: True)
  159. def fake_start_server(value, capture):
  160. nonlocal worker
  161. def target():
  162. with agentswarm_cli_demo._contain_bridge_output(capture):
  163. print("bridge stdout noise")
  164. print("bridge stderr noise", file=sys.stderr)
  165. worker = threading.Thread(target=target)
  166. worker.start()
  167. class LoggingServer(DummyServer):
  168. def stop(self) -> None:
  169. if worker is not None:
  170. worker.join()
  171. super().stop()
  172. state["server"] = LoggingServer()
  173. return state["server"]
  174. monkeypatch.setattr(agentswarm_cli_demo, "_start_server", fake_start_server)
  175. def fake_run(cmd, cwd, env, check):
  176. if worker is not None:
  177. worker.join()
  178. return subprocess.CompletedProcess(cmd, 1)
  179. monkeypatch.setattr(agentswarm_cli_demo.subprocess, "run", fake_run)
  180. log.unlink(missing_ok=True)
  181. with pytest.raises(subprocess.CalledProcessError):
  182. agentswarm_cli_demo.start_tui(agency, reload=False)
  183. captured = capsys.readouterr()
  184. assert captured.out == ""
  185. assert str(log) in captured.err
  186. assert "bridge stdout noise" in log.read_text()
  187. assert "bridge stderr noise" in log.read_text()
  188. assert state["server"].stopped is True
  189. def test_agentswarm_cli_tui_capture_includes_bridge_worker_threads(capsys, tmp_path):
  190. log = tmp_path / "bridge.log"
  191. logger = logging.getLogger("test.agentswarm_cli.capture.worker")
  192. handler = logging.StreamHandler(sys.stderr)
  193. logger.handlers = [handler]
  194. logger.setLevel(logging.WARNING)
  195. logger.propagate = False
  196. def worker() -> None:
  197. sys.stdout.write("bridge worker stdout\n")
  198. sys.stderr.write("bridge worker stderr\n")
  199. logger.warning("bridge worker logger")
  200. try:
  201. with agentswarm_cli_demo._contain_bridge_output(log):
  202. asyncio.run(asyncio.to_thread(worker))
  203. finally:
  204. logger.handlers = []
  205. captured = capsys.readouterr()
  206. assert captured.out == ""
  207. assert captured.err == ""
  208. assert log.read_text() == "bridge worker stdout\nbridge worker stderr\nbridge worker logger\n"
  209. def test_agentswarm_cli_tui_capture_keeps_other_threads_on_real_streams(capsys, tmp_path):
  210. log = tmp_path / "bridge.log"
  211. logger = logging.getLogger("test.agentswarm_cli.capture")
  212. handler = logging.StreamHandler(sys.stderr)
  213. logger.handlers = [handler]
  214. logger.setLevel(logging.WARNING)
  215. logger.propagate = False
  216. def other():
  217. sys.stdout.write("other thread stdout\n")
  218. sys.stderr.write("other thread stderr\n")
  219. logger.warning("other thread logger")
  220. try:
  221. with agentswarm_cli_demo._contain_bridge_output(log):
  222. worker = threading.Thread(target=other)
  223. worker.start()
  224. worker.join()
  225. sys.stdout.write("server thread stdout\n")
  226. sys.stderr.write("server thread stderr\n")
  227. logger.warning("server thread logger")
  228. finally:
  229. logger.handlers = []
  230. captured = capsys.readouterr()
  231. assert captured.out == "other thread stdout\n"
  232. assert captured.err == "other thread stderr\nother thread logger\n"
  233. assert log.read_text() == "server thread stdout\nserver thread stderr\nserver thread logger\n"
  234. def test_agentswarm_cli_tui_downloads_platform_cli(monkeypatch, tmp_path):
  235. root = tmp_path / "cache"
  236. blob = BytesIO()
  237. with tarfile.open(fileobj=blob, mode="w:gz") as tar:
  238. data = b"#!/bin/sh\nexit 0\n"
  239. info = tarfile.TarInfo("package/bin/agentswarm")
  240. info.size = len(data)
  241. info.mode = 0o755
  242. tar.addfile(info, BytesIO(data))
  243. data = blob.getvalue()
  244. sha = agentswarm_cli_demo.hashlib.sha1(data).hexdigest()
  245. calls: list[str] = []
  246. class Response:
  247. def __init__(self, payload=None, chunks=None):
  248. self.payload = payload
  249. self.chunks = chunks or []
  250. def raise_for_status(self):
  251. return None
  252. def json(self):
  253. return self.payload
  254. def iter_content(self, chunk_size=0):
  255. return iter(self.chunks)
  256. def __enter__(self):
  257. return self
  258. def __exit__(self, exc_type, exc, tb):
  259. return False
  260. def fake_get(url, timeout, stream=False):
  261. calls.append(url)
  262. if stream:
  263. return Response(chunks=[data])
  264. return Response(
  265. payload={
  266. "dist": {
  267. "tarball": "https://registry.npmjs.org/@vrsen/agentswarm-cli-darwin-arm64/-/pkg.tgz",
  268. "shasum": sha,
  269. }
  270. }
  271. )
  272. monkeypatch.setattr(agentswarm_cli_demo.requests, "get", fake_get)
  273. monkeypatch.setattr(agentswarm_cli_demo, "_cache", lambda: root)
  274. monkeypatch.setattr(
  275. agentswarm_cli_demo,
  276. "_package",
  277. lambda: agentswarm_cli_demo._Package(
  278. "agentswarm-cli-darwin-arm64",
  279. "agentswarm",
  280. "@vrsen/agentswarm-cli-darwin-arm64",
  281. ),
  282. )
  283. monkeypatch.setattr(agentswarm_cli_demo, "_CLI_VERSION", "1.2.27-test")
  284. path = agentswarm_cli_demo._ensure_cli()
  285. assert path == root / "1.2.27-test" / "agentswarm-cli-darwin-arm64" / "agentswarm"
  286. assert path.read_text() == "#!/bin/sh\nexit 0\n"
  287. assert calls == [
  288. "https://registry.npmjs.org/%40vrsen%2Fagentswarm-cli-darwin-arm64/1.2.27-test",
  289. "https://registry.npmjs.org/@vrsen/agentswarm-cli-darwin-arm64/-/pkg.tgz",
  290. ]
  291. def test_agentswarm_cli_tui_notifies_on_first_run(monkeypatch, tmp_path):
  292. root = tmp_path / "cache"
  293. notices: list[str] = []
  294. monkeypatch.setattr(agentswarm_cli_demo, "_cache", lambda: root)
  295. monkeypatch.setattr(
  296. agentswarm_cli_demo,
  297. "_package",
  298. lambda: agentswarm_cli_demo._Package(
  299. "agentswarm-cli-darwin-arm64",
  300. "agentswarm",
  301. "@vrsen/agentswarm-cli-darwin-arm64",
  302. ),
  303. )
  304. monkeypatch.setattr(agentswarm_cli_demo, "_install", lambda pkg, install_root, path: path.write_text("ok"))
  305. monkeypatch.setattr(agentswarm_cli_demo, "_notify_setup", notices.append)
  306. path = agentswarm_cli_demo._ensure_cli()
  307. assert path.read_text() == "ok"
  308. assert notices == [
  309. agentswarm_cli_demo._SETUP_MESSAGE,
  310. agentswarm_cli_demo._SETUP_COMPLETE_MESSAGE,
  311. ]