test_api.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. from __future__ import annotations
  2. import json
  3. from pathlib import Path
  4. import pytest
  5. from fastapi.testclient import TestClient
  6. from app.main import app
  7. from app.real_runs import _build_id_for_root
  8. client = TestClient(app)
  9. BUILD_ID = 990001
  10. ROOT_TRACE_ID = "root-990001"
  11. NOW = "2026-07-21T00:00:00+00:00"
  12. def _write_json(path: Path, value: object) -> None:
  13. path.parent.mkdir(parents=True, exist_ok=True)
  14. path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
  15. def _task(
  16. task_id: str,
  17. *,
  18. parent_task_id: str | None,
  19. display_path: str,
  20. kind: str,
  21. objective: str,
  22. child_task_ids: list[str] | None = None,
  23. attempt_ids: list[str] | None = None,
  24. validation_ids: list[str] | None = None,
  25. decision_ids: list[str] | None = None,
  26. ) -> dict[str, object]:
  27. context_refs = [] if kind == "root" else [f"script-build://task-kinds/{kind}"]
  28. return {
  29. "task_id": task_id,
  30. "parent_task_id": parent_task_id,
  31. "display_path": display_path,
  32. "specs": [
  33. {
  34. "version": 1,
  35. "objective": objective,
  36. "acceptance_criteria": [
  37. {
  38. "criterion_id": f"{kind}-closed",
  39. "description": "产物闭合",
  40. "hard": True,
  41. }
  42. ],
  43. "context_refs": context_refs,
  44. }
  45. ],
  46. "current_spec_version": 1,
  47. "status": "completed",
  48. "child_task_ids": child_task_ids or [],
  49. "attempt_ids": attempt_ids or [],
  50. "validation_ids": validation_ids or [],
  51. "decision_ids": decision_ids or [],
  52. "blocked_reason": None,
  53. "created_at": NOW,
  54. "updated_at": NOW,
  55. }
  56. def _planner_message(sequence: int, call_id: str, name: str, arguments: dict) -> dict:
  57. return {
  58. "sequence": sequence,
  59. "role": "assistant",
  60. "content": {
  61. "text": f"执行 {name}",
  62. "tool_calls": [
  63. {
  64. "id": call_id,
  65. "function": {"name": name, "arguments": json.dumps(arguments)},
  66. }
  67. ],
  68. },
  69. "tokens": 10,
  70. "cost": 0,
  71. "created_at": NOW,
  72. }
  73. def _tool_result(sequence: int, call_id: str, name: str, result: dict) -> dict:
  74. return {
  75. "sequence": sequence,
  76. "role": "tool",
  77. "tool_call_id": call_id,
  78. "content": {"tool_name": name, "result": result},
  79. "created_at": NOW,
  80. }
  81. @pytest.fixture
  82. def persisted_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
  83. root = tmp_path / "agent-data"
  84. monkeypatch.setenv("SCRIPT_BUILD_AGENT_DATA_ROOT", str(root))
  85. _write_json(
  86. root / "traces" / ROOT_TRACE_ID / "meta.json",
  87. {
  88. "trace_id": ROOT_TRACE_ID,
  89. "mode": "agent",
  90. "agent_role": "explicit",
  91. "context": {"script_build_id": BUILD_ID, "phase": "one"},
  92. },
  93. )
  94. root_messages = root / "traces" / ROOT_TRACE_ID / "messages"
  95. messages = [
  96. {"sequence": 1, "role": "user", "content": '{"phase":"one"}', "created_at": NOW},
  97. _planner_message(2, "call-plan-direction", "plan_script_tasks", {"parent_task_id": "root-task"}),
  98. _tool_result(3, "call-plan-direction", "plan_script_tasks", {"task_ids": ["direction-task"]}),
  99. _planner_message(4, "call-dispatch-direction", "dispatch_script_tasks", {"task_ids": ["direction-task"]}),
  100. _tool_result(5, "call-dispatch-direction", "dispatch_script_tasks", {"task_ids": ["direction-task"]}),
  101. {"sequence": 6, "role": "user", "content": '{"phase":"two"}', "created_at": NOW},
  102. _planner_message(7, "call-plan-paragraph", "inspect_script_plan", {"task_id": "root-task"}),
  103. _tool_result(8, "call-plan-paragraph", "inspect_script_plan", {"task_ids": ["paragraph-task"]}),
  104. _planner_message(9, "call-decide-paragraph", "decide_script_task", {"task_id": "paragraph-task"}),
  105. _tool_result(10, "call-decide-paragraph", "decide_script_task", {"task_id": "paragraph-task"}),
  106. ]
  107. for index, message in enumerate(messages, start=1):
  108. _write_json(root_messages / f"{index:04d}.json", message)
  109. worker_trace_id = "worker-paragraph"
  110. worker_messages = root / "traces" / worker_trace_id / "messages"
  111. worker_values = [
  112. {
  113. "sequence": 1,
  114. "role": "system",
  115. "content": "Produce one independently testable Paragraph from the frozen contract.",
  116. "created_at": NOW,
  117. },
  118. {
  119. "sequence": 2,
  120. "role": "user",
  121. "content": '{"task_spec":{"objective":"write opening"}}',
  122. "created_at": NOW,
  123. },
  124. _planner_message(3, "call-read-input", "read_input_snapshot", {}),
  125. _tool_result(4, "call-read-input", "read_input_snapshot", {"topic": "fixture"}),
  126. ]
  127. for index, message in enumerate(worker_values, start=1):
  128. _write_json(worker_messages / f"{index:04d}.json", message)
  129. tasks = {
  130. "root-task": _task(
  131. "root-task",
  132. parent_task_id=None,
  133. display_path="0",
  134. kind="root",
  135. objective="完成脚本构建",
  136. child_task_ids=["direction-task", "paragraph-task"],
  137. ),
  138. "direction-task": _task(
  139. "direction-task",
  140. parent_task_id="root-task",
  141. display_path="0.1",
  142. kind="direction",
  143. objective="确定创作方向",
  144. ),
  145. "paragraph-task": _task(
  146. "paragraph-task",
  147. parent_task_id="root-task",
  148. display_path="0.2",
  149. kind="paragraph",
  150. objective="创作开场段落",
  151. attempt_ids=["attempt-paragraph"],
  152. validation_ids=["validation-paragraph"],
  153. decision_ids=["decision-paragraph"],
  154. ),
  155. }
  156. ledger = {
  157. "root_task_id": "root-task",
  158. "root_objective": "完成脚本构建",
  159. "revision": 4,
  160. "tasks": tasks,
  161. "attempts": {
  162. "attempt-paragraph": {
  163. "attempt_id": "attempt-paragraph",
  164. "task_id": "paragraph-task",
  165. "status": "completed",
  166. "worker_preset": "script_paragraph_worker",
  167. "worker_trace_id": worker_trace_id,
  168. "execution_mode": "local",
  169. "created_at": NOW,
  170. "started_at": NOW,
  171. "completed_at": NOW,
  172. "execution_stats": {"total_tokens": 20, "total_cost": 0},
  173. "submission": {
  174. "artifact_refs": [
  175. {
  176. "uri": "script-build://artifacts/paragraph/1",
  177. "kind": "paragraph",
  178. "digest": "sha256:fixture",
  179. }
  180. ]
  181. },
  182. "error": None,
  183. }
  184. },
  185. "validations": {
  186. "validation-paragraph": {
  187. "validation_id": "validation-paragraph",
  188. "task_id": "paragraph-task",
  189. "attempt_id": "attempt-paragraph",
  190. "verdict": "passed",
  191. "recommendation": "accept",
  192. "created_at": NOW,
  193. }
  194. },
  195. "decisions": {
  196. "decision-paragraph": {
  197. "decision_id": "decision-paragraph",
  198. "task_id": "paragraph-task",
  199. "attempt_id": "attempt-paragraph",
  200. "action": "accept",
  201. "reason": "段落通过独立验收",
  202. "created_at": NOW,
  203. }
  204. },
  205. }
  206. _write_json(
  207. root / "task-ledger" / ROOT_TRACE_ID / "orchestration" / "ledger.json",
  208. ledger,
  209. )
  210. return root
  211. def test_health_declares_real_persisted_mode(persisted_run: Path) -> None:
  212. response = client.get("/api/health")
  213. assert response.status_code == 200
  214. assert response.json()["data_mode"] == "persisted-real-run"
  215. assert response.json()["agent_data_root"] == str(persisted_run)
  216. def test_explicit_run_is_discovered_from_trace_meta_without_goal_tree(
  217. persisted_run: Path,
  218. ) -> None:
  219. assert not (persisted_run / "traces" / ROOT_TRACE_ID / "goal.json").exists()
  220. response = client.get("/api/runs")
  221. assert response.status_code == 200
  222. assert [run["script_build_id"] for run in response.json()] == [BUILD_ID]
  223. def test_legacy_goal_tree_remains_a_build_id_fallback(tmp_path: Path) -> None:
  224. root = tmp_path / "legacy-agent-data"
  225. _write_json(
  226. root / "traces" / "legacy-root" / "goal.json",
  227. {"mission": "Script build 880001"},
  228. )
  229. assert _build_id_for_root(root, "legacy-root") == 880001
  230. def test_execution_projects_global_planner_and_dynamic_task_tree(
  231. persisted_run: Path,
  232. ) -> None:
  233. del persisted_run
  234. response = client.get(f"/api/runs/{BUILD_ID}/execution")
  235. assert response.status_code == 200
  236. body = response.json()
  237. assert body["data_mode"] == "persisted-real-run"
  238. assert len([task for task in body["tasks"] if task["parent_task_id"] is None]) == 1
  239. assert {task["task_kind"] for task in body["tasks"]} >= {"direction", "paragraph"}
  240. assert {turn["tool_name"] for turn in body["planner_turns"]} >= {
  241. "inspect_script_plan",
  242. "plan_script_tasks",
  243. "dispatch_script_tasks",
  244. "decide_script_task",
  245. }
  246. visible_counts = [len(turn["visible_task_ids"]) for turn in body["planner_turns"]]
  247. assert visible_counts == sorted(visible_counts)
  248. assert visible_counts[-1] == len(body["tasks"])
  249. def test_task_detail_exposes_lifecycle_worker_loop_and_data_proof(
  250. persisted_run: Path,
  251. ) -> None:
  252. del persisted_run
  253. response = client.get(f"/api/runs/{BUILD_ID}/tasks/paragraph-task")
  254. assert response.status_code == 200
  255. body = response.json()
  256. assert {event["kind"] for event in body["lifecycle"]} == {
  257. "task",
  258. "attempt",
  259. "validation",
  260. "decision",
  261. }
  262. assert body["attempts"][0]["prompt"].startswith(
  263. "Produce one independently testable Paragraph"
  264. )
  265. assert any(
  266. step["step_kind"] == "tool_call" for step in body["attempts"][0]["steps"]
  267. )
  268. assert body["data_usage"]["confirmed_reads"]
  269. assert body["data_usage"]["produced_outputs"]
  270. def test_unknown_run_and_task_are_404(persisted_run: Path) -> None:
  271. del persisted_run
  272. assert client.get("/api/runs/999999/execution").status_code == 404
  273. assert client.get(f"/api/runs/{BUILD_ID}/tasks/missing").status_code == 404