test_execution.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. """中立执行入口、HTTP 适配和模块依赖边界的回归测试。"""
  2. import ast
  3. import asyncio
  4. from contextlib import aclosing
  5. import json
  6. from pathlib import Path
  7. import subprocess
  8. import sys
  9. from types import SimpleNamespace
  10. import pytest
  11. from langchain_core.messages import AIMessage, AIMessageChunk
  12. import config
  13. from db import chat_store, billing_store, run_store, schedule_store
  14. from runtime import concurrency
  15. from runtime.execution import admit, build_execution, execute
  16. @pytest.fixture(autouse=True)
  17. def isolated_runtime(monkeypatch, tmp_path):
  18. monkeypatch.setattr(config, "ROOT", tmp_path)
  19. concurrency.reset_for_tests()
  20. yield
  21. concurrency.reset_for_tests()
  22. class Agent:
  23. def __init__(self, *, wait=None, failure=None, pending=()):
  24. self.wait, self.failure, self.pending = wait, failure, pending
  25. self.closed = False
  26. self.called = False
  27. async def aget_state(self, options):
  28. return SimpleNamespace(tasks=[SimpleNamespace(interrupts=self.pending)])
  29. async def astream(self, graph_input, **kwargs):
  30. self.called = True
  31. try:
  32. yield {"type": "messages", "data": (AIMessageChunk(content="回答", usage_metadata={
  33. "input_tokens": 8, "output_tokens": 2, "total_tokens": 10}), {})}
  34. if self.wait:
  35. await self.wait.wait()
  36. if self.failure:
  37. raise self.failure
  38. finally:
  39. self.closed = True
  40. async def prepared(agent):
  41. session = chat_store.create_session("u")
  42. admission = await admit("u", session["id"])
  43. return await build_execution(admission, agent=agent, session_id=session["id"],
  44. thread_id=session["id"], user_name="u", message="问题", trace_id="execution-test",
  45. config={"configurable": {"thread_id": session["id"]}})
  46. def assert_released():
  47. assert concurrency.stats()["global_active"] == 0
  48. assert not concurrency._sessions
  49. class OutputAgent(Agent):
  50. def __init__(self, frames=(), **kwargs):
  51. super().__init__(**kwargs)
  52. self.frames = frames
  53. async def astream(self, *args, **kwargs):
  54. try:
  55. for frame in self.frames:
  56. yield frame
  57. finally:
  58. self.closed = True
  59. @pytest.mark.asyncio
  60. @pytest.mark.parametrize('with_usage', [False, True])
  61. async def test_empty_model_response_is_failed_even_when_stream_ends_normally(pricing_ready, with_usage):
  62. usage = {'input_tokens': 8, 'output_tokens': 0, 'total_tokens': 8} if with_usage else None
  63. agent = OutputAgent([
  64. {'type': 'messages', 'data': (AIMessageChunk(content='', usage_metadata=usage), {})},
  65. {'type': 'updates', 'data': {'model': {'messages': [AIMessage(content='',
  66. response_metadata={'finish_reason': 'stop', 'id': 'empty-response'})]}}},
  67. ])
  68. task = await prepared(agent)
  69. events = [event async for event in task.events()]
  70. result = task.result()
  71. assert result.status == 'failed'
  72. assert result.error == 'empty_model_response'
  73. assert result.output == '' and result.structured_output is None
  74. assert result.diagnostics['model_responses'] == 1
  75. assert result.diagnostics['last_response']['finish_reason'] == 'stop'
  76. assert any(event['type'] == 'error' and event['code'] == result.error for event in events)
  77. assert events[-1]['aborted']
  78. assert not any(event['type'] == 'agent_done' for event in events)
  79. assert run_store.list_runs(task.req.session_id)[0]['status'] == 'failed'
  80. assert agent.closed
  81. assert_released()
  82. @pytest.mark.asyncio
  83. async def test_unknown_stream_shape_is_diagnosed_without_logging_payload(pricing_ready):
  84. task = await prepared(OutputAgent([{'unknown': 'never-log-payload'}]))
  85. result = await task.run()
  86. assert result.status == 'failed' and result.error == 'unsupported_agent_stream'
  87. assert result.diagnostics['unrecognized_frames'] == 1
  88. assert 'never-log-payload' not in json.dumps(result.diagnostics)
  89. assert_released()
  90. @pytest.mark.asyncio
  91. async def test_legacy_subgraph_stream_is_not_silently_discarded(pricing_ready):
  92. message = AIMessageChunk(content='正常回答', usage_metadata={
  93. 'input_tokens': 8, 'output_tokens': 2, 'total_tokens': 10})
  94. task = await prepared(OutputAgent([((), 'messages', (message, {}))]))
  95. result = await task.run()
  96. assert result.status == 'success' and result.output == '正常回答'
  97. assert result.usage['usage_source'] == 'provider'
  98. assert result.diagnostics['unrecognized_frames'] == 0
  99. assert_released()
  100. @pytest.mark.asyncio
  101. async def test_business_text_without_submission_is_failed(pricing_ready):
  102. task = await prepared(Agent())
  103. task.req.result_validator = lambda *args: pytest.fail('没有提交结果时不应调用校验器')
  104. result = await task.run()
  105. assert result.status == 'failed' and result.error == 'missing_business_result'
  106. assert result.output == '' and result.structured_output is None
  107. assert_released()
  108. @pytest.mark.asyncio
  109. async def test_invalid_tool_arguments_have_a_specific_error(pricing_ready):
  110. message = AIMessage(content='', invalid_tool_calls=[{
  111. 'name': 'read_file', 'args': '{broken-json', 'id': 'invalid-call', 'error': 'invalid JSON'}])
  112. task = await prepared(OutputAgent([
  113. {'type': 'updates', 'data': {'model': {'messages': [message]}}},
  114. ]))
  115. result = await task.run()
  116. assert result.status == 'failed' and result.error == 'invalid_model_tool_calls'
  117. assert result.diagnostics['last_response']['invalid_tool_calls'] == 1
  118. assert 'broken-json' not in json.dumps(result.diagnostics)
  119. assert_released()
  120. @pytest.mark.asyncio
  121. async def test_empty_interrupted_stream_remains_waiting_for_input(pricing_ready):
  122. task = await prepared(OutputAgent(pending=[SimpleNamespace(id='approval', value={'question': '继续?'})]))
  123. result = await task.run()
  124. assert result.status == 'waiting_input' and not result.error
  125. assert_released()
  126. @pytest.mark.asyncio
  127. async def test_events_are_dicts_and_result_is_available_after_cleanup(pricing_ready):
  128. agent = Agent()
  129. task = await prepared(agent)
  130. with pytest.raises(RuntimeError, match="尚未完成"):
  131. task.result()
  132. async with aclosing(task.events()) as events:
  133. received = [event async for event in events]
  134. assert all(isinstance(event, dict) for event in received)
  135. assert all(event["session_id"] == task.req.session_id for event in received)
  136. assert all(event["trace_id"] == "execution-test" for event in received)
  137. assert next(e["content"] for e in received if e["type"] == "token") == "回答"
  138. result = task.result()
  139. assert result.status == "success" and result.output == "回答"
  140. assert result.usage["total_tokens"] == 10
  141. assert agent.closed
  142. assert_released()
  143. with pytest.raises(RuntimeError, match="只能执行一次"):
  144. await task.run()
  145. assert len(billing_store.recent_usage("u")) == 1
  146. @pytest.mark.asyncio
  147. async def test_sse_adapter_preserves_event_payload(pricing_ready):
  148. from api.sse_core import stream_sse
  149. from runtime.events import EventBuilder
  150. from api.sse_events import serialize_event
  151. builder = EventBuilder("s", "t", encode=lambda event: event)
  152. raw = builder.tool_call("calc", args={"value": "中文\n换行"}, tool_call_id="c")
  153. assert json.loads(serialize_event(raw)[6:]) == raw
  154. task = await prepared(Agent())
  155. frames = [frame async for frame in stream_sse(task)]
  156. events = [json.loads(frame[6:]) for frame in frames]
  157. assert events[-1]["type"] == "done" and events[-1]["run_id"] == task.req.run_id
  158. assert all(frame.startswith("data: ") and frame.endswith("\n\n") for frame in frames)
  159. assert_released()
  160. @pytest.mark.asyncio
  161. @pytest.mark.parametrize("cancel", [False, True])
  162. async def test_http_adapter_close_and_cancellation_finalize_inner_execution(pricing_ready, cancel):
  163. from api.sse_core import stream_sse
  164. agent = Agent(wait=asyncio.Event())
  165. task = await prepared(agent)
  166. stream = stream_sse(task)
  167. while json.loads((await anext(stream))[6:])["type"] != "token":
  168. pass
  169. if cancel:
  170. pending = asyncio.create_task(anext(stream))
  171. await asyncio.sleep(0)
  172. pending.cancel()
  173. with pytest.raises(asyncio.CancelledError):
  174. await pending
  175. await stream.aclose()
  176. assert task.status == "cancelled"
  177. assert agent.closed
  178. assert_released()
  179. assert len(billing_store.recent_usage("u")) == 1
  180. assert run_store.list_runs(task.req.session_id)[0]["status"] == "cancelled"
  181. @pytest.mark.asyncio
  182. async def test_encoder_failure_still_closes_execution(monkeypatch, pricing_ready):
  183. from api import sse_core
  184. original = sse_core.serialize_event
  185. def broken(event):
  186. if event["type"] == "token":
  187. raise ValueError("encoding failed")
  188. return original(event)
  189. monkeypatch.setattr(sse_core, "serialize_event", broken)
  190. agent = Agent(wait=asyncio.Event())
  191. task = await prepared(agent)
  192. with pytest.raises(ValueError, match="encoding failed"):
  193. async for _ in sse_core.stream_sse(task):
  194. pass
  195. assert agent.closed
  196. assert_released()
  197. assert len(billing_store.recent_usage("u")) == 1
  198. @pytest.mark.asyncio
  199. async def test_batch_entry_reuses_admission_and_persistence(pricing_ready):
  200. sessions = [chat_store.create_session("u") for _ in range(2)]
  201. results = await asyncio.gather(*(execute(user_name="u", session_id=s["id"], message="批处理", agent=Agent()) for s in sessions))
  202. assert all(r.status == "success" and r.output == "回答" for r in results)
  203. assert len({r.run_id for r in results}) == 2
  204. assert len(billing_store.recent_usage("u")) == 2
  205. for result in results:
  206. assert run_store.list_runs(result.session_id)[0]["kind"] == "batch"
  207. assert len(chat_store.get_messages(result.session_id)) == 2
  208. assert_released()
  209. @pytest.mark.asyncio
  210. async def test_batch_rejects_wrong_owner_and_pending_approval(pricing_ready):
  211. from runtime.interrupts import ResumeError
  212. session = chat_store.create_session("u")
  213. agent = Agent()
  214. with pytest.raises(concurrency.SessionMissingError):
  215. await execute(user_name="other", session_id=session["id"], message="q", agent=agent)
  216. assert not agent.called
  217. agent.pending = [SimpleNamespace(id="pending", value={"action_requests": [{"name": "write"}]})]
  218. with pytest.raises(ResumeError) as error:
  219. await execute(user_name="u", session_id=session["id"], message="q", agent=agent)
  220. assert error.value.code == "pending_interrupt"
  221. assert not agent.called
  222. assert_released()
  223. @pytest.mark.asyncio
  224. async def test_scheduler_does_not_encode_sse(monkeypatch, pricing_ready):
  225. from scheduler.runner import run_schedule
  226. from api import sse_core
  227. def forbidden(event):
  228. pytest.fail("定时任务不能经过 SSE 编码")
  229. monkeypatch.setattr(sse_core, "serialize_event", forbidden)
  230. monkeypatch.setattr("agents.agent_registry.get_agent", lambda: Agent())
  231. schedule = schedule_store.create_schedule("u", "report", "0 9 * * *", "生成报告")
  232. result = await run_schedule(schedule["id"])
  233. assert result["ok"] and result["output"] == "回答"
  234. assert result["input_tokens"] == 8
  235. assert_released()
  236. @pytest.mark.asyncio
  237. async def test_failed_run_returns_error_and_settles_usage(pricing_ready):
  238. task = await prepared(Agent(failure=RuntimeError("model error")))
  239. result = await task.run()
  240. assert result.status == "failed" and result.error == "RuntimeError"
  241. assert result.output == "回答"
  242. assert len(billing_store.recent_usage("u")) == 1
  243. assert_released()
  244. def test_resume_validation_is_independent_of_http():
  245. from runtime.interrupts import ResumeDecision, ResumeError, pack, resume_value, token_for
  246. item = SimpleNamespace(id="approval", value={"action_requests": [{"name": "a"}, {"name": "b"}]})
  247. state = SimpleNamespace(tasks=[SimpleNamespace(interrupts=[item])])
  248. decision = ResumeDecision(approved=True, interrupt_token=token_for(pack(item)))
  249. assert len(resume_value(decision, state)["approval"]["decisions"]) == 2
  250. with pytest.raises(ResumeError) as error:
  251. resume_value(ResumeDecision(approved=True, interrupt_token="old"), state)
  252. assert error.value.code == "stale_interrupt"
  253. def test_runtime_and_scheduler_do_not_import_http_layer():
  254. root = Path(__file__).resolve().parents[1]
  255. for directory in ("runtime", "scheduler", "agents"):
  256. for path in (root / directory).rglob("*.py"):
  257. for node in ast.walk(ast.parse(path.read_text())):
  258. modules = ([node.module or ""] if isinstance(node, ast.ImportFrom) else
  259. [alias.name for alias in node.names] if isinstance(node, ast.Import) else [])
  260. assert not any(m.split(".")[0] in {"api", "fastapi", "starlette"} for m in modules), path
  261. # 新进程防止测试会话里提前导入 API 掩盖反向依赖。
  262. script = """
  263. import runtime.environment
  264. import runtime.engine
  265. import runtime.execution
  266. import runtime.interrupts
  267. import scheduler.runner
  268. import sys
  269. assert not any(name.split('.')[0] in {'api', 'fastapi', 'starlette'} for name in sys.modules)
  270. """
  271. subprocess.run([sys.executable, "-B", "-c", script], cwd=root, check=True)