test_runtime_regressions.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. """执行、存储和人机交互的失败路径回归;不访问模型网络。"""
  2. import asyncio
  3. import json
  4. from types import SimpleNamespace
  5. import httpx
  6. import pytest
  7. from langchain_core.messages import AIMessage, AIMessageChunk
  8. import config
  9. from runtime import concurrency
  10. from api.interrupts import pack
  11. from db import billing_store, chat_store, db_conn, run_store, schedule_store
  12. @pytest.fixture(autouse=True)
  13. def isolation(monkeypatch, tmp_path):
  14. monkeypatch.setenv('MAIN_FAN_ENABLED', '0')
  15. monkeypatch.setattr(config, "ROOT", tmp_path)
  16. (tmp_path / "skills").mkdir()
  17. concurrency.reset_for_tests()
  18. yield
  19. concurrency.reset_for_tests()
  20. class FakeAgent:
  21. def __init__(self, interrupt=None, wait=None):
  22. self.interrupt = interrupt
  23. self.wait = wait
  24. self.started = asyncio.Event()
  25. self.inputs = []
  26. async def aget_state(self, config):
  27. return SimpleNamespace(tasks=[])
  28. async def astream(self, graph_input, **kwargs):
  29. self.inputs.append((graph_input, kwargs))
  30. self.started.set()
  31. yield {"type": "messages", "data": (AIMessageChunk(content="部分回答", usage_metadata={"input_tokens": 12, "output_tokens": 3, "total_tokens": 15}), {})}
  32. if self.wait:
  33. await self.wait.wait()
  34. if self.interrupt:
  35. yield {"type": "updates", "data": {"__interrupt__": [self.interrupt]}}
  36. def test_sqlite_rollback_and_nested_savepoint():
  37. adapter = db_conn._SqliteAdapter(":memory:")
  38. adapter.execute("CREATE TABLE probe (id INTEGER)")
  39. with pytest.raises(RuntimeError):
  40. with adapter.transaction():
  41. adapter.execute("INSERT INTO probe VALUES (1)")
  42. adapter.execute_many("INSERT INTO probe VALUES (?)", [(2,), (3,)])
  43. raise RuntimeError()
  44. assert adapter.query("SELECT * FROM probe") == []
  45. with adapter.transaction():
  46. adapter.execute("INSERT INTO probe VALUES (4)")
  47. with pytest.raises(ValueError):
  48. with adapter.transaction():
  49. adapter.execute("INSERT INTO probe VALUES (5)")
  50. raise ValueError()
  51. adapter.execute("INSERT INTO probe VALUES (6)")
  52. assert adapter.query("SELECT id FROM probe") == [{"id": 4}, {"id": 6}]
  53. def test_billing_transaction_rolls_back_quota_failure(monkeypatch):
  54. def fail(*args):
  55. raise RuntimeError("quota database failure")
  56. monkeypatch.setattr(billing_store, "_accumulate_quota", fail)
  57. with pytest.raises(RuntimeError):
  58. billing_store.record_usage("u", "s", "chat", config.MODEL, 10, 2, 0, db_conn.day_key())
  59. assert billing_store.recent_usage("u") == []
  60. def test_failed_settlement_survives_new_meter_and_deduplicates(monkeypatch, pricing_ready):
  61. from billing.meter import UsageMeter, retry_pending
  62. real = billing_store.record_usage
  63. meter = UsageMeter("u", "s", "chat", config.MODEL)
  64. meter.add(10, 2)
  65. def fail(**kwargs):
  66. raise RuntimeError("temporary outage")
  67. monkeypatch.setattr(billing_store, "record_usage", fail)
  68. assert not meter.settle()["settled"]
  69. assert not meter.settled
  70. assert db_conn.scalar("SELECT COUNT(*) FROM billing_pending") == 1
  71. monkeypatch.setattr(billing_store, "record_usage", real)
  72. assert retry_pending() == 1
  73. assert meter.settle()["settled"] # old request's retry cannot double-charge
  74. assert len(billing_store.recent_usage("u")) == 1
  75. assert billing_store.get_quota("u", db_conn.day_key())["used_tokens"] == 12
  76. assert db_conn.scalar("SELECT COUNT(*) FROM billing_pending") == 0
  77. @pytest.mark.asyncio
  78. async def test_http_concurrency_returns_429(monkeypatch, pricing_ready):
  79. from main import app
  80. monkeypatch.setattr(config, "MAX_CONCURRENT_TASKS", 0)
  81. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
  82. response = await c.post("/api/chat", data={"message": "hello"})
  83. assert response.status_code == 429
  84. assert response.headers["retry-after"]
  85. assert not concurrency._sessions
  86. @pytest.mark.asyncio
  87. async def test_cross_user_message_and_schedule_access(pricing_ready):
  88. from main import app
  89. session = chat_store.create_session("alice")
  90. message_id = chat_store.save_message(session["id"], "user", "secret")
  91. schedule = schedule_store.create_schedule("alice", "private", "0 9 * * *", "private")
  92. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
  93. response = await c.delete(f"/api/messages/{message_id}", params={"user_name": "bob"})
  94. assert response.status_code == 404
  95. for method, suffix in [("GET", ""), ("DELETE", ""), ("POST", "/run"), ("GET", "/runs")]:
  96. response = await c.request(method, f"/api/schedules/{schedule['id']}{suffix}", params={"user_name": "bob"})
  97. assert response.status_code == 404
  98. assert chat_store.count_messages(session["id"]) == 1
  99. assert schedule_store.get_schedule(schedule["id"])
  100. @pytest.mark.asyncio
  101. async def test_same_session_concurrent_request_rejected(monkeypatch, pricing_ready):
  102. from main import app
  103. agent = FakeAgent(wait=asyncio.Event())
  104. monkeypatch.setattr("agents.agent_registry.get_agent", lambda: agent)
  105. session = chat_store.create_session("default")
  106. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
  107. first = asyncio.create_task(c.post("/api/chat", data={"message": "one", "session_id": session["id"]}))
  108. await asyncio.wait_for(agent.started.wait(), 2)
  109. second = await c.post("/api/chat", data={"message": "two", "session_id": session["id"]})
  110. assert second.status_code == 409
  111. agent.wait.set()
  112. assert (await first).status_code == 200
  113. assert concurrency.stats()["global_active"] == 0
  114. assert len(run_store.list_runs(session["id"])) == 1
  115. assert "two" not in [m["content"] for m in chat_store.get_messages(session["id"])]
  116. @pytest.mark.asyncio
  117. @pytest.mark.parametrize("cancel", [False, True])
  118. async def test_stream_close_or_cancel_settles_and_releases(cancel, pricing_ready):
  119. from runtime.execution import admit, build_execution
  120. session = chat_store.create_session("default")
  121. agent = FakeAgent(wait=asyncio.Event())
  122. admission = await admit("default", session["id"])
  123. execution = await build_execution(admission, agent=agent, session_id=session["id"], thread_id=session["id"],
  124. user_name="default", message="test", config={"configurable": {"thread_id": session["id"]}})
  125. generator = execution.events()
  126. while True:
  127. frame = await anext(generator)
  128. if frame["type"] == "token":
  129. break
  130. if cancel:
  131. pending = asyncio.create_task(anext(generator))
  132. await asyncio.sleep(0)
  133. pending.cancel()
  134. with pytest.raises(asyncio.CancelledError):
  135. await pending
  136. await generator.aclose()
  137. assert not concurrency._sessions
  138. assert concurrency.stats()["global_active"] == 0
  139. assert billing_store.get_quota("default", db_conn.day_key())["used_tokens"] == 15
  140. assert run_store.list_runs(session["id"])[0]["status"] == "cancelled"
  141. assert "不完整" in chat_store.get_messages(session["id"])[-1]["content"]
  142. @pytest.mark.asyncio
  143. async def test_schedule_uses_shared_runtime_and_pauses(monkeypatch, pricing_ready):
  144. from scheduler.runner import run_schedule
  145. item = SimpleNamespace(id="approval-id", value={"action_requests": [{"name": "calculate", "args": {"expression": "1+1"}}]})
  146. agent = FakeAgent(interrupt=item)
  147. monkeypatch.setattr("agents.agent_registry.get_agent", lambda: agent)
  148. schedule = schedule_store.create_schedule("u", "plan", "0 9 * * *", "question", "plan")
  149. result = await run_schedule(schedule["id"])
  150. assert result["status"] == "waiting_input"
  151. assert schedule_store.latest_run(schedule["id"])["status"] == "waiting_input"
  152. assert run_store.list_runs(result["session_id"])[0]["status"] == "waiting_input"
  153. assert "request_plan_approval" in agent.inputs[0][0]["messages"][-1].content
  154. assert billing_store.get_quota("u", db_conn.day_key())["used_tokens"] == 15
  155. assert concurrency.stats()["global_active"] == 0
  156. @pytest.mark.asyncio
  157. async def test_resume_and_schedule_enforce_quota(pricing_ready):
  158. from main import app
  159. from scheduler.runner import run_schedule
  160. from billing.guard import QuotaExceededError
  161. session = chat_store.create_session("u")
  162. billing_store.set_quota_limit("u", db_conn.day_key(), 1)
  163. billing_store.record_usage("u", session["id"], "chat", config.MODEL, 1, 0, 0, db_conn.day_key())
  164. schedule = schedule_store.create_schedule("u", "x", "0 9 * * *", "x")
  165. with pytest.raises(QuotaExceededError):
  166. await run_schedule(schedule["id"])
  167. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
  168. response = await c.post("/api/chat/resume", json={"session_id": session["id"], "user_name": "u", "approved": True})
  169. assert response.status_code == 429
  170. assert not concurrency._sessions
  171. def test_workspace_isolation_and_skills(monkeypatch, tmp_path):
  172. from agents.workspace import backend_for_runtime, session_root
  173. (tmp_path / "skills" / "guide.md").write_text("shared guide")
  174. def backend(user, session):
  175. return backend_for_runtime(SimpleNamespace(config={"configurable": {"workspace_user": user, "workspace_session": session}}))
  176. first, second, other_user = backend("alice", "one"), backend("alice", "two"), backend("bob", "one")
  177. first.write("/workspace/private.txt", "secret")
  178. assert "secret" in str(first.read("/workspace/private.txt"))
  179. assert second.read("/workspace/private.txt").error
  180. assert other_user.read("/workspace/private.txt").error
  181. assert "shared guide" in str(first.read("/skills/guide.md"))
  182. assert session_root("../escape", "../../other").is_relative_to(tmp_path / "data" / "workspaces")
  183. with pytest.raises(ValueError):
  184. backend_for_runtime(SimpleNamespace(config={}))
  185. def test_batch_requires_current_interrupt_and_shows_all():
  186. from api.interrupts import interrupt_frame, resume_value
  187. from api.models import ResumeRequest
  188. from api.sse_events import EventWriter
  189. from fastapi import HTTPException
  190. item = SimpleNamespace(id="a", value={"action_requests": [{"name": "one", "args": {}}, {"name": "two", "args": {}}]})
  191. frame = json.loads(interrupt_frame(EventWriter("s"), pack(item))[6:])
  192. assert len(frame["tool_approval"]["actions"]) == 2
  193. state = SimpleNamespace(tasks=[SimpleNamespace(interrupts=[item])])
  194. payload = ResumeRequest(session_id="s", approved=True, interrupt_token=frame["tool_approval"]["interrupt_token"])
  195. assert len(resume_value(payload, state)["a"]["decisions"]) == 2
  196. item.id = "newer"
  197. with pytest.raises(HTTPException) as error:
  198. resume_value(payload, state)
  199. assert error.value.status_code == 409
  200. def test_declared_side_effect_tools_require_approval():
  201. from tools.registry import register
  202. from agents.agent_factory import _build_interrupt_on
  203. @register(group="test", requires_approval=True)
  204. def external_write() -> str:
  205. """External change."""
  206. return "done"
  207. assert _build_interrupt_on()["external_write"] is True
  208. def test_unknown_model_is_not_silently_priced(monkeypatch):
  209. from billing import pricing, guard
  210. monkeypatch.setattr(config, "MODEL", "unpriced-test-model")
  211. pricing.seed_default_pricing()
  212. assert billing_store.get_pricing(config.MODEL) is None
  213. with pytest.raises(guard.BillingConfigError):
  214. guard.precheck("u")
  215. def test_startup_marks_stale_runs_interrupted():
  216. run_store.start("stale", "s", "u", "chat")
  217. run_store.recover_interrupted()
  218. assert run_store.list_runs("s")[0]["status"] == "interrupted"
  219. from langchain_core.language_models.chat_models import BaseChatModel
  220. from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
  221. from pydantic import Field
  222. class ScriptedModel(BaseChatModel):
  223. """真实图引擎,确定性模型;覆盖 middleware、工具和 checkpoint。"""
  224. responses: list[AIMessage] = Field(default_factory=list)
  225. calls: int = 0
  226. @property
  227. def _llm_type(self):
  228. return "offline-scripted"
  229. def bind_tools(self, tools, **kwargs):
  230. return self
  231. def _next(self):
  232. response = self.responses[self.calls]
  233. self.calls += 1
  234. return response
  235. def _generate(self, messages, stop=None, run_manager=None, **kwargs):
  236. return ChatResult(generations=[ChatGeneration(message=self._next())])
  237. def _stream(self, messages, stop=None, run_manager=None, **kwargs):
  238. message = self._next()
  239. yield ChatGenerationChunk(message=AIMessageChunk(content=message.content, tool_calls=message.tool_calls,
  240. usage_metadata={"input_tokens": 20, "output_tokens": 5, "total_tokens": 25}))
  241. @pytest.mark.asyncio
  242. @pytest.mark.parametrize("tool_name,args", [
  243. ("ask_user", {"questions": ["请选择区域"]}),
  244. ("request_plan_approval", {"plan": "读取文件后生成报告"}),
  245. ])
  246. async def test_real_graph_interrupt_refresh_resume(monkeypatch, pricing_ready, tool_name, args):
  247. from agents import agent_factory, agent_memory
  248. from main import app
  249. model = ScriptedModel(responses=[
  250. AIMessage(content="", tool_calls=[{"id": "call1", "name": tool_name, "args": args}]),
  251. AIMessage(content="已根据你的确认完成。"),
  252. ])
  253. monkeypatch.setattr("agents.model_factory.get_model", lambda: model)
  254. graph = agent_factory.build_agent()
  255. monkeypatch.setattr("agents.agent_registry.get_agent", lambda: graph)
  256. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
  257. first = await c.post("/api/chat", data={"message": "test", "user_name": "u"})
  258. assert first.status_code == 200, first.text
  259. events = [json.loads(line[6:]) for line in first.text.splitlines() if line.startswith("data: ")]
  260. assert not [e for e in events if e["type"] == "error"], events
  261. interrupted = next(e for e in events if e["type"] == "interrupt")
  262. sid = first.headers["x-session-id"]
  263. token = interrupted.get("interrupt_token") or interrupted["tool_approval"]["interrupt_token"]
  264. refreshed = (await c.get("/api/chat/interrupt", params={"session_id": sid, "user_name": "u"})).json()["interrupts"][0]
  265. assert (refreshed.get("interrupt_token") or refreshed["tool_approval"]["interrupt_token"]) == token
  266. assert events[-1]["waiting_input"]
  267. response = await c.post("/api/chat/resume", json={"session_id": sid, "user_name": "u",
  268. "interrupt_token": token, "approved": True, "answers": ["一区"]})
  269. assert response.status_code == 200, response.text
  270. assert "已根据你的确认完成" in response.text
  271. assert run_store.list_runs(sid)[0]["status"] == "success"
  272. repeated = await c.post("/api/chat/resume", json={"session_id": sid, "user_name": "u", "interrupt_token": token, "approved": True})
  273. assert repeated.status_code == 409
  274. await agent_memory.delete_thread(sid)
  275. @pytest.mark.asyncio
  276. async def test_real_graph_workspace_write(monkeypatch, pricing_ready):
  277. from agents import agent_factory, agent_memory
  278. from agents.workspace import session_root
  279. from main import app
  280. model = ScriptedModel(responses=[
  281. AIMessage(content="", tool_calls=[{"id": "w1", "name": "write_file", "args": {"file_path": "/workspace/result.txt", "content": "隔离的产物"}}]),
  282. AIMessage(content="已写入"),
  283. ])
  284. monkeypatch.setattr("agents.model_factory.get_model", lambda: model)
  285. graph = agent_factory.build_agent()
  286. monkeypatch.setattr("agents.agent_registry.get_agent", lambda: graph)
  287. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
  288. result = await c.post("/api/chat", data={"message": "write", "user_name": "u"})
  289. sid = result.headers["x-session-id"]
  290. assert (session_root("u", sid) / "workspace/result.txt").read_text() == "隔离的产物", result.text
  291. await agent_memory.delete_thread(sid)
  292. @pytest.mark.asyncio
  293. async def test_request_id_does_not_execute_twice(monkeypatch, pricing_ready):
  294. from main import app
  295. agent = FakeAgent()
  296. monkeypatch.setattr("agents.agent_registry.get_agent", lambda: agent)
  297. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
  298. body = {"message": "once", "request_id": "client-generated-key"}
  299. first = await c.post("/api/chat", data=body)
  300. second = await c.post("/api/chat", data=body)
  301. assert first.status_code == 200
  302. assert second.status_code == 409
  303. assert second.json()["detail"]["run_id"] == first.headers["x-run-id"]
  304. assert len(agent.inputs) == 1
  305. assert len(chat_store.list_sessions("default")) == 1
  306. @pytest.mark.asyncio
  307. async def test_real_subagent_shares_only_parent_workspace(monkeypatch, pricing_ready):
  308. from agents import agent_factory, agent_memory
  309. from agents.workspace import session_root
  310. from main import app
  311. model = ScriptedModel(responses=[
  312. AIMessage(content="", tool_calls=[{"id": "t1", "name": "task", "args": {"subagent_type": "demo-searcher", "description": "write a report"}}]),
  313. AIMessage(content="", tool_calls=[{"id": "w2", "name": "write_file", "args": {"file_path": "/workspace/sub.txt", "content": "子任务产物"}}]),
  314. AIMessage(content="子任务完成"),
  315. AIMessage(content="汇总完成"),
  316. ])
  317. monkeypatch.setattr("agents.model_factory.get_model", lambda: model)
  318. graph = agent_factory.build_agent()
  319. monkeypatch.setattr("agents.agent_registry.get_agent", lambda: graph)
  320. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
  321. result = await c.post("/api/chat", data={"message": "delegate", "user_name": "u"})
  322. sid = result.headers["x-session-id"]
  323. assert (session_root("u", sid) / "workspace/sub.txt").read_text() == "子任务产物", result.text
  324. await agent_memory.delete_thread(sid)
  325. @pytest.mark.asyncio
  326. async def test_schedule_timeout_is_failed_and_billed(monkeypatch, pricing_ready):
  327. from scheduler.runner import run_schedule
  328. agent = FakeAgent(wait=asyncio.Event())
  329. monkeypatch.setattr("agents.agent_registry.get_agent", lambda: agent)
  330. monkeypatch.setattr(config, "TASK_WALL_TIMEOUT", 0.05)
  331. schedule = schedule_store.create_schedule("u", "slow", "0 9 * * *", "wait")
  332. result = await run_schedule(schedule["id"])
  333. assert result["status"] == "failed"
  334. assert schedule_store.latest_run(schedule["id"])["status"] == "failed"
  335. assert billing_store.get_quota("u", db_conn.day_key())["used_tokens"] == 15
  336. assert concurrency.stats()["global_active"] == 0
  337. @pytest.mark.asyncio
  338. async def test_upload_limit_removes_partial_file(monkeypatch, pricing_ready):
  339. from fastapi import UploadFile, HTTPException
  340. from io import BytesIO
  341. from api.chat_routes import _save_upload
  342. from agents.workspace import session_root
  343. monkeypatch.setattr("api.chat_routes.MAX_UPLOAD_SIZE", 5)
  344. with pytest.raises(HTTPException) as error:
  345. await _save_upload(UploadFile(filename="data.txt", file=BytesIO(b"too long")), "u", "s")
  346. assert error.value.status_code == 413
  347. assert not list((session_root("u", "s") / "workspace/uploads").iterdir())
  348. def test_child_and_summary_usage_count_without_leaking_text_or_context():
  349. from api.sse_core import ChatStream, StreamRequest
  350. stream = ChatStream(StreamRequest(agent=None, session_id="s", thread_id="s", config={}, message="q"))
  351. main = AIMessageChunk(content="主回答", usage_metadata={"input_tokens": 100, "output_tokens": 10, "total_tokens": 110})
  352. child = AIMessageChunk(content="子任务内部材料", usage_metadata={"input_tokens": 10, "output_tokens": 2, "total_tokens": 12})
  353. summary = AIMessageChunk(content="内部摘要", usage_metadata={"input_tokens": 20, "output_tokens": 3, "total_tokens": 23})
  354. assert stream._handle_messages((main, {}), ())
  355. assert stream._handle_messages((child, {}), ("task:1",)) == []
  356. assert stream._handle_messages((summary, {"lc_source": "summarization"}), ()) == []
  357. assert stream.answer_parts == ["主回答"]
  358. assert stream.meter.total_tokens == 145
  359. assert stream.meter.context_tokens == 100
  360. assert not stream.meter.compacted
  361. @pytest.mark.asyncio
  362. async def test_scheduler_shutdown_waits_for_cleanup(monkeypatch):
  363. from scheduler import scheduler
  364. entered = asyncio.Event()
  365. cleaned = False
  366. calls = []
  367. async def work(*args, **kwargs):
  368. nonlocal cleaned
  369. entered.set()
  370. try:
  371. await asyncio.Event().wait()
  372. finally:
  373. await asyncio.sleep(0)
  374. cleaned = True
  375. class Scheduler:
  376. def pause(self):
  377. calls.append("pause")
  378. def shutdown(self, **kwargs):
  379. assert cleaned
  380. calls.append("shutdown")
  381. monkeypatch.setattr(scheduler, "run_schedule", work)
  382. monkeypatch.setattr(scheduler, "_scheduler", Scheduler())
  383. task = asyncio.create_task(scheduler._invoke("s"))
  384. await entered.wait()
  385. await scheduler.shutdown()
  386. assert task.done() and cleaned
  387. assert calls == ["pause", "shutdown"]
  388. assert not scheduler._active
  389. @pytest.mark.asyncio
  390. async def test_admission_revalidates_deleted_session(pricing_ready):
  391. from agents.execution import admit
  392. session = chat_store.create_session("u")
  393. chat_store.delete_session(session["id"])
  394. with pytest.raises(concurrency.SessionMissingError):
  395. await admit("u", session["id"])
  396. assert concurrency.stats()["global_active"] == 0
  397. assert not concurrency._sessions
  398. @pytest.mark.asyncio
  399. async def test_cancellation_during_persistence_does_not_duplicate_answer(monkeypatch, pricing_ready):
  400. import threading
  401. from runtime.execution import admit, build_execution
  402. session = chat_store.create_session("u")
  403. entered, release = threading.Event(), threading.Event()
  404. original = chat_store.save_message
  405. def slow_save(session_id, role, content, **kwargs):
  406. if role == "assistant":
  407. entered.set()
  408. assert release.wait(3)
  409. return original(session_id, role, content, **kwargs)
  410. monkeypatch.setattr(chat_store, "save_message", slow_save)
  411. admission = await admit("u", session["id"])
  412. execution = await build_execution(admission, agent=FakeAgent(), session_id=session["id"],
  413. thread_id=session["id"], user_name="u", message="question", config={"configurable": {"thread_id": session["id"]}})
  414. async def consume():
  415. async for _ in execution.events():
  416. pass
  417. task = asyncio.create_task(consume())
  418. assert await asyncio.to_thread(entered.wait, 3)
  419. task.cancel()
  420. await asyncio.sleep(0)
  421. release.set()
  422. with pytest.raises(asyncio.CancelledError):
  423. await task
  424. assert len([m for m in chat_store.get_messages(session["id"]) if m["msg_role"] == "assistant"]) == 1
  425. assert len(billing_store.recent_usage("u")) == 1
  426. assert concurrency.stats()["global_active"] == 0
  427. @pytest.mark.asyncio
  428. @pytest.mark.parametrize("delegated", [False, True])
  429. async def test_extension_approval_cannot_be_bypassed_by_main_or_subagent(monkeypatch, pricing_ready, delegated):
  430. import sys
  431. from extensions import registry as extensions
  432. from extensions import define_tool
  433. from tools import registry as tools
  434. from agents import agent_factory, agent_memory
  435. from main import app
  436. writes = []
  437. @define_tool(name="adjust_fan", group="通风")
  438. def write_fan(speed: int) -> str:
  439. """调整风机速度。"""
  440. writes.append(speed)
  441. return "完成"
  442. def setup(catalog):
  443. catalog.register_agent(name="ventilation", description="通风控制", prompt="使用工具调整风机。",
  444. tools=[write_fan], approval_tools=[write_fan])
  445. modules = extensions.discover_modules()
  446. monkeypatch.setitem(sys.modules, "extensions.ventilation.extension", SimpleNamespace(setup=setup))
  447. monkeypatch.setattr(extensions, "discover_modules", lambda: [*modules, "extensions.ventilation.extension"])
  448. tools.reset_for_tests()
  449. responses = []
  450. if delegated:
  451. responses.append(AIMessage(content="", tool_calls=[{"id": "delegate", "name": "task",
  452. "args": {"subagent_type": "ventilation", "description": "调整风机"}}]))
  453. responses.extend([
  454. AIMessage(content="", tool_calls=[{"id": "fan", "name": "adjust_fan", "args": {"speed": 10}}]),
  455. AIMessage(content="已完成"), AIMessage(content="已汇总"),
  456. ])
  457. model = ScriptedModel(responses=responses)
  458. monkeypatch.setattr("agents.model_factory.get_model", lambda: model)
  459. graph = agent_factory.build_agent()
  460. assert tools.get_spec("adjust_fan").requires_approval
  461. monkeypatch.setattr("agents.agent_registry.get_agent", lambda: graph)
  462. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
  463. first = await client.post("/api/chat", data={"message": "调整风机", "user_name": "u"})
  464. events = [json.loads(line[6:]) for line in first.text.splitlines() if line.startswith("data: ")]
  465. assert not [e for e in events if e["type"] == "error"], events
  466. interrupted = next(e for e in events if e["type"] == "interrupt")
  467. assert writes == []
  468. sid = first.headers["x-session-id"]
  469. token = interrupted["tool_approval"]["interrupt_token"]
  470. response = await client.post("/api/chat/resume", json={"session_id": sid, "user_name": "u",
  471. "interrupt_token": token, "approved": True})
  472. assert response.status_code == 200, response.text
  473. assert writes == [10], response.text
  474. assert run_store.list_runs(sid)[0]["status"] == "success", response.text
  475. await agent_memory.delete_thread(sid)