http_e2e.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. """HTTP 层端到端验证脚本(临时工具,不属于 pytest 套件)。
  2. 覆盖:SSE 流式契约、响应头、落库、会话路由、中断/恢复闭环、调度手动触发。
  3. 运行:python scripts/http_e2e.py
  4. """
  5. from __future__ import annotations
  6. import json
  7. import sys
  8. import time
  9. import httpx
  10. BASE = "http://127.0.0.1:8080"
  11. USER = "http_e2e"
  12. PASSED: list[str] = []
  13. FAILED: list[str] = []
  14. def check(name: str, ok: bool, detail: str = "") -> None:
  15. (PASSED if ok else FAILED).append(name)
  16. print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" — {detail}" if detail else ""))
  17. def sse_events(response) -> list[dict]:
  18. """从 httpx 流式响应里解析出全部事件。"""
  19. events: list[dict] = []
  20. for line in response.iter_lines():
  21. if line.startswith("data: "):
  22. try:
  23. events.append(json.loads(line[6:]))
  24. except json.JSONDecodeError:
  25. pass
  26. return events
  27. def test_stream_basic() -> str | None:
  28. print("\n[1] POST /api/chat 流式契约")
  29. with httpx.Client(timeout=180) as client:
  30. with client.stream(
  31. "POST", f"{BASE}/api/chat",
  32. data={"message": "用一句话说明矿井通风的目的", "user_name": USER, "mode": "full"},
  33. ) as res:
  34. headers = res.headers
  35. events = sse_events(res)
  36. check("HTTP 200", res.status_code == 200, str(res.status_code))
  37. check("Content-Type 是 event-stream", "text/event-stream" in headers.get("content-type", ""))
  38. check("响应头 X-Session-Id", bool(headers.get("x-session-id")))
  39. check("响应头 X-Accel-Buffering=no", headers.get("x-accel-buffering") == "no")
  40. check("响应头 Cache-Control=no-cache", "no-cache" in headers.get("cache-control", ""))
  41. check("响应头 X-Trace-Id", bool(headers.get("x-trace-id")))
  42. types = [e["type"] for e in events]
  43. check("有 agent_start", "agent_start" in types)
  44. check("有 token", "token" in types)
  45. check("有 citations", "citations" in types)
  46. check("有 done", "done" in types)
  47. check("无 error", "error" not in types, str([e.get("message", "")[:60] for e in events if e["type"] == "error"]))
  48. check("done 在最后一个", types[-1] == "done", str(types[-3:]))
  49. # 每帧必须带 session_id
  50. check("每帧带 session_id", all(e.get("session_id") for e in events))
  51. sid = events[0].get("session_id") if events else None
  52. return sid
  53. def test_persist_and_history(sid: str) -> None:
  54. print("\n[2] 落库与会话路由")
  55. with httpx.Client(timeout=60) as client:
  56. r = client.get(f"{BASE}/api/chat/history/{sid}", params={"user_name": USER})
  57. check("历史 200", r.status_code == 200, str(r.status_code))
  58. items = r.json().get("items", [])
  59. roles = [m["msg_role"] for m in items]
  60. check("历史有 user+assistant", roles[:2] == ["user", "assistant"], str(roles))
  61. check("顺序正确(user 在前)", roles[0] == "user")
  62. assistant = next((m for m in items if m["msg_role"] == "assistant"), {})
  63. check("assistant 内容非空", bool((assistant.get("content") or "").strip()))
  64. # 正文里不该混入工具结果 JSON
  65. body = assistant.get("content") or ""
  66. check("正文未混入工具结果 JSON", not body.strip().startswith('{"data"'), body[:80].replace("\n", " "))
  67. r = client.get(f"{BASE}/api/sessions", params={"user_name": USER})
  68. check("会话列表 200", r.status_code == 200)
  69. check("列表含该会话", any(s["id"] == sid for s in r.json().get("items", [])))
  70. r = client.get(f"{BASE}/api/sessions/{sid}/context", params={"user_name": USER})
  71. check("上下文用量 200", r.status_code == 200)
  72. latest = (r.json() or {}).get("latest") or {}
  73. check("有用量记录", int(latest.get("total_tokens") or 0) > 0, str(latest.get("total_tokens")))
  74. check("口径为 provider", latest.get("usage_source") == "provider", str(latest.get("usage_source")))
  75. r = client.get(f"{BASE}/api/usage/today", params={"user_name": USER})
  76. check("用量 200", r.status_code == 200)
  77. check("当日有消耗", int(r.json().get("total_tokens") or 0) > 0)
  78. r = client.get(f"{BASE}/api/model")
  79. check("模型信息 200", r.status_code == 200)
  80. d = r.json()
  81. check("工具数 = 4", d["agent"]["tools"]["count"] == 4, str(d["agent"]["tools"]["count"]))
  82. check("interrupt_on 含计划审批",
  83. "request_plan_approval" in d["agent"]["interrupt_on"], str(d["agent"]["interrupt_on"]))
  84. def test_404_and_errors() -> None:
  85. print("\n[3] 错误语义")
  86. with httpx.Client(timeout=60) as client:
  87. r = client.post(f"{BASE}/api/chat", data={
  88. "message": "hi", "user_name": USER, "session_id": "not-exist-id",
  89. })
  90. check("不存在的会话 → 404", r.status_code == 404, str(r.status_code))
  91. r = client.post(f"{BASE}/api/chat", data={
  92. "message": "hi", "user_name": "other_user", "session_id": "not-exist-id",
  93. })
  94. check("他人会话 → 404(不泄露存在性)", r.status_code == 404, str(r.status_code))
  95. r = client.get(f"{BASE}/api/chat/interrupt", params={"session_id": "not-exist-id", "user_name": USER})
  96. check("中断查询 不存在的会话 → 404", r.status_code == 404, str(r.status_code))
  97. def test_interrupt_loop() -> None:
  98. """plan 模式 → agent 调 request_plan_approval → interrupt → resume。"""
  99. print("\n[4] 中断/恢复闭环")
  100. with httpx.Client(timeout=240) as client:
  101. with client.stream(
  102. "POST", f"{BASE}/api/chat",
  103. data={
  104. "message": "请制定一个三步计划:把一句话总结成简报并写到 /workspace/brief.md。先提交计划等我批准。",
  105. "user_name": USER, "mode": "plan",
  106. },
  107. ) as res:
  108. events = sse_events(res)
  109. sid = None
  110. for e in events:
  111. sid = e.get("session_id") or sid
  112. types = [e["type"] for e in events]
  113. print(" 事件:", types)
  114. if "interrupt" not in types:
  115. check("触发 interrupt", False, f"agent 未请求审批;事件={types}")
  116. return
  117. check("触发 interrupt", True)
  118. intr = next(e for e in events if e["type"] == "interrupt")
  119. check("interrupt 带 tool_approval", bool(intr.get("tool_approval")),
  120. json.dumps(intr, ensure_ascii=False)[:150])
  121. # 刷新场景:只读查询挂起的中断
  122. r = client.get(f"{BASE}/api/chat/interrupt", params={"session_id": sid, "user_name": USER})
  123. check("GET /chat/interrupt → 200", r.status_code == 200, str(r.status_code))
  124. if r.status_code == 200:
  125. check("interrupts 非空", len(r.json().get("interrupts", [])) > 0)
  126. # 批准并续跑
  127. r = client.post(f"{BASE}/api/chat/resume", json={
  128. "session_id": sid, "thread_id": sid, "user_name": USER,
  129. "approved": True, "status": "answered",
  130. })
  131. check("resume 200", r.status_code == 200, str(r.status_code))
  132. if r.status_code == 200:
  133. texts = []
  134. for line in r.iter_lines():
  135. if line.startswith("data: "):
  136. try:
  137. texts.append(json.loads(line[6:]))
  138. except json.JSONDecodeError:
  139. pass
  140. rtypes = [e["type"] for e in texts]
  141. print(" resume 事件:", rtypes)
  142. check("恢复后有输出", "token" in rtypes or "tool_call" in rtypes, str(rtypes))
  143. check("恢复后正常收尾", rtypes[-1] == "done" and "error" not in rtypes,
  144. str([e.get("message", "")[:60] for e in texts if e["type"] == "error"]))
  145. # 已恢复:再查应无挂起
  146. r = client.get(f"{BASE}/api/chat/interrupt", params={"session_id": sid, "user_name": USER})
  147. check("恢复后无挂起(204)", r.status_code == 204, str(r.status_code))
  148. def test_scheduler() -> None:
  149. print("\n[5] 定时任务")
  150. with httpx.Client(timeout=240) as client:
  151. r = client.post(f"{BASE}/api/schedules", json={
  152. "title": "e2e 任务", "cron": "0 9 * * *",
  153. "prompt": "用一句话说明今天要做什么(这是定时任务自检)。",
  154. "user_name": USER,
  155. })
  156. check("创建任务 200", r.status_code == 200, r.text[:200])
  157. if r.status_code != 200:
  158. return
  159. sid_task = r.json()["id"]
  160. r = client.post(f"{BASE}/api/schedules/{sid_task}/run")
  161. check("手动触发 200", r.status_code == 200, r.text[:250])
  162. if r.status_code == 200:
  163. body = r.json()
  164. check("触发有输出", bool((body.get("output") or "").strip()), str(body.get("output"))[:80])
  165. check("有独立会话", bool(body.get("session_id")))
  166. check("记录了 token", int(body.get("input_tokens") or 0) > 0, str(body.get("input_tokens")))
  167. r = client.get(f"{BASE}/api/schedules/{sid_task}/runs")
  168. check("执行记录 200", r.status_code == 200)
  169. runs = r.json().get("items", [])
  170. check("有成功记录", any(x["status"] == "success" for x in runs), str([x["status"] for x in runs]))
  171. r = client.post(f"{BASE}/api/schedules", json={
  172. "title": "坏 cron", "cron": "not a cron", "prompt": "x", "user_name": USER,
  173. })
  174. check("非法 cron → 400", r.status_code == 400, str(r.status_code))
  175. r = client.post(f"{BASE}/api/schedules/{sid_task}/pause")
  176. check("暂停 200", r.status_code == 200 and r.json().get("enabled") is False)
  177. r = client.delete(f"{BASE}/api/schedules/{sid_task}")
  178. check("删除 200", r.status_code == 200 and r.json().get("deleted") is True)
  179. def main() -> int:
  180. started = time.time()
  181. print(f"目标 {BASE}")
  182. sid = test_stream_basic()
  183. if sid:
  184. test_persist_and_history(sid)
  185. test_404_and_errors()
  186. test_interrupt_loop()
  187. test_scheduler()
  188. print(f"\n{'=' * 56}")
  189. print(f"通过 {len(PASSED)} / {len(PASSED) + len(FAILED)} 耗时 {time.time() - started:.1f}s")
  190. if FAILED:
  191. print("失败项:")
  192. for name in FAILED:
  193. print(" -", name)
  194. return 1
  195. print("全部通过")
  196. return 0
  197. if __name__ == "__main__":
  198. sys.exit(main())