# ============================================================ # 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 install_test_auth(monkeypatch, "tenant-a-000000000000000000000001") 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