feat: AG-UI 协议对齐与路由导航功能

- 前端: 添加 SSE 流式支持、stateSnapshot 事件、路由导航工具
- 前端: 实现工具调用审批流程,支持 pending 状态展示
- 后端: Agent 状态管理与会话持久化相关重构
- 文档: 新增 agent-agui-full-alignance 设计文档
- 测试: 补充相关单元测试和集成测试
This commit is contained in:
zl-q
2026-03-07 17:30:20 +08:00
parent ec33bb0cee
commit 120df903d2
52 changed files with 4305 additions and 1672 deletions
@@ -1,9 +1,11 @@
from __future__ import annotations
import json
import uuid
from decimal import Decimal
import pytest
from ag_ui.core import RunAgentInput
from sqlalchemy import delete, select
from core.agent.application.resume_service import ResumeService
@@ -84,28 +86,76 @@ async def test_run_then_resume_persists_messages_and_session_state(
published: list[str] = []
def _publish(event_type: str, payload: dict[str, object]) -> None:
del payload
published.append(event_type)
async def _publish(event: dict[str, object]) -> None:
event_type = event.get("type")
if isinstance(event_type, str):
published.append(event_type)
try:
run_result = run_agent_task(
run_input_payload = {
"threadId": str(session_uuid),
"runId": "run-it-1",
"state": {},
"messages": [
{"id": "u1", "role": "user", "content": "帮我打开日历"},
],
"tools": [
{
"name": "navigate_to_route",
"description": "navigate route",
"parameters": {"type": "object"},
}
],
"context": [],
"forwardedProps": {},
}
run_result = await run_agent_task(
{
"command": "run",
"session_id": str(session_uuid),
"user_input": "hello",
"run_input": run_input_payload,
},
publish_event=_publish,
run_service=RunService(),
resume_service=ResumeService(),
)
pending_tool_call_id = str(run_result["pending_tool_call_id"])
state_snapshot = run_result["state_snapshot"]
assert isinstance(state_snapshot, dict)
pending_tool_nonce = state_snapshot["pending_tool_nonce"]
assert isinstance(pending_tool_nonce, str)
run_agent_task(
await run_agent_task(
{
"command": "resume",
"session_id": str(session_uuid),
"tool_call_id": pending_tool_call_id,
"run_input": {
"threadId": str(session_uuid),
"runId": "run-it-2",
"state": {},
"messages": [
{
"id": "tool-1",
"role": "tool",
"toolCallId": pending_tool_call_id,
"content": json.dumps(
{
"toolName": "navigate_to_route",
"toolArgs": {
"target": "/calendar/dayweek",
"replace": False,
"__nonce": pending_tool_nonce,
},
"nonce": pending_tool_nonce,
"result": {"ok": True},
},
ensure_ascii=True,
separators=(",", ":"),
),
}
],
"tools": [],
"context": [],
"forwardedProps": {},
},
},
publish_event=_publish,
run_service=RunService(),
@@ -123,6 +173,9 @@ async def test_run_then_resume_persists_messages_and_session_state(
assert db_session.state_snapshot == {
"status": "completed",
"pending_tool_call_id": None,
"pending_tool_name": None,
"pending_tool_args_sha256": None,
"pending_tool_nonce": None,
}
rows = await verify_session.execute(
@@ -142,7 +195,7 @@ async def test_run_then_resume_persists_messages_and_session_state(
assert messages[1].cost == Decimal("0.002500")
assert "RUN_STARTED" in published
assert "RUN_RESUMED" in published
assert "RUN_FINISHED" in published
assert "TEXT_MESSAGE_CONTENT" in published
finally:
async with AsyncSessionLocal() as cleanup_session:
@@ -219,7 +272,21 @@ async def test_run_service_embeds_profile_settings_in_runtime_system_prompt(
seed_session.add(AgentChatSession(id=session_uuid, user_id=owner_id))
await seed_session.commit()
result = await RunService().run(session_id=str(session_uuid), user_input="hello")
result = await RunService().run(
run_input=RunAgentInput.model_validate(
{
"threadId": str(session_uuid),
"runId": "run-it-1",
"state": {},
"messages": [
{"id": "u1", "role": "user", "content": "hello"},
],
"tools": [],
"context": [],
"forwardedProps": {},
}
)
)
assert result["persisted"] is True
assert captured["user_input"] == "hello"
@@ -16,29 +16,38 @@ class _FakeStorage:
return "etag-1"
def test_closed_loop_run_flow_frontend_to_sse() -> None:
session_id = "00000000-0000-0000-0000-000000000001"
async def test_closed_loop_run_flow_frontend_to_sse() -> None:
thread_id = "00000000-0000-0000-0000-000000000001"
published: list[str] = []
class _FakeRunService:
async def run(self, *, session_id: str, user_input: str) -> dict[str, object]:
return {"session_id": session_id, "user_input": user_input}
async def run(self, *, run_input: object) -> dict[str, object]:
del run_input
return {"threadId": thread_id, "runId": "run-1"}
def _publish(event_type: str, payload: dict[str, object]) -> None:
del payload
published.append(event_type)
async def _publish(event: dict[str, object]) -> None:
event_type = event.get("type")
if isinstance(event_type, str):
published.append(event_type)
result = run_agent_task(
result = await run_agent_task(
{
"command": "run",
"session_id": session_id,
"user_input": "hello",
"run_input": {
"threadId": thread_id,
"runId": "run-1",
"state": {},
"messages": [{"id": "u1", "role": "user", "content": "hello"}],
"tools": [],
"context": [],
"forwardedProps": {},
},
},
publish_event=_publish,
run_service=_FakeRunService(),
)
assert result["session_id"] == session_id
assert result["threadId"] == thread_id
assert published[0] == "RUN_STARTED"
assert published[-1] == "RUN_FINISHED"