test_execution.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  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 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. @pytest.mark.asyncio
  50. async def test_events_are_dicts_and_result_is_available_after_cleanup(pricing_ready):
  51. agent = Agent()
  52. task = await prepared(agent)
  53. with pytest.raises(RuntimeError, match="尚未完成"):
  54. task.result()
  55. async with aclosing(task.events()) as events:
  56. received = [event async for event in events]
  57. assert all(isinstance(event, dict) for event in received)
  58. assert all(event["session_id"] == task.req.session_id for event in received)
  59. assert all(event["trace_id"] == "execution-test" for event in received)
  60. assert next(e["content"] for e in received if e["type"] == "token") == "回答"
  61. result = task.result()
  62. assert result.status == "success" and result.output == "回答"
  63. assert result.usage["total_tokens"] == 10
  64. assert agent.closed
  65. assert_released()
  66. with pytest.raises(RuntimeError, match="只能执行一次"):
  67. await task.run()
  68. assert len(billing_store.recent_usage("u")) == 1
  69. @pytest.mark.asyncio
  70. async def test_sse_adapter_preserves_event_payload(pricing_ready):
  71. from api.sse_core import stream_sse
  72. from runtime.events import EventBuilder
  73. from api.sse_events import serialize_event
  74. builder = EventBuilder("s", "t", encode=lambda event: event)
  75. raw = builder.tool_call("calc", args={"value": "中文\n换行"}, tool_call_id="c")
  76. assert json.loads(serialize_event(raw)[6:]) == raw
  77. task = await prepared(Agent())
  78. frames = [frame async for frame in stream_sse(task)]
  79. events = [json.loads(frame[6:]) for frame in frames]
  80. assert events[-1]["type"] == "done" and events[-1]["run_id"] == task.req.run_id
  81. assert all(frame.startswith("data: ") and frame.endswith("\n\n") for frame in frames)
  82. assert_released()
  83. @pytest.mark.asyncio
  84. @pytest.mark.parametrize("cancel", [False, True])
  85. async def test_http_adapter_close_and_cancellation_finalize_inner_execution(pricing_ready, cancel):
  86. from api.sse_core import stream_sse
  87. agent = Agent(wait=asyncio.Event())
  88. task = await prepared(agent)
  89. stream = stream_sse(task)
  90. while json.loads((await anext(stream))[6:])["type"] != "token":
  91. pass
  92. if cancel:
  93. pending = asyncio.create_task(anext(stream))
  94. await asyncio.sleep(0)
  95. pending.cancel()
  96. with pytest.raises(asyncio.CancelledError):
  97. await pending
  98. await stream.aclose()
  99. assert task.status == "cancelled"
  100. assert agent.closed
  101. assert_released()
  102. assert len(billing_store.recent_usage("u")) == 1
  103. assert run_store.list_runs(task.req.session_id)[0]["status"] == "cancelled"
  104. @pytest.mark.asyncio
  105. async def test_encoder_failure_still_closes_execution(monkeypatch, pricing_ready):
  106. from api import sse_core
  107. original = sse_core.serialize_event
  108. def broken(event):
  109. if event["type"] == "token":
  110. raise ValueError("encoding failed")
  111. return original(event)
  112. monkeypatch.setattr(sse_core, "serialize_event", broken)
  113. agent = Agent(wait=asyncio.Event())
  114. task = await prepared(agent)
  115. with pytest.raises(ValueError, match="encoding failed"):
  116. async for _ in sse_core.stream_sse(task):
  117. pass
  118. assert agent.closed
  119. assert_released()
  120. assert len(billing_store.recent_usage("u")) == 1
  121. @pytest.mark.asyncio
  122. async def test_batch_entry_reuses_admission_and_persistence(pricing_ready):
  123. sessions = [chat_store.create_session("u") for _ in range(2)]
  124. results = await asyncio.gather(*(execute(user_name="u", session_id=s["id"], message="批处理", agent=Agent()) for s in sessions))
  125. assert all(r.status == "success" and r.output == "回答" for r in results)
  126. assert len({r.run_id for r in results}) == 2
  127. assert len(billing_store.recent_usage("u")) == 2
  128. for result in results:
  129. assert run_store.list_runs(result.session_id)[0]["kind"] == "batch"
  130. assert len(chat_store.get_messages(result.session_id)) == 2
  131. assert_released()
  132. @pytest.mark.asyncio
  133. async def test_batch_rejects_wrong_owner_and_pending_approval(pricing_ready):
  134. from runtime.interrupts import ResumeError
  135. session = chat_store.create_session("u")
  136. agent = Agent()
  137. with pytest.raises(concurrency.SessionMissingError):
  138. await execute(user_name="other", session_id=session["id"], message="q", agent=agent)
  139. assert not agent.called
  140. agent.pending = [SimpleNamespace(id="pending", value={"action_requests": [{"name": "write"}]})]
  141. with pytest.raises(ResumeError) as error:
  142. await execute(user_name="u", session_id=session["id"], message="q", agent=agent)
  143. assert error.value.code == "pending_interrupt"
  144. assert not agent.called
  145. assert_released()
  146. @pytest.mark.asyncio
  147. async def test_scheduler_does_not_encode_sse(monkeypatch, pricing_ready):
  148. from scheduler.runner import run_schedule
  149. from api import sse_core
  150. def forbidden(event):
  151. pytest.fail("定时任务不能经过 SSE 编码")
  152. monkeypatch.setattr(sse_core, "serialize_event", forbidden)
  153. monkeypatch.setattr("agents.agent_registry.get_agent", lambda: Agent())
  154. schedule = schedule_store.create_schedule("u", "report", "0 9 * * *", "生成报告")
  155. result = await run_schedule(schedule["id"])
  156. assert result["ok"] and result["output"] == "回答"
  157. assert result["input_tokens"] == 8
  158. assert_released()
  159. @pytest.mark.asyncio
  160. async def test_failed_run_returns_error_and_settles_usage(pricing_ready):
  161. task = await prepared(Agent(failure=RuntimeError("model error")))
  162. result = await task.run()
  163. assert result.status == "failed" and result.error == "RuntimeError"
  164. assert result.output == "回答"
  165. assert len(billing_store.recent_usage("u")) == 1
  166. assert_released()
  167. def test_resume_validation_is_independent_of_http():
  168. from runtime.interrupts import ResumeDecision, ResumeError, pack, resume_value, token_for
  169. item = SimpleNamespace(id="approval", value={"action_requests": [{"name": "a"}, {"name": "b"}]})
  170. state = SimpleNamespace(tasks=[SimpleNamespace(interrupts=[item])])
  171. decision = ResumeDecision(approved=True, interrupt_token=token_for(pack(item)))
  172. assert len(resume_value(decision, state)["approval"]["decisions"]) == 2
  173. with pytest.raises(ResumeError) as error:
  174. resume_value(ResumeDecision(approved=True, interrupt_token="old"), state)
  175. assert error.value.code == "stale_interrupt"
  176. def test_runtime_and_scheduler_do_not_import_http_layer():
  177. root = Path(__file__).resolve().parents[1]
  178. for directory in ("runtime", "scheduler", "agents"):
  179. for path in (root / directory).rglob("*.py"):
  180. for node in ast.walk(ast.parse(path.read_text())):
  181. modules = ([node.module or ""] if isinstance(node, ast.ImportFrom) else
  182. [alias.name for alias in node.names] if isinstance(node, ast.Import) else [])
  183. assert not any(m.split(".")[0] in {"api", "fastapi", "starlette"} for m in modules), path
  184. # 新进程防止测试会话里提前导入 API 掩盖反向依赖。
  185. script = """
  186. import runtime.environment
  187. import runtime.engine
  188. import runtime.execution
  189. import runtime.interrupts
  190. import scheduler.runner
  191. import sys
  192. assert not any(name.split('.')[0] in {'api', 'fastapi', 'starlette'} for name in sys.modules)
  193. """
  194. subprocess.run([sys.executable, "-B", "-c", script], cwd=root, check=True)