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
This commit is contained in:
@@ -23,6 +23,9 @@ def agent_env(tmp_path, monkeypatch):
|
||||
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))
|
||||
@@ -51,6 +54,7 @@ def agent_env(tmp_path, monkeypatch):
|
||||
store.save()
|
||||
mp.reset_pool()
|
||||
ag.reset_agent_service()
|
||||
ag.reset_session_store()
|
||||
ga.rebuild_pipeline()
|
||||
|
||||
|
||||
@@ -403,3 +407,115 @@ def test_dual_agent_executor_error(agent_env, client, monkeypatch):
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user