aps-agent/tests/golden/test_fallback_p4_offline.py

692 lines
29 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.

# ============================================================
# P4 离线/降级 golden(Agent-H):O-01..O-10。
# 全部确定性:fake runner / FakeStore / tmp 隔离 / monkeypatch;
# 零真实 LLM、零网络、零真实 node/pi 子进程。
# 本文件复制既有 golden 的最小 helper,不新增共享测试模块。
# ============================================================
from __future__ import annotations
import copy
import json
import subprocess
import urllib.error
import urllib.request
from pathlib import Path
from typing import ClassVar
import pytest
from server.agent_core import fallback_highrisk, fallback_lane, harness
from server.agent_core.feature_flags import load_feature_flags
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()
old_cache = dict(fallback_lane._MODEL_CACHE)
fallback_lane._MODEL_CACHE.update({"model": None, "note": "", "ts": 0.0})
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)
fallback_lane._MODEL_CACHE.clear()
fallback_lane._MODEL_CACHE.update(old_cache)
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()
class _FakeResponse:
def __init__(self, body: bytes):
self._body = body
def read(self) -> bytes:
return self._body
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def _cfg(tmp_path: Path, **kw) -> fallback_lane.FallbackConfig:
return fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home"), **kw)
def _write_features(tmp_path: Path, features: dict | None = None) -> None:
doc = {"version": 1, "features": {"fallback": True} if features is None else features}
(tmp_path / "features.json").write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
def _write_whitelist(tmp_path: Path, doc) -> None:
path = tmp_path / "fallback-highrisk.json"
if isinstance(doc, str):
path.write_text(doc, encoding="utf-8")
else:
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
def _wl_doc(*, s4=True, s6=True, s7=True) -> dict:
scenarios = {
"S4": {"enabled": s4,
"intents": ["flex.simulate_due", "flex.compare",
"scenario.compare", "scenario.sensitivity"],
"roles": ["planner", "admin"]},
"S6": {"enabled": s6, "intents": ["mes.report"], "roles": ["planner", "admin"]},
"S7": {"enabled": s7,
"intents": ["agent.fallback.ops.config.apply",
"agent.fallback.policy.update"],
"roles": ["ops", "admin"]}}
return {"whitelistVersion": 1, "updatedAt": "2026-09-04T10:00:00",
"updatedBy": "offline-test", "scenarios": scenarios}
def _intent(query: str) -> IntentResult:
return IntentResult(intent="unknown", params={"query": query},
confidence=0.1, source="LLM")
def _fp(world: dict) -> str:
return harness.world_fingerprint(world)
def _fb_audits(store: FakeStore) -> list[dict]:
return [e for e in store.data.get("auditEvents") or []
if e.get("action") == "agent.fallback.propose"]
def _world_write_audits(store: FakeStore) -> list[dict]:
return [e for e in store.data.get("auditEvents") or []
if e.get("category") == "WORLD_WRITE"]
def _run_dirs(tmp_path: Path) -> list[Path]:
root = tmp_path / "fb"
if not root.is_dir():
return []
return [p for p in root.iterdir() if p.is_dir() and p.name != "pi-home"]
def _run_dir_of(tmp_path: Path) -> Path:
runs = _run_dirs(tmp_path)
assert len(runs) == 1
return runs[0]
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": []}
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": "status: success\n\n已生成计划。"}]}}
yield {"type": "agent_end", "messages": []}
return runner
async def _stage(store: FakeStore, tmp_path: Path, runner, query: str):
reply = await fallback_lane.propose_reply(
store, "s1", _intent(query), runner=runner, config=_cfg(tmp_path))
confirm_ids = []
if reply is not None and getattr(reply, "blocks", None):
confirm_ids = [b.props["confirmId"] for b in reply.blocks
if getattr(b, "type", "") == "confirm-card"]
return reply, confirm_ids
def _approve(store: FakeStore, confirm_id: str) -> str:
return execute_confirmed(store, confirm_id, approve=True, actor="offline-test")
def _s4_plan() -> dict:
return {"planVersion": 1, "scenario": "S4", "goal": "缺策略沙盒试排",
"steps": [{"seq": 1, "mode": "frozen", "intent": "flex.simulate_due",
"summary": "交期探测", "params": {"productCode": DEMO_PRODUCT,
"quantity": 10},
"constraints": {}, "expected": []}]}
def _p2_plan() -> dict:
rows = [{"customerName": "锐扬精密", "productCode": DEMO_PRODUCT,
"quantity": 10, "deliveryDate": "2026-09-20", "orderNo": "RY-9001"}]
return {"planVersion": 1, "scenario": "S3", "goal": "导入订单",
"steps": [{"seq": 1, "mode": "frozen", "intent": "import.commit",
"summary": "导入 1 行", "params": {
"batches": [{"kind": "orders", "rows": rows}]},
"constraints": {"kinds": ["orders"], "maxRows": 500},
"expected": [{"table": "salesOrders", "added": 1}]}]}
def _seed_s6_world(store: FakeStore, wo_id: int) -> None:
store.data.setdefault("workOrders", [])
store.data.setdefault("mesLinks", [])
store.data["workOrders"].append({
"id": wo_id, "mesExternalId": "MES-WO-0001", "status": "RUNNING",
"progressPct": 0, "qtyDone": 0, "productCode": DEMO_PRODUCT})
store.data["mesLinks"].append({
"kind": "dispatch", "woId": wo_id, "externalWoId": "MES-WO-0001",
"idemKey": "idem-1"})
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 _report_links(store: FakeStore) -> list[dict]:
return [l for l in store.data.get("mesLinks") or [] if l.get("kind") == "report"]
def _mirror_reports(client) -> list[dict]:
return client._load().get("reports") or []
# ---------------------------------------------------------------------------
# O-01:features 缺失/损坏/结构非法/非 bool → fallback=False,原路径零副作用
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("mode", ["missing", "corrupt", "wrong_top", "nonbool"])
async def test_o01_features_missing_or_bad_default_off(mode, tmp_path):
path = tmp_path / "features.json"
if mode == "corrupt":
path.write_text("{not json", encoding="utf-8")
elif mode == "wrong_top":
path.write_text(json.dumps({"version": 99, "features": {"fallback": True}}),
encoding="utf-8")
elif mode == "nonbool":
path.write_text(json.dumps(
{"version": 1, "features": {"fallback": "yes", "orders": "no"}}),
encoding="utf-8")
flags = load_feature_flags(str(path))
assert flags["features"]["fallback"]["enabled"] is False
store = FakeStore(tmp_path)
fp_before = _fp(store.data)
reply = await fallback_lane.propose_reply(
store, "s1", _intent("随便说说"), runner=_stop_runner, config=_cfg(tmp_path))
assert reply is None # 调用方原路径(零副作用)
assert _fb_audits(store) == []
assert _world_write_audits(store) == []
assert _run_dirs(tmp_path) == []
assert _fp(store.data) == fp_before
# ---------------------------------------------------------------------------
# O-02:无 LLM_BASE_URL / LLM_API_KEY → FallbackUnavailable 显式失败 + FAILED 审计
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("mode", ["both", "url_only", "key_only"])
async def test_o02_missing_llm_env_explicit_failure(mode, tmp_path, monkeypatch):
_write_features(tmp_path)
cli_path = tmp_path / "pi-cli.cjs"
cli_path.write_text("// fixture", encoding="utf-8")
monkeypatch.setattr(fallback_lane, "_resolve_node", lambda _bin: "node-fixture")
if mode != "url_only":
monkeypatch.delenv("LLM_BASE_URL", raising=False)
if mode == "url_only":
monkeypatch.setenv("LLM_BASE_URL", "https://llm.invalid/v1")
if mode != "key_only":
monkeypatch.delenv("LLM_API_KEY", raising=False)
if mode == "key_only":
monkeypatch.setenv("LLM_API_KEY", "secret")
popen_calls: list = []
monkeypatch.setattr(subprocess, "Popen",
lambda *a, **kw: popen_calls.append((a, kw)))
store = FakeStore(tmp_path)
reply = await fallback_lane.propose_reply(
store, "s1", _intent("随便说说"),
config=_cfg(tmp_path, pi_cli=str(cli_path)))
assert reply is not None
assert reply.text == "智能助手服务暂不可用,本次未执行任何操作。请稍后重试或联系管理员。"
audits = _fb_audits(store)
assert len(audits) == 1 and audits[0]["result"] == "FAILED"
assert "unavailable:" in audits[0]["rationale"]["stopReason"]
assert "无模型配置" in audits[0]["rationale"]["stopReason"]
assert popen_calls == [] # 配置失败不触子进程
assert _run_dirs(tmp_path) == []
assert _world_write_audits(store) == []
# ---------------------------------------------------------------------------
# O-03:GET /models HTTP 错 / 超时 / 空清单 → resolve 失败,unavailable 显式失败
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("mode", ["http_error", "timeout", "empty"])
async def test_o03_models_endpoint_unavailable_explicit_failure(
mode, tmp_path, monkeypatch):
_write_features(tmp_path)
cli_path = tmp_path / "pi-cli.cjs"
cli_path.write_text("// fixture", encoding="utf-8")
monkeypatch.setattr(fallback_lane, "_resolve_node", lambda _bin: "node-fixture")
monkeypatch.setenv("LLM_BASE_URL", "https://llm.invalid/v1")
monkeypatch.setenv("LLM_API_KEY", "secret")
monkeypatch.setenv("LLM_MODEL", "model-alpha")
def fake_urlopen(request, timeout=5):
if mode == "http_error":
raise urllib.error.HTTPError(
"https://llm.invalid/v1/models", 503, "Service Unavailable", {}, None)
if mode == "timeout":
raise TimeoutError("models endpoint timed out")
return _FakeResponse(b'{"data": []}')
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
popen_calls: list = []
monkeypatch.setattr(subprocess, "Popen",
lambda *a, **kw: popen_calls.append((a, kw)))
store = FakeStore(tmp_path)
reply = await fallback_lane.propose_reply(
store, "s1", _intent("随便说说"),
config=_cfg(tmp_path, pi_cli=str(cli_path)))
assert reply is not None
assert reply.text == "智能助手服务暂不可用,本次未执行任何操作。请稍后重试或联系管理员。"
audits = _fb_audits(store)
assert len(audits) == 1 and audits[0]["result"] == "FAILED"
assert "unavailable:模型协商失败" in audits[0]["rationale"]["stopReason"]
assert fallback_lane._MODEL_CACHE["model"] is None
assert popen_calls == []
assert _run_dirs(tmp_path) == []
assert _world_write_audits(store) == []
# ---------------------------------------------------------------------------
# O-04:LLM_MODEL 不在清单 → 取第一模型并记协商 note(锁现状)
# ---------------------------------------------------------------------------
def test_o04_model_not_in_catalog_picks_first_with_note(tmp_path, monkeypatch):
monkeypatch.setenv("LLM_BASE_URL", "https://llm.invalid/v1")
monkeypatch.setenv("LLM_API_KEY", "secret")
monkeypatch.setenv("LLM_MODEL", "ghost-model")
def fake_urlopen(request, timeout=5):
return _FakeResponse(b'{"data": [{"id": "model-alpha"}, {"id": "model-beta"}]}')
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
result = fallback_lane.resolve_model(_cfg(tmp_path))
assert result == "aps-fallback/model-alpha"
assert fallback_lane._MODEL_CACHE["model"] == result
note = fallback_lane._MODEL_CACHE["note"] or ""
assert "ghost-model" in note
assert "model-alpha" in note
assert "协商改用" in note
# ---------------------------------------------------------------------------
# O-05:node 缺失 → unavailable 显式失败,不触子进程
# ---------------------------------------------------------------------------
async def test_o05_node_missing_explicit_failure(tmp_path, monkeypatch):
cli_path = tmp_path / "pi-cli.cjs"
cli_path.write_text("// fixture", encoding="utf-8")
missing_node = str(tmp_path / "missing-node.exe")
assert fallback_lane._resolve_node(missing_node) is None
_write_features(tmp_path)
monkeypatch.setattr(fallback_lane, "_resolve_node", lambda _bin: None)
popen_calls: list = []
monkeypatch.setattr(subprocess, "Popen",
lambda *a, **kw: popen_calls.append((a, kw)))
store = FakeStore(tmp_path)
reply = await fallback_lane.propose_reply(
store, "s1", _intent("随便说说"),
config=_cfg(tmp_path, pi_cli=str(cli_path)))
assert reply is not None
assert reply.text == "智能助手服务暂不可用,本次未执行任何操作。请稍后重试或联系管理员。"
audits = _fb_audits(store)
assert len(audits) == 1 and audits[0]["result"] == "FAILED"
assert "node 不在 PATH" in audits[0]["rationale"]["stopReason"]
assert popen_calls == []
assert _run_dirs(tmp_path) == []
assert _world_write_audits(store) == []
# ---------------------------------------------------------------------------
# O-06:pi cli.js 缺失显式 unavailable;损坏现状延迟到 harness_error(不预判内容)
# ---------------------------------------------------------------------------
async def test_o06_pi_cli_missing_explicit_failure(tmp_path, monkeypatch):
_write_features(tmp_path)
popen_calls: list = []
monkeypatch.setattr(subprocess, "Popen",
lambda *a, **kw: popen_calls.append((a, kw)))
store = FakeStore(tmp_path)
reply = await fallback_lane.propose_reply(
store, "s1", _intent("随便说说"),
config=_cfg(tmp_path, pi_cli=str(tmp_path / "missing-cli.cjs")))
assert reply is not None
assert reply.text == "智能助手服务暂不可用,本次未执行任何操作。请稍后重试或联系管理员。"
audits = _fb_audits(store)
assert len(audits) == 1 and audits[0]["result"] == "FAILED"
assert "pi cli 不存在" in audits[0]["rationale"]["stopReason"]
assert popen_calls == []
assert _run_dirs(tmp_path) == []
assert _world_write_audits(store) == []
async def test_o06_pi_cli_corrupt_deferred_to_runtime_error_current_semantics(
tmp_path, monkeypatch):
# 产品现状:build_pi_runner 只做 is_file() 预检,不解析 pi cli 内容。
# 损坏文件不会在预检期被判 unavailable,而是延迟到 Popen/事件流阶段;
# 本用例锁该现状(harness_error + FAILED 审计 + 零世界写)。
_write_features(tmp_path)
cli_path = tmp_path / "corrupt-cli.cjs"
cli_path.write_text("this is not valid pi cli", encoding="utf-8")
monkeypatch.setattr(fallback_lane, "_resolve_node", lambda _bin: "node-fixture")
monkeypatch.setenv("LLM_BASE_URL", "https://llm.invalid/v1")
monkeypatch.setenv("LLM_API_KEY", "secret")
monkeypatch.setenv("LLM_MODEL", "model-alpha")
def fake_urlopen(request, timeout=5):
return _FakeResponse(b'{"data": [{"id": "model-alpha"}]}')
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
popen_calls: list = []
def broken_popen(*args, **kwargs):
popen_calls.append((args, kwargs))
raise OSError("cannot execute corrupt cli")
monkeypatch.setattr(subprocess, "Popen", broken_popen)
store = FakeStore(tmp_path)
fp_before = _fp(store.data)
reply = await fallback_lane.propose_reply(
store, "s1", _intent("随便说说"),
config=_cfg(tmp_path, pi_cli=str(cli_path), timeout_sec=5))
assert reply is not None
assert reply.text == "本次处理未完成,未执行任何操作。请稍后重试。"
audits = _fb_audits(store)
assert len(audits) == 1 and audits[0]["result"] == "FAILED"
assert audits[0]["rationale"]["stopReason"] == "harness_error"
assert len(popen_calls) == 1 # 损坏内容未被预检,走到 spawn 尝试
assert _fp(store.data) == fp_before # 世界仍零写
assert _world_write_audits(store) == []
# ---------------------------------------------------------------------------
# O-07:执行期 runner 异常(网络/API 错)→ harness_error,FAILED,零世界写
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("mode", ["network", "api"])
async def test_o07_runtime_error_harness_error_failed_zero_write(
mode, tmp_path):
_write_features(tmp_path)
store = FakeStore(tmp_path)
fp_before = _fp(store.data)
messages = {
"network": "network disconnected during execution",
"api": "upstream api returned 500 during execution",
}
def runner(task: str, work_dir: Path):
yield {"type": "tool_execution_start", "toolName": "read", "toolCallId": "t1"}
yield {"type": "tool_execution_end", "toolName": "read", "toolCallId": "t1",
"result": "snapshot"}
raise RuntimeError(messages[mode])
yield # pragma: no cover - 保持生成器形态
reply = await fallback_lane.propose_reply(
store, "s1", _intent("随便说说"), runner=runner, config=_cfg(tmp_path))
assert reply is not None
assert reply.text == "本次处理未完成,未执行任何操作。请稍后重试。"
audits = _fb_audits(store)
assert len(audits) == 1 and audits[0]["result"] == "FAILED"
assert audits[0]["rationale"]["stopReason"] == "harness_error"
assert not getattr(reply, "blocks", None)
assert _world_write_audits(store) == []
assert _fp(store.data) == fp_before
async def test_o07b_max_output_breaker_failed_zero_write(tmp_path):
_write_features(tmp_path)
store = FakeStore(tmp_path)
fp_before = _fp(store.data)
def huge_runner(task: str, work_dir: Path):
chunk = "x" * 2048
for _ in range(2):
yield {"type": "message_update", "delta": {"text": chunk}}
yield {"type": "message_end", "message": {"role": "assistant",
"stopReason": "stop", "content": [{"type": "text", "text": chunk}]}}
yield {"type": "agent_end", "messages": []}
reply = await fallback_lane.propose_reply(
store, "s1", _intent("随便说说"), runner=huge_runner,
config=_cfg(tmp_path, max_output_bytes=1024))
assert reply is not None
assert reply.text == "本次处理未完成,未执行任何操作。请稍后重试。"
audits = _fb_audits(store)
assert len(audits) == 1 and audits[0]["result"] == "FAILED"
assert audits[0]["rationale"]["stopReason"].startswith("breaker:max_output")
assert _world_write_audits(store) == []
assert _fp(store.data) == fp_before
# ---------------------------------------------------------------------------
# O-08:run 目录创建失败 → propose_reply 不抛,显式失败,不残留 run 目录
# ---------------------------------------------------------------------------
async def test_o08_run_dir_create_failure_returns_original(tmp_path, monkeypatch):
_write_features(tmp_path)
blocking = tmp_path / "blocked-fb"
blocking.write_text("occupied", encoding="utf-8")
monkeypatch.setenv("APS_FALLBACK_DIR", str(blocking))
store = FakeStore(tmp_path)
reply = await fallback_lane.propose_reply(
store, "s1", _intent("随便说说"), runner=_stop_runner, config=_cfg(tmp_path))
assert reply is not None
assert reply.text == "智能助手服务暂不可用,本次未执行任何操作。请稍后重试。"
assert _fb_audits(store) == [] # 未到完成审计
assert _world_write_audits(store) == []
assert _run_dirs(tmp_path) == [] # 无半成品 run 目录
assert blocking.is_file() # 原占用未被改写
# ---------------------------------------------------------------------------
# O-09:白名单缺失/损坏 → P3 全拒;P2 存量可用(fail-closed 与场景隔离)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("mode", ["missing", "corrupt"])
async def test_o09_whitelist_missing_or_corrupt_p3_denied_p2_ok(
mode, tmp_path):
_write_features(tmp_path)
if mode == "corrupt":
_write_whitelist(tmp_path, "{not a json")
loaded = fallback_highrisk.load_highrisk_whitelist()
assert loaded["ok"] is False
expected_fragment = "白名单文件缺失" if mode == "missing" else "JSON 解析失败"
assert expected_fragment in loaded["error"]
store = FakeStore(tmp_path)
fp_before = _fp(store.data)
reply, confirm_ids = await _stage(
store, tmp_path,
make_plan_runner(lambda _rd: _s4_plan()),
query="没有现成策略,帮我沙盒试排")
assert confirm_ids == []
assert "未通过校验" in reply.text
assert expected_fragment in reply.text
audits = _fb_audits(store)
assert audits and audits[-1]["result"] == "FAILED"
assert _world_write_audits(store) == []
assert _fp(store.data) == fp_before
# P2 不受白名单文件缺失影响(P1/P2 路径不依赖 fail-closed 白名单)
store_p2 = FakeStore(tmp_path)
reply_p2, confirm_p2 = await _stage(
store_p2, tmp_path,
make_plan_runner(lambda _rd: _p2_plan()),
query="把这份客户表格导进来")
assert len(confirm_p2) == 1
assert reply_p2.blocks[0].props["action"] == "agent.fallback.execute"
assert _world_write_audits(store_p2) == []
# ---------------------------------------------------------------------------
# O-10:MES 断连。offlineBooking + 断连事实双成立才本地落账;否则拒绝/回滚。
# ---------------------------------------------------------------------------
async def test_o10a_offline_booking_double_condition_local_ledger(tmp_path, monkeypatch):
_write_features(tmp_path)
_write_whitelist(tmp_path, _wl_doc())
store = FakeStore(tmp_path)
client = _seed_mes_mirror(tmp_path, monkeypatch, 1)
_seed_s6_world(store, 9001)
monkeypatch.setattr(fallback_lane, "_probe_mes_connectivity", lambda: "failed")
_reply, confirm_ids = await _stage(
store, tmp_path,
make_plan_runner(lambda _rd: _s6_plan([{
"woId": 9001, "progressPct": 100, "finish": True,
"track": "fixed", "offlineBooking": True}])),
query="MES 连不上了,帮我补录报工")
assert len(confirm_ids) == 1
msg = _approve(store, confirm_ids[0])
assert "执行完成" in msg
links = _report_links(store)
assert len(links) == 1
assert links[0]["syncStatus"] == "PENDING_SYNC"
assert _mirror_reports(client) == [] # 未同步外部 MES
audits = [e for e in store.data.get("auditEvents") or []
if e.get("action") == "mes.report" and e.get("category") == "INTEGRATION"]
assert len(audits) == 1
assert audits[0]["rationale"]["offlineBooking"] is True
assert audits[0]["rationale"]["syncStatus"] == "PENDING_SYNC"
async def test_o10b_mes_down_without_offline_declaration_fails_and_rolls_back(
tmp_path, monkeypatch):
_write_features(tmp_path)
_write_whitelist(tmp_path, _wl_doc())
store = FakeStore(tmp_path)
client = _seed_mes_mirror(tmp_path, monkeypatch, 1)
_seed_s6_world(store, 9001)
monkeypatch.setattr(fallback_lane, "_probe_mes_connectivity", lambda: "failed")
monkeypatch.setattr(client, "post_report",
lambda *a, **kw: (_ for _ in ()).throw(ConnectionError("MES down")))
_reply, confirm_ids = await _stage(
store, tmp_path,
make_plan_runner(lambda _rd: _s6_plan([{
"woId": 9001, "progressPct": 80, "track": "fixed"}])),
query="MES 连不上了,今天的报工帮我补一下")
assert len(confirm_ids) == 1 # 出卡允许,但没有离线声明
fp_before = _fp(store.data)
msg = _approve(store, confirm_ids[0])
assert "兜底执行失败" in msg
assert "自动回滚" in msg
assert _fp(store.data) == fp_before # 回滚后世界零变更
assert _report_links(store) == []
assert _mirror_reports(client) == []
writes = _world_write_audits(store)
assert len(writes) == 1 # 只允许 FAILED 总账(回滚后补写)
assert writes[0]["result"] == "FAILED"
assert writes[0]["rationale"]["rolledBack"] is True
assert writes[0]["rationale"]["rollbackVerified"] is True
@pytest.mark.parametrize(("mode", "probe", "offline_value", "expected_fragment"), [
("mes_online", "ok", True, "MES 当前连通"),
("non_bool", "failed", "yes", "offlineBooking 必须是 bool"),
])
async def test_o10c_offline_declaration_abuse_or_bad_shape_rejected(
mode, probe, offline_value, expected_fragment, tmp_path, monkeypatch):
_write_features(tmp_path)
_write_whitelist(tmp_path, _wl_doc())
store = FakeStore(tmp_path)
client = _seed_mes_mirror(tmp_path, monkeypatch, 1)
_seed_s6_world(store, 9001)
monkeypatch.setattr(fallback_lane, "_probe_mes_connectivity", lambda: probe)
fp_before = _fp(store.data)
reply, confirm_ids = await _stage(
store, tmp_path,
make_plan_runner(lambda _rd: _s6_plan([{
"woId": 9001, "progressPct": 80, "track": "fixed",
"offlineBooking": offline_value}])),
query="MES 连不上了,帮我补录报工")
assert confirm_ids == []
assert expected_fragment in reply.text
assert _fp(store.data) == fp_before
assert _report_links(store) == []
assert _mirror_reports(client) == []
assert _world_write_audits(store) == []