test_agentpack_e2e.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  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. # 自动目录开辟隔离到 tmp(否则 create-agent 测试会写真实 Workspace/)
  61. monkeypatch.setattr(settings, "workspace_base", str(tmp_path / "Workspace"))
  62. prev_db = _session_mod._db
  63. _session_mod._db = Database("sqlite:///:memory:")
  64. db = _session_mod._db
  65. tid = gen_id("tn_")
  66. uid = gen_id("usr_")
  67. raw_key = f"ap_{secrets.token_hex(16)}"
  68. now = now_utc()
  69. db.execute(
  70. "INSERT INTO tenants (id, name, plan, status, created_at) "
  71. "VALUES (?, 'test', 'free', 'active', ?)", (tid, now),
  72. )
  73. db.execute(
  74. "INSERT INTO users (id, tenant_id, email, role, created_at) "
  75. "VALUES (?, ?, '', 'admin', ?)", (uid, tid, now),
  76. )
  77. db.execute(
  78. "INSERT INTO api_keys "
  79. "(id, tenant_id, user_id, key_hash, key_prefix, name, scopes, "
  80. " rate_limit, status, created_at) "
  81. "VALUES (?, ?, ?, ?, ?, 'test', ?, 600, 'active', ?)",
  82. (gen_id("key_"), tid, uid, hash_key(raw_key), raw_key[:8],
  83. json.dumps(["agents:*", "keys:*"]), now),
  84. )
  85. db.commit()
  86. # Pre-install the built-in reviewer pack directly (bypass loopback gate)
  87. zip_path = _pack_zip_from_disk(tmp_path, REVIEWER_ID)
  88. pack = _install_pack(str(packs_dir), zip_path)
  89. with TestClient(app) as client:
  90. yield client, raw_key, str(packs_dir), pack
  91. _session_mod._db = prev_db
  92. # ── Auth helper ───────────────────────────────────────────────────────────────
  93. def _auth(key: str) -> dict:
  94. return {"Authorization": f"Bearer {key}"}
  95. # ── Tests ─────────────────────────────────────────────────────────────────────
  96. REVIEWER_ID = "research.top-journal-reviewer"
  97. def test_install_loopback_blocked(tmp_path, monkeypatch):
  98. """POST /agentpacks/install from non-loopback → 404 (loopback gate).
  99. TestClient host = 'testclient', not 127.0.0.1 — this is the gate we want.
  100. """
  101. import secrets
  102. from fastapi.testclient import TestClient
  103. from agentpaas.api.app import app
  104. from agentpaas.config import settings
  105. packs_dir = tmp_path / "agentpacks"
  106. packs_dir.mkdir()
  107. monkeypatch.setattr(settings, "agentpacks_dir", str(packs_dir))
  108. zip_path = _pack_zip_from_disk(tmp_path, REVIEWER_ID)
  109. with TestClient(app) as client:
  110. r = client.post(
  111. "/api/v1/agentpacks/install",
  112. json={"zip_path": zip_path},
  113. )
  114. assert r.status_code == 404, r.text[:200]
  115. def test_install_builtin_pack_via_api(tmp_path, http_client):
  116. """Pre-installed reviewer pack is visible via GET /agentpacks after direct install."""
  117. client, key, packs_dir, pack = http_client
  118. # pack was installed in fixture — verify it via the HTTP list
  119. r = client.get("/api/v1/agentpacks", headers=_auth(key))
  120. assert r.status_code == 200, r.text[:300]
  121. body = r.json()
  122. assert body["count"] >= 1
  123. ids = [p["id"] for p in body["agentpacks"]]
  124. assert REVIEWER_ID in ids
  125. # spot-check detail fields
  126. detail = next(p for p in body["agentpacks"] if p["id"] == REVIEWER_ID)
  127. assert detail["version"] == "0.1.0"
  128. assert detail["domain"] == "research"
  129. assert detail["permissions"]["shell"] is False
  130. assert detail["permissions"]["network"] is False
  131. def test_installed_pack_appears_in_list(tmp_path, http_client):
  132. """GET /agentpacks → pre-installed pack shows up."""
  133. client, key, packs_dir, pack = http_client
  134. r = client.get("/api/v1/agentpacks", headers=_auth(key))
  135. assert r.status_code == 200, r.text
  136. body = r.json()
  137. assert body["count"] >= 1
  138. ids = [p["id"] for p in body["agentpacks"]]
  139. assert REVIEWER_ID in ids
  140. def test_get_installed_pack_detail(tmp_path, http_client):
  141. """GET /agentpacks/{id} → correct detail including permission_summary."""
  142. client, key, packs_dir, pack = http_client
  143. r = client.get(f"/api/v1/agentpacks/{REVIEWER_ID}", headers=_auth(key))
  144. assert r.status_code == 200, r.text
  145. p = r.json()
  146. assert p["id"] == REVIEWER_ID
  147. assert len(p["permission_summary"]) > 0
  148. assert "professor" in p["audience"] or "phd_student" in p["audience"]
  149. def test_create_agent_from_installed_pack(tmp_path, http_client):
  150. """POST create-agent → 201, agent_id returned."""
  151. client, key, packs_dir, pack = http_client
  152. r = client.post(
  153. f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
  154. json={"name": "我的审稿助手", "description": "dogfood test"},
  155. headers=_auth(key),
  156. )
  157. assert r.status_code == 201, r.text[:300]
  158. body = r.json()
  159. assert body["ok"] is True
  160. assert body["agent_id"].startswith("ag_")
  161. assert body["pack"]["id"] == REVIEWER_ID
  162. def test_created_agent_has_correct_config(tmp_path, http_client):
  163. """GET /agents/{id} → agent_dir=pack.path, _config_dir correct."""
  164. client, key, packs_dir, pack = http_client
  165. create_r = client.post(
  166. f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
  167. json={"name": "审稿助手"},
  168. headers=_auth(key),
  169. )
  170. agent_id = create_r.json()["agent_id"]
  171. r = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key))
  172. assert r.status_code == 200, r.text
  173. agent = r.json()
  174. assert agent["agent_dir"] == pack.path
  175. assert agent["agent_template"] == REVIEWER_ID
  176. config = agent["config"]
  177. # _config_dir must equal pack.path so sub-agent refs resolve correctly
  178. assert config.get("_config_dir") == pack.path
  179. def test_agent_config_compiles_with_from_config(tmp_path, http_client):
  180. """Critical seam: config stored in DB → from_config() → agent term.
  181. Mirrors what _execute_agent does (dump to tmp YAML, compile).
  182. If this passes, the agent can actually run (LLM aside).
  183. """
  184. import tempfile
  185. from lambdagent.fromconfig import from_config as lambdagent_from_config
  186. client, key, packs_dir, pack = http_client
  187. create_r = client.post(
  188. f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
  189. json={"name": "审稿助手"},
  190. headers=_auth(key),
  191. )
  192. agent_id = create_r.json()["agent_id"]
  193. config = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key)).json()["config"]
  194. with tempfile.NamedTemporaryFile(
  195. mode="w", suffix=".yml", delete=False, encoding="utf-8"
  196. ) as f:
  197. yaml.dump(config, f, allow_unicode=True)
  198. tmp_yml = f.name
  199. try:
  200. term = lambdagent_from_config(tmp_yml)
  201. assert term is not None
  202. finally:
  203. os.unlink(tmp_yml)
  204. def test_agent_appears_in_agents_list(tmp_path, http_client):
  205. """GET /agents after create-agent → new agent visible."""
  206. client, key, packs_dir, pack = http_client
  207. create_r = client.post(
  208. f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
  209. json={"name": "审稿助手"},
  210. headers=_auth(key),
  211. )
  212. agent_id = create_r.json()["agent_id"]
  213. r = client.get("/api/v1/agents", headers=_auth(key))
  214. assert r.status_code == 200
  215. agent_ids = [a["id"] for a in r.json()["agents"]]
  216. assert agent_id in agent_ids
  217. def test_uninstall_pack_does_not_delete_agents(tmp_path, http_client):
  218. """DELETE /agentpacks/{id} removes the pack; derived agent persists.
  219. Agents are data, not derived assets. Deleting the template pack MUST NOT
  220. cascade-delete agents (they may have run history and workspace artifacts).
  221. """
  222. client, key, packs_dir, pack = http_client
  223. create_r = client.post(
  224. f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
  225. json={"name": "审稿助手"},
  226. headers=_auth(key),
  227. )
  228. agent_id = create_r.json()["agent_id"]
  229. del_r = client.delete(f"/api/v1/agentpacks/{REVIEWER_ID}", headers=_auth(key))
  230. assert del_r.status_code == 200
  231. assert del_r.json()["ok"] is True
  232. # Pack is gone
  233. list_r = client.get("/api/v1/agentpacks", headers=_auth(key))
  234. assert list_r.json()["count"] == 0
  235. # Agent STILL exists
  236. agent_r = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key))
  237. assert agent_r.status_code == 200
  238. assert agent_r.json()["name"] == "审稿助手"
  239. def test_get_unknown_pack_returns_404(tmp_path, http_client):
  240. """GET /agentpacks/does.not.exist → 404."""
  241. client, key, packs_dir, pack = http_client
  242. r = client.get("/api/v1/agentpacks/does.not.exist", headers=_auth(key))
  243. assert r.status_code == 404
  244. def test_create_agent_from_uninstalled_pack_returns_404(tmp_path, http_client):
  245. """POST create-agent for a pack that was never installed → 404."""
  246. client, key, packs_dir, pack = http_client
  247. r = client.post(
  248. "/api/v1/agentpacks/does.not.exist/create-agent",
  249. json={"name": "x"},
  250. headers=_auth(key),
  251. )
  252. assert r.status_code == 404
  253. @pytest.mark.skip(reason="requires real LLM (ANTHROPIC_API_KEY). Run: python scripts/dogfood_agentpack.py")
  254. def test_run_agent_produces_review_report(tmp_path, http_client):
  255. """Smoke test: run reviewer pack agent with a short abstract.
  256. Skipped in CI — needs a live LLM. Use scripts/dogfood_agentpack.py for the
  257. full end-to-end dogfood (workspace/review_report.md verification).
  258. """
  259. client, key, packs_dir, pack = http_client
  260. create_r = client.post(
  261. f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
  262. json={"name": "审稿助手"},
  263. headers=_auth(key),
  264. )
  265. agent_id = create_r.json()["agent_id"]
  266. abstract = (
  267. "Title: Quantum Speedup for Matrix Multiplication.\n"
  268. "Abstract: We present a quantum algorithm achieving O(n^{1.5}) "
  269. "matrix multiplication, improving on the classical Strassen bound O(n^{2.37}).\n"
  270. "Verify this claim and produce a review report."
  271. )
  272. run_r = client.post(
  273. f"/api/v1/agents/{agent_id}/run",
  274. json={"input": abstract},
  275. headers=_auth(key),
  276. timeout=300,
  277. )
  278. assert run_r.status_code == 200, run_r.text[:500]
  279. body = run_r.json()
  280. workspace = body.get("workspace_path", "")
  281. assert workspace, "workspace_path missing from response"
  282. assert os.path.isfile(os.path.join(workspace, "review_report.md")), \
  283. f"review_report.md not found in {workspace}"
  284. assert os.path.isfile(os.path.join(workspace, "review_result.json")), \
  285. f"review_result.json not found in {workspace}"
  286. # ── Pack update (zip upload → agents on latest config) ───────────────────────
  287. # AUDIT 后新增功能: POST /agentpacks/{id}/update-upload。HTTP 层的 loopback
  288. # 门单测 + 核心传播逻辑直测(与 install 的测试策略一致)。
  289. def _pack_zip_with_prompt(tmp_path, pack_id: str, new_prompt: str,
  290. new_version: str = "") -> str:
  291. """复制内置包源码,改 entrypoint 的 systemPrompt(和可选版本号),打成 zip。"""
  292. import shutil
  293. repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  294. src = os.path.join(repo_root, "agentexample", "agentpacks", pack_id)
  295. if not os.path.isdir(src):
  296. pytest.skip(f"built-in pack not found on disk: {src}")
  297. work = str(tmp_path / "pack_v2_src")
  298. shutil.copytree(src, work)
  299. with open(os.path.join(work, "manifest.yml"), encoding="utf-8") as f:
  300. manifest = yaml.safe_load(f)
  301. if new_version:
  302. manifest["version"] = new_version
  303. with open(os.path.join(work, "manifest.yml"), "w", encoding="utf-8") as f:
  304. yaml.safe_dump(manifest, f, allow_unicode=True)
  305. entry = os.path.join(work, manifest["entrypoint"])
  306. with open(entry, encoding="utf-8") as f:
  307. cfg = yaml.safe_load(f)
  308. cfg["systemPrompt"] = new_prompt
  309. with open(entry, "w", encoding="utf-8") as f:
  310. yaml.safe_dump(cfg, f, allow_unicode=True)
  311. zip_path = str(tmp_path / "pack_v2.zip")
  312. with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
  313. for dirpath, _, filenames in os.walk(work):
  314. for fname in filenames:
  315. full = os.path.join(dirpath, fname)
  316. zf.write(full, os.path.relpath(full, work))
  317. return zip_path
  318. def test_update_upload_loopback_blocked(http_client, tmp_path):
  319. """非 loopback 调 update-upload → 404(同 /install 的 oracle 防御)。"""
  320. client, key, packs_dir, pack = http_client
  321. zip_path = _pack_zip_with_prompt(tmp_path, REVIEWER_ID, "x")
  322. with open(zip_path, "rb") as f:
  323. r = client.post(
  324. f"/api/v1/agentpacks/{REVIEWER_ID}/update-upload",
  325. files={"file": ("pack.zip", f, "application/zip")},
  326. headers=_auth(key),
  327. )
  328. assert r.status_code == 404
  329. def test_peek_manifest_id_check(tmp_path, http_client):
  330. """peek_manifest_from_zip 返回正确 id(更新端点据此拒绝错包)。"""
  331. from agentpaas.engine.agentpack_store import peek_manifest_from_zip
  332. zip_path = _pack_zip_with_prompt(tmp_path, REVIEWER_ID, "NEW PROMPT")
  333. m = peek_manifest_from_zip(zip_path)
  334. assert m.id == REVIEWER_ID
  335. # zip 不会被安装
  336. assert not os.path.exists(
  337. os.path.join(str(tmp_path), "agentpacks", REVIEWER_ID, "0.9.9"))
  338. def test_pack_update_refreshes_agents(http_client, tmp_path):
  339. """更新包 → 由它创建的智能体生成新版本、配置换新、可回滚。"""
  340. from agentpaas.api.v1.agentpacks import _refresh_agents_from_pack
  341. client, key, packs_dir, pack = http_client
  342. # 1. 从包创建智能体(HTTP)
  343. r = client.post(
  344. f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
  345. json={"name": "更新测试-审稿"},
  346. headers=_auth(key),
  347. )
  348. assert r.status_code == 201, r.text[:300]
  349. agent_id = r.json()["agent_id"]
  350. g0 = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key)).json()
  351. old_prompt = g0["config"]["systemPrompt"]
  352. assert g0["current_version"] == 1
  353. # 2. 构造新版包(新版本号 + 新 systemPrompt)并安装
  354. NEW_PROMPT = "你是更新后的审稿专家 v2。"
  355. zip_path = _pack_zip_with_prompt(
  356. tmp_path, REVIEWER_ID, NEW_PROMPT, new_version="0.9.9")
  357. new_pack = _install_pack(packs_dir, zip_path)
  358. assert new_pack.version == "0.9.9"
  359. # 3. 传播到智能体
  360. refreshed = _refresh_agents_from_pack(new_pack)
  361. assert any(a["agent_id"] == agent_id and a["version"] == 2
  362. for a in refreshed)
  363. # 4. 智能体现在跑最新配置
  364. g1 = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key)).json()
  365. assert g1["current_version"] == 2
  366. assert g1["config"]["systemPrompt"] == NEW_PROMPT
  367. assert g1["config"]["systemPrompt"] != old_prompt
  368. # agent_dir 跟到新版本安装路径
  369. assert g1["agent_dir"].endswith("0.9.9")
  370. # 5. 旧配置仍可回滚
  371. rb = client.post(
  372. f"/api/v1/agents/{agent_id}/rollback",
  373. json={"target_version": 1},
  374. headers=_auth(key),
  375. )
  376. assert rb.status_code == 200, rb.text[:300]
  377. g2 = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key)).json()
  378. assert g2["config"]["systemPrompt"] == old_prompt
  379. def test_create_agent_from_pack_auto_provisions_dirs(http_client, tmp_path):
  380. """从包创建智能体也自动开辟 Workspace/<名>/{data,workspace} 目录。"""
  381. client, key, packs_dir, pack = http_client
  382. r = client.post(
  383. f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
  384. json={"name": "目录开辟-审稿"},
  385. headers=_auth(key),
  386. )
  387. assert r.status_code == 201, r.text[:300]
  388. body = r.json()
  389. assert body["work_dir"].endswith("目录开辟-审稿")
  390. assert body["source_dir"] == os.path.join(body["work_dir"], "data")
  391. assert body["run_dir"] == os.path.join(body["work_dir"], "workspace")
  392. assert os.path.isdir(body["source_dir"])
  393. assert os.path.isdir(body["run_dir"])
  394. # GET 回读一致
  395. g = client.get(f"/api/v1/agents/{body['agent_id']}", headers=_auth(key)).json()
  396. assert g["work_dir"] == body["work_dir"]
  397. assert g["run_dir"] == body["run_dir"]
  398. def test_run_with_context_work_dir_override(tmp_path, http_client, monkeypatch):
  399. """工作区对话模式回归:run 带 context.work_dir 应覆盖工作目录、不报 500。
  400. 曾因 run handler 用裸 `os`(本文件无模块级 import os,仅 `import os as _os`)
  401. 在 work_dir override 分支 NameError 崩 500——且该分支只在 context.work_dir
  402. 有值时触发,普通 run 测试覆盖不到。本例正好走这条分支。
  403. """
  404. client, key, packs_dir, pack = http_client
  405. create_r = client.post(
  406. f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
  407. json={"name": "工作区回归"},
  408. headers=_auth(key),
  409. )
  410. agent_id = create_r.json()["agent_id"]
  411. captured = {}
  412. def _stub_exec(config, input_text, **kw):
  413. captured["work_dir"] = kw.get("work_dir")
  414. captured["source_dir"] = kw.get("source_dir")
  415. captured["inplace_dir"] = kw.get("inplace_dir")
  416. return ("摘要:ok", {"workspace_path": "", "input_tokens": 1,
  417. "output_tokens": 1, "steps": 1, "cost_usd": 0.0,
  418. "cache_read_tokens": 0, "cache_creation_tokens": 0})
  419. import agentpaas.api.v1.agents as _agents_mod
  420. monkeypatch.setattr(_agents_mod, "_execute_agent", _stub_exec)
  421. folder = str(tmp_path)
  422. r = client.post(
  423. f"/api/v1/agents/{agent_id}/run",
  424. json={"input": "总结一下", "context": {"work_dir": folder}},
  425. headers=_auth(key),
  426. )
  427. assert r.status_code != 500, f"work_dir override 崩了: {r.text[:300]}"
  428. assert r.status_code == 200, r.text[:300]
  429. # override 生效:_execute_agent 收到的 work_dir/source_dir 都是所选文件夹
  430. assert captured.get("work_dir") == folder
  431. assert captured.get("source_dir") == folder
  432. # 工作区模式:inplace_dir 传入 → 模型 [工作目录] 会被设为 F(就地操作)
  433. assert captured.get("inplace_dir") == folder