aps-agent/tests/golden/test_plan_runtime.py

257 lines
8.3 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.

# ============================================================
# 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"