Files
projectAIpopular/tests/test_agent_api.py
T
tzt 8358302002 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
2026-09-01 10:16:58 +08:00

247 lines
9.3 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.
"""智能体端点测试:注入脚本化 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