test_agentpack_e2e.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. """
  2. Phase I dogfood — AgentPack end-to-end integration tests (no LLM).
  3. Exercises the full HTTP path that a desktop user takes:
  4. install built-in pack ──► list ──► get detail
  5. │
  6. └──► create-agent ──► GET agent (verify config)
  7. │
  8. └──► from_config() ──► agent compiles ✓
  9. │
  10. └──► uninstall pack ──► agent STILL exists (no cascade delete)
  11. NOTE on install: POST /agentpacks/install is loopback-only (127.0.0.1).
  12. TestClient sends requests as host "testclient", so the loopback check
  13. returns 404. We test the loopback gate separately (test_install_loopback_blocked)
  14. and use install_from_zip() directly for the pack-setup fixture — consistent
  15. with test_agentpack.py::test_create_agent_from_pack_endpoint. All subsequent
  16. API calls (list / get / create-agent / delete) go through HTTP.
  17. Real LLM execution (review_report.md verification) lives in
  18. scripts/dogfood_agentpack.py and is marked skip here.
  19. """
  20. from __future__ import annotations
  21. import json
  22. import os
  23. import zipfile
  24. import pytest
  25. import yaml
  26. os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
  27. os.environ.setdefault("AGENTPAAS_TESTING", "1")
  28. # ── Helpers ───────────────────────────────────────────────────────────────────
  29. def _pack_zip_from_disk(tmp_path, pack_id: str) -> str:
  30. """Zip the on-disk agentexample pack so install_from_zip() can consume it."""
  31. repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  32. pack_dir = os.path.join(repo_root, "agentexample", "agentpacks", pack_id)
  33. if not os.path.isdir(pack_dir):
  34. pytest.skip(f"built-in pack not found on disk: {pack_dir}")
  35. zip_path = str(tmp_path / f"{pack_id}.zip")
  36. with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
  37. for dirpath, _, filenames in os.walk(pack_dir):
  38. for fname in filenames:
  39. full = os.path.join(dirpath, fname)
  40. arcname = os.path.relpath(full, pack_dir)
  41. zf.write(full, arcname)
  42. return zip_path
  43. def _install_pack(packs_dir: str, zip_path: str):
  44. """Install a pack directly (bypasses loopback gate — tested separately)."""
  45. from agentpaas.engine.agentpack_store import install_from_zip
  46. return install_from_zip(zip_path, packs_dir)
  47. @pytest.fixture()
  48. def http_client(tmp_path, monkeypatch):
  49. """TestClient + fresh in-memory DB + one tenant/user/key + reviewer pack pre-installed."""
  50. import secrets
  51. from fastapi.testclient import TestClient
  52. from agentpaas.api.app import app
  53. from agentpaas.api.middleware.auth import hash_key
  54. from agentpaas.config import settings
  55. from agentpaas.db.models import Database, gen_id, now_utc
  56. import agentpaas.db.session as _session_mod
  57. packs_dir = tmp_path / "agentpacks"
  58. packs_dir.mkdir()
  59. monkeypatch.setattr(settings, "agentpacks_dir", str(packs_dir))
  60. prev_db = _session_mod._db
  61. _session_mod._db = Database("sqlite:///:memory:")
  62. db = _session_mod._db
  63. tid = gen_id("tn_")
  64. uid = gen_id("usr_")
  65. raw_key = f"ap_{secrets.token_hex(16)}"
  66. now = now_utc()
  67. db.execute(
  68. "INSERT INTO tenants (id, name, plan, status, created_at) "
  69. "VALUES (?, 'test', 'free', 'active', ?)", (tid, now),
  70. )
  71. db.execute(
  72. "INSERT INTO users (id, tenant_id, email, role, created_at) "
  73. "VALUES (?, ?, '', 'admin', ?)", (uid, tid, now),
  74. )
  75. db.execute(
  76. "INSERT INTO api_keys "
  77. "(id, tenant_id, user_id, key_hash, key_prefix, name, scopes, "
  78. " rate_limit, status, created_at) "
  79. "VALUES (?, ?, ?, ?, ?, 'test', ?, 600, 'active', ?)",
  80. (gen_id("key_"), tid, uid, hash_key(raw_key), raw_key[:8],
  81. json.dumps(["agents:*", "keys:*"]), now),
  82. )
  83. db.commit()
  84. # Pre-install the built-in reviewer pack directly (bypass loopback gate)
  85. zip_path = _pack_zip_from_disk(tmp_path, REVIEWER_ID)
  86. pack = _install_pack(str(packs_dir), zip_path)
  87. with TestClient(app) as client:
  88. yield client, raw_key, str(packs_dir), pack
  89. _session_mod._db = prev_db
  90. # ── Auth helper ───────────────────────────────────────────────────────────────
  91. def _auth(key: str) -> dict:
  92. return {"Authorization": f"Bearer {key}"}
  93. # ── Tests ─────────────────────────────────────────────────────────────────────
  94. REVIEWER_ID = "research.top-journal-reviewer"
  95. def test_install_loopback_blocked(tmp_path, monkeypatch):
  96. """POST /agentpacks/install from non-loopback → 404 (loopback gate).
  97. TestClient host = 'testclient', not 127.0.0.1 — this is the gate we want.
  98. """
  99. import secrets
  100. from fastapi.testclient import TestClient
  101. from agentpaas.api.app import app
  102. from agentpaas.config import settings
  103. packs_dir = tmp_path / "agentpacks"
  104. packs_dir.mkdir()
  105. monkeypatch.setattr(settings, "agentpacks_dir", str(packs_dir))
  106. zip_path = _pack_zip_from_disk(tmp_path, REVIEWER_ID)
  107. with TestClient(app) as client:
  108. r = client.post(
  109. "/api/v1/agentpacks/install",
  110. json={"zip_path": zip_path},
  111. )
  112. assert r.status_code == 404, r.text[:200]
  113. def test_install_builtin_pack_via_api(tmp_path, http_client):
  114. """Pre-installed reviewer pack is visible via GET /agentpacks after direct install."""
  115. client, key, packs_dir, pack = http_client
  116. # pack was installed in fixture — verify it via the HTTP list
  117. r = client.get("/api/v1/agentpacks", headers=_auth(key))
  118. assert r.status_code == 200, r.text[:300]
  119. body = r.json()
  120. assert body["count"] >= 1
  121. ids = [p["id"] for p in body["agentpacks"]]
  122. assert REVIEWER_ID in ids
  123. # spot-check detail fields
  124. detail = next(p for p in body["agentpacks"] if p["id"] == REVIEWER_ID)
  125. assert detail["version"] == "0.1.0"
  126. assert detail["domain"] == "research"
  127. assert detail["permissions"]["shell"] is False
  128. assert detail["permissions"]["network"] is False
  129. def test_installed_pack_appears_in_list(tmp_path, http_client):
  130. """GET /agentpacks → pre-installed pack shows up."""
  131. client, key, packs_dir, pack = http_client
  132. r = client.get("/api/v1/agentpacks", headers=_auth(key))
  133. assert r.status_code == 200, r.text
  134. body = r.json()
  135. assert body["count"] >= 1
  136. ids = [p["id"] for p in body["agentpacks"]]
  137. assert REVIEWER_ID in ids
  138. def test_get_installed_pack_detail(tmp_path, http_client):
  139. """GET /agentpacks/{id} → correct detail including permission_summary."""
  140. client, key, packs_dir, pack = http_client
  141. r = client.get(f"/api/v1/agentpacks/{REVIEWER_ID}", headers=_auth(key))
  142. assert r.status_code == 200, r.text
  143. p = r.json()
  144. assert p["id"] == REVIEWER_ID
  145. assert len(p["permission_summary"]) > 0
  146. assert "professor" in p["audience"] or "phd_student" in p["audience"]
  147. def test_create_agent_from_installed_pack(tmp_path, http_client):
  148. """POST create-agent → 201, agent_id returned."""
  149. client, key, packs_dir, pack = http_client
  150. r = client.post(
  151. f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
  152. json={"name": "我的审稿助手", "description": "dogfood test"},
  153. headers=_auth(key),
  154. )
  155. assert r.status_code == 201, r.text[:300]
  156. body = r.json()
  157. assert body["ok"] is True
  158. assert body["agent_id"].startswith("ag_")
  159. assert body["pack"]["id"] == REVIEWER_ID
  160. def test_created_agent_has_correct_config(tmp_path, http_client):
  161. """GET /agents/{id} → agent_dir=pack.path, _config_dir correct."""
  162. client, key, packs_dir, pack = http_client
  163. create_r = client.post(
  164. f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
  165. json={"name": "审稿助手"},
  166. headers=_auth(key),
  167. )
  168. agent_id = create_r.json()["agent_id"]
  169. r = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key))
  170. assert r.status_code == 200, r.text
  171. agent = r.json()
  172. assert agent["agent_dir"] == pack.path
  173. assert agent["agent_template"] == REVIEWER_ID
  174. config = agent["config"]
  175. # _config_dir must equal pack.path so sub-agent refs resolve correctly
  176. assert config.get("_config_dir") == pack.path
  177. def test_agent_config_compiles_with_from_config(tmp_path, http_client):
  178. """Critical seam: config stored in DB → from_config() → agent term.
  179. Mirrors what _execute_agent does (dump to tmp YAML, compile).
  180. If this passes, the agent can actually run (LLM aside).
  181. """
  182. import tempfile
  183. from lambdagent.fromconfig import from_config as lambdagent_from_config
  184. client, key, packs_dir, pack = http_client
  185. create_r = client.post(
  186. f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
  187. json={"name": "审稿助手"},
  188. headers=_auth(key),
  189. )
  190. agent_id = create_r.json()["agent_id"]
  191. config = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key)).json()["config"]
  192. with tempfile.NamedTemporaryFile(
  193. mode="w", suffix=".yml", delete=False, encoding="utf-8"
  194. ) as f:
  195. yaml.dump(config, f, allow_unicode=True)
  196. tmp_yml = f.name
  197. try:
  198. term = lambdagent_from_config(tmp_yml)
  199. assert term is not None
  200. finally:
  201. os.unlink(tmp_yml)
  202. def test_agent_appears_in_agents_list(tmp_path, http_client):
  203. """GET /agents after create-agent → new agent visible."""
  204. client, key, packs_dir, pack = http_client
  205. create_r = client.post(
  206. f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
  207. json={"name": "审稿助手"},
  208. headers=_auth(key),
  209. )
  210. agent_id = create_r.json()["agent_id"]
  211. r = client.get("/api/v1/agents", headers=_auth(key))
  212. assert r.status_code == 200
  213. agent_ids = [a["id"] for a in r.json()["agents"]]
  214. assert agent_id in agent_ids
  215. def test_uninstall_pack_does_not_delete_agents(tmp_path, http_client):
  216. """DELETE /agentpacks/{id} removes the pack; derived agent persists.
  217. Agents are data, not derived assets. Deleting the template pack MUST NOT
  218. cascade-delete agents (they may have run history and workspace artifacts).
  219. """
  220. client, key, packs_dir, pack = http_client
  221. create_r = client.post(
  222. f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
  223. json={"name": "审稿助手"},
  224. headers=_auth(key),
  225. )
  226. agent_id = create_r.json()["agent_id"]
  227. del_r = client.delete(f"/api/v1/agentpacks/{REVIEWER_ID}", headers=_auth(key))
  228. assert del_r.status_code == 200
  229. assert del_r.json()["ok"] is True
  230. # Pack is gone
  231. list_r = client.get("/api/v1/agentpacks", headers=_auth(key))
  232. assert list_r.json()["count"] == 0
  233. # Agent STILL exists
  234. agent_r = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key))
  235. assert agent_r.status_code == 200
  236. assert agent_r.json()["name"] == "审稿助手"
  237. def test_get_unknown_pack_returns_404(tmp_path, http_client):
  238. """GET /agentpacks/does.not.exist → 404."""
  239. client, key, packs_dir, pack = http_client
  240. r = client.get("/api/v1/agentpacks/does.not.exist", headers=_auth(key))
  241. assert r.status_code == 404
  242. def test_create_agent_from_uninstalled_pack_returns_404(tmp_path, http_client):
  243. """POST create-agent for a pack that was never installed → 404."""
  244. client, key, packs_dir, pack = http_client
  245. r = client.post(
  246. "/api/v1/agentpacks/does.not.exist/create-agent",
  247. json={"name": "x"},
  248. headers=_auth(key),
  249. )
  250. assert r.status_code == 404
  251. @pytest.mark.skip(reason="requires real LLM (ANTHROPIC_API_KEY). Run: python scripts/dogfood_agentpack.py")
  252. def test_run_agent_produces_review_report(tmp_path, http_client):
  253. """Smoke test: run reviewer pack agent with a short abstract.
  254. Skipped in CI — needs a live LLM. Use scripts/dogfood_agentpack.py for the
  255. full end-to-end dogfood (workspace/review_report.md verification).
  256. """
  257. client, key, packs_dir, pack = http_client
  258. create_r = client.post(
  259. f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
  260. json={"name": "审稿助手"},
  261. headers=_auth(key),
  262. )
  263. agent_id = create_r.json()["agent_id"]
  264. abstract = (
  265. "Title: Quantum Speedup for Matrix Multiplication.\n"
  266. "Abstract: We present a quantum algorithm achieving O(n^{1.5}) "
  267. "matrix multiplication, improving on the classical Strassen bound O(n^{2.37}).\n"
  268. "Verify this claim and produce a review report."
  269. )
  270. run_r = client.post(
  271. f"/api/v1/agents/{agent_id}/run",
  272. json={"input": abstract},
  273. headers=_auth(key),
  274. timeout=300,
  275. )
  276. assert run_r.status_code == 200, run_r.text[:500]
  277. body = run_r.json()
  278. workspace = body.get("workspace_path", "")
  279. assert workspace, "workspace_path missing from response"
  280. assert os.path.isfile(os.path.join(workspace, "review_report.md")), \
  281. f"review_report.md not found in {workspace}"
  282. assert os.path.isfile(os.path.join(workspace, "review_result.json")), \
  283. f"review_result.json not found in {workspace}"