aps-agent/tests/golden/test_fallback_offline_booki...

380 lines
17 KiB
Python
Raw Normal View History

# ============================================================
# FB-03-BUG-1 修复专项(Agent-O,全部确定性):
# S6 断连补录的显式离线落账模式(offlineBooking)。
# 语义:显式声明 + 断连事实双成立才走离线落账(本地落账 +
# syncStatus=PENDING_SYNC);在线路径逐字节不变;非断连声明即滥用,拒绝。
# 手法与 test_fallback_highrisk.py 一致:fake runner / FakeStore / tmp 隔离 /
# monkeypatch _probe_mes_connectivity seam(零网络零真 LLM)。
# ============================================================
from __future__ import annotations
import copy
import json
from pathlib import Path
from typing import ClassVar
import pytest
from server.agent_core import fallback_lane, harness
from server.agent_core.providers import reset_provider
from server.aps_domain.workflow import execute_confirmed
from server.contracts import IntentResult
from server.state.checkpoints import CheckpointStore
from server.state.seed import seed_world
DEMO_PRODUCT = "CTRL-A"
@pytest.fixture(autouse=True)
def _isolate(tmp_path, monkeypatch):
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fb"))
monkeypatch.setenv("APS_FEATURES_PATH", str(tmp_path / "features.json"))
monkeypatch.setenv("APS_FALLBACK_HIGHRISK_PATH", str(tmp_path / "fallback-highrisk.json"))
monkeypatch.delenv("LLM_API_KEY", raising=False)
monkeypatch.delenv("LLM_BASE_URL", raising=False)
monkeypatch.delenv("LLM_MODEL", raising=False)
monkeypatch.delenv("LLM_PROVIDER", raising=False)
reset_provider()
from server.state import checkpoints as _cp_mod
from server.state import store as _store_mod
kept_scoped = dict(_store_mod._stores)
kept_cps = dict(_cp_mod._checkpoints)
yield
_store_mod._stores.clear()
_store_mod._stores.update(kept_scoped)
_cp_mod._checkpoints.clear()
_cp_mod._checkpoints.update(kept_cps)
reset_provider()
class FakeStore:
_KIND_TABLE: ClassVar[dict[str, str]] = {
"salesOrder": "salesOrders", "material": "materials",
"audit": "auditEvents", "importBatch": "importBatches"}
def __init__(self, tmp_path: Path):
self.data = seed_world()
self._counters: dict[str, int] = {}
self.tenant_uuid = "platform"
self.world_key = "default"
self.path = str(tmp_path / "world.json")
self.checkpoints = CheckpointStore(str(tmp_path / "checkpoints.json"))
def next_id(self, kind: str) -> int:
if kind not in self._counters:
table = self._KIND_TABLE.get(kind)
self._counters[kind] = max(
(x.get("id", 0) for x in self.data.get(table, [])
if isinstance(x.get("id"), int)), default=0) if table else 0
self._counters[kind] += 1
return self._counters[kind]
def save(self) -> None:
pass
def restore(self, world: dict) -> None:
self.data = copy.deepcopy(world)
self._counters.clear()
def _cfg(tmp_path: Path) -> fallback_lane.FallbackConfig:
return fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home"))
def _write_features(tmp_path: Path) -> None:
(tmp_path / "features.json").write_text(
json.dumps({"version": 1, "features": {"fallback": True}}, ensure_ascii=False),
encoding="utf-8")
def _write_whitelist(tmp_path: Path) -> None:
doc = {"whitelistVersion": 1, "updatedAt": "2026-09-05T10:00:00",
"updatedBy": "offline-booking-test",
"scenarios": {
"S4": {"enabled": True,
"intents": ["flex.simulate_due", "flex.compare",
"scenario.compare", "scenario.sensitivity"],
"roles": ["planner", "admin"]},
"S6": {"enabled": True, "intents": ["mes.report"],
"roles": ["planner", "admin"]},
"S7": {"enabled": True,
"intents": ["agent.fallback.ops.config.apply",
"agent.fallback.policy.update"],
"roles": ["ops", "admin"]}}}
(tmp_path / "fallback-highrisk.json").write_text(
json.dumps(doc, ensure_ascii=False), encoding="utf-8")
def _intent(query: str, **params) -> IntentResult:
"""Pi 的结构化信封:query 是原话,params 是 Pi 已选定的工具槽位。"""
return IntentResult(intent="unknown", params={"query": query, **params},
confidence=0.1, source="LLM")
def _fp(world: dict) -> str:
return harness.world_fingerprint(world)
def _make_plan_runner(plan_builder):
def runner(task: str, work_dir: Path):
run_dir = work_dir.parent
plan = plan_builder(run_dir)
(run_dir / "outbox").mkdir(parents=True, exist_ok=True)
(run_dir / "outbox" / "plan.json").write_text(
json.dumps(plan, ensure_ascii=False), encoding="utf-8")
yield {"type": "message_end", "message": {"role": "assistant",
"stopReason": "stop", "content": [{"type": "text", "text": "ok"}]}}
yield {"type": "agent_end", "messages": []}
return runner
def _stop_runner(task: str, work_dir: Path):
yield {"type": "message_end", "message": {"role": "assistant",
"stopReason": "stop",
"content": [{"type": "text", "text": "status: success\n\n建议人工核对。"}]}}
yield {"type": "agent_end", "messages": []}
async def _stage(store: FakeStore, tmp_path: Path, plan: dict, query: str):
reply = await fallback_lane.propose_reply(
store, "s1", _intent(query), runner=_make_plan_runner(lambda rd: plan),
config=_cfg(tmp_path))
confirm_ids = [b.props["confirmId"] for b in (reply.blocks or [])
if getattr(b, "type", "") == "confirm-card"] if reply else []
return reply, confirm_ids
def _approve(store: FakeStore, confirm_id: str, *, approve: bool = True) -> str:
return execute_confirmed(store, confirm_id, approve=approve, actor="offline-test")
def _seed_s6_world(store: FakeStore, items: list[tuple]) -> None:
store.data.setdefault("workOrders", [])
store.data.setdefault("mesLinks", [])
for wo_id, ext_id, status, pct, qty in items:
store.data["workOrders"].append({
"id": wo_id, "mesExternalId": ext_id, "status": status,
"progressPct": pct, "qtyDone": qty, "productCode": DEMO_PRODUCT})
store.data["mesLinks"].append({
"kind": "dispatch", "woId": wo_id, "externalWoId": ext_id,
"idemKey": f"idem-{wo_id}"})
def _seed_mes_mirror(tmp_path: Path, monkeypatch, count: int):
from server.integrations import mes_stub
from server.integrations.mes_stub import MockMesClient
client = MockMesClient(tmp_path / "mes_mirror.json")
monkeypatch.setattr(mes_stub, "_client", client)
for i in range(count):
client.create_work_order({"productCode": DEMO_PRODUCT}, idem_key=f"idem-m-{i}")
return client
def _s6_plan(items: list[dict]) -> dict:
return {"planVersion": 1, "scenario": "S6", "goal": "MES 断连补录",
"steps": [{"seq": i + 1, "mode": "frozen", "intent": "mes.report",
"summary": f"补录工单 {p['woId']}", "params": p,
"constraints": {}, "expected": []}
for i, p in enumerate(items)]}
def _setup(tmp_path: Path, monkeypatch, wo_ids=(9001, 9002)):
_write_features(tmp_path)
_write_whitelist(tmp_path)
store = FakeStore(tmp_path)
client = _seed_mes_mirror(tmp_path, monkeypatch, len(wo_ids))
_seed_s6_world(store, [
(w, f"MES-WO-{i + 1:04d}", "RUNNING", 0, 0) for i, w in enumerate(wo_ids)])
return store, client
def _report_links(store: FakeStore) -> list[dict]:
return [l for l in store.data.get("mesLinks") or [] if l.get("kind") == "report"]
def _mes_report_audits(store: FakeStore) -> list[dict]:
return [e for e in store.data.get("auditEvents") or []
if e.get("action") == "mes.report" and e.get("category") == "INTEGRATION"]
def _mirror_reports(client) -> list[dict]:
return client._load().get("reports") or []
# ---------------------------------------------------------------------------
# O-1:离线落账成功路径——声明 + 断连事实双成立 → 本地落账 + PENDING_SYNC 标记,
# post_report 全程未被调用;确认卡摘要明示「MES 断连,本地落账待同步」
# ---------------------------------------------------------------------------
async def test_o1_offline_booking_success_path(tmp_path, monkeypatch):
store, client = _setup(tmp_path, monkeypatch)
monkeypatch.setattr(fallback_lane, "_probe_mes_connectivity", lambda: "failed")
items = [{"woId": 9001, "progressPct": 100, "finish": True,
"track": "fixed", "offlineBooking": True},
{"woId": 9002, "progressPct": 60, "track": "fixed",
"offlineBooking": True}]
reply, confirm_ids = await _stage(
store, tmp_path, _s6_plan(items), "MES 连不上了,今天的报工帮我补一下")
assert len(confirm_ids) == 2
# 审批人知情:卡摘要明示断连本地落账 + 待同步标记
summary_blob = json.dumps(
[b.props.get("summary") for b in reply.blocks], ensure_ascii=False)
assert "MES 断连" in summary_blob and "PENDING_SYNC" in summary_blob
msg1 = _approve(store, confirm_ids[0])
msg2 = _approve(store, confirm_ids[1])
assert "执行完成" in msg1 and "执行完成" in msg2
wos = {w["id"]: w for w in store.data["workOrders"]}
assert wos[9001]["status"] == "COMPLETED"
assert wos[9002]["progressPct"] == 60
# 可识别状态:两笔报工记录均带 syncStatus=PENDING_SYNC
links = _report_links(store)
assert len(links) == 2
assert all(l.get("syncStatus") == "PENDING_SYNC" for l in links)
# 关键断言:post_report 全程未被调用(MES 镜像报工流水零新增)
assert _mirror_reports(client) == []
# 审计 rationale 同步标记(链上可回放)
audits = _mes_report_audits(store)
assert len(audits) == 2
assert all(e["rationale"].get("offlineBooking") is True
and e["rationale"].get("syncStatus") == "PENDING_SYNC"
for e in audits)
# ---------------------------------------------------------------------------
# O-2:在线路径不变断言——未声明 offlineBooking:post_report 照常同步推送、
# mesLinks/审计形态逐字节口径不变;且全程不触发连通性探测(零新增副作用)
# ---------------------------------------------------------------------------
async def test_o2_online_path_unchanged(tmp_path, monkeypatch):
store, client = _setup(tmp_path, monkeypatch, wo_ids=(9001,))
def _probe_must_not_be_called():
raise AssertionError("在线路径不应触发连通性探测")
monkeypatch.setattr(fallback_lane, "_probe_mes_connectivity",
_probe_must_not_be_called)
items = [{"woId": 9001, "progressPct": 80, "track": "fixed"}]
_reply, confirm_ids = await _stage(
store, tmp_path, _s6_plan(items), "MES 连不上了,今天的报工帮我补一下")
assert len(confirm_ids) == 1
msg = _approve(store, confirm_ids[0])
assert "执行完成" in msg
wos = {w["id"]: w for w in store.data["workOrders"]}
assert wos[9001]["progressPct"] == 80
# post_report 被照常调用(镜像报工流水 +1,原语义)
assert len(_mirror_reports(client)) == 1
# mesLinks 报工记录无 syncStatus 键(在线路径形态逐字节不变)
links = _report_links(store)
assert len(links) == 1 and "syncStatus" not in links[0]
# 审计 rationale 键集与原口径完全一致
audits = _mes_report_audits(store)
assert len(audits) == 1
assert set(audits[0]["rationale"]) == {"pct", "status", "orderDone"}
# ---------------------------------------------------------------------------
# O-3:滥用拒绝(出卡闸)——MES 连通时声明 offlineBooking → 拒绝出卡 + 零变更
# ---------------------------------------------------------------------------
async def test_o3_abuse_rejected_at_stage_gate(tmp_path, monkeypatch):
store, client = _setup(tmp_path, monkeypatch, wo_ids=(9001,))
monkeypatch.setattr(fallback_lane, "_probe_mes_connectivity", lambda: "ok")
fp_before = _fp(store.data)
items = [{"woId": 9001, "progressPct": 80, "track": "fixed",
"offlineBooking": True}]
reply, confirm_ids = await _stage(
store, tmp_path, _s6_plan(items), "MES 连不上了,今天的报工帮我补一下")
assert confirm_ids == []
assert "未通过校验" in reply.text
assert "MES 当前连通" in reply.text
assert _fp(store.data) == fp_before # 世界零变更
assert _report_links(store) == []
assert _mirror_reports(client) == []
# ---------------------------------------------------------------------------
# O-4:滥用拒绝(执行端)——出卡时断连、审批窗口内 MES 恢复 → 熔断回滚,
# 零落账(前提不再成立即拒,绝不借离线模式跳过 MES 同步)
# ---------------------------------------------------------------------------
async def test_o4_abuse_rejected_at_execute_gate(tmp_path, monkeypatch):
store, client = _setup(tmp_path, monkeypatch, wo_ids=(9001,))
probe_state = {"v": "failed"}
monkeypatch.setattr(fallback_lane, "_probe_mes_connectivity",
lambda: probe_state["v"])
items = [{"woId": 9001, "progressPct": 80, "track": "fixed",
"offlineBooking": True}]
_reply, confirm_ids = await _stage(
store, tmp_path, _s6_plan(items), "MES 连不上了,今天的报工帮我补一下")
assert len(confirm_ids) == 1 # 断连事实下允许出卡
probe_state["v"] = "ok" # 审批窗口内 MES 恢复
msg = _approve(store, confirm_ids[0])
assert "已熔断并自动回滚" in msg
assert "未留下任何变更" in msg
wos = {w["id"]: w for w in store.data["workOrders"]}
assert wos[9001]["progressPct"] == 0 # 零落账
assert _report_links(store) == []
assert _mirror_reports(client) == []
# ---------------------------------------------------------------------------
# O-5:PENDING_SYNC 对账呈现——恢复后对账识别离线落账记录并如实呈现
# (回复文案 / 对账报告 / ALGO_RUN rationale 三处一致)
# ---------------------------------------------------------------------------
async def test_o5_pending_sync_presented_in_reconcile(tmp_path, monkeypatch):
_write_features(tmp_path)
_write_whitelist(tmp_path)
store = FakeStore(tmp_path)
_seed_s6_world(store, [(9101, "MES-WO-0001", "RUNNING", 50, 5)])
store.data["mesLinks"].append({ # 断连期离线落账的事实记录
"kind": "report", "woId": 9101, "externalWoId": "MES-WO-0001",
"track": "fixed", "progressPct": 50, "status": "RUNNING",
"syncedAt": "2026-09-05", "actor": "pi-fallback",
"syncStatus": "PENDING_SYNC"})
monkeypatch.setattr(fallback_lane, "_probe_mes_connectivity", lambda: "ok")
monkeypatch.setattr(
fallback_lane, "_fetch_external_wo",
lambda ext_id: {"id": ext_id, "status": "RUNNING",
"progressPct": 50, "qtyDone": 5})
fp_before = _fp(store.data)
reply = await fallback_lane.propose_reply(
store, "s1", _intent("MES 恢复了,对一下账",
p3Scenario="S6", p3Action="reconcile"),
runner=_stop_runner, config=_cfg(tmp_path))
assert reply is not None
assert "一致 1" in reply.text
assert "PENDING_SYNC" in reply.text and "1 笔" in reply.text
runs = [p for p in (tmp_path / "fb").iterdir() if p.is_dir() and p.name != "pi-home"]
assert len(runs) == 1
report = (runs[0] / "outbox" / "reconcile-report.md").read_text(encoding="utf-8")
assert "待同步(PENDING_SYNC)1 笔" in report
assert "本地待同步(PENDING_SYNC)" in report
recon = [e for e in store.data.get("auditEvents") or []
if e.get("action") == "agent.fallback.reconcile"]
assert recon and recon[-1]["rationale"].get("pendingSync") == 1
assert _fp(store.data) == fp_before # 对账纯读:世界零变更
# ---------------------------------------------------------------------------
# O-6:形态校验——offlineBooking 非 bool → 出卡闸显式拒绝(参数形态防线)
# ---------------------------------------------------------------------------
async def test_o6_offline_booking_non_bool_rejected(tmp_path, monkeypatch):
store, _client = _setup(tmp_path, monkeypatch, wo_ids=(9001,))
monkeypatch.setattr(fallback_lane, "_probe_mes_connectivity", lambda: "failed")
items = [{"woId": 9001, "progressPct": 80, "track": "fixed",
"offlineBooking": "yes"}]
reply, confirm_ids = await _stage(
store, tmp_path, _s6_plan(items), "MES 连不上了,今天的报工帮我补一下")
assert confirm_ids == []
assert "未通过校验" in reply.text
assert "offlineBooking 必须是 bool" in reply.text