http_e2e.py 10 KB

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