Files
projectAIpopular/tests/test_agent_features.py
T
tzt 747d85c3ba feat(v3): T29 收尾 + T31 dsh 功能对齐
T29 流式收尾:
- _stream_partial 每次流式调用前复位(防上次成功置位导致本次失败误抛不回退)

T31 dsh(deepseek-harness)功能对齐:
- OpenAICompatChat 重试退避:传输错误/408/429/5xx 指数退避(max_retries=2),4xx 不重试
- web_fetch 工具:公网 http/https 抓取,SSRF 防护(DNS 后拒绝私网/环回/链路本地/NAT64,
  512KB/15s/12k 上限,二进制嗅探拒绝),agent.allow_net 开关(默认开)
- 原子写入:write_file/edit_file 临时文件 + os.replace(Windows EPERM 退避)
- 慢工具线程卸载:run_command/web_fetch/search_files 独立线程 + asyncio.sleep 轮询
- search_files:os.walk 修剪依赖目录(替代 rglob 全量物化),不跟随符号链接
- run_command:危险命令黑名单独立拦截 + 显式 COMSPEC/sh 解释器执行
- 重复调用提醒:同工具同参数第 3 次起回喂系统提示 + repeat_warning 事件
- 会话重命名:PATCH /agent/sessions/{sid} + 前端 ✎
- 前端:SettingsView 适配密钥打码(留空保留),SPA 重新构建
- 新增 tests/test_agent_features.py(9 项);全量 318 测试通过
2026-09-02 00:02:24 +08:00

