feat(v3): T22-T23 harness 级工具扩展 + 工作区选择后端
- T22 工具内核:edit_file(old_string 唯一命中才替换,防误改)、search_files (跨文件内容搜索,跳过 .git/node_modules 与二进制大文件)、run_command (allow_shell 默认关;超时+输出截断+Windows CREATE_NO_WINDOW) - T23 工作区选择(参考 deepseek-harness 打开文件夹体验):/agent/fs 磁盘目录浏览 (空 path 列 Windows 盘符)、/agent/workspaces 最近列表持久化、 POST /agent 接受 workspace(须存在目录),运行状态记录所用工作区 - 新增测试 12 项,全量 274 passed
This commit is contained in:
@@ -168,3 +168,79 @@ def test_agent_task_validation(agent_env, client):
|
||||
def test_agent_404(agent_env, client):
|
||||
assert client.get("/agent/ghost/status").status_code == 404
|
||||
assert client.get("/agent/ghost/events").json() == []
|
||||
|
||||
|
||||
# ---------------- 工作区选择(T23) ----------------
|
||||
|
||||
def test_agent_run_with_selected_workspace(agent_env, client, tmp_path):
|
||||
"""显式 workspace 应成为本次运行的工作目录(文件写进去,状态记录目录)。"""
|
||||
target = tmp_path / "my_project"
|
||||
target.mkdir()
|
||||
agent_env["set_script"]([
|
||||
{"content": None,
|
||||
"tool_calls": [{"id": "c1", "name": "write_file",
|
||||
"arguments": {"path": "build.py", "content": "print('ok')"}}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 2}},
|
||||
{"content": "已写入 build.py。", "tool_calls": [], "usage": {}},
|
||||
])
|
||||
r = client.post("/agent", json={"task": "写 build.py", "workspace": str(target)})
|
||||
assert r.status_code == 200
|
||||
rid = r.json()["request_id"]
|
||||
info = _wait_done(agent_env["service"], rid)
|
||||
assert info.state == "done"
|
||||
assert (target / "build.py").read_text(encoding="utf-8") == "print('ok')"
|
||||
st = client.get(f"/agent/{rid}/status").json()
|
||||
assert st["workspace"] == str(target.resolve())
|
||||
|
||||
|
||||
def test_agent_workspace_not_exists(agent_env, client, tmp_path):
|
||||
r = client.post("/agent", json={"task": "t", "workspace": str(tmp_path / "ghost")})
|
||||
assert r.status_code == 400
|
||||
assert "不存在" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_workspace_open_and_recent(agent_env, client, tmp_path):
|
||||
"""打开目录:设为当前 + 记入最近列表;支持 create 新建。"""
|
||||
d1 = tmp_path / "proj_a"
|
||||
d1.mkdir()
|
||||
r1 = client.post("/agent/workspaces", json={"path": str(d1)})
|
||||
assert r1.status_code == 200
|
||||
assert r1.json()["current"] == str(d1.resolve())
|
||||
assert str(d1.resolve()) in r1.json()["recent"]
|
||||
# create 新建
|
||||
new_dir = tmp_path / "proj_b" / "nested"
|
||||
r2 = client.post("/agent/workspaces", json={"path": str(new_dir), "create": True})
|
||||
assert r2.status_code == 200
|
||||
assert new_dir.is_dir()
|
||||
assert r2.json()["current"] == str(new_dir.resolve())
|
||||
# 不存在且不建 -> 400
|
||||
r3 = client.post("/agent/workspaces", json={"path": str(tmp_path / "nope")})
|
||||
assert r3.status_code == 400
|
||||
# 列表端点
|
||||
lst = client.get("/agent/workspaces").json()
|
||||
assert lst["current"] == str(new_dir.resolve())
|
||||
assert len(lst["recent"]) >= 2
|
||||
|
||||
|
||||
def test_fs_browse_endpoint(agent_env, client, tmp_path):
|
||||
r = client.get("/agent/fs", params={"path": str(tmp_path)})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
assert "dirs" in r.json()
|
||||
r2 = client.get("/agent/fs", params={"path": str(tmp_path / "nope")})
|
||||
assert r2.json()["ok"] is False
|
||||
|
||||
|
||||
def test_agent_workspace_and_file_accept_root(agent_env, client, tmp_path):
|
||||
"""浏览/读取端点可指定 root(选中工作区)。"""
|
||||
other = tmp_path / "other_ws"
|
||||
other.mkdir()
|
||||
(other / "x.txt").write_text("外部工作区", encoding="utf-8")
|
||||
ls = client.get("/agent/workspace", params={"root": str(other)}).json()
|
||||
assert ls["ok"] is True
|
||||
assert any(e["name"] == "x.txt" for e in ls["entries"])
|
||||
f = client.get("/agent/file", params={"path": "x.txt", "root": str(other)}).json()
|
||||
assert f["content"] == "外部工作区"
|
||||
# 非法 root -> 400
|
||||
r = client.get("/agent/workspace", params={"root": str(tmp_path / "nope")})
|
||||
assert r.status_code == 400
|
||||
|
||||
@@ -174,3 +174,66 @@ def test_toolloop_chat_error(ws):
|
||||
result = asyncio_run(loop.run("任务"))
|
||||
assert result["reason"] == "error"
|
||||
assert "RuntimeError" in result["error"]
|
||||
|
||||
|
||||
# ---------------- 扩展工具(T22):edit_file / search_files / run_command ----------------
|
||||
|
||||
def test_edit_file_unique_replace(ws):
|
||||
ws.write_file("app.py", "def main():\n print('v1')\n return 0\n")
|
||||
r = ws.execute("edit_file", {"path": "app.py", "old_string": "print('v1')",
|
||||
"new_string": "print('v2 — 已修复')"})
|
||||
assert r["ok"] is True and r["replaced"] == 1
|
||||
assert "print('v2 — 已修复')" in ws.read_file("app.py")["content"]
|
||||
|
||||
|
||||
def test_edit_file_rejects_ambiguous_and_missing(ws):
|
||||
ws.write_file("dup.txt", "abc-abc")
|
||||
r1 = ws.execute("edit_file", {"path": "dup.txt", "old_string": "abc", "new_string": "x"})
|
||||
assert r1["ok"] is False and "2 次" in r1["error"]
|
||||
r2 = ws.execute("edit_file", {"path": "dup.txt", "old_string": "zzz", "new_string": "x"})
|
||||
assert r2["ok"] is False and "未在文件中找到" in r2["error"]
|
||||
assert ws.read_file("dup.txt")["content"] == "abc-abc" # 原文未被破坏
|
||||
|
||||
|
||||
def test_search_files(ws):
|
||||
ws.write_file("a.py", "DEFAULT_PORT = 8000\n")
|
||||
ws.write_file("docs/note.md", "端口 8000 是默认值\n")
|
||||
ws.write_file("node_modules/pkg/index.js", "port 8000\n") # 应被跳过
|
||||
r = ws.execute("search_files", {"query": "8000"})
|
||||
assert r["ok"] is True
|
||||
files = {m["file"] for m in r["matches"]}
|
||||
assert files == {"a.py", "docs/note.md"}
|
||||
assert all("node_modules" not in f for f in files)
|
||||
# 空查询
|
||||
assert ws.execute("search_files", {"query": ""})["ok"] is False
|
||||
|
||||
|
||||
def test_run_command_disabled_by_default(ws):
|
||||
r = ws.execute("run_command", {"command": "echo hi"})
|
||||
assert r["ok"] is False and "allow_shell" in r["error"]
|
||||
|
||||
|
||||
def test_run_command_enabled(tmp_path):
|
||||
import sys
|
||||
ws2 = WorkspaceTools(tmp_path / "ws2", allow_shell=True, shell_timeout_s=15)
|
||||
r = ws2.execute("run_command", {"command": f'"{sys.executable}" -c "print(40+2)"'})
|
||||
assert r["ok"] is True and r["exit_code"] == 0
|
||||
assert "42" in r["output"]
|
||||
|
||||
|
||||
def test_run_command_timeout(tmp_path):
|
||||
import sys
|
||||
ws2 = WorkspaceTools(tmp_path / "ws3", allow_shell=True, shell_timeout_s=2)
|
||||
r = ws2.execute("run_command",
|
||||
{"command": f'"{sys.executable}" -c "import time; time.sleep(30)"'})
|
||||
assert r["ok"] is False and "超时" in r["error"]
|
||||
|
||||
|
||||
def test_browse_directories(tmp_path):
|
||||
from router_system.tools import browse_directories
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "file.txt").write_text("x", encoding="utf-8")
|
||||
r = browse_directories(str(tmp_path))
|
||||
assert r["ok"] is True and r["dirs"] == ["sub"] # 只列目录不列文件
|
||||
assert r["parent"] # 可以上级
|
||||
assert browse_directories(str(tmp_path / "ghost"))["ok"] is False
|
||||
|
||||
Reference in New Issue
Block a user