207 lines
7.6 KiB
Python
207 lines
7.6 KiB
Python
"""send_message 接入 run_agent_turn 的验证测试(阶段2b 步骤B)。
|
||
|
||
mock 边界:
|
||
- ``app.application.chat.send_message.run_agent_turn`` —— 不真实跑 Agent 循环,
|
||
只校验 send_message 是否正确接入(传 tools/default_tools、on_chunk、on_tool_event)。
|
||
- ``app.application.chat.send_message.default_tools`` —— 校验被调用。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import AsyncGenerator
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
from sqlalchemy import select
|
||
|
||
from app.ai_provider.types.chat import ChatChunk
|
||
from app.application.agent.runtime import AgentTurnResult
|
||
from app.db.session import AsyncSessionLocal
|
||
from app.main import app
|
||
from app.models.runtime import Event
|
||
|
||
|
||
@pytest.fixture
|
||
async def client(
|
||
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
|
||
) -> AsyncGenerator[AsyncClient, None]:
|
||
# 清理前序(test_tools 等)残留的 app.state 避免污染
|
||
|
||
for _attr in ("tool_runtime", "confirmation_registry", "confirmation_store", "event_store"):
|
||
app.state.__dict__.pop(_attr, None)
|
||
|
||
agent_content = "agent 最终回复正文。"
|
||
|
||
async def fake_run_agent_turn(
|
||
*,
|
||
on_chunk: Any,
|
||
on_tool_event: Any,
|
||
**_kwargs: object,
|
||
) -> AgentTurnResult:
|
||
# 模拟 Agent 内部流:先一段 reasoning(若 thinking 启用)、再 content chunk
|
||
await on_chunk(ChatChunk(delta_reasoning="思考摘要"))
|
||
await on_chunk(ChatChunk(delta_content=agent_content))
|
||
# 模拟一次工具调用生命周期
|
||
await on_tool_event("tool.started", {"tool_name": "echo", "tool_call_id": "c1"})
|
||
await on_tool_event(
|
||
"tool.completed", {"tool_name": "echo", "tool_call_id": "c1", "status": "success"}
|
||
)
|
||
return AgentTurnResult(content=agent_content, reasoning="思考摘要")
|
||
|
||
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn)
|
||
transport = ASGITransport(app=app)
|
||
async with AsyncClient(
|
||
transport=transport, base_url="http://test", headers=auth_headers
|
||
) as http_client:
|
||
yield http_client
|
||
|
||
|
||
async def _create_session(client: AsyncClient) -> str:
|
||
resp = await client.post("/api/v1/chat/sessions", json={"title": "agent 接入"})
|
||
assert resp.status_code == 200
|
||
return str(resp.json()["session_id"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_message_invokes_run_agent_turn_with_tools_and_callbacks(
|
||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""send_message 调用 run_agent_turn 时传了 tools=default_tools()。"""
|
||
|
||
app.dependency_overrides.clear()
|
||
|
||
captured: dict[str, Any] = {}
|
||
captured_count: int = 0
|
||
|
||
real_default_tools = __import__(
|
||
"app.application.chat.send_message", fromlist=["default_tools"]
|
||
).default_tools
|
||
|
||
def spy_default_tools(*args: Any, **kwargs: Any) -> list[dict[str, Any]]:
|
||
nonlocal captured_count
|
||
captured_count += 1
|
||
captured["kwargs"] = kwargs
|
||
captured["tools"] = real_default_tools(*args, **kwargs)
|
||
return captured["tools"]
|
||
|
||
monkeypatch.setattr("app.application.chat.send_message.default_tools", spy_default_tools)
|
||
|
||
from app.application.chat.send_message import drain_pending_streams
|
||
|
||
session_id = await _create_session(client)
|
||
sent = await client.post(
|
||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||
json={"content": "你好", "modality": "text"},
|
||
)
|
||
assert sent.status_code == 200
|
||
await drain_pending_streams()
|
||
|
||
# default_tools 被调用,至少包含 5 个核心工具
|
||
assert captured_count > 0, f"spy_default_tools never called, captured={captured}"
|
||
assert "tools" in captured
|
||
tool_names = {t["function"]["name"] for t in captured["tools"]}
|
||
assert tool_names >= {"datetime", "read_file", "list_dir", "write_file", "shell"}
|
||
assert captured["tools"][0]["type"] == "function"
|
||
names = {t["function"]["name"] for t in captured["tools"]}
|
||
assert {"datetime", "shell"} <= names
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_message_persists_agent_result_content_and_tool_events(
|
||
client: AsyncClient,
|
||
) -> None:
|
||
"""message.completed 的 content == agent_result.content;tool.* 事件落库 source=agent。"""
|
||
|
||
from app.application.chat.send_message import drain_pending_streams
|
||
|
||
session_id = await _create_session(client)
|
||
sent = await client.post(
|
||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||
json={"content": "你好", "modality": "text", "thinking": True},
|
||
)
|
||
assert sent.status_code == 200
|
||
run_id = str(sent.json()["run_id"])
|
||
await drain_pending_streams()
|
||
|
||
async with AsyncSessionLocal() as db:
|
||
events = (await db.execute(select(Event).where(Event.run_id == run_id))).scalars().all()
|
||
|
||
by_type: dict[str, list[Event]] = {}
|
||
for ev in events:
|
||
by_type.setdefault(ev.type, []).append(ev)
|
||
|
||
# 关键事件齐全
|
||
assert "run.started" in by_type
|
||
assert "message.delta" in by_type # on_chunk 触发了 content flush
|
||
assert "message.completed" in by_type
|
||
assert "run.completed" in by_type
|
||
|
||
# tool 事件存在且 source=agent
|
||
assert "tool.started" in by_type
|
||
assert "tool.completed" in by_type
|
||
assert all(ev.source == "agent" for ev in by_type["tool.started"])
|
||
assert all(ev.source == "agent" for ev in by_type["tool.completed"])
|
||
|
||
# message.completed 的 content == agent_result.content(权威值)
|
||
completed = by_type["message.completed"][-1]
|
||
import json
|
||
|
||
payload = json.loads(completed.payload_json)
|
||
assert payload["content"] == "agent 最终回复正文。"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_message_writes_extracted_session_goal_to_run(
|
||
client: AsyncClient,
|
||
) -> None:
|
||
"""太一最小闭环:会话目标从用户消息提取写入 run.goal,取代默认值。"""
|
||
|
||
from app.application.chat.send_message import drain_pending_streams
|
||
from app.models.runtime import Run
|
||
|
||
session_id = await _create_session(client)
|
||
sent = await client.post(
|
||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||
json={"content": "帮我整理今天的笔记", "modality": "text"},
|
||
)
|
||
assert sent.status_code == 200
|
||
run_id = str(sent.json()["run_id"])
|
||
await drain_pending_streams()
|
||
|
||
async with AsyncSessionLocal() as db:
|
||
run = (await db.execute(select(Run).where(Run.id == run_id))).scalar_one()
|
||
|
||
assert run.goal == "帮我整理今天的笔记"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_message_injects_session_goal_into_system_messages(
|
||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""洛书最小闭环:会话目标作为 system instruction 注入 ReAct。"""
|
||
|
||
from app.application.chat.send_message import drain_pending_streams
|
||
|
||
captured: dict[str, Any] = {}
|
||
|
||
async def capture_agent_turn(*, messages: Any, **_kwargs: object) -> AgentTurnResult:
|
||
captured["messages"] = messages
|
||
return AgentTurnResult(content="好的。", reasoning="")
|
||
|
||
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", capture_agent_turn)
|
||
|
||
session_id = await _create_session(client)
|
||
await client.post(
|
||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||
json={"content": "帮我整理笔记", "modality": "text"},
|
||
)
|
||
await drain_pending_streams()
|
||
|
||
# to_langchain_messages 把每个 system message 作为独立 SystemMessage,
|
||
# 目标片段出现在某个 system message 中(非 messages[0])
|
||
system_text = "\n".join(
|
||
str(m.content) for m in captured["messages"] if getattr(m, "role", "") == "system"
|
||
)
|
||
assert "当前会话目标:帮我整理笔记" in system_text
|