210 lines
8.1 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.
"""dsh 功能对齐测试(T31):
- 流式 _stream_partial 复位(首次成功后再次流式失败应回退非流式,而非误抛)
- LLM 调用重试退避(5xx/传输错误重试,非可重试错误不重试)
- 会话重命名(PATCH /agent/sessions/{sid}
- 重复工具调用提醒(同工具同参数第 3 次起回喂警语)
- search_files 目录修剪(node_modules 等不进入)
- 原子写入(write_file 落盘内容完整、无 .tmp 残留)
- 慢工具线程卸载(run_tool_async 快内联/慢走线程,异常回传)
"""
import asyncio
import json
import httpx
import pytest
pytest.importorskip("fastapi")
pytest.importorskip("httpx")
from fastapi.testclient import TestClient
import gateway.agent as ag
from gateway.agent import OpenAICompatChat
@pytest.fixture()
def ws(tmp_path):
from router_system.tools import WorkspaceTools
return WorkspaceTools(tmp_path / "ws")
# ---------------- _stream_partial 复位 ----------------
def _ok_sse_body():
chunks = [{"choices": [{"delta": {"content": "第一段答复"}}]},
{"choices": [{"delta": {}}], "usage": {"prompt_tokens": 1,
"completion_tokens": 1}}]
lines = [f"data: {json.dumps(c, ensure_ascii=False)}" for c in chunks]
lines.append("data: [DONE]")
return ("\n\n".join(lines) + "\n\n").encode("utf-8")
def _nonstream_body(text: str) -> bytes:
return json.dumps({
"choices": [{"message": {"content": text}}],
"usage": {"prompt_tokens": 2, "completion_tokens": 2},
}).encode("utf-8")
def test_stream_partial_flag_resets_between_calls():
"""首次流式成功(置位)后,第二次流式失败应正常回退非流式。"""
state = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
state["n"] += 1
if state["n"] == 1:
return httpx.Response(200, content=_ok_sse_body())
# 第二次:流式 500(无部分输出)-> 应回退非流式(第 3 次请求)
if state["n"] == 2:
return httpx.Response(500, content=b"boom")
return httpx.Response(200, content=_nonstream_body("回退答案"))
chat = OpenAICompatChat(base_url="http://x", api_key=None, model="m",
transport=httpx.MockTransport(handler),
retry_delay_s=0)
r1 = asyncio.run(chat([{"role": "user", "content": "a"}], []))
assert r1["content"] == "第一段答复"
r2 = asyncio.run(chat([{"role": "user", "content": "b"}], []))
assert r2["content"] == "回退答案" # 不因上次置位而误抛
assert state["n"] == 3
# ---------------- 重试退避 ----------------
def test_retry_on_5xx_then_success():
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
if calls["n"] == 1:
return httpx.Response(502, content=b"bad gateway")
return httpx.Response(200, content=_nonstream_body("恢复"))
chat = OpenAICompatChat(base_url="http://x", api_key=None, model="m",
stream=False, max_retries=2, retry_delay_s=0,
transport=httpx.MockTransport(handler))
r = asyncio.run(chat([{"role": "user", "content": "x"}], []))
assert r["content"] == "恢复" and calls["n"] == 2
def test_retry_exhausted_raises():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, content=b"always down")
chat = OpenAICompatChat(base_url="http://x", api_key=None, model="m",
stream=False, max_retries=1, retry_delay_s=0,
transport=httpx.MockTransport(handler))
with pytest.raises(httpx.HTTPStatusError):
asyncio.run(chat([{"role": "user", "content": "x"}], []))
def test_no_retry_on_4xx_client_error():
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
return httpx.Response(401, content=b"unauthorized")
chat = OpenAICompatChat(base_url="http://x", api_key=None, model="m",
stream=False, max_retries=2, retry_delay_s=0,
transport=httpx.MockTransport(handler))
with pytest.raises(httpx.HTTPStatusError):
asyncio.run(chat([{"role": "user", "content": "x"}], []))
assert calls["n"] == 1 # 4xx 不重试
# ---------------- 会话重命名 ----------------
def test_session_rename_endpoint(tmp_path):
import gateway.api as ga
ag.reset_session_store()
ag._session_store = ag.SessionStore(root=tmp_path / "sess")
client = TestClient(ga.app)
r = client.post("/agent/sessions", json={"title": "旧名", "workspace": ""})
sid = r.json()["id"]
r2 = client.patch(f"/agent/sessions/{sid}", json={"title": "新名字"})
assert r2.status_code == 200
assert r2.json()["title"] == "新名字"
assert client.get(f"/agent/sessions/{sid}").json()["title"] == "新名字"
# 空标题 400;不存在 404
assert client.patch(f"/agent/sessions/{sid}", json={"title": " "}).status_code == 400
assert client.patch("/agent/sessions/asdeadbeef99",
json={"title": "x"}).status_code == 404
ag.reset_session_store()
# ---------------- 重复调用提醒 ----------------
def test_repeat_call_warning(ws, tmp_path):
"""同工具同参数第 3 次调用:回喂内容带系统提示 + repeat_warning 事件。"""
from router_system.tools import ToolLoop
seen_msgs = []
calls = {"n": 0}
async def chat(messages, tools_spec):
seen_msgs.append(list(messages))
calls["n"] += 1
if calls["n"] <= 3:
return {"content": None,
"tool_calls": [{"id": "c" + str(calls["n"]), "name": "read_file",
"arguments": {"path": "a.txt"}}],
"usage": {}}
return {"content": "收手了", "tool_calls": [], "usage": {}}
events = []
loop = ToolLoop(ws, chat, on_event=events.append)
(tmp_path / "ws" / "a.txt").parent.mkdir(parents=True, exist_ok=True)
(tmp_path / "ws" / "a.txt").write_text("x", encoding="utf-8")
result = asyncio.run(loop.run("反复读"))
assert result["response"] == "收手了"
# 第 3 次工具结果消息应带提醒
tool_msgs = [m for m in seen_msgs[3] if m.get("role") == "tool"]
assert any("系统提示" in m["content"] for m in tool_msgs)
assert any(e["type"] == "repeat_warning" and e["count"] == 3 for e in events)
# ---------------- search_files 修剪 ----------------
def test_search_files_prunes_skip_dirs(ws, tmp_path):
root = tmp_path / "ws"
(root / "node_modules" / "pkg").mkdir(parents=True, exist_ok=True)
(root / "node_modules" / "pkg" / "dep.js").write_text("NEEDLE", encoding="utf-8")
(root / "src").mkdir(parents=True, exist_ok=True)
(root / "src" / "app.js").write_text("NEEDLE", encoding="utf-8")
r = ws.search_files("NEEDLE")
files = {m["file"] for m in r["matches"]}
assert files == {"src/app.js"} # node_modules 被修剪
# ---------------- 原子写入 ----------------
def test_atomic_write_roundtrip(ws, tmp_path):
ws.write_file("sub/atomic.txt", "第一版")
ws.edit_file("sub/atomic.txt", "第一版", "第二版")
assert (tmp_path / "ws" / "sub" / "atomic.txt").read_text(
encoding="utf-8") == "第二版"
# 无 .tmp 残留
leftovers = [p.name for p in (tmp_path / "ws" / "sub").iterdir()
if p.name.endswith(".tmp")]
assert leftovers == []
# ---------------- 慢工具线程卸载 ----------------
def _boom(*_args):
raise RuntimeError("线程内炸了")
def test_run_tool_async_fast_inline_and_thread_exception(ws):
from router_system.tools import run_tool_async
# 快工具:内联
ws.write_file("fast.txt", "v")
r = asyncio.run(run_tool_async(ws, "read_file", {"path": "fast.txt"}))
assert r["ok"] is True and r["content"] == "v"
# 异常从线程回传(execute 以属性形式提供)
class Boom:
execute = staticmethod(_boom)
with pytest.raises(RuntimeError):
asyncio.run(run_tool_async(Boom(), "read_file", {"path": "x"}))