games-development-ai/cheap-worker/tests/test_cheap_service_driver.py
lili 21186b81b0 fix(cheap): 封死 M3 400 流式 tool_calls 聚合炸弹——parallel_tool_calls=False 主防线 + 同 index 异 id 按 id 分桶兜底补丁 (fix400 A/B/C)
根因(前置诊断坐实):agentscope 2.0.2 _parse_stream_response 按 tool_call.index 分桶,
M3 经 new-api 并行 call 不区分 index → 第二 call 的 arguments 拼进首桶(非法 JSON)→
服务端丢 call → tool result 孤儿 → 400 code 2013 → ChatService.run 吞异常 → SSE 无终结 →
driver idle 600s 慢失败。触发开关 = Service get_model 不传 stream(类默认 True)。

A 主防线:driver session parameters 加 parallel_tool_calls=False(经 Parameters 透传,
  _call_api 对 API 带 parallel_tool_calls=false,源头禁并行);fake-SSE 契约测试加断言锚。
B 纵深兜底:m3_stream_patch 运行时 monkeypatch(绝不改 venv 本体;钉 agentscope==2.0.2,
  升级必须复核):chunk 流 index 重映射——同 index 但携非空且不同 id 按 id 开新桶;聚合完
  args 非法 JSON log.warning 带指纹。挂载 build_cheap_app(import agentscope 后)。
  单测 6 项:2.0.2 真聚合函数复刻 93001 炸弹指纹对照 + 补丁分桶 + 正常流不回归 +
  同 id 复帧同桶 + 坏 args warning 指纹 + apply 幂等。
C 对账:tier2 服务路 anthropic_credential → AnthropicChatModel(stream 同样默认 True),
  但其聚合由 content_block_start 显式开桶(_anthropic/_model.py:359-368 赋值建桶,
  非 += 隐式拼接),无本炸弹路径;parallel_tool_calls 参数仅 openai_chat/dashscope 有。
  tier2 进程内路 build_model stream=False(worker/config.py)不走流式聚合。均不动。

测试:test_m3_stream_patch 6/6 + test_cheap_service_driver/app 19/19 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:09:44 -07:00

292 lines
18 KiB
Python

"""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
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
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")
monkeypatch.setattr(D, "_resolve_key", lambda: "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_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 = {"pass": True, "guards": {"A_boot": {"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 = {"pass": True, "guards": {"A_boot": {"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")
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: {"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}}}), 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)
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
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 _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")
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: {"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