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