test_business_wait.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import asyncio
  2. from datetime import timedelta
  3. from uuid import UUID
  4. import pytest
  5. from sqlalchemy import delete, 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.modules.after_sales.models import Approval, OrderProjection, Ticket
  10. from zhifu.modules.agent_runtime.business_wait import reconcile
  11. from zhifu.modules.agent_runtime.config import GRAPH_VERSION
  12. from zhifu.modules.agent_runtime.models import AgentRun, RunInputEvent
  13. from zhifu.modules.agent_runtime.runtime import build_tool_graph
  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. from zhifu.modules.integrations.models import ExternalOperation
  19. pytestmark = pytest.mark.integration
  20. async def draft_wait(context):
  21. path = await setup(context)
  22. row = await tool_run(context, "create_exchange_proposal", context["proposal"])
  23. sessions = context["app"].state.sessions
  24. async with sessions.begin() as session:
  25. run = await session.get(AgentRun, row.id)
  26. run.graph_version = GRAPH_VERSION
  27. conversation = await session.get(Conversation, UUID(path.rsplit("/", 1)[1]).bytes)
  28. conversation.active_run_id = run.id
  29. run.conversation_id, run.control_version = conversation.id, conversation.control_version
  30. run_id, token = await claim(sessions, 2)
  31. result = await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
  32. assert result["next_node"] == "wait"
  33. run_path = f"/openapi/v1/agent/runs/{UUID(bytes=run_id)}"
  34. view = (await context["call"]("GET", run_path)).json()
  35. assert view["status"] == "WAITING_USER"
  36. assert view["waiting"]["expected_input_type"] == "proposal_confirmation"
  37. ticket_id = view["waiting"]["proposal_id"]
  38. ticket = (await context["call"]("GET", f"/openapi/v1/tickets/{ticket_id}")).json()
  39. return run_id, run_path, path, ticket, view
  40. async def confirm(context, ticket, key="business-confirm"):
  41. response = await context["call"](
  42. "POST", f"/openapi/v1/after-sales/proposals/{ticket['proposal_id']}/confirm", payload={
  43. "proposal_version": ticket["proposal_version"],
  44. "expected_ticket_version": ticket["version"],
  45. }, key=key)
  46. assert response.status_code == 200, response.text
  47. return response.json()
  48. async def decide(context, ticket, decision="APPROVED"):
  49. response = await context["call"](
  50. "POST", f"/openapi/v1/approvals/{ticket['approval_id']}/decisions", "reviewer", {
  51. "proposal_version": ticket["proposal_version"], "expected_version": ticket["version"],
  52. "decision": decision, "reason": "已核对当前版本",
  53. }, key="business-decision")
  54. assert response.status_code == 200, response.text
  55. return response.json()
  56. async def test_confirmation_approval_restore_same_run_once(scenario): # noqa: F811
  57. run_id, path, conversation_path, ticket, view = await draft_wait(scenario)
  58. sessions = scenario["app"].state.sessions
  59. text = await scenario["call"]("POST", path + "/inputs", payload={
  60. "interrupt_id": view["waiting"]["interrupt_id"],
  61. "expected_run_version": view["run_version"], "answer": "我确认且主管批准",
  62. })
  63. assert text.status_code == 409 and text.json()["code"] == "BUSINESS_EVENT_REQUIRED"
  64. confirmed = await asyncio.gather(*[confirm(scenario, ticket) for _ in range(3)])
  65. assert sum(await asyncio.gather(*[reconcile(sessions) for _ in range(3)])) == 1
  66. waiting = (await scenario["call"]("GET", path)).json()
  67. assert waiting["status"] == "WAITING_APPROVAL"
  68. assert waiting["waiting"]["approval_id"] == confirmed[0]["approval_id"]
  69. assert await claim(sessions, 2) is None
  70. await decide(scenario, confirmed[0])
  71. assert sum(await asyncio.gather(*[reconcile(sessions) for _ in range(3)])) == 1
  72. resumed = (await scenario["call"]("GET", path)).json()
  73. assert resumed["run_id"] == str(UUID(bytes=run_id)) and resumed["status"] == "QUEUED"
  74. assert resumed["waiting"] is None and resumed["model_calls"] == 0
  75. events = (await scenario["call"]("GET", conversation_path + "/messages")).json()["items"]
  76. assert [event["type"] for event in events].count("run.resumed") == 1
  77. async with sessions() as session:
  78. for model, expected in (
  79. (RunInputEvent, 2), (Approval, 1), (Ticket, 1), (ExternalOperation, 0)
  80. ):
  81. assert await session.scalar(select(func.count()).select_from(model).where(
  82. model.tenant_id == scenario["tenant"].bytes)) == expected
  83. assert (await claim(sessions, 2))[0] == run_id
  84. @pytest.mark.parametrize("decision", ["APPROVED", "REJECTED"])
  85. async def test_decision_before_event_poll_uses_current_proof(scenario, decision): # noqa: F811
  86. _, path, _, ticket, _ = await draft_wait(scenario)
  87. await decide(scenario, await confirm(scenario, ticket), decision)
  88. assert await reconcile(scenario["app"].state.sessions) == 1
  89. assert (await scenario["call"]("GET", path)).json()["status"] == "QUEUED"
  90. assert await reconcile(scenario["app"].state.sessions) == 0
  91. async def test_revision_replaces_wait_and_requires_new_confirmation(scenario): # noqa: F811
  92. _, path, _, ticket, view = await draft_wait(scenario)
  93. confirmed = await confirm(scenario, ticket)
  94. sessions = scenario["app"].state.sessions
  95. await reconcile(sessions)
  96. revised = await scenario["call"](
  97. "POST", f"/openapi/v1/after-sales/proposals/{ticket['proposal_id']}/revisions", payload={
  98. "expected_version": confirmed["version"], "quantity": 1, "reason": "修订故障描述",
  99. })
  100. assert revised.status_code == 200, revised.text
  101. assert await reconcile(sessions) == 1
  102. waiting = (await scenario["call"]("GET", path)).json()
  103. assert waiting["status"] == "WAITING_USER"
  104. assert waiting["waiting"]["proposal_version"] == 2
  105. assert waiting["waiting"]["interrupt_id"] != view["waiting"]["interrupt_id"]
  106. old = await scenario["call"](
  107. "POST", f"/openapi/v1/approvals/{confirmed['approval_id']}/decisions", "reviewer", {
  108. "proposal_version": 1, "expected_version": confirmed["version"],
  109. "decision": "APPROVED", "reason": "旧版本",
  110. })
  111. assert old.status_code == 409
  112. await confirm(scenario, revised.json(), key="revised-confirm")
  113. assert await reconcile(sessions) == 1
  114. assert (await scenario["call"]("GET", path)).json()["status"] == "WAITING_APPROVAL"
  115. async def test_rule_revocation_requires_revision_and_wait_expires(scenario): # noqa: F811
  116. run_id, path, _, ticket, _ = await draft_wait(scenario)
  117. await confirm(scenario, ticket)
  118. sessions = scenario["app"].state.sessions
  119. response = await scenario["call"](
  120. "POST", f"/admin/v1/stores/{scenario['store']}/exchange-rules/revoke", "agent",
  121. {"expected_version": 1, "reason": "当前规则撤销"})
  122. assert response.status_code == 200, response.text
  123. assert await reconcile(sessions) == 1
  124. result = (await scenario["call"]("GET", f"/openapi/v1/tickets/{ticket['ticket_id']}")).json()
  125. assert result["status"] == "NEEDS_REVISION" and result["approval_status"] == "INVALIDATED"
  126. assert (await scenario["call"]("GET", path)).json()["status"] == "WAITING_USER"
  127. async with sessions.begin() as session:
  128. (await session.get(AgentRun, run_id)).deadline_at = now() - timedelta(seconds=1)
  129. assert await claim(sessions, 2) is None
  130. assert (await scenario["call"]("GET", path)).json()["status"] == "FAILED"
  131. async def test_takeover_and_approver_revocation_prevent_resume(scenario): # noqa: F811
  132. _, path, conversation, ticket, _ = await draft_wait(scenario)
  133. await confirm(scenario, ticket)
  134. sessions = scenario["app"].state.sessions
  135. await reconcile(sessions)
  136. response = await scenario["call"]("POST", conversation + "/takeover", "agent",
  137. {"expected_control_version": 1})
  138. assert response.status_code == 200, response.text
  139. assert await reconcile(sessions) == 0
  140. assert (await scenario["call"]("GET", path)).json()["status"] == "CANCELLED"
  141. # 新的独立运行验证审批完成后、恢复前的当前权限检查。
  142. row = await tool_run(scenario, "create_exchange_proposal", scenario["proposal"])
  143. async with sessions.begin() as session:
  144. (await session.get(AgentRun, row.id)).graph_version = GRAPH_VERSION
  145. run_id, token = await claim(sessions, 2)
  146. await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
  147. async with sessions() as session:
  148. run = await session.get(AgentRun, run_id)
  149. ticket_id = run.waiting["proposal_id"]
  150. ticket = (await scenario["call"]("GET", f"/openapi/v1/tickets/{ticket_id}")).json()
  151. await decide(scenario, await confirm(scenario, ticket))
  152. async with sessions.begin() as session:
  153. await session.execute(delete(StoreGrant).where(
  154. StoreGrant.principal_id == scenario["accounts"]["reviewer"].bytes,
  155. StoreGrant.permission == "approvals:decide"))
  156. await reconcile(sessions)
  157. async with sessions() as session:
  158. run = await session.get(AgentRun, run_id)
  159. assert run.status == "FAILED" and run.error_code == "APPROVER_REVOKED"
  160. async def test_stale_snapshot_waits_and_changed_order_requires_revision(scenario): # noqa: F811
  161. _, path, _, ticket, _ = await draft_wait(scenario)
  162. sessions = scenario["app"].state.sessions
  163. async with sessions.begin() as session:
  164. order = await session.get(OrderProjection, scenario["order_id"].bytes)
  165. order.observed_at = now() - timedelta(minutes=6)
  166. assert await reconcile(sessions) == 0
  167. unchanged = (await scenario["call"]("GET", f"/openapi/v1/tickets/{ticket['ticket_id']}")).json()
  168. assert unchanged["status"] == "DRAFT"
  169. response = await scenario["call"]("PUT", f"/internal/v1/orders/{scenario['order_id']}",
  170. "connector", {**scenario["source"], "source_version": 2})
  171. assert response.status_code == 200, response.text
  172. assert await reconcile(sessions) == 1
  173. changed = (await scenario["call"]("GET", f"/openapi/v1/tickets/{ticket['ticket_id']}")).json()
  174. assert changed["status"] == "NEEDS_REVISION"
  175. assert (await scenario["call"]("GET", path)).json()["status"] == "WAITING_USER"