Files
hunjing/backend/tests/test_send_message_agent.py
T
2026-08-21 16:00:13 +08:00

207 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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