| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528 |
- """
- Phase I dogfood — AgentPack end-to-end integration tests (no LLM).
- Exercises the full HTTP path that a desktop user takes:
- install built-in pack ──► list ──► get detail
- │
- └──► create-agent ──► GET agent (verify config)
- │
- └──► from_config() ──► agent compiles ✓
- │
- └──► uninstall pack ──► agent STILL exists (no cascade delete)
- NOTE on install: POST /agentpacks/install is loopback-only (127.0.0.1).
- TestClient sends requests as host "testclient", so the loopback check
- returns 404. We test the loopback gate separately (test_install_loopback_blocked)
- and use install_from_zip() directly for the pack-setup fixture — consistent
- with test_agentpack.py::test_create_agent_from_pack_endpoint. All subsequent
- API calls (list / get / create-agent / delete) go through HTTP.
- Real LLM execution (review_report.md verification) lives in
- scripts/dogfood_agentpack.py and is marked skip here.
- """
- from __future__ import annotations
- import json
- import os
- import zipfile
- import pytest
- import yaml
- os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
- os.environ.setdefault("AGENTPAAS_TESTING", "1")
- # ── Helpers ───────────────────────────────────────────────────────────────────
- def _pack_zip_from_disk(tmp_path, pack_id: str) -> str:
- """Zip the on-disk agentexample pack so install_from_zip() can consume it."""
- repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
- pack_dir = os.path.join(repo_root, "agentexample", "agentpacks", pack_id)
- if not os.path.isdir(pack_dir):
- pytest.skip(f"built-in pack not found on disk: {pack_dir}")
- zip_path = str(tmp_path / f"{pack_id}.zip")
- with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
- for dirpath, _, filenames in os.walk(pack_dir):
- for fname in filenames:
- full = os.path.join(dirpath, fname)
- arcname = os.path.relpath(full, pack_dir)
- zf.write(full, arcname)
- return zip_path
- def _install_pack(packs_dir: str, zip_path: str):
- """Install a pack directly (bypasses loopback gate — tested separately)."""
- from agentpaas.engine.agentpack_store import install_from_zip
- return install_from_zip(zip_path, packs_dir)
- @pytest.fixture()
- def http_client(tmp_path, monkeypatch):
- """TestClient + fresh in-memory DB + one tenant/user/key + reviewer pack pre-installed."""
- import secrets
- from fastapi.testclient import TestClient
- from agentpaas.api.app import app
- from agentpaas.api.middleware.auth import hash_key
- from agentpaas.config import settings
- from agentpaas.db.models import Database, gen_id, now_utc
- import agentpaas.db.session as _session_mod
- packs_dir = tmp_path / "agentpacks"
- packs_dir.mkdir()
- monkeypatch.setattr(settings, "agentpacks_dir", str(packs_dir))
- # 自动目录开辟隔离到 tmp(否则 create-agent 测试会写真实 Workspace/)
- monkeypatch.setattr(settings, "workspace_base", str(tmp_path / "Workspace"))
- prev_db = _session_mod._db
- _session_mod._db = Database("sqlite:///:memory:")
- db = _session_mod._db
- tid = gen_id("tn_")
- uid = gen_id("usr_")
- raw_key = f"ap_{secrets.token_hex(16)}"
- now = now_utc()
- db.execute(
- "INSERT INTO tenants (id, name, plan, status, created_at) "
- "VALUES (?, 'test', 'free', 'active', ?)", (tid, now),
- )
- db.execute(
- "INSERT INTO users (id, tenant_id, email, role, created_at) "
- "VALUES (?, ?, '', 'admin', ?)", (uid, tid, now),
- )
- db.execute(
- "INSERT INTO api_keys "
- "(id, tenant_id, user_id, key_hash, key_prefix, name, scopes, "
- " rate_limit, status, created_at) "
- "VALUES (?, ?, ?, ?, ?, 'test', ?, 600, 'active', ?)",
- (gen_id("key_"), tid, uid, hash_key(raw_key), raw_key[:8],
- json.dumps(["agents:*", "keys:*"]), now),
- )
- db.commit()
- # Pre-install the built-in reviewer pack directly (bypass loopback gate)
- zip_path = _pack_zip_from_disk(tmp_path, REVIEWER_ID)
- pack = _install_pack(str(packs_dir), zip_path)
- with TestClient(app) as client:
- yield client, raw_key, str(packs_dir), pack
- _session_mod._db = prev_db
- # ── Auth helper ───────────────────────────────────────────────────────────────
- def _auth(key: str) -> dict:
- return {"Authorization": f"Bearer {key}"}
- # ── Tests ─────────────────────────────────────────────────────────────────────
- REVIEWER_ID = "research.top-journal-reviewer"
- def test_install_loopback_blocked(tmp_path, monkeypatch):
- """POST /agentpacks/install from non-loopback → 404 (loopback gate).
- TestClient host = 'testclient', not 127.0.0.1 — this is the gate we want.
- """
- import secrets
- from fastapi.testclient import TestClient
- from agentpaas.api.app import app
- from agentpaas.config import settings
- packs_dir = tmp_path / "agentpacks"
- packs_dir.mkdir()
- monkeypatch.setattr(settings, "agentpacks_dir", str(packs_dir))
- zip_path = _pack_zip_from_disk(tmp_path, REVIEWER_ID)
- with TestClient(app) as client:
- r = client.post(
- "/api/v1/agentpacks/install",
- json={"zip_path": zip_path},
- )
- assert r.status_code == 404, r.text[:200]
- def test_install_builtin_pack_via_api(tmp_path, http_client):
- """Pre-installed reviewer pack is visible via GET /agentpacks after direct install."""
- client, key, packs_dir, pack = http_client
- # pack was installed in fixture — verify it via the HTTP list
- r = client.get("/api/v1/agentpacks", headers=_auth(key))
- assert r.status_code == 200, r.text[:300]
- body = r.json()
- assert body["count"] >= 1
- ids = [p["id"] for p in body["agentpacks"]]
- assert REVIEWER_ID in ids
- # spot-check detail fields
- detail = next(p for p in body["agentpacks"] if p["id"] == REVIEWER_ID)
- assert detail["version"] == "0.1.0"
- assert detail["domain"] == "research"
- assert detail["permissions"]["shell"] is False
- assert detail["permissions"]["network"] is False
- def test_installed_pack_appears_in_list(tmp_path, http_client):
- """GET /agentpacks → pre-installed pack shows up."""
- client, key, packs_dir, pack = http_client
- r = client.get("/api/v1/agentpacks", headers=_auth(key))
- assert r.status_code == 200, r.text
- body = r.json()
- assert body["count"] >= 1
- ids = [p["id"] for p in body["agentpacks"]]
- assert REVIEWER_ID in ids
- def test_get_installed_pack_detail(tmp_path, http_client):
- """GET /agentpacks/{id} → correct detail including permission_summary."""
- client, key, packs_dir, pack = http_client
- r = client.get(f"/api/v1/agentpacks/{REVIEWER_ID}", headers=_auth(key))
- assert r.status_code == 200, r.text
- p = r.json()
- assert p["id"] == REVIEWER_ID
- assert len(p["permission_summary"]) > 0
- assert "professor" in p["audience"] or "phd_student" in p["audience"]
- def test_create_agent_from_installed_pack(tmp_path, http_client):
- """POST create-agent → 201, agent_id returned."""
- client, key, packs_dir, pack = http_client
- r = client.post(
- f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
- json={"name": "我的审稿助手", "description": "dogfood test"},
- headers=_auth(key),
- )
- assert r.status_code == 201, r.text[:300]
- body = r.json()
- assert body["ok"] is True
- assert body["agent_id"].startswith("ag_")
- assert body["pack"]["id"] == REVIEWER_ID
- def test_created_agent_has_correct_config(tmp_path, http_client):
- """GET /agents/{id} → agent_dir=pack.path, _config_dir correct."""
- client, key, packs_dir, pack = http_client
- create_r = client.post(
- f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
- json={"name": "审稿助手"},
- headers=_auth(key),
- )
- agent_id = create_r.json()["agent_id"]
- r = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key))
- assert r.status_code == 200, r.text
- agent = r.json()
- assert agent["agent_dir"] == pack.path
- assert agent["agent_template"] == REVIEWER_ID
- config = agent["config"]
- # _config_dir must equal pack.path so sub-agent refs resolve correctly
- assert config.get("_config_dir") == pack.path
- def test_agent_config_compiles_with_from_config(tmp_path, http_client):
- """Critical seam: config stored in DB → from_config() → agent term.
- Mirrors what _execute_agent does (dump to tmp YAML, compile).
- If this passes, the agent can actually run (LLM aside).
- """
- import tempfile
- from lambdagent.fromconfig import from_config as lambdagent_from_config
- client, key, packs_dir, pack = http_client
- create_r = client.post(
- f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
- json={"name": "审稿助手"},
- headers=_auth(key),
- )
- agent_id = create_r.json()["agent_id"]
- config = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key)).json()["config"]
- with tempfile.NamedTemporaryFile(
- mode="w", suffix=".yml", delete=False, encoding="utf-8"
- ) as f:
- yaml.dump(config, f, allow_unicode=True)
- tmp_yml = f.name
- try:
- term = lambdagent_from_config(tmp_yml)
- assert term is not None
- finally:
- os.unlink(tmp_yml)
- def test_agent_appears_in_agents_list(tmp_path, http_client):
- """GET /agents after create-agent → new agent visible."""
- client, key, packs_dir, pack = http_client
- create_r = client.post(
- f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
- json={"name": "审稿助手"},
- headers=_auth(key),
- )
- agent_id = create_r.json()["agent_id"]
- r = client.get("/api/v1/agents", headers=_auth(key))
- assert r.status_code == 200
- agent_ids = [a["id"] for a in r.json()["agents"]]
- assert agent_id in agent_ids
- def test_uninstall_pack_does_not_delete_agents(tmp_path, http_client):
- """DELETE /agentpacks/{id} removes the pack; derived agent persists.
- Agents are data, not derived assets. Deleting the template pack MUST NOT
- cascade-delete agents (they may have run history and workspace artifacts).
- """
- client, key, packs_dir, pack = http_client
- create_r = client.post(
- f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
- json={"name": "审稿助手"},
- headers=_auth(key),
- )
- agent_id = create_r.json()["agent_id"]
- del_r = client.delete(f"/api/v1/agentpacks/{REVIEWER_ID}", headers=_auth(key))
- assert del_r.status_code == 200
- assert del_r.json()["ok"] is True
- # Pack is gone
- list_r = client.get("/api/v1/agentpacks", headers=_auth(key))
- assert list_r.json()["count"] == 0
- # Agent STILL exists
- agent_r = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key))
- assert agent_r.status_code == 200
- assert agent_r.json()["name"] == "审稿助手"
- def test_get_unknown_pack_returns_404(tmp_path, http_client):
- """GET /agentpacks/does.not.exist → 404."""
- client, key, packs_dir, pack = http_client
- r = client.get("/api/v1/agentpacks/does.not.exist", headers=_auth(key))
- assert r.status_code == 404
- def test_create_agent_from_uninstalled_pack_returns_404(tmp_path, http_client):
- """POST create-agent for a pack that was never installed → 404."""
- client, key, packs_dir, pack = http_client
- r = client.post(
- "/api/v1/agentpacks/does.not.exist/create-agent",
- json={"name": "x"},
- headers=_auth(key),
- )
- assert r.status_code == 404
- @pytest.mark.skip(reason="requires real LLM (ANTHROPIC_API_KEY). Run: python scripts/dogfood_agentpack.py")
- def test_run_agent_produces_review_report(tmp_path, http_client):
- """Smoke test: run reviewer pack agent with a short abstract.
- Skipped in CI — needs a live LLM. Use scripts/dogfood_agentpack.py for the
- full end-to-end dogfood (workspace/review_report.md verification).
- """
- client, key, packs_dir, pack = http_client
- create_r = client.post(
- f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
- json={"name": "审稿助手"},
- headers=_auth(key),
- )
- agent_id = create_r.json()["agent_id"]
- abstract = (
- "Title: Quantum Speedup for Matrix Multiplication.\n"
- "Abstract: We present a quantum algorithm achieving O(n^{1.5}) "
- "matrix multiplication, improving on the classical Strassen bound O(n^{2.37}).\n"
- "Verify this claim and produce a review report."
- )
- run_r = client.post(
- f"/api/v1/agents/{agent_id}/run",
- json={"input": abstract},
- headers=_auth(key),
- timeout=300,
- )
- assert run_r.status_code == 200, run_r.text[:500]
- body = run_r.json()
- workspace = body.get("workspace_path", "")
- assert workspace, "workspace_path missing from response"
- assert os.path.isfile(os.path.join(workspace, "review_report.md")), \
- f"review_report.md not found in {workspace}"
- assert os.path.isfile(os.path.join(workspace, "review_result.json")), \
- f"review_result.json not found in {workspace}"
- # ── Pack update (zip upload → agents on latest config) ───────────────────────
- # AUDIT 后新增功能: POST /agentpacks/{id}/update-upload。HTTP 层的 loopback
- # 门单测 + 核心传播逻辑直测(与 install 的测试策略一致)。
- def _pack_zip_with_prompt(tmp_path, pack_id: str, new_prompt: str,
- new_version: str = "") -> str:
- """复制内置包源码,改 entrypoint 的 systemPrompt(和可选版本号),打成 zip。"""
- import shutil
- repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
- src = os.path.join(repo_root, "agentexample", "agentpacks", pack_id)
- if not os.path.isdir(src):
- pytest.skip(f"built-in pack not found on disk: {src}")
- work = str(tmp_path / "pack_v2_src")
- shutil.copytree(src, work)
- with open(os.path.join(work, "manifest.yml"), encoding="utf-8") as f:
- manifest = yaml.safe_load(f)
- if new_version:
- manifest["version"] = new_version
- with open(os.path.join(work, "manifest.yml"), "w", encoding="utf-8") as f:
- yaml.safe_dump(manifest, f, allow_unicode=True)
- entry = os.path.join(work, manifest["entrypoint"])
- with open(entry, encoding="utf-8") as f:
- cfg = yaml.safe_load(f)
- cfg["systemPrompt"] = new_prompt
- with open(entry, "w", encoding="utf-8") as f:
- yaml.safe_dump(cfg, f, allow_unicode=True)
- zip_path = str(tmp_path / "pack_v2.zip")
- with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
- for dirpath, _, filenames in os.walk(work):
- for fname in filenames:
- full = os.path.join(dirpath, fname)
- zf.write(full, os.path.relpath(full, work))
- return zip_path
- def test_update_upload_loopback_blocked(http_client, tmp_path):
- """非 loopback 调 update-upload → 404(同 /install 的 oracle 防御)。"""
- client, key, packs_dir, pack = http_client
- zip_path = _pack_zip_with_prompt(tmp_path, REVIEWER_ID, "x")
- with open(zip_path, "rb") as f:
- r = client.post(
- f"/api/v1/agentpacks/{REVIEWER_ID}/update-upload",
- files={"file": ("pack.zip", f, "application/zip")},
- headers=_auth(key),
- )
- assert r.status_code == 404
- def test_peek_manifest_id_check(tmp_path, http_client):
- """peek_manifest_from_zip 返回正确 id(更新端点据此拒绝错包)。"""
- from agentpaas.engine.agentpack_store import peek_manifest_from_zip
- zip_path = _pack_zip_with_prompt(tmp_path, REVIEWER_ID, "NEW PROMPT")
- m = peek_manifest_from_zip(zip_path)
- assert m.id == REVIEWER_ID
- # zip 不会被安装
- assert not os.path.exists(
- os.path.join(str(tmp_path), "agentpacks", REVIEWER_ID, "0.9.9"))
- def test_pack_update_refreshes_agents(http_client, tmp_path):
- """更新包 → 由它创建的智能体生成新版本、配置换新、可回滚。"""
- from agentpaas.api.v1.agentpacks import _refresh_agents_from_pack
- client, key, packs_dir, pack = http_client
- # 1. 从包创建智能体(HTTP)
- r = client.post(
- f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
- json={"name": "更新测试-审稿"},
- headers=_auth(key),
- )
- assert r.status_code == 201, r.text[:300]
- agent_id = r.json()["agent_id"]
- g0 = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key)).json()
- old_prompt = g0["config"]["systemPrompt"]
- assert g0["current_version"] == 1
- # 2. 构造新版包(新版本号 + 新 systemPrompt)并安装
- NEW_PROMPT = "你是更新后的审稿专家 v2。"
- zip_path = _pack_zip_with_prompt(
- tmp_path, REVIEWER_ID, NEW_PROMPT, new_version="0.9.9")
- new_pack = _install_pack(packs_dir, zip_path)
- assert new_pack.version == "0.9.9"
- # 3. 传播到智能体
- refreshed = _refresh_agents_from_pack(new_pack)
- assert any(a["agent_id"] == agent_id and a["version"] == 2
- for a in refreshed)
- # 4. 智能体现在跑最新配置
- g1 = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key)).json()
- assert g1["current_version"] == 2
- assert g1["config"]["systemPrompt"] == NEW_PROMPT
- assert g1["config"]["systemPrompt"] != old_prompt
- # agent_dir 跟到新版本安装路径
- assert g1["agent_dir"].endswith("0.9.9")
- # 5. 旧配置仍可回滚
- rb = client.post(
- f"/api/v1/agents/{agent_id}/rollback",
- json={"target_version": 1},
- headers=_auth(key),
- )
- assert rb.status_code == 200, rb.text[:300]
- g2 = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key)).json()
- assert g2["config"]["systemPrompt"] == old_prompt
- def test_create_agent_from_pack_auto_provisions_dirs(http_client, tmp_path):
- """从包创建智能体也自动开辟 Workspace/<名>/{data,workspace} 目录。"""
- client, key, packs_dir, pack = http_client
- r = client.post(
- f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
- json={"name": "目录开辟-审稿"},
- headers=_auth(key),
- )
- assert r.status_code == 201, r.text[:300]
- body = r.json()
- assert body["work_dir"].endswith("目录开辟-审稿")
- assert body["source_dir"] == os.path.join(body["work_dir"], "data")
- assert body["run_dir"] == os.path.join(body["work_dir"], "workspace")
- assert os.path.isdir(body["source_dir"])
- assert os.path.isdir(body["run_dir"])
- # GET 回读一致
- g = client.get(f"/api/v1/agents/{body['agent_id']}", headers=_auth(key)).json()
- assert g["work_dir"] == body["work_dir"]
- assert g["run_dir"] == body["run_dir"]
- def test_run_with_context_work_dir_override(tmp_path, http_client, monkeypatch):
- """工作区对话模式回归:run 带 context.work_dir 应覆盖工作目录、不报 500。
- 曾因 run handler 用裸 `os`(本文件无模块级 import os,仅 `import os as _os`)
- 在 work_dir override 分支 NameError 崩 500——且该分支只在 context.work_dir
- 有值时触发,普通 run 测试覆盖不到。本例正好走这条分支。
- """
- client, key, packs_dir, pack = http_client
- create_r = client.post(
- f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
- json={"name": "工作区回归"},
- headers=_auth(key),
- )
- agent_id = create_r.json()["agent_id"]
- captured = {}
- def _stub_exec(config, input_text, **kw):
- captured["work_dir"] = kw.get("work_dir")
- captured["source_dir"] = kw.get("source_dir")
- captured["inplace_dir"] = kw.get("inplace_dir")
- return ("摘要:ok", {"workspace_path": "", "input_tokens": 1,
- "output_tokens": 1, "steps": 1, "cost_usd": 0.0,
- "cache_read_tokens": 0, "cache_creation_tokens": 0})
- import agentpaas.api.v1.agents as _agents_mod
- monkeypatch.setattr(_agents_mod, "_execute_agent", _stub_exec)
- folder = str(tmp_path)
- r = client.post(
- f"/api/v1/agents/{agent_id}/run",
- json={"input": "总结一下", "context": {"work_dir": folder}},
- headers=_auth(key),
- )
- assert r.status_code != 500, f"work_dir override 崩了: {r.text[:300]}"
- assert r.status_code == 200, r.text[:300]
- # override 生效:_execute_agent 收到的 work_dir/source_dir 都是所选文件夹
- assert captured.get("work_dir") == folder
- assert captured.get("source_dir") == folder
- # 工作区模式:inplace_dir 传入 → 模型 [工作目录] 会被设为 F(就地操作)
- assert captured.get("inplace_dir") == folder
|