| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171 |
- import asyncio
- import hashlib
- import json
- from datetime import timedelta
- from uuid import UUID, uuid4
- import pytest
- from langchain_core.messages import AIMessage, message_to_dict
- from sqlalchemy import func, select
- from test_after_sales import scenario # noqa: F401
- from zhifu.core.errors import BusinessError
- from zhifu.modules.after_sales.models import Ticket
- from zhifu.modules.agent_runtime.capacity import acquire, keys
- from zhifu.modules.agent_runtime.config import AgentSettings
- from zhifu.modules.agent_runtime.models import AgentRun
- from zhifu.modules.agent_runtime.runtime import build_tool_graph
- from zhifu.modules.agent_runtime.worker import claim
- from zhifu.modules.identity.access_models import AccessSession
- from zhifu.modules.identity.auth import now
- pytestmark = pytest.mark.integration
- async def tool_run(context, name, arguments):
- token = context["headers"]["customer"]["Authorization"].split()[1]
- async with context["app"].state.sessions.begin() as session:
- run = AgentRun(
- id=uuid4().bytes,
- tenant_id=context["tenant"].bytes,
- actor_id=context["accounts"]["customer"].bytes,
- store_id=context["store"].bytes,
- token_hash=hashlib.sha256(token.encode()).digest(),
- request_key=uuid4().hex,
- request_hash=hashlib.sha256(json.dumps(arguments).encode()).digest(),
- messages=[
- message_to_dict(
- AIMessage(
- content="",
- tool_calls=[
- {
- "name": name,
- "args": arguments,
- "id": uuid4().hex,
- "type": "tool_call",
- }
- ],
- )
- )
- ],
- next_node="tools",
- model_name="",
- model_url="",
- model_credential_env="",
- created_at=now(),
- deadline_at=now() + timedelta(minutes=10),
- )
- session.add(run)
- await session.flush()
- return run
- async def test_tool_graph_creates_one_proposal_on_replay(scenario): # noqa: F811
- run = await tool_run(scenario, "create_exchange_proposal", scenario["proposal"])
- sessions = scenario["app"].state.sessions
- run_id, token = await claim(sessions, 2)
- graph = build_tool_graph(sessions)
- state = {"run_id": run_id, "token": token, "next_node": "tools"}
- await graph.ainvoke(state)
- await graph.ainvoke(state)
- async with sessions() as session:
- count = await session.scalar(
- select(func.count())
- .select_from(Ticket)
- .where(Ticket.tenant_id == scenario["tenant"].bytes)
- )
- assert count == 1
- current = await session.get(AgentRun, run.id)
- result = json.loads(current.messages[-1]["data"]["content"])
- assert result["status"] == "DRAFT"
- assert current.step == 1
- @pytest.mark.parametrize("name", ["confirm", "decide", "submit_exchange", "cancel"])
- async def test_unauthorized_tools_are_rejected(scenario, name): # noqa: F811
- await tool_run(scenario, name, {})
- sessions = scenario["app"].state.sessions
- run_id, token = await claim(sessions, 2)
- with pytest.raises(BusinessError, match="工具不在授权清单"):
- await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
- async def test_tool_cannot_read_another_customer_order(scenario): # noqa: F811
- run = await tool_run(scenario, "get_order", {"order_id": str(scenario["order_id"])})
- sessions = scenario["app"].state.sessions
- other_token = scenario["headers"]["other"]["Authorization"].split()[1]
- async with sessions.begin() as session:
- current = await session.get(AgentRun, run.id)
- current.actor_id = scenario["accounts"]["other"].bytes
- current.token_hash = hashlib.sha256(other_token.encode()).digest()
- run_id, token = await claim(sessions, 2)
- with pytest.raises(BusinessError, match="不可访问"):
- await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
- async def test_revoked_session_stops_tool_execution(scenario): # noqa: F811
- run = await tool_run(scenario, "search_orders", {})
- sessions = scenario["app"].state.sessions
- run_id, token = await claim(sessions, 2)
- async with sessions.begin() as session:
- access = await session.get(AccessSession, run.token_hash)
- access.revoked = True
- with pytest.raises(BusinessError, match="访问令牌无效"):
- await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
- async def test_cancelled_run_rejects_old_execution(scenario): # noqa: F811
- run = await tool_run(scenario, "search_orders", {})
- sessions = scenario["app"].state.sessions
- run_id, token = await claim(sessions, 2)
- response = await scenario["call"]("POST", f"/openapi/v1/agent/runs/{UUID(bytes=run.id)}/cancel")
- assert response.status_code == 200
- with pytest.raises(BusinessError, match="执行权已失效"):
- await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
- response = await scenario["call"](
- "GET", f"/openapi/v1/agent/runs/{UUID(bytes=run.id)}", "foreign"
- )
- assert response.status_code == 404
- async def test_tenant_and_global_concurrency_limits(scenario): # noqa: F811
- for _ in range(3):
- await tool_run(scenario, "search_orders", {})
- sessions = scenario["app"].state.sessions
- claims = await asyncio.gather(*(claim(sessions, 2) for _ in range(3)))
- assert sum(value is not None for value in claims) == 2
- state = scenario["app"].state.state
- key = "verify:agent:capacity:" + uuid4().hex
- try:
- results = await asyncio.gather(
- *(
- acquire(state, AgentSettings(), "background", uuid4().hex, prefix=key)
- for _ in range(8)
- )
- )
- assert sum(results) == 2
- finally:
- await state.delete(*keys(key))
- async def test_expired_lease_resumes_committed_tool_without_duplicate(scenario): # noqa: F811
- run = await tool_run(scenario, "create_exchange_proposal", scenario["proposal"])
- sessions = scenario["app"].state.sessions
- run_id, token = await claim(sessions, 2)
- await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
- async with sessions.begin() as session:
- current = await session.get(AgentRun, run.id)
- current.lease_until = now() - timedelta(seconds=1)
- recovered_id, recovered_token = await claim(sessions, 2)
- assert recovered_id == run_id and recovered_token > token
- with pytest.raises(BusinessError, match="执行权已失效"):
- await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
- result = await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": recovered_token})
- assert result["next_node"] == "model"
- async with sessions() as session:
- count = await session.scalar(
- select(func.count())
- .select_from(Ticket)
- .where(Ticket.tenant_id == scenario["tenant"].bytes)
- )
- assert count == 1
|