373 lines
16 KiB
Python
373 lines
16 KiB
Python
# ============================================================
|
||
# 智能兜底(Pi Agent)黄金测试 —— 全部确定性(注入 fake runner,
|
||
# 不依赖真实 node/pi/网络/LLM)。覆盖 P1-DESIGN §6 测试矩阵 13 例。
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import itertools
|
||
import json
|
||
import subprocess
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import get_args
|
||
|
||
import pytest
|
||
|
||
from server.agent_core import fallback_lane, harness
|
||
from server.agent_core.assistant import reply as assistant_reply
|
||
from server.agent_core.feature_flags import load_feature_flags
|
||
from server.agent_core.providers import reset_provider
|
||
from server.agent_core.tool_runtime import check_tool
|
||
from server.aps_domain.workflow import handle_intent
|
||
from server.contracts import IntentName, IntentResult
|
||
from server.integrations.pi_bridge import PiBridge, ToolBridgeViolation
|
||
from server.state.seed import seed_world
|
||
|
||
|
||
class FakeStore:
|
||
"""照 test_assistant.py 的 seed_world 版。"""
|
||
|
||
def __init__(self):
|
||
self.data = seed_world()
|
||
self._counters: dict[str, int] = {}
|
||
|
||
def next_id(self, kind: str) -> int:
|
||
self._counters[kind] = self._counters.get(kind, 0) + 1
|
||
return self._counters[kind]
|
||
|
||
def save(self) -> None:
|
||
pass
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _isolate(tmp_path, monkeypatch):
|
||
"""环境隔离:run 目录与开关文件指向 tmp;清掉 LLM env 保证离线确定性。"""
|
||
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fb"))
|
||
monkeypatch.setenv("APS_FEATURES_PATH", str(tmp_path / "features.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()
|
||
yield
|
||
reset_provider()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# fake runner 剧本构造(唯一注入点 = propose_reply(..., runner=..., config=...))
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _read_issued_call_ids(work_dir: Path) -> list[str]:
|
||
calls = work_dir.parent / "calls.jsonl"
|
||
if not calls.exists():
|
||
return []
|
||
return [json.loads(line)["call_id"]
|
||
for line in calls.read_text(encoding="utf-8").splitlines()
|
||
if line.strip() and json.loads(line).get("status") == "issued"]
|
||
|
||
|
||
def make_success_runner(report_template: str):
|
||
"""1 次 read + stop 报告;报告模板里的 {call_id} 在 resume 时读 calls.jsonl
|
||
填成真实签发的凭证(确定性:生成器是惰性的,凭证在 tool 事件处理后立即可读)。"""
|
||
|
||
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 ok"}
|
||
ids = _read_issued_call_ids(work_dir)
|
||
report = report_template.format(call_id=ids[0] if ids else "call-missing")
|
||
yield {"type": "message_update", "delta": {"text": report}}
|
||
yield {"type": "message_end", "message": {"role": "assistant", "stopReason": "stop",
|
||
"content": [{"type": "text", "text": report}]}}
|
||
yield {"type": "agent_end", "messages": [{"role": "assistant", "stopReason": "stop",
|
||
"content": [{"type": "text", "text": report}]}]}
|
||
|
||
return runner
|
||
|
||
|
||
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:
|
||
(tmp_path / "features.json").write_text(
|
||
json.dumps({"version": 1, "features": features}, ensure_ascii=False),
|
||
encoding="utf-8")
|
||
|
||
|
||
def _intent(query: str) -> IntentResult:
|
||
return IntentResult(intent="unknown", params={"query": query},
|
||
confidence=0.1, source="LLM")
|
||
|
||
|
||
def _fb_audits(store: FakeStore) -> list[dict]:
|
||
return [e for e in store.data.get("auditEvents", [])
|
||
if e.get("action") == "agent.fallback.propose"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1-2. 开关关 = 原行为不变;开关开 = propose 路径触发
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_flag_off_preserves_original_behavior(tmp_path):
|
||
store = FakeStore()
|
||
direct = await assistant_reply(store.data, "随便说说", history=[], session_id="s1")
|
||
reply = await handle_intent(store, "s1", _intent("随便说说"))
|
||
assert reply.text == direct.text # 原话术逐字节不变
|
||
assert _fb_audits(store) == [] # 零审计噪音
|
||
assert not (tmp_path / "fb").exists() # 零 run 目录副作用
|
||
|
||
|
||
async def test_flag_on_success_proposes(tmp_path):
|
||
_write_features(tmp_path, {"fallback": True})
|
||
store = FakeStore()
|
||
reply = await fallback_lane.propose_reply(
|
||
store, "s1", _intent("帮我分析下订单结构"),
|
||
runner=make_success_runner("status: success\n\n报告正文:订单结构 [callId: {call_id}]"),
|
||
config=_cfg(tmp_path))
|
||
assert reply is not None
|
||
assert "[智能兜底 · 草稿]" in reply.text
|
||
assert "报告正文:订单结构" in reply.text
|
||
|
||
audits = _fb_audits(store)
|
||
assert len(audits) == 1
|
||
assert audits[0]["result"] == "SUCCESS"
|
||
assert audits[0]["power"] == "P1"
|
||
assert harness.power_of("agent.fallback.propose") == "P1"
|
||
|
||
runs = [p for p in (tmp_path / "fb").iterdir() if p.is_dir() and p.name != "pi-home"]
|
||
assert len(runs) == 1
|
||
assert (runs[0] / "result.json").is_file()
|
||
assert (runs[0] / "calls.jsonl").is_file()
|
||
assert (runs[0] / "outbox" / "report.md").is_file()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3-4. 未登记意图仍拒绝;审计落链
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_unregistered_intent_still_denied(tmp_path):
|
||
_write_features(tmp_path, {"fallback": True})
|
||
store = FakeStore()
|
||
denied = check_tool(store, _intent("随便说说")) # unknown 未登记 → 拒绝
|
||
assert denied is not None
|
||
assert "不是已登记的操作" in denied.text
|
||
denied_audits = [e for e in store.data.get("auditEvents", [])
|
||
if e.get("action") == "tool.denied"]
|
||
assert denied_audits and denied_audits[0]["result"] == "DENIED"
|
||
# agent.fallback.propose 只是权力登记动作名,绝不是意图枚举成员
|
||
assert "agent.fallback.propose" not in get_args(IntentName)
|
||
|
||
|
||
async def test_audit_chain_links(tmp_path):
|
||
_write_features(tmp_path, {"fallback": True})
|
||
store = FakeStore()
|
||
reply = await fallback_lane.propose_reply(
|
||
store, "s1", _intent("分析订单"),
|
||
runner=make_success_runner("status: success\n\n报告 [callId: {call_id}]"),
|
||
config=_cfg(tmp_path))
|
||
assert reply is not None
|
||
events = store.data.get("auditEvents", [])
|
||
assert len(events) >= 2 # tool.run + 完成事件
|
||
for prev, cur in itertools.pairwise(events):
|
||
assert cur["prevHash"] == prev["hash"] # 链不断
|
||
tool_runs = [e for e in events if e.get("action") == "tool.run"]
|
||
assert any(str(e.get("actor", "")).startswith("pi-fallback:") for e in tool_runs)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 5-8. 熔断 / 伪造凭证 / 异常:全部显式失败
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_breaker_timeout_explicit_failure(tmp_path):
|
||
_write_features(tmp_path, {"fallback": True})
|
||
store = FakeStore()
|
||
|
||
def heartbeat_runner(task: str, work_dir: Path):
|
||
while True:
|
||
yield {"type": "harness_heartbeat"}
|
||
|
||
reply = await fallback_lane.propose_reply(
|
||
store, "s1", _intent("随便说说"), runner=heartbeat_runner,
|
||
config=_cfg(tmp_path, timeout_sec=0.05))
|
||
assert reply is not None # 绝不抛、永远有回复
|
||
assert "智能兜底本次未完成" in reply.text
|
||
audits = _fb_audits(store)
|
||
assert audits[0]["result"] == "FAILED"
|
||
assert audits[0]["rationale"]["stopReason"].startswith("breaker:timeout")
|
||
|
||
|
||
async def test_breaker_max_steps(tmp_path):
|
||
_write_features(tmp_path, {"fallback": True})
|
||
store = FakeStore()
|
||
|
||
def busy_runner(task: str, work_dir: Path):
|
||
for i in range(3):
|
||
yield {"type": "tool_execution_start", "toolName": "read",
|
||
"toolCallId": f"t{i}"}
|
||
yield {"type": "agent_end", "messages": []}
|
||
|
||
await fallback_lane.propose_reply(
|
||
store, "s1", _intent("随便说说"), runner=busy_runner,
|
||
config=_cfg(tmp_path, max_steps=2))
|
||
audits = _fb_audits(store)
|
||
assert audits[0]["result"] == "FAILED"
|
||
assert audits[0]["rationale"]["stopReason"].startswith("breaker:max_steps")
|
||
|
||
|
||
async def test_forged_callid_rejected(tmp_path):
|
||
_write_features(tmp_path, {"fallback": True})
|
||
store = FakeStore()
|
||
forged = "call-" + str(uuid.uuid4())
|
||
report = f"status: success\n\n编造的数据结论 [callId: {forged}]"
|
||
|
||
def forging_runner(task: str, work_dir: Path):
|
||
yield {"type": "message_end", "message": {"role": "assistant",
|
||
"stopReason": "stop", "content": [{"type": "text", "text": report}]}}
|
||
yield {"type": "agent_end", "messages": []}
|
||
|
||
reply = await fallback_lane.propose_reply(
|
||
store, "s1", _intent("随便说说"), runner=forging_runner, config=_cfg(tmp_path))
|
||
assert "智能兜底本次未完成" in reply.text
|
||
audits = _fb_audits(store)
|
||
assert audits[0]["result"] == "FAILED"
|
||
assert audits[0]["rationale"]["stopReason"] == "forged_citation"
|
||
|
||
runs = [p for p in (tmp_path / "fb").iterdir() if p.is_dir() and p.name != "pi-home"]
|
||
check = PiBridge("x", runs[0]).validate_report_citations(report)
|
||
assert check["valid"] is False and check["missing"] == [forged]
|
||
|
||
|
||
async def test_runner_exception_fails_explicit(tmp_path):
|
||
_write_features(tmp_path, {"fallback": True})
|
||
store = FakeStore()
|
||
|
||
def boom_runner(task: str, work_dir: Path):
|
||
raise RuntimeError("spawn exploded")
|
||
yield # pragma: no cover - 保持生成器形态
|
||
|
||
reply = await fallback_lane.propose_reply(
|
||
store, "s1", _intent("随便说说"), runner=boom_runner, config=_cfg(tmp_path))
|
||
assert reply is not None # propose_reply 不抛
|
||
assert "智能兜底本次未完成" in reply.text
|
||
audits = _fb_audits(store)
|
||
assert audits[0]["result"] == "FAILED"
|
||
assert audits[0]["rationale"]["stopReason"] == "harness_error"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 9-11. 「默认关」开关语义
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_fallback_default_off_when_config_missing(tmp_path):
|
||
result = load_feature_flags(str(tmp_path / "features.json"))
|
||
assert result["features"]["fallback"]["enabled"] is False
|
||
assert result["defaultOff"] == ["fallback"]
|
||
assert all(info["enabled"] for key, info in result["features"].items()
|
||
if key not in result["defaultOff"])
|
||
|
||
|
||
def test_fallback_explicit_true_enables(tmp_path):
|
||
_write_features(tmp_path, {"fallback": True})
|
||
result = load_feature_flags(str(tmp_path / "features.json"))
|
||
assert result["source"] == "file"
|
||
assert result["features"]["fallback"]["enabled"] is True
|
||
|
||
|
||
def test_fallback_non_bool_stays_off(tmp_path):
|
||
_write_features(tmp_path, {"fallback": "yes", "orders": "no"})
|
||
result = load_feature_flags(str(tmp_path / "features.json"))
|
||
assert result["features"]["fallback"]["enabled"] is False # 非 bool 回退各自默认
|
||
assert result["features"]["orders"]["enabled"] is True # 开的就是开、关的就是关
|
||
assert result["error"] is not None and "fallback" in result["error"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 12-13. 路径越界拦截;运行时不可用显式回话术
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_fs_read_path_escape_blocked(tmp_path):
|
||
run_dir = tmp_path / "run"
|
||
run_dir.mkdir()
|
||
(run_dir / "ok.txt").write_text("界内内容", encoding="utf-8")
|
||
bridge = PiBridge("rb-1", run_dir)
|
||
assert bridge.handle_fs_read("ok.txt") == "界内内容"
|
||
with pytest.raises(ToolBridgeViolation):
|
||
bridge.handle_fs_read("../../server/contracts.py")
|
||
with pytest.raises(ToolBridgeViolation):
|
||
bridge.handle_fs_read(str(tmp_path / "outside.txt"))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 14. 守卫模板 format 防回归(P1 冒烟 Bug A:注释行未转义花括号曾致真实 runner 100% 失败)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_write_guard_extension_real_template_format(tmp_path):
|
||
"""真实模板 str.format 不抛 + 生成守卫文件含字面量 { block: true(fake runner
|
||
结构性摸不到这条路径,本条是真实 runner 模板回归的唯一网)。"""
|
||
run_dir = tmp_path / "fb-20990101-000000-abcdef"
|
||
run_dir.mkdir()
|
||
guard = fallback_lane.write_guard_extension(run_dir) # 不抛 KeyError 即过半
|
||
assert guard.is_file() and guard.name == f"guard-{run_dir.name}.ts"
|
||
content = guard.read_text(encoding="utf-8")
|
||
assert "{ block: true" in content # 字面量花括号必须真实出现
|
||
assert "{RUN_ROOT_POSIX}" not in content # 占位符必须被替换干净
|
||
assert run_dir.as_posix() in content
|
||
|
||
|
||
def test_write_guard_readonly_mode_preserves_p1_semantics(tmp_path):
|
||
"""readonly 模式(默认)逐字节保持 P1 围墙语义:bash/edit/write 全禁、
|
||
无 WRITE_DIRS 放行面(P2 守卫模板参数化对 P1 的唯一约束)。"""
|
||
run_dir = tmp_path / "fb-20990101-000000-abcdef"
|
||
run_dir.mkdir()
|
||
default_content = fallback_lane.write_guard_extension(run_dir).read_text(encoding="utf-8")
|
||
explicit = fallback_lane.write_guard_extension(run_dir, mode="readonly")
|
||
assert explicit.read_text(encoding="utf-8") == default_content
|
||
assert "disabled by fallback guard (read-only lane)" in default_content # P1 全禁原文
|
||
assert "WRITE_DIRS" not in default_content # 无写放行面
|
||
assert 'name === "edit"' in default_content # edit 仍在全禁名单
|
||
|
||
|
||
def test_write_guard_plan_mode_opens_work_outbox_only(tmp_path):
|
||
"""plan/execute 模式(v2 模板):write/edit 仅放行 run 目录内 work/+outbox/,
|
||
bash 仍全禁、只读工具防逃逸不变。"""
|
||
run_dir = tmp_path / "fb-20990101-000000-bcdef0"
|
||
run_dir.mkdir()
|
||
for mode in ("plan", "execute"):
|
||
guard = fallback_lane.write_guard_extension(run_dir, mode=mode)
|
||
content = guard.read_text(encoding="utf-8")
|
||
assert "{ block: true" in content # 字面量花括号真实出现
|
||
assert "{RUN_ROOT_POSIX}" not in content # 占位符替换干净
|
||
assert "{MODE_LABEL}" not in content
|
||
assert "WRITE_DIRS" in content
|
||
assert "write outside work/outbox (fallback guard)" in content
|
||
assert 'name === "bash"' in content # bash 仍全禁
|
||
assert "path escapes run root" in content # 防逃逸不变
|
||
assert "read-only lane" not in content # 不再是 P1 全禁语义
|
||
|
||
|
||
async def test_runtime_unavailable_falls_back_to_canned_reply(tmp_path, monkeypatch):
|
||
_write_features(tmp_path, {"fallback": True})
|
||
monkeypatch.setenv("APS_FALLBACK_PI_CLI", str(tmp_path / "no-such-cli.js"))
|
||
popen_calls = []
|
||
monkeypatch.setattr(subprocess, "Popen",
|
||
lambda *a, **kw: popen_calls.append((a, kw)))
|
||
store = FakeStore()
|
||
direct = await assistant_reply(store.data, "随便说说", history=[], session_id="s1")
|
||
reply = await fallback_lane.propose_reply(store, "s1", _intent("随便说说"))
|
||
assert reply is not None
|
||
assert popen_calls == [] # 不可用即显式失败,不触子进程
|
||
assert "智能兜底本次未完成" in reply.text
|
||
assert "兜底运行时不可用" in reply.text
|
||
assert direct.text in reply.text # 原话术仍在
|
||
audits = _fb_audits(store)
|
||
assert audits[0]["result"] == "FAILED"
|
||
assert audits[0]["rationale"]["stopReason"].startswith("unavailable:")
|