# ============================================================ # PlanNode 可重生运行时黄金测试(plan.md §3.1 / §4.3) # ============================================================ from __future__ import annotations import json from pathlib import Path import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from pydantic import ValidationError from server.agent_core.plan_runtime import ( PlanInputMismatchError, PlanParentError, PlanRegenerationFusedError, PlanStore, canonical_inputs_hash, ) from server.gateway.plan_api import resolve_plan_store, router ROOT = Path(__file__).resolve().parents[2] def test_inputs_hash_is_canonical_for_json_objects() -> None: left = {"orderIds": [3, 1], "settings": {"weight": 2, "enabled": True}} right = {"settings": {"enabled": True, "weight": 2}, "orderIds": [3, 1]} assert canonical_inputs_hash(left) == canonical_inputs_hash(right) def test_plan_hierarchy_and_schema_contract(tmp_path: Path) -> None: store = PlanStore(str(tmp_path / "plans.json")) intent = store.create( plan_id="intent-1", layer="L0", parent_id=None, inputs={"prompt": "本周按交期优先排产"}, payload={"goal": "按交期优先"}, created_by="LLM", ) strategy = store.create( plan_id="strategy-1", layer="L1", parent_id=intent.planId, inputs={"goal": intent.payload}, payload={"engine": "HYBRID"}, evidence_refs=["evidence-1"], created_by="LLM", ) assert strategy.parentId == intent.planId assert strategy.version == 1 assert strategy.regenCount == 0 assert strategy.evidenceRefs == ("evidence-1",) with pytest.raises(PlanParentError): store.create( layer="L2", parent_id=intent.planId, inputs={}, payload={}, ) schema = json.loads((ROOT / "shared/schemas/plan_node.schema.json").read_text(encoding="utf-8")) assert set(schema["required"]) == set(type(intent).model_fields) assert schema["additionalProperties"] is False def test_regeneration_appends_and_preserves_old_versions(tmp_path: Path) -> None: store = PlanStore(str(tmp_path / "plans.json")) inputs = {"orders": ["SO-1"], "objective": "due-date"} first = store.create( plan_id="plan-immutable", layer="L0", parent_id=None, inputs=inputs, payload={"acceptance": ["无逾期"]}, status="APPROVED", evidence_refs=["audit-1"], created_by="USER", ) second = store.regenerate( first.planId, inputs=inputs, expected_inputs_hash=first.inputsHash, payload={"acceptance": ["无逾期", "不超产能"]}, status="DRAFT", created_by="LLM", ) assert second.planId == first.planId assert second.version == 2 assert second.regenCount == 1 assert second.inputsHash == first.inputsHash versions = store.versions(first.planId) assert [node.version for node in versions] == [1, 2] assert versions[0].status == "APPROVED" assert versions[0].payload == {"acceptance": ["无逾期"]} assert versions[1].evidenceRefs == ("audit-1",) with pytest.raises(ValidationError): first.version = 99 assert isinstance(versions[0].payload, dict) versions[0].payload["acceptance"].append("本地篡改") assert store.versions(first.planId)[0].payload == {"acceptance": ["无逾期"]} reloaded = PlanStore(str(tmp_path / "plans.json")) assert [node.model_dump() for node in reloaded.versions(first.planId)] == [ node.model_dump() for node in store.versions(first.planId) ] def test_regeneration_rejects_hash_drift_without_appending(tmp_path: Path) -> None: store = PlanStore(str(tmp_path / "plans.json")) first = store.create(layer="L0", parent_id=None, inputs={"scope": "A"}, payload={}) with pytest.raises(PlanInputMismatchError): store.regenerate( first.planId, inputs={"scope": "B"}, expected_inputs_hash=canonical_inputs_hash({"scope": "B"}), payload={"changed": True}, ) with pytest.raises(PlanInputMismatchError): store.regenerate( first.planId, inputs={"scope": "A"}, expected_inputs_hash="0" * 64, payload={"changed": True}, ) assert len(store.versions(first.planId)) == 1 def test_regeneration_fuses_after_default_threshold(tmp_path: Path) -> None: store = PlanStore(str(tmp_path / "plans.json")) inputs = {"scope": "stable"} node = store.create(layer="L0", parent_id=None, inputs=inputs, payload={"attempt": 0}) for attempt in range(1, 4): node = store.regenerate( node.planId, inputs=inputs, expected_inputs_hash=node.inputsHash, payload={"attempt": attempt}, ) assert node.regenCount == 3 with pytest.raises(PlanRegenerationFusedError): store.regenerate( node.planId, inputs=inputs, expected_inputs_hash=node.inputsHash, payload={"attempt": 4}, ) assert [item.regenCount for item in store.versions(node.planId)] == [0, 1, 2, 3] def test_failed_atomic_write_does_not_publish_in_memory_version( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: store = PlanStore(str(tmp_path / "plans.json")) inputs = {"scope": "stable"} first = store.create(layer="L0", parent_id=None, inputs=inputs, payload={"attempt": 0}) def fail_write() -> None: raise OSError("disk full") monkeypatch.setattr(store, "_write", fail_write) with pytest.raises(OSError, match="disk full"): store.regenerate( first.planId, inputs=inputs, expected_inputs_hash=first.inputsHash, payload={"attempt": 1}, ) assert [node.version for node in store.versions(first.planId)] == [1] def test_minimal_plan_api_create_read_versions_and_regenerate(tmp_path: Path) -> None: store = PlanStore(str(tmp_path / "plans.json"), max_regen=1) app = FastAPI() app.include_router(router) app.dependency_overrides[resolve_plan_store] = lambda: store client = TestClient(app) inputs = {"prompt": "试排一版"} created = client.post( "/api/plans", json={ "planId": "api-plan", "layer": "L0", "inputs": inputs, "payload": {"goal": "试排"}, }, ) assert created.status_code == 201 first = created.json()["plan"] assert first["createdBy"] == "USER" spoofed = client.post( "/api/plans", json={ "planId": "spoofed-plan", "layer": "L0", "inputs": inputs, "payload": {"goal": "伪造系统来源"}, "createdBy": "SYSTEM", }, ) assert spoofed.status_code == 422 regenerated = client.post( "/api/plans/api-plan/regenerate", json={ "inputs": inputs, "expectedInputsHash": first["inputsHash"], "payload": {"goal": "试排并评估"}, }, ) assert regenerated.status_code == 201 assert regenerated.json()["plan"]["version"] == 2 assert regenerated.json()["plan"]["createdBy"] == "USER" latest = client.get("/api/plans/api-plan") history = client.get("/api/plans/api-plan/versions") assert latest.json()["plan"]["version"] == 2 assert [item["version"] for item in history.json()["versions"]] == [1, 2] fused = client.post( "/api/plans/api-plan/regenerate", json={ "inputs": inputs, "expectedInputsHash": first["inputsHash"], "payload": {"goal": "第三版"}, }, ) assert fused.status_code == 409 assert fused.json()["detail"]["code"] == "PLAN_REGEN_FUSED" def test_gateway_registers_plan_routes() -> None: from server.gateway.app import create_app app = create_app() assert str(app.url_path_for("create_plan")) == "/api/plans" assert str(app.url_path_for("get_plan", plan_id="p1")) == "/api/plans/p1" assert str(app.url_path_for("get_plan_versions", plan_id="p1")) == "/api/plans/p1/versions" assert str(app.url_path_for("regenerate_plan", plan_id="p1")) == "/api/plans/p1/regenerate"