"""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