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:
tzt
2026-09-01 10:16:58 +08:00
parent 10bd4cc71d
commit 8358302002
6 changed files with 440 additions and 24 deletions
+76
View File
@@ -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
+63
View File
@@ -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