from __future__ import annotations import asyncio import json import os import uuid from typing import Any import httpx import pytest pytestmark = [pytest.mark.asyncio, pytest.mark.e2e, pytest.mark.slow] RUN_TIMEOUT_SECONDS = int(os.getenv("E2E_RUN_TIMEOUT_SECONDS", "300")) def _assert_ok(response: httpx.Response) -> None: assert response.status_code < 400, response.text async def _create_agent( client: httpx.AsyncClient, headers: dict[str, str], payload: dict[str, Any], ) -> dict[str, Any]: response = await client.post("/api/agent", json=payload, headers=headers) _assert_ok(response) agent = response.json().get("agent") assert isinstance(agent, dict), response.text return agent 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_id: str, marker: str) -> str: response = await client.post( "/api/chat/thread", json={"agent_id": agent_id, "title": f"subagent-stream-e2e-{marker}", "metadata": {"marker": marker}}, headers=headers, ) _assert_ok(response) 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, query: str, ) -> str: response = await client.post( "/api/agent/runs", json={ "query": query, "agent_slug": agent_slug, "thread_id": thread_id, "meta": {"request_id": f"subagent-stream-e2e-{uuid.uuid4()}"}, }, headers=headers, ) _assert_ok(response) run_id = response.json().get("run_id") assert run_id, response.text return str(run_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", headers=headers) as response: _assert_ok(response) event = "message" event_id = None data_lines: list[str] = [] async for line in response.aiter_lines(): if not line: if data_lines: data_text = "\n".join(data_lines) yield event, json.loads(data_text), event_id event = "message" event_id = None data_lines = [] continue if line.startswith(":"): continue if line.startswith("event:"): event = line[len("event:") :].strip() or "message" elif line.startswith("id:"): event_id = line[len("id:") :].strip() elif line.startswith("data:"): data_lines.append(line[len("data:") :].strip()) def _collect_message_chunks(payload: dict[str, Any]) -> list[dict[str, Any]]: chunks = [] chunk = payload.get("chunk") if isinstance(chunk, dict): chunks.append(chunk) items = payload.get("items") if isinstance(items, list): chunks.extend(item for item in items if isinstance(item, dict)) return chunks async def _consume_run_stream( client: httpx.AsyncClient, headers: dict[str, str], run_id: str, ) -> tuple[dict[str, int], dict[str, Any], list[dict[str, Any]]]: event_counts: dict[str, int] = {} latest_agent_state: dict[str, Any] = {} message_chunks: list[dict[str, Any]] = [] terminal_status = "" async def consume() -> None: nonlocal latest_agent_state, terminal_status async for event, payload, _event_id in _iter_sse(client, headers, run_id): event_counts[event] = event_counts.get(event, 0) + 1 if event == "messages": message_chunks.extend(_collect_message_chunks(payload)) if event == "custom" and payload.get("name") == "yuxi.agent_state": agent_state = payload.get("agent_state") if isinstance(agent_state, dict): latest_agent_state = agent_state assert event != "error", payload if event == "end": event_payload = payload.get("payload") if isinstance(payload.get("payload"), dict) else payload terminal_status = str(event_payload.get("status") or "") return await asyncio.wait_for(consume(), timeout=RUN_TIMEOUT_SECONDS) assert terminal_status == "completed", {"status": terminal_status, "event_counts": event_counts} return event_counts, latest_agent_state, message_chunks def _find_tool_call_ids(value: Any) -> set[str]: ids: set[str] = set() if isinstance(value, dict): tool_calls = value.get("tool_calls") if isinstance(tool_calls, list): for tool_call in tool_calls: if isinstance(tool_call, dict) and tool_call.get("id"): ids.add(str(tool_call["id"])) for child in value.values(): ids.update(_find_tool_call_ids(child)) elif isinstance(value, list): for item in value: ids.update(_find_tool_call_ids(item)) return ids async def _read_thread_file( client: httpx.AsyncClient, headers: dict[str, str], thread_id: str, path: str, ) -> str: response = await client.get( f"/api/chat/thread/{thread_id}/files/content", params={"path": path}, headers=headers, ) _assert_ok(response) content = response.json().get("content") assert isinstance(content, list), response.text return "\n".join(str(line) for line in content) async def test_subagent_stream_records_run_and_shares_output_files( e2e_client: httpx.AsyncClient, e2e_headers: dict[str, str], ): me_response = await e2e_client.get("/api/auth/me", headers=e2e_headers) _assert_ok(me_response) me = me_response.json() if me.get("role") not in {"admin", "superadmin"}: pytest.skip("Subagent E2E needs an admin user to create temporary agents.") uid = str(me.get("uid") or "") assert uid, me suffix = uuid.uuid4().hex[:8] marker = f"YUXI_SUBAGENT_STREAM_E2E_{suffix}" sub_slug = f"e2e-subagent-{suffix}" main_slug = f"e2e-main-{suffix}" output_path = "/home/gem/user-data/outputs/subagents.txt" expected_content = "由这个子智能体创建" created_agents: list[str] = [] run_id: str | None = None run_completed = False default_response = await e2e_client.get("/api/agent/default", headers=e2e_headers) _assert_ok(default_response) default_context = ((default_response.json().get("agent") or {}).get("config_json") or {}).get("context") or {} base_context: dict[str, Any] = {"tools": [], "knowledges": [], "mcps": [], "skills": []} if default_context.get("model"): base_context["model"] = default_context["model"] share_config = {"access_level": "user", "department_ids": [], "user_uids": [uid]} try: sub_agent = await _create_agent( e2e_client, e2e_headers, { "name": f"E2E 子智能体 {suffix}", "slug": sub_slug, "backend_id": "SubAgentBackend", "description": "真实流式 E2E 子智能体", "config_json": { "context": { **base_context, "system_prompt": ( "你是专门负责文件写入和文件校验的子智能体。收到任务后必须使用文件系统工具完成任务," "不要向用户提问。必须严格写入用户指定路径,文件内容必须完全符合用户要求," "不要自动追加句号、引号、说明或其他字符。完成后只回复写入的路径和文件内容。" ), } }, "share_config": share_config, "is_subagent": True, }, ) created_agents.append(sub_slug) await _create_agent( e2e_client, e2e_headers, { "name": f"E2E 主智能体 {suffix}", "slug": main_slug, "backend_id": "ChatbotAgent", "description": "真实流式 E2E 主智能体", "config_json": { "context": { **base_context, "subagents": [sub_slug], "system_prompt": ( "你是主智能体。遇到用户要求创建、修改或验证文件的任务时," "必须调用 task 工具交给可用子智能体完成,不要自己写文件," "也不要通过 shell、curl 或 HTTP API 调用子智能体。子智能体完成后,简短汇总结果。" ), } }, "share_config": share_config, "is_subagent": False, }, ) created_agents.append(main_slug) default_agents_response = await e2e_client.get("/api/agent", headers=e2e_headers) _assert_ok(default_agents_response) default_agent_slugs = {str(item.get("slug")) for item in default_agents_response.json().get("agents") or []} assert sub_slug not in default_agent_slugs management_agents_response = await e2e_client.get("/api/agent?include_subagents=true", headers=e2e_headers) _assert_ok(management_agents_response) management_agent_slugs = { str(item.get("slug")) for item in management_agents_response.json().get("agents") or [] } assert sub_slug in management_agent_slugs thread_id = await _create_thread(e2e_client, e2e_headers, main_slug, marker) query = ( f"请调用子智能体 {sub_slug} 在 outputs 目录创建文件 {output_path}。" "必须通过 task 工具调用子智能体完成。" f"文件内容必须完全等于下面一行:\n{expected_content}\n" "不要添加句号、引号、说明或其他任何字符。完成后只需要回复文件路径。" ) run_id = await _create_run( e2e_client, e2e_headers, agent_slug=main_slug, thread_id=thread_id, query=query, ) event_counts, stream_agent_state, message_chunks = await _consume_run_stream( e2e_client, e2e_headers, run_id, ) assert event_counts.get("messages", 0) > 0 run_response = await e2e_client.get(f"/api/agent/runs/{run_id}", headers=e2e_headers) _assert_ok(run_response) assert (run_response.json().get("run") or {}).get("status") == "completed" state_response = await e2e_client.get(f"/api/chat/thread/{thread_id}/state", headers=e2e_headers) _assert_ok(state_response) final_agent_state = state_response.json().get("agent_state") or stream_agent_state subagent_runs = final_agent_state.get("subagent_runs") or [] assert subagent_runs, final_agent_state completed_run = next((item for item in subagent_runs if item.get("status") == "completed"), subagent_runs[0]) assert completed_run.get("subagent_slug") == sub_slug assert completed_run.get("subagent_name") == sub_agent["name"] assert completed_run.get("child_thread_id") assert completed_run.get("id") child_thread_id = str(completed_run["child_thread_id"]) child_state_response = await e2e_client.get( f"/api/chat/thread/{child_thread_id}/state", params={"include_messages": "true"}, headers=e2e_headers, ) _assert_ok(child_state_response) child_state_payload = child_state_response.json() assert child_state_payload.get("parent_thread_id") == thread_id child_subagent_run = child_state_payload.get("subagent_run") or {} assert child_subagent_run.get("child_thread_id") == child_thread_id assert child_subagent_run.get("run_id") child_run_response = await e2e_client.get( f"/api/agent/runs/{child_subagent_run['run_id']}", headers=e2e_headers, ) _assert_ok(child_run_response) child_run = child_run_response.json().get("run") or {} assert child_run.get("run_type") == "subagent" assert child_run.get("conversation_thread_id") == child_thread_id assert child_run.get("created_by_run_id") == run_id assert child_run.get("status") == "completed" assert child_state_payload.get("messages"), child_state_payload leaked_child_chunks = [ chunk for chunk in message_chunks if child_thread_id in json.dumps(chunk, ensure_ascii=False, default=str) ] assert leaked_child_chunks == [] history_response = await e2e_client.get(f"/api/chat/thread/{thread_id}/history", headers=e2e_headers) _assert_ok(history_response) history_payload = history_response.json() tool_call_ids = _find_tool_call_ids(history_payload) assert str(completed_run["id"]) in tool_call_ids assert child_thread_id in json.dumps(history_payload, ensure_ascii=False) files_response = await e2e_client.get( f"/api/chat/thread/{thread_id}/files", params={"path": "/home/gem/user-data/outputs", "recursive": "true"}, headers=e2e_headers, ) _assert_ok(files_response) files_payload = files_response.json() file_paths = {str(item.get("path") or "") for item in files_payload.get("files") or []} assert output_path in file_paths, { "output_path": output_path, "files": files_payload, "subagent_run": completed_run, } assert (await _read_thread_file(e2e_client, e2e_headers, thread_id, output_path)).strip() == expected_content tree_response = await e2e_client.get( "/api/viewer/filesystem/tree", params={"thread_id": thread_id, "path": "/home/gem/user-data/outputs"}, headers=e2e_headers, ) _assert_ok(tree_response) assert output_path in json.dumps(tree_response.json(), ensure_ascii=False) viewer_file_response = await e2e_client.get( "/api/viewer/filesystem/file", params={"thread_id": thread_id, "path": output_path}, headers=e2e_headers, ) _assert_ok(viewer_file_response) assert expected_content in json.dumps(viewer_file_response.json(), ensure_ascii=False) run_completed = True finally: if not run_completed: await _cancel_run(e2e_client, e2e_headers, run_id) for slug in reversed(created_agents): await _delete_agent(e2e_client, e2e_headers, slug)