test_agent_inputs.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. import asyncio
  2. from datetime import timedelta
  3. from uuid import UUID, uuid4
  4. import pytest
  5. from sqlalchemy import func, select
  6. from test_after_sales import scenario # noqa: F401
  7. from test_agent_runtime import tool_run
  8. from test_conversations import setup
  9. from zhifu.core.errors import BusinessError
  10. from zhifu.modules.agent_runtime.config import GRAPH_RELEASES, GRAPH_VERSION
  11. from zhifu.modules.agent_runtime.models import AgentRun, RunInputEvent
  12. from zhifu.modules.agent_runtime.runtime import build_tool_graph, locked_run
  13. from zhifu.modules.agent_runtime.tools import model_tools
  14. from zhifu.modules.agent_runtime.worker import claim
  15. from zhifu.modules.conversations.models import Conversation
  16. from zhifu.modules.identity.access_models import StoreGrant
  17. from zhifu.modules.identity.auth import now
  18. pytestmark = pytest.mark.integration
  19. async def waiting_run(context, *, conversation=False):
  20. # 直接提交工具命令验证真实图节点与事务,不产生或替代供应商模型响应。
  21. path = await setup(context) if conversation else None
  22. row = await tool_run(context, "request_clarification", {
  23. "question": "请选择订单并补充换货原因。", "missing_fields": ["order_id", "reason"],
  24. "candidate_order_ids": [str(context["order_id"])],
  25. })
  26. sessions = context["app"].state.sessions
  27. async with sessions.begin() as session:
  28. run = await session.get(AgentRun, row.id)
  29. run.graph_version = GRAPH_VERSION
  30. if path:
  31. item = await session.get(Conversation, UUID(path.rsplit("/", 1)[1]).bytes)
  32. item.active_run_id = run.id
  33. run.conversation_id, run.control_version = item.id, item.control_version
  34. run_id, token = await claim(sessions, 2)
  35. result = await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
  36. assert result["next_node"] == "wait"
  37. response = await context["call"]("GET", f"/openapi/v1/agent/runs/{UUID(bytes=run_id)}")
  38. assert response.status_code == 200, response.text
  39. view = response.json()
  40. assert view["status"] == "WAITING_USER" and view["waiting"]["candidate_orders"]
  41. return view, token, path
  42. def answer(view):
  43. return {"interrupt_id": view["waiting"]["interrupt_id"],
  44. "expected_run_version": view["run_version"], "answer": "选择当前订单,按键失灵"}
  45. async def test_resume_same_run_atomic_idempotency_and_event(scenario): # noqa: F811
  46. view, old_token, conversation = await waiting_run(scenario, conversation=True)
  47. call = scenario["call"]
  48. path = f"/openapi/v1/agent/runs/{view['run_id']}"
  49. assert await claim(scenario["app"].state.sessions, 2) is None
  50. responses = await asyncio.gather(*[
  51. call("POST", path + "/inputs", payload=answer(view), key="same-input") for _ in range(3)])
  52. assert all(response.status_code == 202 for response in responses), [r.text for r in responses]
  53. assert len({response.json()["input_event_id"] for response in responses}) == 1
  54. result = responses[0].json()
  55. assert result["run"]["run_id"] == view["run_id"]
  56. assert result["run"]["run_version"] == view["run_version"] + 1
  57. assert result["run"]["waiting"] is None and result["run"]["status"] == "QUEUED"
  58. conflict = await call("POST", path + "/inputs", payload={**answer(view), "answer": "其他内容"},
  59. key="same-input")
  60. assert conflict.json()["code"] == "IDEMPOTENCY_CONFLICT"
  61. repeat = await call("POST", path + "/inputs", payload=answer(view))
  62. assert repeat.json()["code"] == "RUN_NOT_WAITING"
  63. history = (await call("GET", conversation + "/messages")).json()["items"]
  64. assert [event["type"] for event in history].count("message.user") == 1
  65. assert {"run.waiting", "run.resumed"} <= {event["type"] for event in history}
  66. sessions = scenario["app"].state.sessions
  67. run_id, token = await claim(sessions, 2)
  68. assert str(UUID(bytes=run_id)) == view["run_id"] and token > old_token
  69. async with sessions.begin() as session:
  70. with pytest.raises(BusinessError, match="执行权已失效"):
  71. await locked_run(session, run_id, old_token)
  72. async with sessions() as session:
  73. assert await session.scalar(select(func.count()).select_from(RunInputEvent).where(
  74. RunInputEvent.run_id == run_id)) == 1
  75. async def test_resume_identity_version_and_cancel_boundaries(scenario): # noqa: F811
  76. view, _, conversation = await waiting_run(scenario, conversation=True)
  77. call = scenario["call"]
  78. path = f"/openapi/v1/agent/runs/{view['run_id']}"
  79. for actor in ("other", "foreign"):
  80. assert (await call("POST", path + "/inputs", actor, answer(view))).status_code == 404
  81. assert (await call("POST", path + "/inputs", "agent", answer(view))).status_code == 403
  82. extra = await call("POST", path + "/inputs", payload={**answer(view), "approved": True})
  83. assert extra.status_code == 422
  84. old = await call("POST", path + "/inputs", payload={**answer(view), "expected_run_version": 1})
  85. assert old.json()["code"] == "RUN_VERSION_CONFLICT"
  86. wrong = await call("POST", path + "/inputs",
  87. payload={**answer(view), "interrupt_id": str(uuid4())})
  88. assert wrong.json()["code"] == "INTERRUPT_CONFLICT"
  89. assert (await call("POST", conversation + "/takeover", "agent",
  90. {"expected_control_version": 1})).status_code == 200
  91. assert (await call("GET", path)).json()["status"] == "CANCELLED"
  92. assert (await call("POST", path + "/inputs", payload=answer(view))).status_code == 409
  93. async def test_wait_expiry_and_service_receipt_authorization(scenario): # noqa: F811
  94. view, _, _ = await waiting_run(scenario)
  95. call = scenario["call"]
  96. path = f"/openapi/v1/agent/runs/{view['run_id']}"
  97. sessions = scenario["app"].state.sessions
  98. response = await call("POST", path + "/inputs", payload=answer(view))
  99. assert response.status_code == 202, response.text
  100. body = {key: value for key, value in answer(view).items() if key != "answer"}
  101. body["input_event_id"] = response.json()["input_event_id"]
  102. assert (await call("POST", path + "/resume", payload=body)).status_code == 403
  103. assert (await call("POST", path + "/resume", "connector", body)).status_code == 404
  104. async with sessions.begin() as session:
  105. session.add(StoreGrant(tenant_id=scenario["tenant"].bytes,
  106. store_id=scenario["store"].bytes, principal_id=scenario["accounts"]["connector"].bytes,
  107. permission="agent:resume"))
  108. assert (await call("POST", path + "/resume", "connector", body)).status_code == 202
  109. wrong = await call("POST", path + "/resume", "connector", {**body, "expected_run_version": 1})
  110. assert wrong.json()["code"] == "INTERRUPT_CONFLICT"
  111. await call("POST", path + "/cancel")
  112. cancelled = await call("POST", path + "/resume", "connector", body)
  113. assert cancelled.json()["code"] == "RUN_NOT_RESUMABLE"
  114. expired, _, _ = await waiting_run(scenario)
  115. async with sessions.begin() as session:
  116. row = await session.get(AgentRun, UUID(expired["run_id"]).bytes)
  117. row.deadline_at = now() - timedelta(seconds=1)
  118. expired_path = f"/openapi/v1/agent/runs/{expired['run_id']}"
  119. expired_response = await call("POST", expired_path + "/inputs", payload=answer(expired))
  120. assert expired_response.json()["code"] == "RUN_WAIT_EXPIRED"
  121. assert await claim(sessions, 2) is None
  122. assert (await call("GET", expired_path)).json()["status"] == "FAILED"
  123. async def test_registered_old_graph_remains_executable(scenario): # noqa: F811
  124. assert {"after-sales-v1", GRAPH_VERSION} <= GRAPH_RELEASES.keys()
  125. old_tools = {tool["function"]["name"] for tool in model_tools("after-sales-v1")}
  126. current_tools = {tool["function"]["name"] for tool in model_tools(GRAPH_VERSION)}
  127. assert "request_clarification" not in old_tools
  128. assert "request_clarification" in current_tools
  129. row = await tool_run(scenario, "search_orders", {})
  130. sessions = scenario["app"].state.sessions
  131. run_id, token = await claim(sessions, 2)
  132. async with sessions() as session:
  133. assert (await session.get(AgentRun, row.id)).graph_version == "after-sales-v1"
  134. result = await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
  135. assert result["next_node"] == "model"
  136. async with sessions.begin() as session:
  137. (await session.get(AgentRun, row.id)).graph_version = "unregistered-version"
  138. async with sessions.begin() as session:
  139. with pytest.raises(BusinessError, match="运行版本不可用"):
  140. await locked_run(session, run_id, token)