Files
xerrors--yuxi/backend/test/e2e/test_agent_async_e2e.py
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

277 lines
10 KiB
Python

from __future__ import annotations
import asyncio
import json
import os
import uuid
from typing import Any
import asyncpg
import httpx
import pytest
pytestmark = [pytest.mark.asyncio, pytest.mark.e2e, pytest.mark.slow]
POLL_INTERVAL_SECONDS = float(os.getenv("E2E_RUN_POLL_INTERVAL_SECONDS", "2"))
RUN_TIMEOUT_SECONDS = int(os.getenv("E2E_RUN_TIMEOUT_SECONDS", "240"))
EXPECTED_OUTPUT = "ASYNC_AGENT_E2E_OK"
def _postgres_dsn() -> str:
return os.getenv("POSTGRES_URL", "postgresql+asyncpg://postgres:postgres@postgres:5432/yuxi").replace(
"+asyncpg", ""
)
async def _create_agent(client: httpx.AsyncClient, headers: dict[str, str], uid: str) -> str:
default_response = await client.get("/api/agent/default", headers=headers)
assert default_response.status_code == 200, default_response.text
default_context = ((default_response.json().get("agent") or {}).get("config_json") or {}).get("context") or {}
slug = f"e2e-async-agent-{uuid.uuid4().hex[:8]}"
context: dict[str, Any] = {
"system_prompt": f"你是端到端测试专用智能体。不要调用任何工具,只输出 {EXPECTED_OUTPUT}。",
"tools": [],
"knowledges": [],
"mcps": [],
"skills": [],
"subagents": [],
}
if default_context.get("model"):
context["model"] = default_context["model"]
response = await client.post(
"/api/agent",
json={
"name": f"E2E 异步 Agent {slug[-8:]}",
"slug": slug,
"backend_id": "ChatbotAgent",
"description": "真实异步 Agent E2E 临时智能体",
"config_json": {"context": context},
"share_config": {"access_level": "user", "department_ids": [], "user_uids": [uid]},
},
headers=headers,
)
assert response.status_code == 200, response.text
assert (response.json().get("agent") or {}).get("slug") == slug
return slug
async def _delete_agent(client: httpx.AsyncClient, headers: dict[str, str], slug: str) -> None:
response = await client.delete(f"/api/agent/{slug}", headers=headers)
assert response.status_code in {200, 404}, response.text
async def _cancel_run(client: httpx.AsyncClient, headers: dict[str, str], run_id: str | None) -> None:
if not run_id:
return
response = await client.post(f"/api/agent/runs/{run_id}/cancel", headers=headers)
assert response.status_code < 500, response.text
async def _create_thread(client: httpx.AsyncClient, headers: dict[str, str], agent_slug: str) -> str:
response = await client.post(
"/api/chat/thread",
json={
"agent_id": agent_slug,
"title": f"agent-async-e2e-{uuid.uuid4().hex[:8]}",
"metadata": {"test": "agent-async-e2e"},
},
headers=headers,
)
assert response.status_code == 200, response.text
payload = response.json()
thread_id = payload.get("thread_id") or payload.get("id")
assert thread_id, payload
return str(thread_id)
async def _create_run(
client: httpx.AsyncClient,
headers: dict[str, str],
*,
agent_slug: str,
thread_id: str,
) -> tuple[str, str]:
request_id = f"agent-async-e2e-{uuid.uuid4()}"
response = await client.post(
"/api/agent/runs",
json={
"query": f"请只回复 {EXPECTED_OUTPUT},不要添加任何解释。",
"agent_slug": agent_slug,
"thread_id": thread_id,
"meta": {"request_id": request_id},
},
headers=headers,
)
assert response.status_code == 200, response.text
run_id = response.json().get("run_id")
assert run_id, response.text
assert response.json().get("stream_url") == f"/api/agent/runs/{run_id}/events"
assert response.json().get("request_id") == request_id
return str(run_id), request_id
async def _iter_sse(client: httpx.AsyncClient, headers: dict[str, str], run_id: str):
async with client.stream("GET", f"/api/agent/runs/{run_id}/events?verbose=false", headers=headers) as response:
assert response.status_code == 200, response.text
event = "message"
data_lines: list[str] = []
async for line in response.aiter_lines():
if not line:
if data_lines:
yield event, json.loads("\n".join(data_lines))
event = "message"
data_lines = []
continue
if line.startswith(":"):
continue
if line.startswith("event:"):
event = line[len("event:") :].strip() or "message"
elif line.startswith("data:"):
data_lines.append(line[len("data:") :].strip())
async def _consume_events(client: httpx.AsyncClient, headers: dict[str, str], run_id: str) -> dict[str, int]:
event_counts: dict[str, int] = {}
async def consume() -> None:
async for event, payload in _iter_sse(client, headers, run_id):
event_counts[event] = event_counts.get(event, 0) + 1
if event == "end" or payload.get("status") in {"completed", "failed", "cancelled", "interrupted"}:
return
await asyncio.wait_for(consume(), timeout=RUN_TIMEOUT_SECONDS)
return event_counts
async def _wait_for_run(client: httpx.AsyncClient, headers: dict[str, str], run_id: str) -> dict:
deadline = asyncio.get_running_loop().time() + RUN_TIMEOUT_SECONDS
last_payload: dict | None = None
while asyncio.get_running_loop().time() < deadline:
response = await client.get(f"/api/agent/runs/{run_id}", headers=headers)
assert response.status_code == 200, response.text
last_payload = response.json().get("run") or {}
status = str(last_payload.get("status") or "")
if status in {"completed", "failed", "cancelled", "interrupted"}:
return last_payload
await asyncio.sleep(POLL_INTERVAL_SECONDS)
pytest.fail("Run timed out: " + json.dumps(last_payload or {}, ensure_ascii=False))
async def _assert_run_persisted(
*,
run_id: str,
request_id: str,
thread_id: str,
agent_slug: str,
uid: str,
) -> None:
conn = await asyncpg.connect(_postgres_dsn())
try:
row = await conn.fetchrow(
"""
SELECT
ar.id,
ar.request_id,
ar.status,
ar.run_type,
ar.agent_slug,
ar.uid,
ar.conversation_thread_id,
ar.conversation_id,
ar.input_message_id,
ar.output_message_id,
input_msg.role AS input_role,
input_msg.request_id AS input_request_id,
output_msg.role AS output_role,
output_msg.run_id AS output_run_id,
output_msg.content AS output_content,
conv.thread_id AS persisted_thread_id
FROM agent_runs ar
JOIN conversations conv ON conv.id = ar.conversation_id
LEFT JOIN messages input_msg ON input_msg.id = ar.input_message_id
LEFT JOIN messages output_msg ON output_msg.id = ar.output_message_id
WHERE ar.id = $1
""",
run_id,
)
assert row, f"agent_runs row missing for {run_id}"
assert row["request_id"] == request_id
assert row["status"] == "completed"
assert row["run_type"] == "chat"
assert row["agent_slug"] == agent_slug
assert row["uid"] == uid
assert row["conversation_thread_id"] == thread_id
assert row["conversation_id"] is not None
assert row["input_message_id"] is not None
assert row["output_message_id"] is not None
assert row["input_role"] == "user"
assert row["input_request_id"] == request_id
assert row["output_role"] == "assistant"
assert row["output_run_id"] == run_id
assert EXPECTED_OUTPUT in row["output_content"]
assert row["persisted_thread_id"] == thread_id
finally:
await conn.close()
async def test_async_agent_run_stream_result_and_persistence(
e2e_client: httpx.AsyncClient,
e2e_headers: dict[str, str],
e2e_agent_context: dict[str, str],
):
uid = e2e_agent_context["uid"]
agent_slug = await _create_agent(e2e_client, e2e_headers, uid)
run_id: str | None = None
run_completed = False
try:
thread_id = await _create_thread(e2e_client, e2e_headers, agent_slug)
run_id, request_id = await _create_run(
e2e_client,
e2e_headers,
agent_slug=agent_slug,
thread_id=thread_id,
)
event_counts = await _consume_events(e2e_client, e2e_headers, run_id)
assert event_counts.get("messages", 0) > 0, event_counts
assert event_counts.get("end", 0) == 1, event_counts
run_payload = await _wait_for_run(e2e_client, e2e_headers, run_id)
assert run_payload.get("status") == "completed", run_payload
assert run_payload.get("request_id") == request_id
result_response = await e2e_client.get(f"/api/agent/runs/{run_id}/result", headers=e2e_headers)
assert result_response.status_code == 200, result_response.text
result_payload = result_response.json()
assert result_payload.get("status") == "completed", result_payload
assert result_payload.get("agent_run_id") == run_id
assert result_payload.get("thread_id") == thread_id
assert result_payload.get("request_id") == request_id
assert EXPECTED_OUTPUT in str(result_payload.get("output") or ""), result_payload
history_response = await e2e_client.get(f"/api/chat/thread/{thread_id}/history", headers=e2e_headers)
assert history_response.status_code == 200, history_response.text
history_text = json.dumps(history_response.json(), ensure_ascii=False)
assert request_id in history_text, history_text
assert EXPECTED_OUTPUT in history_text, history_text
await _assert_run_persisted(
run_id=run_id,
request_id=request_id,
thread_id=thread_id,
agent_slug=agent_slug,
uid=uid,
)
run_completed = True
finally:
if not run_completed:
await _cancel_run(e2e_client, e2e_headers, run_id)
await _delete_agent(e2e_client, e2e_headers, agent_slug)