mirror of
https://github.com/snowy-yang/xiaonuan.git
synced 2026-10-20 23:54:48 +00:00
- tests/conftest.py: 内存版 FakeDB 复刻 db.py 全部签名,绕过 lifespan 的测试应用 - test_chat_flow.py: WebSocket 全链路(回声/路由信号落库/档案/慢信/连续天数) - test_delivery.py: 事件/每日任务/关怀投递(在线与离线)、天气工具城市链、system prompt 注入 - test_router.py: 小模型路由清洗(去重/丢过去事件/非法作息/500优雅降级/无模型空判定) - test_tool_loop.py: 原生工具调用循环(喂结果/超轮次强制收尾/坏参数) - test_pure.py: 纯逻辑(连续天数/哈希槽位/时间间隔/模板/向量) - test_db_pg.py: PG 集成测试,无 PG_DSN 自动跳过,配好即真实跑库 - 旧散装脚本移除;tools_live/ws_client/tui_smoke 保留为手动脚本
103 lines
3.9 KiB
Python
103 lines
3.9 KiB
Python
"""原生工具调用循环的离线测试(mock 模型服务)"""
|
|
import asyncio
|
|
import json
|
|
|
|
import httpx
|
|
|
|
from app import connector
|
|
|
|
|
|
def test_tool_loop_executes_and_feeds_back(monkeypatch):
|
|
calls = {"model": 0}
|
|
captured = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
body = json.loads(request.content)
|
|
calls["model"] += 1
|
|
if calls["model"] == 1:
|
|
assert body.get("tools"), "第一轮应带上工具声明"
|
|
return httpx.Response(200, json={"choices": [{"message": {
|
|
"role": "assistant", "content": "",
|
|
"tool_calls": [{"id": "call-1", "type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"arguments": json.dumps({"city": "杭州"})}}]}}]})
|
|
tool_msg = body["messages"][-1]
|
|
captured["tool_msg"] = tool_msg
|
|
assert tool_msg["role"] == "tool"
|
|
assert tool_msg["tool_call_id"] == "call-1"
|
|
return httpx.Response(200, json={"choices": [{"message": {
|
|
"role": "assistant",
|
|
"content": "杭州今天有小雨,出门记得带伞哦~"}}]})
|
|
|
|
monkeypatch.setattr(connector, "_TEST_TRANSPORT",
|
|
httpx.MockTransport(handler))
|
|
|
|
async def exec_(name, args):
|
|
captured["exec"] = (name, args)
|
|
return "杭州 小雨 15~22°C 降水概率70%"
|
|
|
|
out = asyncio.run(connector.run_model_tools(
|
|
[{"role": "user", "content": "今天出门要带伞吗"}], exec_))
|
|
|
|
assert out == "杭州今天有小雨,出门记得带伞哦~"
|
|
assert captured["exec"] == ("get_weather", {"city": "杭州"})
|
|
assert calls["model"] == 2
|
|
|
|
|
|
def test_tool_loop_gives_up_after_max_rounds(monkeypatch):
|
|
calls = {"model": 0}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
body = json.loads(request.content)
|
|
calls["model"] += 1
|
|
if "tool_calls" not in json.dumps(body["messages"][-1]) and \
|
|
calls["model"] <= 3:
|
|
return httpx.Response(200, json={"choices": [{"message": {
|
|
"role": "assistant", "content": "",
|
|
"tool_calls": [{"id": f"c{calls['model']}", "type": "function",
|
|
"function": {"name": "web_search",
|
|
"arguments": "{}"}}]}}]})
|
|
# 第 4 次(超过 max_rounds 的强制收尾)不带 tools
|
|
assert "tools" not in body
|
|
return httpx.Response(200, json={"choices": [{"message": {
|
|
"role": "assistant", "content": "我直接说吧"}}]})
|
|
|
|
monkeypatch.setattr(connector, "_TEST_TRANSPORT",
|
|
httpx.MockTransport(handler))
|
|
|
|
async def exec_(name, args):
|
|
return "结果"
|
|
|
|
out = asyncio.run(connector.run_model_tools(
|
|
[{"role": "user", "content": "搜一下"}], exec_, max_rounds=3))
|
|
assert out == "我直接说吧"
|
|
assert calls["model"] == 4
|
|
|
|
|
|
def test_tool_loop_bad_arguments_become_empty_dict(monkeypatch):
|
|
calls = {"model": 0}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
calls["model"] += 1
|
|
if calls["model"] == 1:
|
|
return httpx.Response(200, json={"choices": [{"message": {
|
|
"role": "assistant", "content": "",
|
|
"tool_calls": [{"id": "c1", "type": "function",
|
|
"function": {"name": "get_weather",
|
|
"arguments": "{坏了"}}]}}]})
|
|
return httpx.Response(200, json={"choices": [{"message": {
|
|
"role": "assistant", "content": "好"}}]})
|
|
|
|
monkeypatch.setattr(connector, "_TEST_TRANSPORT",
|
|
httpx.MockTransport(handler))
|
|
seen = {}
|
|
|
|
async def exec_(name, args):
|
|
seen["args"] = args
|
|
return "ok"
|
|
|
|
out = asyncio.run(connector.run_model_tools(
|
|
[{"role": "user", "content": "天气"}], exec_))
|
|
assert out == "好" and seen["args"] == {}
|