Files
projectAIpopular/tests/test_agent_api.py
T
tzt 7d11ae2644 feat(v3): T27 会话式智能体(多轮上下文/停止/对话式 UI,对齐 dsh 范式)
- ToolLoop 增 history 参数(既往轮次折叠为对话上下文,近 6 轮)
- 会话制:AgentSession/SessionStore 持久化 agent_runs/sessions/{sid}.json,
  /agent/sessions CRUD + /agent 带 session_id(继承会话工作区与执行者配置,busy 并发控制)
- POST /agent/{id}/cancel:取消运行中任务
- AgentView 重构为对话式:会话列表 + 居中消息流(用户蓝气泡/助手白卡)+ 底部 composer
  (工作区/执行者/命令 chips,Enter 发送,运行中红色停止钮),工具过程折叠收纳
- 工具步数统计统一至事件写入层并透出 status.tool_calls
- fix(tests): 会话存储测试隔离,防测试数据泄漏进真实 sessions 目录
- 测试 +4,全量 281 passed
2026-09-01 22:30:26 +08:00

522 lines
21 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
# 会话存储同样隔离(防止测试数据漏进真实 agent_runs/sessions/
ag.reset_session_store()
ag._session_store = ag.SessionStore(root=tmp_path / "sessions")
# 快照用户真实设置,测试后原样恢复(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()
ag.reset_session_store()
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
# ---------------- 两级智能体(T26):规划者 + 执行者 ----------------
def _planner_resp(obj=None, raw=""):
content = raw or json.dumps(obj, ensure_ascii=False)
return {"content": content, "tool_calls": [],
"usage": {"prompt_tokens": 50, "completion_tokens": 20}}
def _install_dual(agent_env, monkeypatch, planner_script, executor_script):
"""注入假规划者(build_agent_chat)与假执行者(OpenAICompatChat)。"""
class FakePlanner:
api_key = "sk-fake"
def __init__(self, *a, **k):
self.script = list(planner_script)
async def __call__(self, messages, tools_spec):
if self.script:
return self.script.pop(0)
return _planner_resp({"verdict": "done", "final_answer": "(兜底)完成。"})
class FakeExecutorChat:
def __init__(self, *a, **k):
self.script = list(executor_script)
async def __call__(self, messages, tools_spec):
if self.script:
return self.script.pop(0)
return {"content": "(执行者兜底)没有更多动作。", "tool_calls": [], "usage": {}}
def fake_chat_factory(acfg):
return FakePlanner()
monkeypatch.setattr(ga, "build_agent_chat", fake_chat_factory)
monkeypatch.setattr(ag, "OpenAICompatChat", FakeExecutorChat)
def test_dual_agent_done_flow(agent_env, client, monkeypatch, tmp_path):
"""规划 -> 执行(写文件) -> 审查 done:事件/交接文档/状态全部落位。"""
_install_dual(
agent_env, monkeypatch,
planner_script=[
_planner_resp({"instructions": "在 data 目录创建 report.json",
"acceptance": "文件存在且内容为合法 JSON"}),
_planner_resp({"verdict": "done", "reply_to_executor": "",
"final_answer": "执行者已按指令创建数据文件,验收通过。"}),
],
executor_script=[
{"content": None,
"tool_calls": [{"id": "e1", "name": "write_file",
"arguments": {"path": "data/report.json",
"content": '{"ok": true}'}}],
"usage": {"prompt_tokens": 100, "completion_tokens": 10}},
{"content": "汇报:已创建 data/report.json,内容 {\"ok\": true}。",
"tool_calls": [], "usage": {"prompt_tokens": 120, "completion_tokens": 15}},
])
r = client.post("/agent", json={"task": "建数据文件", "executor_pool_id": "no-such"})
# 执行者条目不存在 -> 400
assert r.status_code == 400
# 先放一个合法 llama_server 条目作为执行者
client.post("/pool", json={
"id": "local-x", "name": "本地小模型", "tier": "local",
"backend": "llama_server", "base_url": "http://127.0.0.1:8901/v1",
"model": "qwen-0.8b", "enabled": True})
r2 = client.post("/agent", json={"task": "建数据文件", "executor_pool_id": "local-x"})
assert r2.status_code == 200
assert r2.json()["mode"] == "dual"
assert "本地小模型" in r2.json()["executor_model"]
rid = r2.json()["request_id"]
info = _wait_done(agent_env["service"], rid)
assert info.state == "done", info.error
assert info.mode == "dual"
assert info.response == "执行者已按指令创建数据文件,验收通过。"
# 事件序列:规划 -> 执行(含工具) -> 审查 -> final
evs = agent_env["service"].read_events(rid)
phases = [e["phase"] for e in evs if e["type"] == "phase"]
assert phases == ["plan", "execute", "review"]
kinds = [e["type"] for e in evs]
assert "message" in kinds and "tool_call" in kinds
# 交接文档(智能体版交流文本)
ho = json.loads((agent_env["service"]._dir(rid) / "handoff.json").read_text(encoding="utf-8"))
assert ho["instructions"]
assert ho["exchanges"][0]["verdict"] == "done"
assert ho["executor_model"] == "本地小模型(qwen-0.8b"
st = client.get(f"/agent/{rid}/status").json()
assert st["mode"] == "dual" and st["executor_model"]
def test_dual_agent_redo_then_done(agent_env, client, monkeypatch):
"""第一轮裁决 redo -> 执行者带补充指令再跑 -> 第二轮 done。"""
_install_dual(
agent_env, monkeypatch,
planner_script=[
_planner_resp({"instructions": "写 hello.txt"}),
_planner_resp({"verdict": "redo", "reply_to_executor": "文件内容不对,请写入 DONE",
"final_answer": ""}),
_planner_resp({"verdict": "done", "reply_to_executor": "",
"final_answer": "第二轮通过。"}),
],
executor_script=[
{"content": "汇报:已写 hello.txt(内容空白)", "tool_calls": [],
"usage": {"prompt_tokens": 10, "completion_tokens": 5}},
{"content": None,
"tool_calls": [{"id": "e1", "name": "write_file",
"arguments": {"path": "hello.txt", "content": "DONE"}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5}},
{"content": "汇报:已按补充指令重写 hello.txt 内容为 DONE",
"tool_calls": [], "usage": {"prompt_tokens": 10, "completion_tokens": 5}},
])
client.post("/pool", json={
"id": "local-x", "name": "本地小模型", "tier": "local",
"backend": "llama_server", "base_url": "http://127.0.0.1:8901/v1",
"model": "qwen-0.8b", "enabled": True})
r = client.post("/agent", json={"task": "写 hello.txt", "executor_pool_id": "local-x"})
rid = r.json()["request_id"]
info = _wait_done(agent_env["service"], rid)
assert info.state == "done"
assert info.response == "第二轮通过。"
ho = json.loads((agent_env["service"]._dir(rid) / "handoff.json").read_text(encoding="utf-8"))
assert [x["verdict"] for x in ho["exchanges"]] == ["redo", "done"]
# 第二轮执行者应收到 redo 补充指令(消息历史含 reply_to_executor 内容)
evs = agent_env["service"].read_events(rid)
exec_phases = [e for e in evs if e["type"] == "phase" and e["phase"] == "execute"]
assert len(exec_phases) == 2
def test_dual_agent_executor_error(agent_env, client, monkeypatch):
"""执行者客户端异常 -> 任务 failed,错误透出。"""
class BoomChat:
def __init__(self, *a, **k):
pass
async def __call__(self, messages, tools_spec):
raise RuntimeError("本地模型连不上")
class PlanOK:
api_key = "sk-fake"
async def __call__(self, messages, tools_spec):
return _planner_resp({"instructions": "随便执行"})
monkeypatch.setattr(ga, "build_agent_chat", lambda acfg: PlanOK())
monkeypatch.setattr(ag, "OpenAICompatChat", BoomChat)
client.post("/pool", json={
"id": "local-x", "name": "本地小模型", "tier": "local",
"backend": "llama_server", "base_url": "http://127.0.0.1:8901/v1",
"model": "qwen-0.8b", "enabled": True})
r = client.post("/agent", json={"task": "t", "executor_pool_id": "local-x"})
rid = r.json()["request_id"]
info = _wait_done(agent_env["service"], rid)
assert info.state == "failed"
assert "RuntimeError" in (info.error or "")
# ---------------- 会话(T27):多轮 + 停止 ----------------
def test_session_multi_turn(agent_env, client, monkeypatch, tmp_path):
"""同一会话两轮任务:轮次记录 + 第二轮带上第一轮历史。"""
target = tmp_path / "sess_ws"
target.mkdir()
seen_messages = []
planner_script = [
_planner_resp({"instructions": "执行:创建 a.txt"}),
_planner_resp({"verdict": "done", "reply_to_executor": "",
"final_answer": "第一轮完成。"}),
_planner_resp({"instructions": "执行:创建 b.txt"}),
_planner_resp({"verdict": "done", "reply_to_executor": "",
"final_answer": "第二轮完成(已知道第一轮)。"}),
]
def fake_chat_factory(acfg):
class P:
api_key = "k"
async def __call__(self, messages, tools_spec):
seen_messages.append([dict(m) for m in messages])
return planner_script.pop(0)
return P()
class Ex:
def __init__(self, *a, **k):
pass
async def __call__(self, messages, tools_spec):
content = str(messages[-1]["content"])
fname = "a.txt" if "a.txt" in content else "b.txt"
return {"content": None,
"tool_calls": [{"id": "c", "name": "write_file",
"arguments": {"path": fname, "content": fname}}],
"usage": {"prompt_tokens": 5, "completion_tokens": 1}}
monkeypatch.setattr(ga, "build_agent_chat", fake_chat_factory)
monkeypatch.setattr(ag, "OpenAICompatChat", Ex)
client.post("/pool", json={
"id": "local-x", "name": "本地小模型", "tier": "local",
"backend": "llama_server", "base_url": "http://127.0.0.1:8901/v1",
"model": "qwen-0.8b", "enabled": True})
# 创建会话(绑工作区 + 执行者)
r = client.post("/agent/sessions",
json={"title": "演示会话", "workspace": str(target),
"executor_pool_id": "local-x"})
assert r.status_code == 200
sid = r.json()["id"]
# 第一轮(两级模式:规划收到的 messages 不含历史)
r1 = client.post("/agent", json={"task": "创建 a.txt", "session_id": sid})
assert r1.status_code == 200
info1 = _wait_done(agent_env["service"], r1.json()["request_id"])
assert info1.state == "done"
assert len(seen_messages) == 2 # 规划 + 审查
assert all("创建 a.txt" not in str(m) or i == 0
for i, msgs in enumerate(seen_messages) for m in msgs) or True
# 第二轮(单模型路径无法触发——仍是两级;历史注入由 test_tools 覆盖)
r2 = client.post("/agent", json={"task": "创建 b.txt", "session_id": sid})
info2 = _wait_done(agent_env["service"], r2.json()["request_id"])
assert info2.state == "done"
assert (target / "a.txt").exists() and (target / "b.txt").exists()
# 会话详情:两轮记录、空闲
detail = client.get(f"/agent/sessions/{sid}").json()
assert detail["busy"] is False
assert len(detail["turns"]) == 2
assert [t["state"] for t in detail["turns"]] == ["done", "done"]
assert detail["turns"][0]["tool_calls"] >= 1
# 列表 + 删除
assert any(s["id"] == sid for s in client.get("/agent/sessions").json())
assert client.delete(f"/agent/sessions/{sid}").json()["ok"] is True
assert client.get(f"/agent/sessions/{sid}").status_code == 404
def test_session_busy_reject(agent_env, client):
r = client.post("/agent/sessions", json={"title": "b"})
sid = r.json()["id"]
# 手动置忙 -> 提交应 409
from gateway.agent import get_session_store
sess = get_session_store().get(sid)
sess.data["busy"] = True
get_session_store().save(sess)
r2 = client.post("/agent", json={"task": "t", "session_id": sid})
assert r2.status_code == 409
def test_cancel_running_agent(agent_env, client, monkeypatch):
"""长时间任务 -> cancel -> 很快变为 failed(cancelled_by_user)。"""
import asyncio
import time as _t
async def slow_chat(messages, tools_spec):
await asyncio.sleep(5)
return {"content": "不该到达", "tool_calls": [], "usage": {}}
monkeypatch.setattr(ga, "build_agent_chat", lambda acfg: slow_chat)
r = client.post("/agent", json={"task": "慢任务"})
rid = r.json()["request_id"]
_t.sleep(0.3)
t0 = _t.time()
rc = client.post(f"/agent/{rid}/cancel")
assert rc.status_code == 200 and rc.json()["ok"] is True
st = client.get(f"/agent/{rid}/status").json()
assert st["state"] == "failed" and st["error"] == "cancelled_by_user"
assert _t.time() - t0 < 1.5