"""cheap_service_driver 单测:OpenAI 兼容凭据体(base+/v1) + _build_summary 组 result_out 可吃形 + sidecar 往返 + worker_service 默认 run_fn 已切到驱动 Service(不再进程内 run_studio)。真起 Service/真门排 mini-desktop 窗口。 跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_cheap_service_driver.py -v """ import sys import json import subprocess from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/ import _bootstrap # noqa: E402,F401 import cheap_run # noqa: E402 import cheap_service_driver as D # noqa: E402 import result_out # noqa: E402 import worker_service as W # noqa: E402 MATCH3_PROFILE_ID = "match3.orthogonal-swap-v1" def test_credential_payload_openai_compatible_with_v1(monkeypatch): # 便宜档 M3 走 OpenAI 兼容路:type=openai_credential、base_url 带 /v1、api_key 非空。 monkeypatch.setattr(D, "_resolve_base", lambda: "http://100.64.0.8:3000") # WU2 §3.7:_resolve_key 现签名 (user_token=None);缺 token 走 env 回落(此桩恒回同一 key)。 monkeypatch.setattr(D, "_resolve_key", lambda user_token=None: "sk-test") payload = D._cheap_credential_payload() assert payload["data"]["type"] == "openai_credential" assert payload["data"]["base_url"].endswith("/v1") assert payload["data"]["api_key"] == "sk-test" def test_resolve_key_prefers_user_token_else_env_fallback(monkeypatch): # WU2 §3.7 F:job 带 userToken 则用它(不碰 env);缺失回落全局 env key(client.get_api_key)。 import worker.client as _wc monkeypatch.setattr(_wc, "get_api_key", lambda: "sk-env-fallback") assert D._resolve_key("sk-user-per") == "sk-user-per" # per-user token 优先 assert D._resolve_key(None) == "sk-env-fallback" # 缺失回落 env assert D._resolve_key("") == "sk-env-fallback" # 空串视同缺失 # 凭据体据 user_token 选 key(有则 per-user、无则回落 env)。 monkeypatch.setattr(D, "_resolve_base", lambda: "http://100.64.0.8:3000") assert D._cheap_credential_payload("sk-user-per")["data"]["api_key"] == "sk-user-per" assert D._cheap_credential_payload()["data"]["api_key"] == "sk-env-fallback" def test_build_summary_shape_feeds_result_out(tmp_path, monkeypatch): # _build_summary 组的 summary 能被 result_out.build_result_out 吃出 succeeded + trace(costRmb/repairs)。 monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}") gd = tmp_path / "amgen-g9" gd.mkdir(parents=True) (gd / "bundle.iife.js").write_text("var x=1; window.__GameBundle={};", encoding="utf-8") monkeypatch.setattr(D, "_read_driver_type", lambda gid: "tap-targets") # 真实 verdict.guards 带完整四门(A/B/C/D)——W-AXIS-V2 预筛权威 = 四门投影;H_progress 保留(降观测门,trace 仍带)。 verdict = {"pass": True, "guards": {"A_boot": {"pass": True}, "B_uncaught": {"pass": True}, "C_frame": {"pass": True}, "D_render": {"pass": True}, "H_progress": {"pass": True}}} svc = {"costRmb": 0.42, "rmbGate": "active", "repairs": 2, "budgetSoftTripped": False} turn = {"ended": True, "reason": "REPLY_END"} summary = D._build_summary("g9", "点击得分小游戏", verdict, svc, turn, t0=0.0) assert summary["ok"] is True and summary["finished"] is True assert summary["verdict"]["pass"] is True assert summary["attempts"] == 3 # repairs=2 → attempts=repairs+1(result_out repairs=attempts-1 反推回 2) assert summary["costRmb"] == 0.42 assert summary["verdictFull"] == verdict and summary["driverType"] == "tap-targets" payload = result_out.build_result_out({"traceId": "t", "brief": "点击"}, summary, cheap_run.game_dir("g9")) assert payload["status"] == "succeeded" assert payload["trace"]["repairs"] == 2 and payload["trace"]["cost"]["totalRmb"] == 0.42 assert payload["trace"]["gatespec"]["driver"] == "tap-targets" def test_build_summary_no_verdict_is_failed(tmp_path, monkeypatch): # 回合没跑出 verdict(续修耗尽/软停无绿)→ ok=False,result_out 走 failed(七值枚举)。 monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}") (tmp_path / "amgen-g8").mkdir(parents=True) # 无 bundle monkeypatch.setattr(D, "_read_driver_type", lambda gid: None) summary = D._build_summary("g8", "brief", None, {"costRmb": 0.1, "rmbGate": "active", "repairs": 6}, {"ended": True, "reason": "REPLY_END"}, t0=0.0) assert summary["ok"] is False payload = result_out.build_result_out({"traceId": "t"}, summary, cheap_run.game_dir("g8")) assert payload["status"] == "failed" assert payload["failureReason"] in result_out._FAILURE_REASONS def test_session_cfg_roundtrip_maps_session_to_backend_gameid(tmp_path, monkeypatch): # C2:driver 按 session_id 写会话注册表(含 external_game_id=后端 gameId),Service 工厂读 session_id → 解析回后端 gameId。 import cheap_service_app as A monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json") # create 路:driver 写映射、白名单 None → restricted False。 D._write_session_cfg("sess-abc", external_game_id="70012", write_whitelist=None, scaffold_template=None) cfg = A._read_session_cfg("sess-abc") assert A._resolve_external_game_id("sess-abc", cfg) == "70012", "session_id → 后端 gameId" assert A._resolve_write_whitelist(cfg) is None, "create 路不收窄" # reskin 路:driver 传白名单 → restricted True + 工厂收窄成 set。 D._write_session_cfg("sess-def", external_game_id="70013", write_whitelist={"game-logic.js"}) cfg2 = A._read_session_cfg("sess-def") assert cfg2["restricted"] is True assert A._resolve_write_whitelist(cfg2) == {"game-logic.js"} # 空集白名单(Minor#4 fail-closed):restricted 必须由「白名单是否给定(is not None)」决定、非 bool(空集会误成 False)。 # 空集 → restricted True → T2 读侧 I1 收窄成空集禁写,不再退化成 restricted False 放开全写。 D._write_session_cfg("sess-empty", external_game_id="70014", write_whitelist=set()) cfg3 = A._read_session_cfg("sess-empty") assert cfg3["restricted"] is True, "空集白名单 → restricted True(fail-closed,不因 bool(空集)退成 False)" assert A._resolve_write_whitelist(cfg3) == set(), "T2 读侧空集禁写(不放开全写)" def test_build_summary_trace_contract_end_to_end(tmp_path, monkeypatch): # I4:_build_summary 组的 summary 喂 build_result_out 后,trace 七项 + sevenGateVerdict + gatespec.driver + # cost.totalRmb 齐备,engineBundle 承重;不空口说「result_out 零改动」,而是端到端断言其当前消费字段。 monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}") gd = tmp_path / "amgen-70012" gd.mkdir(parents=True) (gd / "bundle.iife.js").write_text("var x=1; window.__GameBundle={};", encoding="utf-8") monkeypatch.setattr(D, "_read_driver_type", lambda gid: "tap-targets") # 真实 verdict.guards 带完整四门(A/B/C/D)——W-AXIS-V2 预筛权威 = 四门投影;H_progress 保留(降观测门,trace 仍带)。 verdict = {"pass": True, "guards": {"A_boot": {"pass": True}, "B_uncaught": {"pass": True}, "C_frame": {"pass": True}, "D_render": {"pass": True}, "H_progress": {"pass": True}}} svc = {"costRmb": 0.42, "rmbGate": "active", "repairs": 2, "budgetSoftTripped": False} summary = D._build_summary("70012", "点击得分小游戏", verdict, svc, {"ended": True, "reason": "REPLY_END"}, t0=0.0) payload = result_out.build_result_out({"traceId": "t-1", "brief": "点击"}, summary, cheap_run.game_dir("70012")) assert payload["status"] == "succeeded" assert payload["traceId"] == "t-1" # 回调关联键 = job.traceId(非 gameId) assert "__GameBundle" in payload["engineBundle"] # engineBundle 承重入 feed tr = payload["trace"] for k in ("pass", "repairs", "wallS", "models", "attempts", "gameId", "stage"): assert k in tr, f"trace 必填七项缺 {k}" assert tr["gameId"] == "70012" # C2:trace.gameId = 后端 gameId(诊断可关联) assert tr["repairs"] == 2 and tr["cost"]["totalRmb"] == 0.42 assert tr["sevenGateVerdict"]["pass"] is True and "H_progress" in tr["sevenGateVerdict"]["guards"] assert tr["gatespec"]["driver"] == "tap-targets" # 键名 driver(非 driverType,后端 hasDriver 据此) def test_read_service_run_summary_bounded_poll(tmp_path, monkeypatch): # C1:sidecar 在 wait 窗口内出现 → 读到;窗口内始终缺失 → 诚实降级 {}(不阻塞、不伪造)。 import json monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}") assert D._read_service_run_summary("miss", wait_s=0.05) == {}, "缺失且过期 → 降级 {}" ev = tmp_path / "amgen-hit" / "evidence" ev.mkdir(parents=True) (ev / "service-run-summary.json").write_text(json.dumps({"costRmb": 0.3, "repairs": 1}), encoding="utf-8") got = D._read_service_run_summary("hit", wait_s=0.05) assert got["costRmb"] == 0.3 and got["repairs"] == 1 def test_drive_cheap_generation_fake_sse(tmp_path, monkeypatch): # 契约(fake-SSE):mock httpx + _wait_for_turn_end,驱动真 drive_cheap_generation —— 断言 C2(scaffold 用后端 # gameId、注册表写 external_game_id=后端 gameId)+ 回合真结束读 verdict/sidecar 组 succeeded + 回合未结束诚实降级。 import asyncio import json import service.control_plane as CP monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}") monkeypatch.setattr(cheap_run, "wg1_game_dir", lambda gid: tmp_path / "_wg1-gen" / gid) monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json") # W-AXIS 波1:driver 现在 scaffold 前会 per-run 归档(archive_prior_run 用 _ARCHIVE_DIR 全局)——沙箱到 tmp, # 否则真驱动会把预铺的 amgen-70012 移进真实仓 game-runtime/games/_amgen-archive(测试隔离泄漏)。 monkeypatch.setattr(cheap_run, "_ARCHIVE_DIR", tmp_path / "_amgen-archive") scaffolded = {} monkeypatch.setattr(cheap_run, "scaffold", lambda gid, tpl=None: scaffolded.update(gid=gid) or {"ok": True, "output": ""}) # richness 非阻塞:stub 成同步返回(避免真 LLM)。 async def _fake_richness(gid, *, brief=""): return {"score": 5, "max": 8, "degraded": False} monkeypatch.setattr("cheap_verify.verify_richness", _fake_richness) # 无网络/无 key 也能跑:stub 凭据体 + key env(真凭据/旁路真跑在 mini-desktop 窗口验)。 import _bootstrap monkeypatch.setattr(_bootstrap, "ensure_api_key_env", lambda: None) monkeypatch.setattr(D, "_cheap_credential_payload", lambda user_token=None: {"data": {"type": "openai_credential", "api_key": "sk", "base_url": "http://x/v1"}}) class _Resp: def __init__(self, d): self._d = d def json(self): return self._d posted = {} class _FakeHttp: def __init__(self, *a, **k): pass async def __aenter__(self): return self async def __aexit__(self, *a): return False async def post(self, url, **k): if url.endswith("/sessions/"): posted["session_body"] = k.get("json") # 捕获 session 体(fix400 主防线断言用) return _Resp({"credential_id": "c1"} if url.endswith("/credential/") else {"agent_id": "a1"} if url.endswith("/agent/") else {"session_id": "sess-9"} if url.endswith("/sessions/") else {}) async def patch(self, url, **k): return _Resp({}) import httpx monkeypatch.setattr(httpx, "AsyncClient", _FakeHttp) # 造后端 gameId=70012 的 on-disk 产物:bundle 由 scaffold 阶段就位(engineBundle 承重);verdict + 收口 sidecar # 改为在 fake turn 内写(模拟 Service 回合中跑门写盘)——driver 现在回合前会清残留(红线③修 b),预铺在 drive # 之前会被清掉,故必须让「本次回合」写入,才对齐真实时序(先清陈旧、回合中写本次、回合后读本次)。 (tmp_path / "amgen-70012").mkdir(parents=True) (tmp_path / "amgen-70012" / "bundle.iife.js").write_text("window.__GameBundle={};", encoding="utf-8") # 有界轮询提速:sidecar 缺失时默认等 5s,测试里压到 50ms(保留真实 bounded-poll 逻辑,只缩等待窗口)。 _orig_rss = D._read_service_run_summary monkeypatch.setattr(D, "_read_service_run_summary", lambda gid, wait_s=0.05: _orig_rss(gid, wait_s=wait_s)) # ① 回合真结束(REPLY_END)→ fake turn 写本次 verdict/sidecar → driver 回合后读它组 succeeded 形。 async def _ended(*a, **k): vev = tmp_path / "_wg1-gen" / "70012" / "evidence"; vev.mkdir(parents=True, exist_ok=True) (vev / "verdict.json").write_text(json.dumps({"pass": True, "guards": { "A_boot": {"pass": True}, "B_uncaught": {"pass": True}, "C_frame": {"pass": True}, "D_render": {"pass": True}}}), encoding="utf-8") sev = tmp_path / "amgen-70012" / "evidence"; sev.mkdir(parents=True, exist_ok=True) (sev / "service-run-summary.json").write_text( json.dumps({"costRmb": 0.5, "rmbGate": "active", "repairs": 1}), encoding="utf-8") return {"ended": True, "reason": "REPLY_END", "endEvent": {}} monkeypatch.setattr(CP, "_wait_for_turn_end", _ended) # W-AXIS-V2 波1:本用例验的是单 POST 驱动器管路(scaffold/session/verdict/sidecar/costRmb → ok 映射)=四门预筛 # 这一半;测试 agent 真玩是独立阻断层、桩环境(无 Chrome/无判定模型)起不了服,故设 acceptance.mode=v1 隔离出 # 管路本身(v1 走 apply_gameplay_judge 不起服测试员)+ 关 blocking 让 ok=预筛(四门投影)。测试员阻断/mode 三态 # 由 test_acceptance_v2.py 隔离覆盖、真玩由考卷真跑覆盖。判定仍 observe-only 跑一遍(无截图 → fail-closed degraded)。 monkeypatch.setenv("TIER2_GEN__ACCEPTANCE__MODE", "v1") monkeypatch.setenv("TIER2_GEN__JUDGE__BLOCKING", "false") summary, gdir = asyncio.run(D.drive_cheap_generation({"gameId": 70012, "traceId": "t9", "brief": "点球"})) assert scaffolded["gid"] == "70012", "C2:scaffold 用后端 gameId(str),非 session_id" cfg = json.loads((tmp_path / "_cheap-sessions" / "sess-9.json").read_text(encoding="utf-8")) assert cfg["external_game_id"] == "70012", "C2:注册表写 session→后端 gameId 映射" assert summary["ok"] is True and summary["gameId"] == "70012" and summary["costRmb"] == 0.5 # 判定段 additive 落盘(observe-only:blocking=false 不阻断,但如实记裁决;无截图证据 → fail-closed degraded)。 assert summary["judge"]["blocking"] is False and summary["accepted"] is False assert summary["judge"]["degraded"] is True assert gdir == cheap_run.game_dir("70012") # fix400 主防线锚:session parameters 必带 parallel_tool_calls=False(源头禁并行 call,防 M3 经 new-api # 并行 call 同 index 拼桶 → 非法 JSON → 孤儿 tool result → 400 code 2013;纵深兜底见 m3_stream_patch)。 params = posted["session_body"]["chat_model_config"]["parameters"] assert params["parallel_tool_calls"] is False, "fix400:必须源头禁并行 tool call" # ② 回合未真结束(SSE 超时)→ 诚实降级(不卡死),summary 仍组出、reason 反映未结束。 async def _not_ended(*a, **k): return {"ended": False, "reason": "total_timeout", "endEvent": None} monkeypatch.setattr(CP, "_wait_for_turn_end", _not_ended) s2, _ = asyncio.run(D.drive_cheap_generation({"gameId": 70012, "traceId": "t9b", "brief": "点球"})) assert s2["stoppedReason"] == "total_timeout" def test_drive_v3_verified_reject_posts_exactly_one_repair(tmp_path, monkeypatch): """Service v3 不靠 RepairMiddleware;只有首轮 verified reject 才给同一 session 再发一次修复。""" import asyncio import httpx import _bootstrap import cheap_studio import cheap_verify import service.control_plane as CP _install_common_stubs(tmp_path, monkeypatch) # W-GOLD-LIVE 检查点 2:编排升 acceptance-request/3,validate.py 语义层钉死 /3 配 2026-07-15.v3 # 版本线(rolling proof-obligations.v2.json + 完整 ProofObligationRegistry schema),pin 随接线同步。 assert cheap_verify._V3_OBLIGATIONS_FILE.name == "proof-obligations.v2.json" assert cheap_verify._V3_OBLIGATIONS_SCHEMA.name == "proof-obligation-registry.schema.json" monkeypatch.setattr(cheap_studio, "acceptance_v3_mode", lambda: "v3") monkeypatch.setattr(_bootstrap, "ensure_api_key_env", lambda: None) verdict = {"pass": True, "guards": {name: {"pass": True} for name in cheap_verify._FLOOR_GATES}} floor_calls = [] async def _floor(gid): floor_calls.append(gid) return verdict monkeypatch.setattr(D, "_run_v3_floor_gates", _floor) svc_rows = iter([ {"costRmb": 0.4, "rmbGate": "active", "repairs": 0}, {"costRmb": 0.2, "rmbGate": "active", "repairs": 0}, ]) monkeypatch.setattr(D, "_read_service_run_summary", lambda gid, wait_s=5.0: next(svc_rows)) acceptance_requests = [] async def _accept(req): acceptance_requests.append(req) if len(acceptance_requests) == 1: return {"runId": "r1", "floor": {"pass": True}, "artifactHash": "a" * 64, "acceptanceRequestHash": req["acceptanceIdentity"]["acceptanceRequestHash"], "decision": {"outcome": "reject", "accepted": False, "publishFrozen": True, "repairEligible": True, "repairFeedback": "按硬证修一次", "parentChainCostRmb": 0.3}, "compatibility": {"accepted": False, "ok": False, "acceptanceVersion": "v3", "playtest": {}, "judge": {}, "failureLayer": {"layer": "gameplay"}, "failureReason": "硬证拒绝", "trace": {}}} return {"runId": "r2", "floor": {"pass": True}, "artifactHash": "b" * 64, "acceptanceRequestHash": req["acceptanceIdentity"]["acceptanceRequestHash"], "decision": {"outcome": "accept", "accepted": True, "publishFrozen": False, "repairEligible": False, "parentChainCostRmb": 0.5}, "compatibility": {"accepted": True, "ok": True, "acceptanceVersion": "v3", "playtest": {"outcome": "accept"}, "judge": {"verdict": "accept"}, "failureLayer": {"layer": "none"}, "failureReason": None, "trace": {}}} monkeypatch.setattr(cheap_verify, "run_acceptance_v3", _accept) monkeypatch.setattr(cheap_verify, "is_v3_repair_authorized", lambda payload: (payload.get("decision") or {}).get("repairEligible") is True) monkeypatch.setattr(CP, "_wait_for_turn_end", lambda *a, **k: None) async def _ended(*a, **k): return {"ended": True, "reason": "REPLY_END", "endEvent": {}} monkeypatch.setattr(CP, "_wait_for_turn_end", _ended) chat_texts = [] class _Resp: def __init__(self, data): self._data = data def json(self): return self._data class _Http: def __init__(self, *a, **k): pass async def __aenter__(self): return self async def __aexit__(self, *a): return False async def post(self, url, **kwargs): if url.endswith("/chat/"): chat_texts.append(kwargs["json"]["input"]["content"][0]["text"]) return _Resp({}) if url.endswith("/credential/"): return _Resp({"credential_id": "c1"}) if url.endswith("/agent/"): return _Resp({"agent_id": "a1"}) if url.endswith("/sessions/"): return _Resp({"session_id": "s1"}) return _Resp({}) async def patch(self, *a, **k): return _Resp({}) monkeypatch.setattr(httpx, "AsyncClient", _Http) summary, _ = asyncio.run(D.drive_cheap_generation( {"gameId": "g-v3", "traceId": "t-v3", "brief": "解谜点击"})) assert len(chat_texts) == 2 and chat_texts[1].startswith("按硬证修一次") assert "check → build → finish" in chat_texts[1] assert len(floor_calls) == 2 and len(acceptance_requests) == 2 assert acceptance_requests[1]["repairCountAcrossParentChain"] == 1 assert acceptance_requests[1]["parentRunId"] == "r1" assert acceptance_requests[0]["writerCostRmb"] == 0.4 assert acceptance_requests[1]["writerCostRmb"] == 0.2 assert all(request["acceptanceIdentity"]["interactionBinding"] is None for request in acceptance_requests), "普通 puzzle 不得按 brief/template 自动选 Match-3" assert all("parentChainCostRmb" not in request for request in acceptance_requests) assert summary["acceptanceV3"]["runId"] == "r2" and summary["accepted"] is True assert summary["acceptanceV3FirstPass"]["runId"] == "r1" assert summary["repairAttempted"] is True and summary["acceptedAfterRepair"] is True assert summary["attempts"] == 2 and summary["costRmb"] == 0.6 def test_explicit_match3_shadow_job_reaches_scaffold_identity_and_obligation_resolution(tmp_path, monkeypatch): """可信 job 显式 profile 才贯通 scaffold/identity,并在 Node 义务解析中提升有效交换义务。""" import asyncio import httpx import _bootstrap import cheap_studio import cheap_verify import service.control_plane as CP _install_common_stubs(tmp_path, monkeypatch) monkeypatch.setattr(cheap_studio, "acceptance_v3_mode", lambda: "v3_shadow") monkeypatch.setattr(_bootstrap, "ensure_api_key_env", lambda: None) captured = {"scaffold": None, "request": None} def _scaffold(gid, template=None, interaction_profile_id=None): captured["scaffold"] = (gid, template, interaction_profile_id) return {"ok": True, "output": ""} monkeypatch.setattr(cheap_run, "scaffold", _scaffold) verdict = {"pass": True, "guards": {name: {"pass": True} for name in cheap_verify._FLOOR_GATES}} async def _floor(_gid): return verdict async def _accept(request): captured["request"] = request return { "runId": "match3-shadow-run", "floor": {"pass": True}, "artifactHash": "a" * 64, "acceptanceRequestHash": request["acceptanceIdentity"]["acceptanceRequestHash"], "decision": {"outcome": "accept", "accepted": False, "shadowAccepted": True, "publishFrozen": True, "repairEligible": False}, "compatibility": {"accepted": False, "ok": False, "acceptanceVersion": "v3_shadow", "playtest": {"outcome": "accept"}, "judge": {"verdict": "accept"}, "failureLayer": {"layer": "none"}, "failureReason": None, "trace": {}}, } monkeypatch.setattr(D, "_run_v3_floor_gates", _floor) monkeypatch.setattr(D, "_read_service_run_summary", lambda gid, wait_s=5.0: {"costRmb": 0.2, "rmbGate": "active", "repairs": 0}) monkeypatch.setattr(cheap_verify, "run_acceptance_v3", _accept) async def _ended(*args, **kwargs): return {"ended": True, "reason": "REPLY_END", "endEvent": {}} monkeypatch.setattr(CP, "_wait_for_turn_end", _ended) class _Resp: def __init__(self, data): self._data = data def json(self): return self._data class _Http: def __init__(self, *args, **kwargs): pass async def __aenter__(self): return self async def __aexit__(self, *args): return False async def post(self, url, **kwargs): if url.endswith("/credential/"): return _Resp({"credential_id": "c1"}) if url.endswith("/agent/"): return _Resp({"agent_id": "a1"}) if url.endswith("/sessions/"): return _Resp({"session_id": "s1"}) return _Resp({}) async def patch(self, *args, **kwargs): return _Resp({}) monkeypatch.setattr(httpx, "AsyncClient", _Http) asyncio.run(D.drive_cheap_generation({ "gameId": "match3-job", "traceId": "trace-match3-job", "brief": "标准三消解谜", "interactionProfileId": MATCH3_PROFILE_ID, })) assert captured["scaffold"] == ("match3-job", "_template-puzzle", MATCH3_PROFILE_ID) identity = captured["request"]["acceptanceIdentity"] assert identity["interactionBinding"]["interactionProfileId"] == MATCH3_PROFILE_ID # 调真实 Node 义务解析器,证明 job 冻结的 binding 会提升 Match-3 专属有效交换义务。 core = Path(__file__).resolve().parents[2] / "game-runtime/games/_wg1-gen/_shared/playtest-v3.core.cjs" registry = Path(__file__).resolve().parents[2] / "contracts/play-loop/proof-obligations.v2.json" code = ( "const fs=require('fs');const c=require(process.argv[1]);" "const r=JSON.parse(fs.readFileSync(process.argv[2],'utf8'));" "const i=JSON.parse(process.argv[3]);" "const o=c.resolveProofObligations(r,i.proofProfileId,{genre:i.genre," "templateRoute:i.templateRoute,registryVersion:i.proofRegistryVersion," "taskBindingHash:i.taskBindingHash,interactionBinding:i.interactionBinding});" "process.stdout.write(JSON.stringify(o));" ) proc = subprocess.run( ["node", "-e", code, str(core), str(registry), json.dumps(identity)], capture_output=True, text=True, check=True, ) obligations = json.loads(proc.stdout) valid_swap = next(item for item in obligations["obligations"] if item["id"] == "puzzle.match3-valid-swap") assert obligations["interactionRuleMatches"] == ["puzzle-match3-standard-swap"] assert valid_swap["required"] is True def test_service_rejects_explicit_match3_profile_outside_shadow_before_scaffold(tmp_path, monkeypatch): """显式 interactionProfileId 只属于 v3_shadow 校准;v3 live 必须在 scaffold/Writer 前拒绝。""" import asyncio import cheap_studio _install_common_stubs(tmp_path, monkeypatch) monkeypatch.setattr(cheap_studio, "acceptance_v3_mode", lambda: "v3") scaffolded = [] monkeypatch.setattr(cheap_run, "scaffold", lambda *args, **kwargs: scaffolded.append(args) or {"ok": True}) summary, _ = asyncio.run(D.drive_cheap_generation({ "gameId": "match3-live", "traceId": "trace-match3-live", "brief": "标准三消解谜", "interactionProfileId": MATCH3_PROFILE_ID, })) assert scaffolded == [] assert summary["ok"] is False assert "v3_shadow" in (summary.get("stoppedReason") or summary.get("failureReason") or "") def _install_fake_http(monkeypatch, cred=None): """装 fake httpx.AsyncClient:setup 三 POST 返可控体(cred 缺省给全 id),patch 空体。供回合前 unlink / fail-fast 用例复用。""" import httpx class _Resp: def __init__(self, d): self._d = d def json(self): return self._d class _FakeHttp: def __init__(self, *a, **k): pass async def __aenter__(self): return self async def __aexit__(self, *a): return False async def post(self, url, **k): if url.endswith("/credential/"): return _Resp(cred if cred is not None else {"credential_id": "c1"}) return _Resp({"agent_id": "a1"} if url.endswith("/agent/") else {"session_id": "sess-9"} if url.endswith("/sessions/") else {}) async def patch(self, url, **k): return _Resp({}) monkeypatch.setattr(httpx, "AsyncClient", _FakeHttp) def _install_common_stubs(tmp_path, monkeypatch): """装 drive 公共桩:目录重定向到 tmp、scaffold ok、richness/key 旁路、凭据体桩(零网络/LLM)。""" import _bootstrap monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}") monkeypatch.setattr(cheap_run, "wg1_game_dir", lambda gid: tmp_path / "_wg1-gen" / gid) monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json") # W-AXIS 波1:driver scaffold 前 per-run 归档(archive_prior_run 用 _ARCHIVE_DIR 全局)——沙箱到 tmp, # 否则真驱动把预铺的 amgen-70012 移进真实仓 game-runtime/games/_amgen-archive(测试隔离泄漏)。 monkeypatch.setattr(cheap_run, "_ARCHIVE_DIR", tmp_path / "_amgen-archive") monkeypatch.setattr(cheap_run, "scaffold", lambda gid, tpl=None: {"ok": True, "output": ""}) async def _fake_richness(gid, *, brief=""): return {"score": None, "degraded": True, "reason": "stub"} monkeypatch.setattr("cheap_verify.verify_richness", _fake_richness) monkeypatch.setattr(_bootstrap, "ensure_api_key_env", lambda: None) monkeypatch.setattr(D, "_cheap_credential_payload", lambda user_token=None: {"data": {"type": "openai_credential", "api_key": "sk", "base_url": "http://x/v1"}}) # 有界轮询提速:sidecar 缺失时默认等 5s → 50ms(保留真实 bounded-poll,只缩窗口)。 _orig_rss = D._read_service_run_summary monkeypatch.setattr(D, "_read_service_run_summary", lambda gid, wait_s=0.05: _orig_rss(gid, wait_s=wait_s)) def test_drive_unlinks_stale_before_turn(tmp_path, monkeypatch): # 红线③(修 b,driver 侧):回合前清残留 verdict + service-run-summary。预铺上一局绿 verdict + 旧成本, # fake SSE not-ended(本回合 Service 零门跑、啥也没写)→ 断言 drive 后 ok=False、costRmb=0(没被上一局绿 verdict # 顶成假 succeeded、没读回上一局成本)。这封「零门跑路径:setup 静默失败 / SSE 超时未跑一次 check」。 import asyncio import json import service.control_plane as CP _install_common_stubs(tmp_path, monkeypatch) _install_fake_http(monkeypatch) # 预铺上一局残留(模拟同 gameId 重跑局的陈旧盘态):绿 verdict + 旧 summary。 (tmp_path / "amgen-70012").mkdir(parents=True) (tmp_path / "amgen-70012" / "bundle.iife.js").write_text("window.__GameBundle={};", encoding="utf-8") vev = tmp_path / "_wg1-gen" / "70012" / "evidence"; vev.mkdir(parents=True) (vev / "verdict.json").write_text(json.dumps({"pass": True, "guards": {"A_boot": {"pass": True}}}), encoding="utf-8") sev = tmp_path / "amgen-70012" / "evidence"; sev.mkdir(parents=True) (sev / "service-run-summary.json").write_text( json.dumps({"costRmb": 0.9, "rmbGate": "active", "repairs": 3}), encoding="utf-8") async def _not_ended(*a, **k): return {"ended": False, "reason": "total_timeout", "endEvent": None} monkeypatch.setattr(CP, "_wait_for_turn_end", _not_ended) summary, _ = asyncio.run(D.drive_cheap_generation({"gameId": 70012, "traceId": "tX", "brief": "点球"})) assert summary["ok"] is False, "陈旧绿 verdict 必须已被回合前清掉,不得顶成假 succeeded" assert summary["costRmb"] == 0.0, "陈旧 summary 也须清掉,成本不得读回上一局" def test_drive_setup_failfast_on_missing_id(tmp_path, monkeypatch): # #2:Service /credential 未返 id(setup 失败)→ driver fail-fast(ok=False + reason 反映 setup 失败), # 且不进入 _wait_for_turn_end(计数坐实)——不再 id=None 硬挂 ~600s SSE 空等。 import asyncio import service.control_plane as CP _install_common_stubs(tmp_path, monkeypatch) _install_fake_http(monkeypatch, cred={}) # /credential 返 {} 无 id → fail-fast called = {"wait": 0} async def _counting_wait(*a, **k): called["wait"] += 1 return {"ended": True, "reason": "REPLY_END"} monkeypatch.setattr(CP, "_wait_for_turn_end", _counting_wait) summary, _ = asyncio.run(D.drive_cheap_generation({"gameId": 70012, "traceId": "tf", "brief": "b"})) assert summary["ok"] is False assert "setup" in (summary.get("stoppedReason") or ""), "reason 应反映 setup 失败" assert called["wait"] == 0, "setup 失败必须 fail-fast,不进入 _wait_for_turn_end 空等" def test_worker_default_run_fn_is_service_driver(): # worker_service 默认 run_fn 已切到驱动 Service(不再进程内 run_studio)。 state = W.WorkerState() assert state.run_fn is W._service_run_fn