test_sse_contract.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. """SSE 契约测试。
  2. 两层:
  3. 1. **字段级**:逐个事件断言 ``type`` 与必需字段名——这是与 1.5 前端兼容的
  4. 硬约束,字段被改名或删除必须在这里失败。
  5. 2. **内核级**:用假 agent(手工构造 astream chunk)驱动 :class:`ChatStream`,
  6. 验证「哪个通道产出哪种事件」的分工,不需要模型网络。
  7. """
  8. from __future__ import annotations
  9. import json
  10. import pytest
  11. from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage
  12. from api.sse_events import ALL_EVENT_TYPES, EventWriter
  13. SESSION = "sess-contract-001"
  14. TRACE = "trace-abc"
  15. def writer() -> EventWriter:
  16. return EventWriter(SESSION, TRACE)
  17. def parse(frame: str) -> dict:
  18. assert frame.startswith("data: "), f"帧必须以 'data: ' 开头:{frame[:40]!r}"
  19. assert frame.endswith("\n\n"), "帧必须以空行结束"
  20. return json.loads(frame[6:])
  21. # ============================================================
  22. # 1. 字段级契约
  23. # ============================================================
  24. class TestEventContract:
  25. """每个事件类型的最小字段集。字段名改动会在这里失败。"""
  26. def test_event_types_frozen(self):
  27. """事件类型全集固定为 15 种——前端 switch 分支依赖它。"""
  28. assert len(ALL_EVENT_TYPES) == 15
  29. for name in (
  30. "agent_start", "thinking", "thinking_token", "executing", "tool_call",
  31. "tool_result", "updated_todo_list", "token", "progress", "interrupt",
  32. "citations", "agent_done", "agent_error", "error", "done",
  33. ):
  34. assert name in ALL_EVENT_TYPES, f"缺少事件类型 {name}"
  35. def test_every_frame_carries_session_and_trace(self):
  36. w = writer()
  37. frames = [
  38. w.agent_start("a", "A"),
  39. w.thinking("m"),
  40. w.thinking_token("t"),
  41. w.executing("m"),
  42. w.tool_call("t"),
  43. w.tool_result("t"),
  44. w.token("x"),
  45. w.todo_list([]),
  46. w.progress("p"),
  47. w.interrupt_raw("i"),
  48. w.citations([]),
  49. w.agent_done("a"),
  50. w.agent_error("a", "e"),
  51. w.error("e"),
  52. w.done(),
  53. ]
  54. for frame in frames:
  55. payload = parse(frame)
  56. assert payload["session_id"] == SESSION
  57. assert payload["trace_id"] == TRACE
  58. assert payload["type"] in ALL_EVENT_TYPES
  59. def test_trace_absent_when_empty(self):
  60. """未绑定 trace 时不发 trace_id 字段(而不是发空串)。"""
  61. payload = parse(EventWriter(SESSION).token("x"))
  62. assert "trace_id" not in payload
  63. def test_agent_start_fields(self):
  64. p = parse(writer().agent_start("deep-agent", "深度智能体"))
  65. assert p["type"] == "agent_start"
  66. # 前端 `agentCardKey` 依次读 agent_id → cn_agent → agent
  67. assert p["agent"] == "deep-agent"
  68. assert p["cn_agent"] == "深度智能体"
  69. def test_agent_start_cn_falls_back_to_agent(self):
  70. assert parse(writer().agent_start("x"))["cn_agent"] == "x"
  71. def test_thinking_fields(self):
  72. p = parse(writer().thinking("正在分析", "深度智能体"))
  73. assert p["type"] == "thinking"
  74. assert p["message"] == "正在分析"
  75. assert p["cn_agent"] == "深度智能体"
  76. def test_thinking_token_content(self):
  77. p = parse(writer().thinking_token("推理中"))
  78. assert p["type"] == "thinking_token" and p["content"] == "推理中"
  79. def test_executing_tools_and_cn_tools(self):
  80. p = parse(writer().executing("调用工具:计算", tools=["calculate"], cn_tools=["安全算术计算"]))
  81. assert p["type"] == "executing"
  82. assert p["message"] == "调用工具:计算"
  83. assert p["tools"] == ["calculate"]
  84. assert p["cn_tools"] == ["安全算术计算"]
  85. def test_executing_without_tools_omits_keys(self):
  86. p = parse(writer().executing("执行中"))
  87. assert "tools" not in p and "cn_tools" not in p
  88. def test_tool_call_fields(self):
  89. p = parse(writer().tool_call("calculate", {"expression": "1+1"}, "安全算术计算", "sub-1"))
  90. assert p["type"] == "tool_call"
  91. assert p["tool"] == "calculate"
  92. assert p["cn_tool"] == "安全算术计算"
  93. assert p["args"] == {"expression": "1+1"}
  94. assert p["agent_id"] == "sub-1"
  95. def test_tool_call_id_is_additive_2_0_field(self):
  96. """tool_call_id 是 2.0 新增字段(1.5 没有):同一工具多次调用时按它
  97. 索引才不会把结果挂错卡。附加字段不破坏兼容。"""
  98. call = parse(writer().tool_call("calculate", {}, "计算", tool_call_id="call-abc"))
  99. result = parse(writer().tool_result("calculate", 2, "计算", tool_call_id="call-abc"))
  100. assert call["tool_call_id"] == "call-abc"
  101. assert result["tool_call_id"] == call["tool_call_id"]
  102. def test_tool_call_id_omitted_when_absent(self):
  103. """没有 id 时不发该字段,而不是发空串。"""
  104. assert "tool_call_id" not in parse(writer().tool_call("t", {}))
  105. def test_tool_result_fields(self):
  106. p = parse(writer().tool_result("calculate", {"result": 2}, "安全算术计算", error=False))
  107. assert p["type"] == "tool_result"
  108. assert p["tool"] == "calculate" and p["error"] is False
  109. assert p["content"] == {"result": 2}
  110. def test_token_content(self):
  111. assert parse(writer().token("你好"))["content"] == "你好"
  112. def test_todo_list_field_is_todos(self):
  113. todos = [{"content": "第一步", "status": "in_progress"}]
  114. p = parse(writer().todo_list(todos))
  115. assert p["type"] == "updated_todo_list"
  116. assert p["todos"] == todos
  117. def test_progress_message(self):
  118. p = parse(writer().progress("进行中"))
  119. assert p["type"] == "progress" and p["message"] == "进行中"
  120. def test_citations_items_and_model_only(self):
  121. items = [{"title": "T", "url": "u"}]
  122. p = parse(writer().citations(items))
  123. assert p["type"] == "citations"
  124. assert p["items"] == items
  125. assert p["model_only"] is False
  126. assert parse(writer().citations([], model_only=True))["model_only"] is True
  127. def test_interrupt_tool_approval_shape(self):
  128. """前端 `showToolApprovalCard` 从 event.tool_approval 读四个字段。"""
  129. p = parse(writer().interrupt_tool_approval(
  130. "request_plan_approval", {"plan": "..."}, "需确认计划", "需人工确认"
  131. ))
  132. assert p["type"] == "interrupt"
  133. ta = p["tool_approval"]
  134. assert ta["tool"] == "request_plan_approval"
  135. assert ta["args"] == {"plan": "..."}
  136. assert ta["reason"] == "需确认计划"
  137. assert ta["side_effect"] == "需人工确认"
  138. def test_interrupt_ask_user_shape(self):
  139. """前端 `showAskUserCard` 从 event.ask_user.questions 读 q.question 等。"""
  140. questions = [{"question": "选哪个?", "type": "multiple_choice",
  141. "choices": ["A", "B"], "required": True}]
  142. p = parse(writer().interrupt_ask_user(questions, "请选择"))
  143. assert p["type"] == "interrupt"
  144. assert p["ask_user"]["questions"] == questions
  145. assert p["message"] == "请选择"
  146. def test_agent_done_fields(self):
  147. p = parse(writer().agent_done("deep-agent", "深度智能体", duration_ms=1234, progress="已完成"))
  148. assert p["type"] == "agent_done"
  149. assert p["agent"] == "deep-agent" and p["cn_agent"] == "深度智能体"
  150. assert p["duration_ms"] == 1234 and p["progress"] == "已完成"
  151. def test_agent_error_fields(self):
  152. p = parse(writer().agent_error("deep-agent", "炸了", "深度智能体", 50))
  153. assert p["type"] == "agent_error"
  154. assert p["message"] == "炸了" and p["duration_ms"] == 50
  155. def test_error_code_and_retry_after(self):
  156. p = parse(writer().error("太挤了", code="too_many_tasks", retry_after=15))
  157. assert p["type"] == "error"
  158. assert p["code"] == "too_many_tasks"
  159. assert p["retry_after"] == 15
  160. def test_error_without_retry_after_omits_key(self):
  161. assert "retry_after" not in parse(writer().error("x", code="internal_error"))
  162. def test_done_duration(self):
  163. p = parse(writer().done(duration_ms=999))
  164. assert p["type"] == "done" and p["duration_ms"] == 999
  165. def test_comment_frame_is_ignored_form(self):
  166. """心跳帧是 SSE 注释,客户端会忽略。"""
  167. assert writer().comment("ping").startswith(": ")
  168. def test_unknown_types_serializable(self):
  169. """args 里出现不可 JSON 化的对象时不能让整帧崩掉。"""
  170. from datetime import datetime
  171. p = parse(writer().tool_call("t", {"when": datetime(2026, 1, 1)}))
  172. assert isinstance(p["args"]["when"], str)
  173. # ============================================================
  174. # 2. 内核级:假 agent 驱动 ChatStream
  175. # ============================================================
  176. class FakeAgent:
  177. """按脚本产出 astream chunk 的假 agent。
  178. 只实现 :class:`ChatStream` 用到的接口:``astream`` 与 ``aget_state``。
  179. """
  180. def __init__(self, chunks: list, state=None) -> None:
  181. self._chunks = chunks
  182. self._state = state
  183. self.inputs: list = []
  184. async def astream(self, graph_input, **kwargs):
  185. self.inputs.append(graph_input)
  186. for chunk in self._chunks:
  187. yield chunk
  188. async def aget_state(self, config):
  189. return self._state
  190. def updates(data: dict, ns: tuple = ()) -> dict:
  191. return {"type": "updates", "ns": ns, "data": data}
  192. def messages(chunk, metadata: dict | None = None, ns: tuple = ()) -> dict:
  193. return {"type": "messages", "ns": ns, "data": (chunk, metadata or {})}
  194. class _State:
  195. def __init__(self, interrupts=()) -> None:
  196. self.tasks = [type("T", (), {"interrupts": list(interrupts)})()] if interrupts else []
  197. class _Interrupt:
  198. def __init__(self, value) -> None:
  199. self.value = value
  200. async def drive(agent: FakeAgent, session_id: str, **overrides) -> list[dict]:
  201. """跑一遍 ChatStream,返回解析后的事件列表。"""
  202. from runtime import concurrency
  203. from api.sse_core import ChatStream, StreamRequest
  204. kwargs = {
  205. "user_name": "contract_user",
  206. "kind": "test",
  207. "model": "deepseek-flash",
  208. }
  209. kwargs.update(overrides)
  210. slot = concurrency.acquire_task_slot(kwargs["user_name"])
  211. req = StreamRequest(
  212. agent=agent,
  213. session_id=session_id,
  214. thread_id=session_id,
  215. config={"configurable": {"thread_id": session_id}},
  216. message="测试消息",
  217. slot=slot,
  218. **kwargs,
  219. )
  220. events = []
  221. async for frame in ChatStream(req).generate():
  222. if frame.startswith("data: "):
  223. events.append(json.loads(frame[6:]))
  224. return events
  225. @pytest.fixture
  226. def _reset_concurrency():
  227. from runtime import concurrency
  228. concurrency.reset_for_tests()
  229. yield
  230. concurrency.reset_for_tests()
  231. pytestmark = pytest.mark.usefixtures("_reset_concurrency")
  232. class TestKernel:
  233. """通道分工:updates 出工具事件,messages 出正文 token。"""
  234. @pytest.mark.asyncio
  235. async def test_full_sequence(self, session_id, pricing_ready):
  236. tool_call = {"name": "calculate", "args": {"expression": "1+1"}, "id": "call-1"}
  237. agent = FakeAgent([
  238. updates({"model": {"messages": [AIMessage(content="", tool_calls=[tool_call])]}}),
  239. messages(AIMessageChunk(content="答案")),
  240. updates({"tools": {"messages": [
  241. ToolMessage(content='{"result": 2}', name="calculate", tool_call_id="call-1")
  242. ]}}),
  243. ])
  244. events = await drive(agent, session_id)
  245. types = [e["type"] for e in events]
  246. assert types[0] == "agent_start"
  247. assert "tool_call" in types
  248. assert "tool_result" in types
  249. assert "token" in types
  250. assert types[-1] == "done"
  251. assert "error" not in types
  252. # 正文只应包含模型说的话
  253. text = "".join(e["content"] for e in events if e["type"] == "token")
  254. assert text == "答案"
  255. assert '{"result"' not in text
  256. @pytest.mark.asyncio
  257. async def test_tool_message_not_streamed_as_token(self, session_id, pricing_ready):
  258. """ToolMessage 也走 messages 通道,但它的内容不能当正文推出去。"""
  259. agent = FakeAgent([
  260. messages(ToolMessage(content="工具返回的原始数据", name="t", tool_call_id="c")),
  261. ])
  262. events = await drive(agent, session_id)
  263. assert not [e for e in events if e["type"] == "token"]
  264. @pytest.mark.asyncio
  265. async def test_summarization_tokens_filtered(self, session_id, pricing_ready):
  266. """摘要中间件的模型调用也在 messages 通道上,它的 token 不能给用户看。"""
  267. agent = FakeAgent([
  268. messages(AIMessageChunk(content="## SESSION INTENT 摘要正文"),
  269. {"lc_source": "summarization"}),
  270. messages(AIMessageChunk(content="真正的回答")),
  271. ])
  272. events = await drive(agent, session_id)
  273. text = "".join(e["content"] for e in events if e["type"] == "token")
  274. assert text == "真正的回答"
  275. assert "SESSION INTENT" not in text
  276. @pytest.mark.asyncio
  277. async def test_todo_list_event(self, session_id, pricing_ready):
  278. todos = [{"content": "步骤一", "status": "in_progress"}]
  279. agent = FakeAgent([updates({"model": {"todos": todos}})])
  280. events = await drive(agent, session_id)
  281. todo_events = [e for e in events if e["type"] == "updated_todo_list"]
  282. assert todo_events and todo_events[0]["todos"] == todos
  283. @pytest.mark.asyncio
  284. async def test_reasoning_content_becomes_thinking_token(self, session_id, pricing_ready):
  285. chunk = AIMessageChunk(content="", additional_kwargs={"reasoning_content": "我在想"})
  286. agent = FakeAgent([messages(chunk)])
  287. events = await drive(agent, session_id)
  288. thinking = [e for e in events if e["type"] == "thinking_token"]
  289. assert thinking and thinking[0]["content"] == "我在想"
  290. @pytest.mark.asyncio
  291. async def test_unknown_chunk_ignored(self, session_id, pricing_ready):
  292. """无法识别的 chunk 形态应被忽略,而不是让流崩掉。"""
  293. agent = FakeAgent([{"unexpected": "shape"}, messages(AIMessageChunk(content="ok"))])
  294. events = await drive(agent, session_id)
  295. assert "error" not in [e["type"] for e in events]
  296. assert any(e["type"] == "token" for e in events)
  297. @pytest.mark.asyncio
  298. async def test_legacy_tuple_chunk_supported(self, session_id, pricing_ready):
  299. """旧版 astream 的 (mode, data) 元组形态也要能解析。"""
  300. agent = FakeAgent([("messages", (AIMessageChunk(content="旧格式"), {}))])
  301. events = await drive(agent, session_id)
  302. assert any(e["type"] == "token" and e["content"] == "旧格式" for e in events)
  303. @pytest.mark.asyncio
  304. async def test_graph_input_is_dict(self, session_id, pricing_ready):
  305. """图输入必须是 {"messages": [...]}:传裸 list 会触发 InvalidUpdateError。"""
  306. agent = FakeAgent([messages(AIMessageChunk(content="x"))])
  307. await drive(agent, session_id)
  308. assert isinstance(agent.inputs[0], dict)
  309. assert "messages" in agent.inputs[0]
  310. @pytest.mark.asyncio
  311. async def test_user_message_persisted_before_streaming(self, session_id, pricing_ready):
  312. """用户消息要在流开始前落库,否则流挂了历史里就没有用户问了什么。"""
  313. from db import chat_store
  314. agent = FakeAgent([messages(AIMessageChunk(content="回答"))])
  315. await drive(agent, session_id)
  316. rows = chat_store.get_messages(session_id)
  317. assert rows[0]["msg_role"] == "user"
  318. assert rows[1]["msg_role"] == "assistant"
  319. assert rows[1]["content"] == "回答"
  320. @pytest.mark.asyncio
  321. async def test_interrupt_from_state_is_adapted(self, session_id, pricing_ready):
  322. """官方 HITL 载荷要转成 1.5 的 tool_approval 形状。"""
  323. payload = {
  324. "action_requests": [
  325. {"name": "request_plan_approval", "args": {"plan": "P"}, "description": "审批计划"}
  326. ],
  327. "review_configs": [{"action_name": "request_plan_approval",
  328. "allowed_decisions": ["approve", "reject"]}],
  329. }
  330. agent = FakeAgent(
  331. [messages(AIMessageChunk(content=""))],
  332. state=_State(interrupts=[_Interrupt(payload)]),
  333. )
  334. events = await drive(agent, session_id)
  335. interrupts = [e for e in events if e["type"] == "interrupt"]
  336. assert interrupts, [e["type"] for e in events]
  337. ta = interrupts[0]["tool_approval"]
  338. assert ta["tool"] == "request_plan_approval"
  339. assert ta["reason"] == "审批计划"
  340. @pytest.mark.asyncio
  341. async def test_interrupt_from_stream_channel(self, session_id, pricing_ready):
  342. """流里捕获到的 __interrupt__ 优先于图状态。"""
  343. payload = {"action_requests": [{"name": "x", "args": {}, "description": "d"}]}
  344. agent = FakeAgent([updates({"__interrupt__": [_Interrupt(payload)]})])
  345. events = await drive(agent, session_id)
  346. assert any(e["type"] == "interrupt" for e in events)
  347. @pytest.mark.asyncio
  348. async def test_citations_from_tool_sources(self, session_id, pricing_ready):
  349. """来源由工具侧结构化产出,不依赖模型自觉。"""
  350. from tools.registry import source, sourced
  351. payload = json.dumps(
  352. sourced(data=[{"x": 1}], sources=[source("标题A", "https://e.invalid/a", "摘要", 0.9)]),
  353. ensure_ascii=False,
  354. )
  355. agent = FakeAgent([
  356. updates({"tools": {"messages": [
  357. ToolMessage(content=payload, name="search_demo_knowledge", tool_call_id="c1")
  358. ]}}),
  359. ])
  360. events = await drive(agent, session_id)
  361. cites = [e for e in events if e["type"] == "citations"]
  362. assert cites and len(cites[0]["items"]) == 1
  363. assert cites[0]["items"][0]["title"] == "标题A"
  364. assert cites[0]["model_only"] is False
  365. @pytest.mark.asyncio
  366. async def test_model_only_when_no_sources(self, session_id, pricing_ready):
  367. agent = FakeAgent([messages(AIMessageChunk(content="纯模型回答"))])
  368. events = await drive(agent, session_id)
  369. cites = [e for e in events if e["type"] == "citations"]
  370. assert cites and cites[0]["model_only"] is True
  371. @pytest.mark.asyncio
  372. async def test_usage_recorded_with_real_numbers(self, session_id, pricing_ready, unique_user):
  373. """有真实 usage 时按 provider 口径入账。"""
  374. chunk = AIMessageChunk(
  375. content="答",
  376. usage_metadata={"input_tokens": 1000, "output_tokens": 50, "total_tokens": 1050},
  377. )
  378. agent = FakeAgent([messages(chunk)])
  379. await drive(agent, session_id, user_name=unique_user)
  380. from billing import guard
  381. snapshot = guard.snapshot(unique_user)
  382. assert snapshot["requests"] == 1
  383. assert snapshot["input_tokens"] == 1000
  384. assert snapshot["output_tokens"] == 50
  385. @pytest.mark.asyncio
  386. async def test_billing_settles_once(self, session_id, pricing_ready, unique_user):
  387. """SSE 有正常/异常/断连三条退出路径,全汇到同一个 finally:不能重复扣费。"""
  388. from billing import guard
  389. agent = FakeAgent([
  390. messages(AIMessageChunk(
  391. content="答",
  392. usage_metadata={"input_tokens": 500, "output_tokens": 10, "total_tokens": 510},
  393. ))
  394. ])
  395. await drive(agent, session_id, user_name=unique_user)
  396. snapshot = guard.snapshot(unique_user)
  397. assert snapshot["requests"] == 1, f"重复入账:{snapshot}"
  398. @pytest.mark.asyncio
  399. async def test_concurrency_slot_released(self, session_id, pricing_ready):
  400. """流结束后并发位必须归还,否则服务会被自己锁死。"""
  401. from runtime import concurrency
  402. agent = FakeAgent([messages(AIMessageChunk(content="x"))])
  403. await drive(agent, session_id)
  404. assert concurrency.stats()["global_active"] == 0
  405. @pytest.mark.asyncio
  406. async def test_slot_released_on_error(self, session_id, pricing_ready):
  407. """异常路径同样要归还并发位。"""
  408. from runtime import concurrency
  409. class Boom(FakeAgent):
  410. async def astream(self, graph_input, **kwargs):
  411. raise RuntimeError("模拟图崩溃")
  412. yield # pragma: no cover
  413. await drive(Boom([]), session_id)
  414. assert concurrency.stats()["global_active"] == 0
  415. @pytest.mark.asyncio
  416. async def test_error_frame_on_exception(self, session_id, pricing_ready):
  417. class Boom(FakeAgent):
  418. async def astream(self, graph_input, **kwargs):
  419. raise RuntimeError("模拟图崩溃")
  420. yield # pragma: no cover
  421. events = await drive(Boom([]), session_id)
  422. errors = [e for e in events if e["type"] == "error"]
  423. assert errors and errors[0]["code"] == "internal_error"
  424. assert events[-1]["type"] == "done"
  425. @pytest.mark.asyncio
  426. async def test_resume_sends_command(self, session_id, pricing_ready):
  427. """恢复时必须喂 Command(resume=...),而不是组装消息。"""
  428. from langgraph.types import Command
  429. agent = FakeAgent([messages(AIMessageChunk(content="继续"))])
  430. await drive(agent, session_id, resume_payload={"decisions": [{"type": "approve"}]})
  431. assert isinstance(agent.inputs[0], Command)