- 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
247 lines
9.3 KiB
Python
247 lines
9.3 KiB
Python
"""智能体端点测试:注入脚本化 chat_fn,不依赖真实模型/API key。"""
|
||
import json
|
||
import time
|
||
|
||
import pytest
|
||
|
||
pytest.importorskip("fastapi")
|
||
pytest.importorskip("httpx")
|
||
|
||
from fastapi.testclient import TestClient
|
||
|
||
import gateway.agent as ag
|
||
import gateway.api as ga
|
||
from gateway.model_pool import PoolStore
|
||
import gateway.model_pool as mp
|
||
|
||
|
||
@pytest.fixture()
|
||
def agent_env(tmp_path, monkeypatch):
|
||
"""隔离:池/服务/工作区全部指向临时目录,chat_fn 用脚本替身。"""
|
||
mp.reset_pool()
|
||
mp._store = PoolStore(path=tmp_path / "pool.json")
|
||
ag.reset_agent_service()
|
||
service = ag.AgentService(run_dir=tmp_path / "agent_runs")
|
||
ag._service = service
|
||
# 快照用户真实设置,测试后原样恢复(settings.json 是活文件,不能污染)
|
||
store = ga.settings_store()
|
||
snapshot = json.loads(json.dumps(store._data, ensure_ascii=False))
|
||
# 工作区指向临时目录 + 给经典回退一个假 key(防止 .env 缺失时 400)
|
||
store.update({"agent": {"workspace_dir": str(tmp_path / "ws")},
|
||
"architect": {"api_key": "sk-fake-test"}})
|
||
ga.rebuild_pipeline()
|
||
|
||
script = []
|
||
|
||
def set_script(events):
|
||
script.clear()
|
||
script.extend(events)
|
||
|
||
def fake_chat_factory(acfg):
|
||
async def chat_fn(messages, tools_spec):
|
||
if not script:
|
||
return {"content": "(脚本用尽)好的。", "tool_calls": [], "usage": {}}
|
||
return script.pop(0)
|
||
return chat_fn
|
||
|
||
monkeypatch.setattr(ga, "build_agent_chat", fake_chat_factory)
|
||
yield {"service": service, "set_script": set_script, "ws": tmp_path / "ws"}
|
||
|
||
store._data = snapshot
|
||
store.save()
|
||
mp.reset_pool()
|
||
ag.reset_agent_service()
|
||
ga.rebuild_pipeline()
|
||
|
||
|
||
@pytest.fixture()
|
||
def client():
|
||
return TestClient(ga.app)
|
||
|
||
|
||
def _wait_done(service, rid, timeout=10.0):
|
||
t0 = time.time()
|
||
while time.time() - t0 < timeout:
|
||
info = service.get(rid)
|
||
if info and info.state in ("done", "failed"):
|
||
return info
|
||
time.sleep(0.05)
|
||
return service.get(rid)
|
||
|
||
|
||
def test_agent_full_flow(agent_env, client):
|
||
"""写文件 -> 最终答复:验证事件、工作区落盘、状态终态。"""
|
||
agent_env["set_script"]([
|
||
{"content": None,
|
||
"tool_calls": [{"id": "c1", "name": "write_file",
|
||
"arguments": {"path": "notes.md", "content": "# 笔记"}}],
|
||
"usage": {"prompt_tokens": 30, "completion_tokens": 6}},
|
||
{"content": "已创建 notes.md,任务完成。", "tool_calls": [],
|
||
"usage": {"prompt_tokens": 40, "completion_tokens": 8}},
|
||
])
|
||
r = client.post("/agent", json={"task": "帮我建一个 notes.md"})
|
||
assert r.status_code == 200
|
||
rid = r.json()["request_id"]
|
||
assert r.json()["status"] == "running"
|
||
|
||
info = _wait_done(agent_env["service"], rid)
|
||
assert info.state == "done", info.error
|
||
assert "notes.md" in info.response
|
||
|
||
# 工作区真实落盘
|
||
assert (agent_env["ws"] / "notes.md").read_text(encoding="utf-8") == "# 笔记"
|
||
|
||
# 事件序列
|
||
events = client.get(f"/agent/{rid}/events").json()
|
||
kinds = [e["type"] for e in events]
|
||
assert "tool_call" in kinds and "tool_result" in kinds and "final" in kinds
|
||
assert events[-1]["reason"] == "answer"
|
||
|
||
# 状态端点
|
||
st = client.get(f"/agent/{rid}/status").json()
|
||
assert st["state"] == "done"
|
||
assert st["prompt_tokens"] == 70 and st["completion_tokens"] == 14
|
||
|
||
# 工作区浏览端点
|
||
ls = client.get("/agent/workspace").json()
|
||
assert ls["ok"] is True
|
||
assert any(e["name"] == "notes.md" for e in ls["entries"])
|
||
f = client.get("/agent/file", params={"path": "notes.md"}).json()
|
||
assert f["content"] == "# 笔记"
|
||
|
||
|
||
def test_agent_jail_via_api(agent_env, client):
|
||
"""工具结果为 ok=False(越界被拒),循环仍能继续到最终答复。"""
|
||
agent_env["set_script"]([
|
||
{"content": None,
|
||
"tool_calls": [{"id": "c1", "name": "read_file",
|
||
"arguments": {"path": "../../secret.txt"}}],
|
||
"usage": {"prompt_tokens": 10, "completion_tokens": 2}},
|
||
{"content": "越界访问被拒绝。", "tool_calls": [], "usage": {}},
|
||
])
|
||
r = client.post("/agent", json={"task": "读一下上级目录"})
|
||
rid = r.json()["request_id"]
|
||
info = _wait_done(agent_env["service"], rid)
|
||
assert info.state == "done"
|
||
events = agent_env["service"].read_events(rid)
|
||
tool_result = next(e for e in events if e["type"] == "tool_result")
|
||
assert tool_result["ok"] is False
|
||
|
||
# 文件读取 API 直接越界 -> 404/400
|
||
r2 = client.get("/agent/file", params={"path": "../../x.txt"})
|
||
assert r2.status_code in (400, 404)
|
||
|
||
|
||
def test_agent_model_from_pool(agent_env, client, monkeypatch):
|
||
"""池 agent 角色(或显式 pool_id)应被采用;mock 池模型拒绝。"""
|
||
from gateway.agent import OpenAICompatChat
|
||
captured = {}
|
||
real_factory = None
|
||
|
||
# 先放一个 openai 池条目并指派 agent 角色
|
||
client.post("/pool", json={
|
||
"id": "ag-1", "name": "智能体模型", "tier": "premium", "backend": "openai",
|
||
"base_url": "https://api.example.com", "model": "big-model-x",
|
||
"api_key": "sk-abc1234567", "enabled": True,
|
||
})
|
||
client.put("/pool/roles", json={"agent": "ag-1"})
|
||
|
||
# /agent 不带 pool_id -> 用池 agent 角色
|
||
r = client.post("/agent", json={"task": "hi"})
|
||
assert r.status_code == 200
|
||
assert r.json()["model"] == "big-model-x"
|
||
|
||
# mock 条目 -> 400
|
||
client.post("/pool", json={
|
||
"id": "mk-1", "name": "mock", "tier": "local", "backend": "mock",
|
||
"model": "mock", "enabled": True,
|
||
})
|
||
r2 = client.post("/agent", json={"task": "hi", "pool_id": "mk-1"})
|
||
assert r2.status_code == 400
|
||
|
||
|
||
def test_agent_task_validation(agent_env, client):
|
||
assert client.post("/agent", json={"task": ""}).status_code == 400
|
||
assert client.post("/agent", json={}).status_code == 400
|
||
|
||
|
||
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
|