aps-agent/tests/e2e/pi_real_workbook_e2e.py

930 lines
44 KiB
Python

"""Real Pi CLI + real LLM + real workbook product-chat acceptance harness.
This module is intentionally opt-in. It never starts or calls the live web or
desktop services; all product calls go through an in-process FastAPI TestClient
and all persistence paths are redirected below one temporary artifact root.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import subprocess
import tempfile
import time
import traceback
import uuid
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from fastapi.testclient import TestClient
from tests.auth_provider import install_test_auth
from tests.workbook_acceptance import load_expectations, resolve_source
OPT_IN_ENV = "APS_REAL_PI_E2E"
ARTIFACT_ENV = "APS_REAL_PI_E2E_ARTIFACT_DIR"
REPRO_COMMAND = (
".\\scripts\\run-pi-real-workbook-e2e.ps1 "
"-SourcePath \"$env:ROUND87_SOURCE\" -ArtifactRoot \"<ARTIFACT_ROOT>\""
)
class RealPiE2EEnvironmentError(RuntimeError):
"""The opt-in run is missing a required real-Pi/runtime dependency."""
def _sha256_bytes(raw: bytes) -> str:
return hashlib.sha256(raw).hexdigest()
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _json_text(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, indent=2, default=str)
def _read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
if not path.is_file():
return []
rows: list[dict[str, Any]] = []
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line:
continue
try:
value = json.loads(line)
except json.JSONDecodeError:
rows.append({"parseError": line})
continue
rows.append(value if isinstance(value, dict) else {"value": value})
return rows
def _safe_name(value: str) -> str:
return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-") or "step"
def _parse_sse(raw: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
chunks: list[dict[str, Any]] = []
events: list[dict[str, Any]] = []
for line in raw.splitlines():
if line.startswith("data:"):
payload_text = line[5:].lstrip()
record: dict[str, Any] = {"raw": line}
try:
event = json.loads(payload_text)
except json.JSONDecodeError as exc:
record["parseError"] = str(exc)
else:
record["event"] = event
if isinstance(event, dict):
events.append(event)
chunks.append(record)
elif line.strip():
chunks.append({"raw": line})
return chunks, events
def _block(result: ChatResult, block_type: str) -> dict[str, Any] | None:
return next((row for row in result.blocks if row.get("type") == block_type), None)
@dataclass
class PiRunEvidence:
run_id: str
path: Path
inventory: dict[str, dict[str, Any]]
events: list[dict[str, Any]]
calls: list[dict[str, Any]]
result: dict[str, Any] | None
chat_tools: list[dict[str, Any]]
def to_json(self) -> dict[str, Any]:
return {
"runId": self.run_id,
"path": str(self.path),
"inventory": self.inventory,
"events": self.events,
"calls": self.calls,
"result": self.result,
"chatTools": self.chat_tools,
}
@dataclass
class ChatResult:
label: str
prompt: str
attempt: int
status_code: int
raw_sse: str
sse_chunks: list[dict[str, Any]]
events: list[dict[str, Any]]
blocks: list[dict[str, Any]]
text: str
errors: list[str]
new_run_ids: list[str]
pi_runs: list[PiRunEvidence] = field(default_factory=list)
def to_json(self) -> dict[str, Any]:
return {
"label": self.label,
"prompt": self.prompt,
"attempt": self.attempt,
"statusCode": self.status_code,
"text": self.text,
"errors": self.errors,
"blocks": self.blocks,
"newRunIds": self.new_run_ids,
"sseChunkCount": len(self.sse_chunks),
}
class RealPiWorkbookE2E:
"""Run one isolated product-chat acceptance flow with the real Pi runner."""
def __init__(self, monkeypatch: Any, *, source_path: str | Path | None = None,
artifact_base: str | Path | None = None) -> None:
if os.environ.get(OPT_IN_ENV) != "1":
raise RealPiE2EEnvironmentError(
f"{OPT_IN_ENV}=1 is required; ordinary pytest must not invoke the real model."
)
self.monkeypatch = monkeypatch
self.repo_root = Path(__file__).resolve().parents[2]
self.expectations = load_expectations()
self.source_path_override = Path(source_path).expanduser() if source_path is not None else None
configured_base = artifact_base or os.environ.get(ARTIFACT_ENV)
self.artifact_base = (
Path(configured_base).expanduser().resolve()
if configured_base
else Path(tempfile.mkdtemp(prefix="aps-pi-real-e2e-")).resolve()
)
self.artifact_base.mkdir(parents=True, exist_ok=True)
self.artifact_root = self.artifact_base / "pending-source-resolution"
self.artifact_root.mkdir(parents=True, exist_ok=True)
self.evidence_dir = self.artifact_root / "evidence"
self.evidence_dir.mkdir(parents=True, exist_ok=True)
self.source: Path | None = None
self.source_sha_before = ""
self.source_sha_after: str | None = None
self.client: TestClient | None = None
self.project_id = ""
self.session_id = ""
self.pi_cli: Path | None = None
self.pi_cli_sha256 = ""
self.node_bin = ""
self.node_version = ""
self.steps: list[dict[str, Any]] = []
self.pi_runs: list[dict[str, Any]] = []
self.assertions: list[str] = []
self.persisted_messages: list[dict[str, Any]] = []
@property
def fallback_root(self) -> Path:
configured = (os.environ.get("APS_FALLBACK_DIR") or "").strip()
return Path(configured).resolve() if configured else self.artifact_root / "runtime" / "fallback"
def _record_assertion(self, text: str) -> None:
self.assertions.append(text)
def _redact(self, text: str) -> str:
secret = (os.environ.get("LLM_API_KEY") or "").strip()
return text.replace(secret, "***REDACTED***") if secret else text
def _write_json(self, path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(_json_text(payload), encoding="utf-8")
@staticmethod
def _reset_runtime_caches() -> None:
"""Reset path-bound singletons before and after an isolated run."""
try:
from server.db.database import reset_engine
reset_engine()
except Exception: # noqa: BLE001, S110 - best-effort cache reset before isolation
pass
try:
from server.state import store as world_store
with world_store._stores_lock:
world_store._stores.clear()
except Exception: # noqa: BLE001, S110 - best-effort cache reset before isolation
pass
try:
from server.state.branches import reset_branch_stores
reset_branch_stores()
except Exception: # noqa: BLE001, S110 - best-effort cache reset before isolation
pass
try:
from server.agent_core.plan_runtime import reset_plan_stores
reset_plan_stores()
except Exception: # noqa: BLE001, S110 - best-effort cache reset before isolation
pass
try:
from server.agent_core.mcp_bus import reset_mcp_bus
reset_mcp_bus()
except Exception: # noqa: BLE001, S110 - best-effort cache reset before isolation
pass
try:
from server.knowledge import assets, embedding, preferences
with assets._stores_lock:
assets._stores.clear()
with preferences._stores_lock:
preferences._stores.clear()
with embedding._stores_lock:
embedding._stores.clear()
embedding._provider = None
except Exception: # noqa: BLE001, S110 - best-effort cache reset before isolation
pass
try:
from server.agent_core import fallback_lane
fallback_lane._MODEL_CACHE.update({"model": None, "note": "", "ts": 0.0})
except Exception: # noqa: BLE001, S110 - best-effort cache reset before isolation
pass
def _configure_environment(self) -> None:
runtime = self.artifact_root / "runtime"
data = runtime / "data"
home = runtime / "home"
for path in (
runtime, data, home, runtime / "fallback", runtime / "pi-home",
data / "audit-ledger", data / "audit-mirror", data / "branches",
):
path.mkdir(parents=True, exist_ok=True)
path_env: dict[str, Path] = {
"APS_HOME": home,
"APS_DATA_DIR": data,
"APS_DB_PATH": data / "master.db",
"APS_WORLD_PATH": data / "world.json",
"APS_PROJECTS_PATH": runtime / "sessions" / "workspace.json",
"APS_KNOWLEDGE_PATH": data / "knowledge.json",
"APS_CHECKPOINT_PATH": data / "checkpoints.json",
"APS_PREFERENCE_PATH": data / "preferences.json",
"APS_EMBEDDINGS_PATH": data / "embeddings.json",
"APS_APPROVAL_PATH": data / "approvals.json",
"APS_AUDIT_LEDGER_DIR": data / "audit-ledger",
"APS_AUDIT_MIRROR_DIR": data / "audit-mirror",
"APS_AUTOMATION_STATE_PATH": data / "automation.json",
"APS_MCP_BUS_PATH": data / "mcp-bus.json",
"APS_BRANCH_PATH": data / "branches.json",
"APS_BRANCH_DIR": data / "branches",
"APS_GOLDEN_CACHE": data / "golden-tests.json",
"APS_MESH_PATH": data / "mesh.json",
"APS_FALLBACK_DIR": runtime / "fallback",
"APS_FALLBACK_PI_HOME": runtime / "pi-home",
}
for name, value in path_env.items():
self.monkeypatch.setenv(name, str(value))
for name in (
"APS_DATABASE_URL", "APS_DB_DISABLED", "APS_SEED_PACK",
"APS_WORKBOOK_PROFILE_DIR", "MES_HTTP_BASE_URL", "APS_FALLBACK_MODEL",
):
self.monkeypatch.delenv(name, raising=False)
self.monkeypatch.setenv("APS_MODE", "web")
self.monkeypatch.setenv("APS_AUTH_ENABLED", "1")
self.monkeypatch.setenv("APS_SEED_DEMO", "0")
self.monkeypatch.setenv("APS_APPROVAL_BACKEND", "file")
self.monkeypatch.setenv("APS_AUTH_COOKIE_SECURE", "0")
self.monkeypatch.setenv("APS_AUTOMATION_DRIVER", "0")
self.monkeypatch.setenv("APS_AUDIT_MIRROR", "1")
self.monkeypatch.setenv("EMBEDDING_PROVIDER", "off")
self.monkeypatch.setenv("ROUND87_REQUIRED", "1")
self.monkeypatch.setenv("ROUND87_SOURCE", str(self.source))
self.monkeypatch.setenv("APS_REAL_PI_E2E_ARTIFACT_DIR", str(self.artifact_base))
self.monkeypatch.setenv("APS_FALLBACK_TIMEOUT_SEC", os.environ.get("APS_FALLBACK_TIMEOUT_SEC", "300"))
self.monkeypatch.setenv("APS_FALLBACK_EXEC_TIMEOUT_SEC", os.environ.get("APS_FALLBACK_EXEC_TIMEOUT_SEC", "300"))
self.monkeypatch.setenv("APS_FALLBACK_MAX_STEPS", os.environ.get("APS_FALLBACK_MAX_STEPS", "50"))
self.monkeypatch.setenv("APS_FALLBACK_EXEC_MAX_STEPS", os.environ.get("APS_FALLBACK_EXEC_MAX_STEPS", "50"))
def _resolve_runtime_paths(self) -> None:
configured_cli = (os.environ.get("APS_FALLBACK_PI_CLI") or "").strip()
candidates: list[Path] = []
if configured_cli:
candidates.append(Path(configured_cli).expanduser())
candidates.append(
self.repo_root / "poc" / "pi-fallback" / "runtime" / "node_modules"
/ "@mariozechner" / "pi-coding-agent" / "dist" / "cli.js"
)
cli: Path | None = None
for candidate in candidates:
resolved = candidate if candidate.is_absolute() else (self.repo_root / candidate)
resolved = resolved.resolve()
if resolved.is_file():
cli = resolved
break
if cli is None:
raise RealPiE2EEnvironmentError(
"Real Pi CLI not found. Set APS_FALLBACK_PI_CLI to pi-coding-agent/dist/cli.js."
)
self.pi_cli = cli
self.pi_cli_sha256 = _sha256_file(cli)
self.monkeypatch.setenv("APS_FALLBACK_PI_CLI", str(cli))
configured_node = (os.environ.get("APS_FALLBACK_NODE") or "").strip()
if configured_node:
node_candidate = Path(configured_node).expanduser()
if not node_candidate.is_absolute():
node_candidate = (self.repo_root / node_candidate).resolve()
node = str(node_candidate) if node_candidate.is_file() else shutil.which(configured_node)
else:
node = shutil.which("node")
if not node:
raise RealPiE2EEnvironmentError("Node.js is required for the real Pi CLI but was not found.")
self.node_bin = str(node)
self.monkeypatch.setenv("APS_FALLBACK_NODE", self.node_bin)
version = subprocess.run(
[self.node_bin, "--version"], capture_output=True, text=True, timeout=15, check=False,
)
if version.returncode != 0:
raise RealPiE2EEnvironmentError(f"Node.js version probe failed: {version.stderr.strip()}")
self.node_version = version.stdout.strip()
if not (os.environ.get("LLM_BASE_URL") or "").strip():
raise RealPiE2EEnvironmentError("LLM_BASE_URL is required for the real Pi LLM run.")
if not (os.environ.get("LLM_API_KEY") or "").strip():
raise RealPiE2EEnvironmentError("LLM_API_KEY is required for the real Pi LLM run.")
def prepare(self) -> None:
selected = self.source_path_override if self.source_path_override is not None else os.environ.get("ROUND87_SOURCE")
source = resolve_source(selected, required=True, expectations=self.expectations)
if source is None:
raise RealPiE2EEnvironmentError("ROUND87_SOURCE did not resolve to a workbook.")
self.source = source
self.source_sha_before = _sha256_file(source)
stamp = time.strftime("%Y%m%d-%H%M%S")
self.artifact_root = self.artifact_base / f"{stamp}-{uuid.uuid4().hex[:8]}-{self.source_sha_before[:12]}"
self.artifact_root.mkdir(parents=True, exist_ok=True)
self.evidence_dir = self.artifact_root / "evidence"
self.evidence_dir.mkdir(parents=True, exist_ok=True)
self._reset_runtime_caches()
self._configure_environment()
self._resolve_runtime_paths()
from server.agent_core import harness
harness.configure_approval_store(str(self.artifact_root / "runtime" / "data" / "approvals.json"))
self._reset_runtime_caches()
install_test_auth(self.monkeypatch, "real-pi-workbook-acceptance")
from server.gateway.app import create_app
self.client = TestClient(create_app(), raise_server_exceptions=False)
self.client.__enter__()
login = self.client.post(
"/api/auth/login",
json={"method": "password", "username": "planner", "password": "test"},
)
if login.status_code != 200:
raise AssertionError(f"test login failed: {login.status_code} {login.text}")
created = self.client.post("/api/projects", json={"name": f"真实 Pi 工作簿验收 {self.source.stem}"})
if created.status_code != 200:
raise AssertionError(f"project create failed: {created.status_code} {created.text}")
created_json = created.json()
self.project_id = str(created_json["project"]["id"])
self.session_id = str(created_json["session"]["id"])
raw = self.source.read_bytes()
if _sha256_bytes(raw) != self.source_sha_before:
raise AssertionError("source workbook changed before upload")
upload = self.client.post(
f"/api/projects/{self.project_id}/files/upload",
files=[(
"files",
(self.source.name, raw,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
)],
)
if upload.status_code != 200:
raise AssertionError(f"workbook upload failed: {upload.status_code} {upload.text}")
if self.source.name not in (upload.json().get("saved") or []):
raise AssertionError(f"upload response did not include the source workbook: {upload.text}")
self._record_assertion("real workbook uploaded through /api/projects/{id}/files/upload")
def close(self) -> None:
try:
if self.client is not None:
self.client.__exit__(None, None, None)
finally:
self.client = None
self._reset_runtime_caches()
def _get_json(self, path: str) -> dict[str, Any]:
if self.client is None:
raise RuntimeError("client is not ready")
response = self.client.get(path)
if response.status_code != 200:
raise AssertionError(f"GET {path} failed: {response.status_code} {response.text}")
value = response.json()
if not isinstance(value, dict):
raise TypeError(f"GET {path} did not return an object")
return value
def _run_ids(self) -> set[str]:
root = self.fallback_root
if not root.is_dir():
return set()
return {row.name for row in root.iterdir() if row.is_dir() and row.name.startswith("fb-")}
def _capture_pi_runs(self, new_run_ids: set[str]) -> list[PiRunEvidence]:
captured: list[PiRunEvidence] = []
root = self.fallback_root
for run_id in sorted(new_run_ids):
run_dir = root / run_id
inventory: dict[str, dict[str, Any]] = {}
for path in sorted(run_dir.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(run_dir).as_posix()
inventory[rel] = {
"path": str(path), "size": path.stat().st_size, "sha256": _sha256_file(path),
}
chat_tools: list[dict[str, Any]] = []
tools_dir = run_dir / "outbox" / "chat-tools"
if tools_dir.is_dir():
for request_path in sorted(tools_dir.glob("*.json")):
if request_path.name.endswith(".result.json"):
continue
result_path = tools_dir / f"{request_path.stem}.result.json"
request_value: Any = None
result_value: Any = None
try:
request_value = _read_json(request_path)
except (OSError, ValueError) as exc:
request_value = {"parseError": str(exc)}
if result_path.is_file():
try:
result_value = _read_json(result_path)
except (OSError, ValueError) as exc:
result_value = {"parseError": str(exc)}
chat_tools.append({
"requestFile": str(request_path),
"resultFile": str(result_path) if result_path.is_file() else None,
"request": request_value,
"result": result_value,
})
result_path = run_dir / "result.json"
result_value: dict[str, Any] | None = None
if result_path.is_file():
try:
result_value = _read_json(result_path)
except (OSError, ValueError) as exc:
result_value = {"parseError": str(exc)}
evidence = PiRunEvidence(
run_id=run_id,
path=run_dir,
inventory=inventory,
events=_read_jsonl(run_dir / "events.jsonl"),
calls=_read_jsonl(run_dir / "calls.jsonl"),
result=result_value,
chat_tools=chat_tools,
)
self._write_json(self.evidence_dir / "pi-runs" / f"{run_id}.json", evidence.to_json())
self.pi_runs.append({"runId": run_id, "path": str(run_dir), "chatToolCount": len(chat_tools)})
captured.append(evidence)
return captured
def chat(self, prompt: str, *, label: str, attempt: int = 1) -> ChatResult:
if self.client is None:
raise RuntimeError("client is not ready")
before = self._run_ids()
response = self.client.post(
"/api/chat",
json={"text": prompt, "sessionId": self.session_id, "projectId": self.project_id},
)
raw = response.text
chunks, events = _parse_sse(raw)
blocks = [
row["block"] for row in events
if row.get("type") == "block" and isinstance(row.get("block"), dict)
]
text = "".join(str(row.get("text") or "") for row in events if row.get("type") == "token")
errors = [str(row.get("message") or "") for row in events if row.get("type") == "error"]
new_ids = self._run_ids() - before
result = ChatResult(
label=label, prompt=prompt, attempt=attempt, status_code=response.status_code,
raw_sse=raw, sse_chunks=chunks, events=events, blocks=blocks, text=text,
errors=errors, new_run_ids=sorted(new_ids), pi_runs=self._capture_pi_runs(new_ids),
)
self._write_json(
self.evidence_dir / "chats" / f"{_safe_name(label)}-{attempt}.json", result.to_json(),
)
(self.evidence_dir / "chats" / f"{_safe_name(label)}-{attempt}.sse.txt").write_text(
raw, encoding="utf-8",
)
self.steps.append(result.to_json())
return result
def chat_until(self, prompt: str, predicate: Callable[[ChatResult], bool], *,
label: str, attempts: int = 2) -> ChatResult:
last: ChatResult | None = None
for attempt in range(1, attempts + 1):
last = self.chat(prompt, label=label, attempt=attempt)
if predicate(last):
return last
if last.new_run_ids and not last.errors:
break
assert last is not None
return last
def _fail_with_chat(self, result: ChatResult, message: str) -> None:
tail = result.text[-600:] if result.text else result.raw_sse[-600:]
raise AssertionError(f"{message}; status={result.status_code}; tail={tail!r}")
def _assert_initial_world(self) -> None:
master = self._get_json("/api/master")
flex = self._get_json("/api/flex/world")
if master.get("materials") != []:
raise AssertionError("isolated project unexpectedly contains master materials")
if flex.get("orders") != [] or flex.get("latestVersion") is not None or flex.get("workOrders") != []:
raise AssertionError("isolated project unexpectedly contains scheduling state")
self._record_assertion("isolated APS world starts empty")
def _failure_injection_then_retry(self) -> ChatResult:
"""Prove fail-closed recovery before the real model analysis."""
key = (os.environ.get("LLM_API_KEY") or "").strip()
if not key:
raise RealPiE2EEnvironmentError("LLM_API_KEY must be present before failure injection")
failed: ChatResult | None = None
try:
self.monkeypatch.delenv("LLM_API_KEY", raising=False)
failed = self.chat("分析一下数据文件", label="failure-injection-missing-llm-key")
finally:
self.monkeypatch.setenv("LLM_API_KEY", key)
if failed is None:
raise AssertionError("failure injection did not return a chat result")
if failed.errors:
raise AssertionError(f"failure injection leaked an SSE error: {failed.errors}")
if failed.new_run_ids:
raise AssertionError("failure injection unexpectedly started a Pi run")
if _block(failed, "folder-pack") is not None or _block(failed, "confirm-card") is not None:
raise AssertionError("failure injection unexpectedly produced a business review card")
if not any(token in failed.text for token in ("暂不可用", "未执行")):
self._fail_with_chat(failed, "failure injection did not fail closed with a visible message")
master = self._get_json("/api/master")
flex = self._get_json("/api/flex/world")
if master.get("materials") != [] or flex.get("orders") not in ([], None):
raise AssertionError("failure injection wrote business state")
self._record_assertion("missing LLM key failed closed; business world unchanged; then key restored")
analysis = self.chat_until(
"分析一下数据文件",
lambda row: _block(row, "folder-pack") is not None and _block(row, "confirm-card") is not None,
label="real-pi-analysis",
attempts=2,
)
folder = _block(analysis, "folder-pack")
card = _block(analysis, "confirm-card")
if analysis.errors:
self._fail_with_chat(analysis, "real Pi analysis returned an SSE error")
if folder is None or card is None:
self._fail_with_chat(analysis, "real Pi analysis did not return folder-pack + confirmation card")
props = folder["props"]
expected_counts = self.expectations["entityCounts"]
if props.get("summary") != self.expectations["reviewCounts"]:
raise AssertionError(f"folder-pack summary mismatch: {props.get('summary')!r}")
entity_counts = props.get("entityCounts") or {}
for key_name in ("orders", "materials", "routing", "equipment", "personnel", "sandboxOrders"):
if entity_counts.get(key_name) != expected_counts.get(key_name):
raise AssertionError(
f"folder-pack entity count {key_name}={entity_counts.get(key_name)!r}, "
f"oracle={expected_counts.get(key_name)!r}"
)
if len(props.get("sheetSummary") or []) != len(self.expectations["sheets"]):
raise AssertionError("folder-pack sheet count differs from the independent oracle")
source_sha = ((props.get("source") or {}).get("sha256") or "").lower()
if source_sha != self.expectations["sourceSha256"].lower():
raise AssertionError("folder-pack source SHA does not match the independent oracle")
if card["props"].get("action") != "import.commit":
raise AssertionError(f"unexpected adoption action: {card['props'].get('action')!r}")
if props.get("adopted") is not False:
raise AssertionError("folder-pack must remain unreviewed before confirmation")
if _block(analysis, "flex-schedule") is not None:
raise AssertionError("real Pi must not schedule before adoption")
if self._get_json("/api/master").get("materials") != []:
raise AssertionError("analysis wrote master data before confirmation")
self._record_assertion(
"real Pi folder.analyze returned oracle counts/SHA and an import.commit confirmation card"
)
return analysis
def _confirm_adoption(self, analysis: ChatResult) -> dict[str, Any]:
card = _block(analysis, "confirm-card")
if card is None:
raise AssertionError("analysis confirmation card is missing")
confirm_id = str(card["props"].get("confirmId") or "")
if not confirm_id:
raise AssertionError("confirmation card has no confirmId")
if self.client is None:
raise RuntimeError("client is not ready")
response = self.client.post(
"/api/actions/confirm",
json={"sessionId": self.session_id, "confirmId": confirm_id, "approve": True},
)
if response.status_code != 200:
raise AssertionError(f"adoption confirmation failed: {response.status_code} {response.text}")
payload = response.json()
if payload.get("errorCode"):
raise AssertionError(f"adoption confirmation returned errorCode={payload['errorCode']}: {payload}")
if payload.get("refresh") is not True:
raise AssertionError(f"adoption did not request product refresh: {payload}")
self._write_json(self.evidence_dir / "adoption-confirm.json", payload)
expected_world = self.expectations["worldCounts"]
master = self._get_json("/api/master")
flex = self._get_json("/api/flex/world")
if len(master.get("materials") or []) != expected_world["flexMaterials"]:
raise AssertionError("adopted material count differs from the oracle")
if len(master.get("equipment") or []) != expected_world["flexEquipment"]:
raise AssertionError("adopted equipment count differs from the oracle")
routing_steps = sum(len(row.get("steps") or []) for row in master.get("routings") or [])
if routing_steps != expected_world["flexRoutings"]:
raise AssertionError("adopted routing step count differs from the oracle")
if len(flex.get("orders") or []) != expected_world["flexOrders"]:
raise AssertionError("adopted formal order count differs from the oracle")
if flex.get("latestVersion") is not None or flex.get("workOrders") != []:
raise AssertionError("adoption unexpectedly created a schedule")
self._record_assertion(
"confirmation adopted the workbook; material/equipment/routing/order counts match oracle"
)
return payload
def _schedule(self) -> ChatResult:
result = self.chat_until(
"立即排产",
lambda row: _block(row, "flex-schedule") is not None,
label="real-pi-immediate-schedule",
attempts=2,
)
if result.errors:
self._fail_with_chat(result, "real Pi scheduling returned an SSE error")
if _block(result, "flex-schedule") is None:
self._fail_with_chat(result, "real Pi scheduling did not return flex-schedule")
self._record_assertion("real Pi selected the scheduling tool for the natural-language command 立即排产")
return result
def _assert_schedule_and_no_dispatch(self, schedule: ChatResult) -> None:
block = _block(schedule, "flex-schedule")
if block is None:
raise AssertionError("flex-schedule block is missing")
props = block["props"]
stats = props.get("stats") or {}
expected_orders = int(self.expectations["entityCounts"]["orders"])
formal = {str(value) for value in self.expectations["formalOrderNos"]}
sandbox = {str(value) for value in self.expectations["sandboxOrderNos"]}
plan = self.expectations["trialPlan"]
scheduled_expected = {str(value) for value in plan["scheduledOrderNos"]}
blocked_expected = {str(value) for value in plan["blockedOrderNos"]}
if props.get("trialOnly") is not True:
raise AssertionError(f"flex-schedule trialOnly must be true: {props.get('trialOnly')!r}")
if props.get("productionReady") is not False:
raise AssertionError(f"flex-schedule productionReady must be false: {props.get('productionReady')!r}")
if stats.get("orderCount") != expected_orders:
raise AssertionError(f"flex-schedule orderCount mismatch: {stats.get('orderCount')!r}")
if stats.get("blockedOrderCount") != len(blocked_expected):
raise AssertionError(f"flex-schedule blockedOrderCount mismatch: {stats.get('blockedOrderCount')!r}")
if int(stats.get("woCount") or 0) <= 0:
raise AssertionError(f"trial draft must carry work orders: {stats.get('woCount')!r}")
if int(stats.get("vlCount") or 0) != len(scheduled_expected):
raise AssertionError(f"flex-schedule virtual line count mismatch: {stats.get('vlCount')!r}")
if props.get("downloadUrl") or props.get("filename"):
raise AssertionError("trial schedule must not expose a published/downloadable production file")
lines = props.get("lines") or []
rows = {str(row.get("orderNo")): row for row in lines if row.get("orderNo")}
if set(rows) != formal:
raise AssertionError(f"flex-schedule line orders differ from formal oracle: {sorted(rows)}")
if set(rows) & sandbox:
raise AssertionError("sandbox order leaked into the formal trial schedule")
scheduled = {order_no for order_no, row in rows.items() if row.get("status") == "scheduled"}
blocked = {order_no for order_no, row in rows.items() if row.get("status") == "blocked"}
if scheduled != scheduled_expected:
raise AssertionError(f"trial scheduled orders differ from oracle: {sorted(scheduled)}")
if blocked != blocked_expected:
raise AssertionError(f"trial blocked orders differ from oracle: {sorted(blocked)}")
for order_no in sorted(blocked):
row = rows[order_no]
if not str(row.get("blockReason") or "").strip():
raise AssertionError(f"blocked order has no real reason: {row}")
if row.get("steps"):
raise AssertionError(f"blocked order carries schedule steps: {row}")
for order_no in sorted(scheduled):
if not rows[order_no].get("steps"):
raise AssertionError(f"scheduled order carries no work-order steps: {rows[order_no]}")
conflicts = props.get("conflicts") or []
assumptions = [item for item in conflicts if item.get("severity") == plan["assumptionSeverity"]]
if {str(item.get("type")) for item in assumptions} != set(plan["assumptionTypes"]):
raise AssertionError(f"trial assumptions differ from oracle: {sorted(str(i.get('type')) for i in assumptions)}")
for item in assumptions:
if not str(item.get("description") or "").startswith(plan["assumptionDescriptionPrefix"]):
raise AssertionError(f"trial assumption is not marked as such: {item}")
if not str(item.get("suggestion") or item.get("suggestedSolution") or "").strip():
raise AssertionError(f"trial assumption has no follow-up advice: {item}")
blocking = [item for item in conflicts if item.get("severity") == "CRITICAL"]
blocking_types = {str(item.get("type")) for item in blocking}
if not blocking or not blocking_types <= set(plan["blockedReasonTypes"]):
raise AssertionError(f"blocking conflict types differ from oracle: {sorted(blocking_types)}")
if {str(item.get("orderNo")) for item in blocking} != blocked_expected:
raise AssertionError(f"blocking conflicts do not match blocked orders: {blocking}")
self._record_assertion(
f"trial schedule: {len(scheduled)} scheduled / {len(blocked)} blocked on real shortage, "
f"{len(assumptions)} explicit trial assumptions, {stats.get('woCount')} draft work orders"
)
world = self._get_json("/api/flex/world")
latest = world.get("latestVersion")
if not isinstance(latest, dict):
raise TypeError("flex world has no latest schedule version")
if latest.get("status") != "DRAFT":
raise AssertionError(f"latest schedule version is not DRAFT: {latest.get('status')!r}")
if int(latest.get("woCount") or 0) != int(stats.get("woCount") or 0):
raise AssertionError(f"latest version work order count differs from the block: {latest}")
if int(latest.get("vlCount") or 0) != int(stats.get("vlCount") or 0):
raise AssertionError(f"latest version virtual line count differs from the block: {latest}")
if latest.get("publishedAt") or latest.get("dispatchedAt"):
raise AssertionError("latest draft version was published or dispatched")
world_work_orders = world.get("workOrders") or []
if len(world_work_orders) != int(stats.get("woCount") or 0):
raise AssertionError(f"flex world work order count differs from the block: {len(world_work_orders)}")
if any(str(row.get("status") or "") != "PENDING" for row in world_work_orders):
raise AssertionError("trial work orders must all stay PENDING")
world_orders = {str(row.get("orderNo")) for row in world.get("orders") or []}
if world_orders != formal:
raise AssertionError(f"flex world orders differ from formal oracle: {sorted(world_orders)}")
if world_orders & sandbox:
raise AssertionError("sandbox order leaked into flex world")
if not world.get("conflicts"):
raise AssertionError("flex world has no real blocking conflicts")
mes = self._get_json("/api/mes/execution")
if mes.get("total") != 0 or mes.get("rows") != []:
raise AssertionError(f"MES execution is not empty: {mes}")
pending = self._get_json("/api/gov/pending").get("pending") or []
forbidden = {"schedule.publish", "mes.dispatch"}
if any(str(row.get("action") or "") in forbidden for row in pending):
raise AssertionError("a publish or MES dispatch confirmation is pending")
audit = self._get_json("/api/gov/audit?limit=200")
audit_actions = [str(row.get("action") or "") for row in audit.get("events") or []]
leaked = sorted(set(audit_actions) & forbidden)
if leaked:
raise AssertionError(f"publish/MES actions leaked into audit: {leaked}")
if any(row.get("mesExternalId") for row in world.get("workOrders") or []):
raise AssertionError("work order carries a MES external id")
self._record_assertion("latest version remains DRAFT; no publish, MES execution, or dispatch action")
self._write_json(self.evidence_dir / "final-flex-world.json", world)
self._write_json(self.evidence_dir / "final-mes-execution.json", mes)
self._write_json(self.evidence_dir / "final-gov-pending.json", {"pending": pending})
self._write_json(self.evidence_dir / "final-gov-audit.json", audit)
def _replace_and_reload(self, messages: list[dict[str, Any]], expected_blocks: list[str]) -> list[dict[str, Any]]:
if self.client is None:
raise RuntimeError("client is not ready")
response = self.client.put(
f"/api/sessions/{self.session_id}/messages",
json={"messages": messages},
)
if response.status_code != 200:
raise AssertionError(f"message persistence failed: {response.status_code} {response.text}")
loaded = self._get_json(f"/api/sessions/{self.session_id}/messages")
restored = loaded.get("messages") or []
if len(restored) != len(messages):
raise AssertionError(f"message count mismatch after reload: {len(restored)} != {len(messages)}")
for block_type in expected_blocks:
if not any(
isinstance(block, dict) and block.get("type") == block_type
for message in restored
for block in (message.get("blocks") or [])
):
raise AssertionError(f"persisted message lost block type {block_type}")
self.persisted_messages = messages
self._write_json(self.evidence_dir / "session-messages.json", loaded)
return restored
def _persist_after_analysis(self, analysis: ChatResult) -> None:
self._replace_and_reload(
[
{"role": "user", "text": "分析一下数据文件"},
{"role": "agent", "text": analysis.text, "blocks": analysis.blocks},
],
["folder-pack", "confirm-card"],
)
self._record_assertion("analysis blocks persisted and reloaded through session message APIs")
def _persist_after_schedule(self, analysis: ChatResult, schedule: ChatResult) -> None:
self._replace_and_reload(
[
{"role": "user", "text": "分析一下数据文件"},
{"role": "agent", "text": analysis.text, "blocks": analysis.blocks},
{"role": "user", "text": "立即排产"},
{"role": "agent", "text": schedule.text, "blocks": schedule.blocks},
],
["folder-pack", "confirm-card", "flex-schedule"],
)
self._record_assertion("full analysis-to-schedule conversation persisted and reloaded")
def _source_hash_now(self) -> str | None:
if self.source is None or not self.source.is_file():
return None
return _sha256_file(self.source)
def _summary(self, status: str, error: BaseException | None = None) -> dict[str, Any]:
source_after = self._source_hash_now()
payload: dict[str, Any] = {
"schemaVersion": 1,
"status": status,
"startedAt": getattr(self, "_started_at", None),
"finishedAt": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"artifactRoot": str(self.artifact_root),
"source": {
"path": str(self.source) if self.source else None,
"sha256Before": self.source_sha_before,
"sha256After": source_after,
"unchanged": bool(self.source and source_after == self.source_sha_before),
},
"oracle": {
"sourceSha256": self.expectations["sourceSha256"],
"entityCounts": self.expectations["entityCounts"],
"formalOrderNos": self.expectations["formalOrderNos"],
"sandboxOrderNos": self.expectations["sandboxOrderNos"],
},
"runtime": {
"piCli": str(self.pi_cli) if self.pi_cli else None,
"piCliSha256": self.pi_cli_sha256,
"node": self.node_bin,
"nodeVersion": self.node_version,
"fallbackDir": str(self.fallback_root),
"piHome": os.environ.get("APS_FALLBACK_PI_HOME"),
"llmConfigured": bool(
(os.environ.get("LLM_BASE_URL") or "").strip()
and (os.environ.get("LLM_API_KEY") or "").strip()
),
"llmProvider": os.environ.get("LLM_PROVIDER") or None,
"llmModel": os.environ.get("LLM_MODEL") or None,
"authEnabled": os.environ.get("APS_AUTH_ENABLED"),
"seedDemo": os.environ.get("APS_SEED_DEMO"),
},
"steps": self.steps,
"piRuns": self.pi_runs,
"assertions": self.assertions,
"projectId": self.project_id,
"sessionId": self.session_id,
"error": None,
}
if error is not None:
payload["error"] = {
"type": type(error).__name__,
"message": self._redact(str(error)),
"traceback": self._redact(
"".join(traceback.format_exception(type(error), error, error.__traceback__))
),
}
return payload
def _write_summary(self, status: str, error: BaseException | None = None) -> dict[str, Any]:
payload = self._summary(status, error)
self._write_json(self.artifact_root / "latest-summary.json", payload)
return payload
def run(self) -> dict[str, Any]:
self._started_at = time.strftime("%Y-%m-%dT%H:%M:%S%z")
try:
self.prepare()
self._assert_initial_world()
analysis = self._failure_injection_then_retry()
self._confirm_adoption(analysis)
self._persist_after_analysis(analysis)
schedule = self._schedule()
self._assert_schedule_and_no_dispatch(schedule)
self._persist_after_schedule(analysis, schedule)
self.source_sha_after = self._source_hash_now()
if self.source_sha_after != self.source_sha_before:
raise AssertionError("source workbook SHA changed during the E2E run")
self._record_assertion("source workbook SHA unchanged before and after the product flow")
return self._write_summary("passed")
except BaseException as exc:
self.source_sha_after = self._source_hash_now()
self._write_summary("failed", exc)
raise
finally:
self.close()