aps-agent/tests/golden/test_plan_branch_restore.py

248 lines
11 KiB
Python
Raw Permalink 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.

# ============================================================
# PlanStore 分支回退黄金测试(矩阵 52 行:plan 版本随分支切换回退)
# 覆盖:plans_snapshot / restore_for_branch 回退与恢复、分支间隔离、
# 无锚定兼容(round-30/31 行为不变)、持久化、gateway switch 联动。
# ============================================================
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from server.agent_core.plan_runtime import PlanNotFoundError, PlanStore
from tests.auth_provider import install_test_auth
@pytest.fixture()
def secure_app(tmp_path, monkeypatch):
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "tenant.db"))
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
monkeypatch.setenv("APS_BRANCH_DIR", str(tmp_path / "branches"))
monkeypatch.setenv("APS_CHECKPOINT_PATH", str(tmp_path / "ckpt.json"))
import server.state.checkpoints as ck_mod
from server.agent_core.plan_runtime import reset_plan_stores
from server.db.database import reset_engine
from server.state import store as world_store
from server.state.branches import reset_branch_stores
from tests.pi_primary_adapter import install_fake_pi_tool_adapter
install_test_auth(monkeypatch, "tenant-a-000000000000000000000001")
install_fake_pi_tool_adapter(monkeypatch)
world_store._stores.clear()
ck_mod._checkpoints.clear()
reset_branch_stores()
reset_plan_stores()
reset_engine()
from server.gateway.app import create_app
app = create_app()
yield app
reset_engine()
world_store._stores.clear()
reset_plan_stores()
def _login(client: TestClient, username: str = "planner") -> None:
r = client.post("/api/auth/login", json={
"method": "password", "username": username, "password": "test",
})
assert r.status_code == 200
def _tree(client, sid):
return client.get(f"/api/sessions/{sid}/branches").json()["tree"]
def _checkpoint(client, sid) -> str:
"""建档并把 checkpointId 锚定到当前活动分支。"""
r = client.post("/api/chat", json={"sessionId": sid, "text": "建一个检查点"})
assert r.status_code == 200, r.text[:300]
tree = _tree(client, sid)
active = next(b for b in tree["branches"] if b["id"] == tree["activeNode"])
cpid = active.get("checkpointId")
assert cpid, "建档后活动分支应锚定检查点"
return cpid
# ---------------- 存储层:分支视图回退 / 恢复 / 隔离 ----------------
def test_plan_views_roll_back_restore_and_isolate(tmp_path):
"""plans_snapshot/restore_for_branch:版本回退、恢复、分支间 plan 隔离。"""
store = PlanStore(str(tmp_path / "plans.json"))
store.create(plan_id="plan-a", layer="L0", parent_id=None,
inputs={"x": 1}, payload={"goal": "v1"}, created_by="USER")
store.create(plan_id="plan-b", layer="L0", parent_id=None,
inputs={"x": 2}, payload={"goal": "b1"}, created_by="USER")
# B1 锚定于 v1 状态
snap = store.plans_snapshot("branch-B1", checkpoint_id="ckpt-1")
assert snap["plans"] == {"plan-a": 1, "plan-b": 1}
assert store.restore_for_branch("branch-B1", checkpoint_id="ckpt-1")["restored"] is True
# B1 上流转 + 新建 plan(记录进 B1 视图)
store.transition_status("plan-a", new_status="APPROVED", created_by="USER")
store.create(plan_id="plan-c", layer="L0", parent_id=None, inputs={"x": 3}, payload={})
assert store.latest("plan-a").version == 2
# 从 B1 派生 B2(快照当前可见状态)
store.plans_snapshot("branch-B2", checkpoint_id="ckpt-2")
assert store.restore_for_branch("branch-B2", checkpoint_id="ckpt-2")["restored"] is True
assert store.latest("plan-a").version == 2
# 只在 B2 上创建 plan-d
store.create(plan_id="plan-d", layer="L0", parent_id=None, inputs={"x": 4}, payload={})
assert store.latest("plan-d").version == 1
# 回退到 B1:plan-d 不可见;plan-a 回到 B1 头(v2);B2 的新版本不可见
assert store.restore_for_branch("branch-B1", checkpoint_id="ckpt-1")["restored"] is True
assert store.latest("plan-a").version == 2
assert [n.version for n in store.versions("plan-a")] == [1, 2]
with pytest.raises(PlanNotFoundError):
store.latest("plan-d")
with pytest.raises(PlanNotFoundError):
store.node_at("plan-a", 3)
# B1 上继续流转 → 追加 v3(与 B2 的 v2 视图分叉)
store.transition_status("plan-a", new_status="DONE", created_by="USER")
assert store.latest("plan-a").version == 3
# 恢复 B2:plan-a 回到 v2,plan-d 可见,v3 不可见
assert store.restore_for_branch("branch-B2", checkpoint_id="ckpt-2")["restored"] is True
assert store.latest("plan-a").version == 2
assert store.latest("plan-d").version == 1
assert [n.version for n in store.versions("plan-a")] == [1, 2]
with pytest.raises(PlanNotFoundError):
store.node_at("plan-a", 3)
# 审计/诊断视图
views = store.plan_views()
assert set(views) == {"branch-B1", "branch-B2"}
assert store.active_view()["branchId"] == "branch-B2"
def test_plan_views_persist_across_reload(tmp_path):
"""分支视图随 plans.json 持久化,重载后可继续回退。"""
path = str(tmp_path / "plans.json")
store = PlanStore(path)
store.create(plan_id="plan-p", layer="L0", parent_id=None,
inputs={"x": 1}, payload={"goal": "v1"})
store.plans_snapshot("branch-X", checkpoint_id="ckpt-x")
store.restore_for_branch("branch-X", checkpoint_id="ckpt-x")
store.transition_status("plan-p", new_status="APPROVED", created_by="USER")
assert store.latest("plan-p").version == 2
reloaded = PlanStore(path)
assert "branch-X" in reloaded.plan_views()
assert reloaded.restore_for_branch("branch-X", checkpoint_id="ckpt-x")["restored"] is True
assert reloaded.latest("plan-p").version == 2
def test_no_anchor_keeps_legacy_behavior(tmp_path):
"""无锚定:restore 返回 restored=False,latest()/versions()/创建流转与既有行为一致。"""
store = PlanStore(str(tmp_path / "plans.json"))
p = store.create(plan_id="plan-legacy", layer="L0", parent_id=None,
inputs={"x": 1}, payload={"goal": "v1"}, created_by="USER")
r = store.restore_for_branch("branch-none", checkpoint_id="ckpt-x", archived=None)
assert r["restored"] is False
assert store.active_view() is None
# 无锚定下行为不变(round-30/31 语义)
store.transition_status(p.planId, new_status="APPROVED", created_by="USER")
assert store.latest(p.planId).version == 2
assert [n.version for n in store.versions(p.planId)] == [1, 2]
# 仅快照不改变全局视图
store.plans_snapshot("branch-y", checkpoint_id="ckpt-y")
assert store.active_view() is None
assert store.latest(p.planId).version == 2
# ---------------- 网关层:分支切换联动回退 ----------------
def test_switch_restores_plan_view_via_gateway(secure_app):
"""gateway 分支切换:plan 版本随锚定 checkpoint 回退/恢复(矩阵 52 行)。"""
client = TestClient(secure_app)
_login(client)
created = client.post("/api/projects", json={"id": "proj-pv", "name": "plan回退项目"})
assert created.status_code == 200
sid = created.json()["session"]["id"]
tree = _tree(client, sid)
root = tree["treeRoot"]
# 主干创建 plan(v1)+ 审批(v2)
r = client.post("/api/plans", json={
"planId": "plan-rt", "layer": "L0",
"inputs": {"prompt": "交付优先"}, "payload": {"goal": "v1"},
})
assert r.status_code == 201, r.text[:300]
v2 = client.post("/api/plans/plan-rt/approve", json={"action": "approve"})
assert v2.status_code == 200, v2.text[:300]
assert v2.json()["plan"]["version"] == 2
# 排产 + 建档 → 主干锚定 C1(对话侧归档 plan v1/v2)
r = client.post("/api/chat", json={"sessionId": sid, "text": "试排一版交付优先"})
assert r.status_code == 200, r.text[:300]
ck_root = _checkpoint(client, sid)
# fork 分支(继承 C1),分支上再生 plan → v3
forked = client.post(f"/api/sessions/{sid}/branches",
json={"name": "plan分支", "checkpointId": ck_root}).json()["branch"]
regen = client.post("/api/plans/plan-rt/regenerate", json={
"inputs": {"prompt": "交付优先"}, "expectedInputsHash": v2.json()["plan"]["inputsHash"],
"payload": {"goal": "v3"},
})
assert regen.status_code == 201, regen.text[:300]
assert regen.json()["plan"]["version"] == 3
# 分支再建档 → C2 归档 v1/v2/v3
r = client.post("/api/chat", json={"sessionId": sid, "text": "试排一版产能均衡"})
assert r.status_code == 200, r.text[:300]
_checkpoint(client, sid)
# 切回主干:plan 视图回退到锚定版本(v2)
switched = client.post(f"/api/sessions/{sid}/branches/{root}/switch").json()
assert switched.get("restored") is True
assert switched.get("planView", {}).get("restored") is True
got = client.get("/api/plans/plan-rt")
assert got.status_code == 200
assert got.json()["plan"]["version"] == 2 # 版本回退
# 切到分支:plan 视图恢复分支状态(v3)
switched2 = client.post(f"/api/sessions/{sid}/branches/{forked['id']}/switch").json()
assert switched2.get("restored") is True
got2 = client.get("/api/plans/plan-rt")
assert got2.status_code == 200
assert got2.json()["plan"]["version"] == 3 # 版本恢复
# 分支上新建 plan → 切回主干后不可见(只见该分支 plan)
only_b = client.post("/api/plans", json={
"planId": "plan-only-b", "layer": "L0",
"inputs": {"prompt": "分支专属"}, "payload": {"goal": "b"},
})
assert only_b.status_code == 201
assert client.get("/api/plans/plan-only-b").json()["plan"]["version"] == 1
client.post(f"/api/sessions/{sid}/branches/{root}/switch")
hidden = client.get("/api/plans/plan-only-b")
assert hidden.status_code == 404 # 主干视图不可见
assert client.get("/api/plans/plan-rt").json()["plan"]["version"] == 2
def test_switch_without_anchor_keeps_plan_global(secure_app):
"""无锚定分支切换:plan 视图不激活,latest() 仍为全局(兼容既有行为)。"""
client = TestClient(secure_app)
_login(client)
created = client.post("/api/projects", json={"id": "proj-na2", "name": "无锚点plan"})
assert created.status_code == 200
sid = created.json()["session"]["id"]
r = client.post("/api/plans", json={
"planId": "plan-global", "layer": "L0",
"inputs": {"prompt": "全局"}, "payload": {"goal": "g"},
})
assert r.status_code == 201
forked = client.post(f"/api/sessions/{sid}/branches", json={"name": "无锚分支"}).json()["branch"]
assert forked["checkpointId"] is None
switched = client.post(f"/api/sessions/{sid}/branches/{forked['id']}/switch").json()
assert switched.get("restored") is False
assert switched.get("planView") is None
got = client.get("/api/plans/plan-global")
assert got.status_code == 200
assert got.json()["plan"]["version"] == 1