"""test_service_synthetic_reply_end.py — Service 壳 run 崩合成终结事件单测(T5 慢失败 600s 出口封口)。 守的不变量:collector(最外层 on_reply)在内层 reply 生成器抛异常时—— · 先 yield 一枚合成 ReplyEndEvent(消费侧 _run_impl 的 async-for 会先 publish 它到 bus,driver 据此 立刻收到回合终结、快速失败),再把原异常原样上抛(不掩盖失败;框架 ChatService.run 照记日志); · 崩溃路也 flush 收口采集 sidecar(部分成本/repairs 可回收); · 正常路(REPLY_END 自然产生)行为不变:透传全部事件、REPLY_END 前 flush、不追加合成事件。 跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_service_synthetic_reply_end.py -v """ import asyncio import json 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_app as app # noqa: E402 class _FakeBreaker: spent_rmb = 1.23 _rmb_gate_active = True budget_soft_tripped = False class _FakeRepair: repairs = 2 class _FakeTracer: def summary(self): return {"traceId": "t", "steps": 0, "dropped": 0} class _FakeState: session_id = "sess-1" reply_id = "reply-1" class _FakeAgent: state = _FakeState() def _collector(game_id): cls = app._get_collector_cls() return cls(game_id=game_id, breaker=_FakeBreaker(), repair=_FakeRepair(), tracer=_FakeTracer()) def _consume(collector, handler): """驱动 on_reply,返回 (产出事件列表, 捕获的异常)。模拟框架 _run_impl 的 async-for 消费方。""" async def _run(): out, err = [], None try: async for evt in collector.on_reply(_FakeAgent(), {}, handler): out.append(evt) except Exception as e: # noqa: BLE001 err = e return out, err return asyncio.run(_run()) def _sidecar(game_id): return cheap_run.game_dir(game_id) / "evidence" / "service-run-summary.json" def test_run_crash_yields_synthetic_reply_end(tmp_path, monkeypatch): """run 崩(如压缩崩/框架异常):消费方先收到合成 ReplyEndEvent,再收到原异常(快失败链)。""" monkeypatch.setattr(cheap_run, "_GAMES_DIR", tmp_path) async def crashing_handler(**_kwargs): yield type("TextDeltaEvent", (), {})() # 崩前有若干普通事件 raise RuntimeError("'important_discoveries' is a required property") # 实证崩因形态 events, err = _consume(_collector("crash-t"), crashing_handler) assert err is not None and "important_discoveries" in str(err), "原异常必须原样上抛(不掩盖失败)" from agentscope.event import ReplyEndEvent assert events and isinstance(events[-1], ReplyEndEvent), "崩溃前必须先产出合成 ReplyEndEvent(快终结)" assert events[-1].session_id == "sess-1" and events[-1].reply_id == "reply-1" # 崩溃路收口采集也已落盘(部分成本可回收)。 sc = _sidecar("crash-t") assert sc.exists() obj = json.loads(sc.read_text(encoding="utf-8")) assert obj["costRmb"] == 1.23 and obj["repairs"] == 2 def test_normal_path_unchanged(tmp_path, monkeypatch): """正常路:真 REPLY_END 自然产生 → 全事件透传、不追加合成事件、REPLY_END 前已 flush。""" monkeypatch.setattr(cheap_run, "_GAMES_DIR", tmp_path) from agentscope.event import ReplyEndEvent real_end = ReplyEndEvent(session_id="sess-1", reply_id="reply-1") async def normal_handler(**_kwargs): yield type("TextDeltaEvent", (), {})() yield real_end events, err = _consume(_collector("ok-t"), normal_handler) assert err is None ends = [e for e in events if isinstance(e, ReplyEndEvent)] assert ends == [real_end], "正常路不得追加合成终结事件(只透传真 REPLY_END)" assert _sidecar("ok-t").exists() def test_synthetic_failure_still_reraises(tmp_path, monkeypatch): """合成兜底自身失败(极端):退回慢失败路,但原异常仍上抛、不被遮蔽。""" monkeypatch.setattr(cheap_run, "_GAMES_DIR", tmp_path) collector = _collector("synthfail-t") async def crashing_handler(**_kwargs): raise ValueError("original-crash") yield # pragma: no cover —— 使其成为 async generator # 让合成事件构造必败:agent.state 缺失 → getattr 兜空串仍能构造;改为直接破坏 import 不现实, # 这里用「state 为 None」走 getattr(None,...) 兜底路——合成仍应成功(空串 id 合法)。 class _NoStateAgent: state = None async def _run(): out, err = [], None try: async for evt in collector.on_reply(_NoStateAgent(), {}, crashing_handler): out.append(evt) except Exception as e: # noqa: BLE001 err = e return out, err events, err = asyncio.run(_run()) assert err is not None and "original-crash" in str(err) from agentscope.event import ReplyEndEvent # state=None 时合成事件用空串 id 仍产出(快终结优先);关键断言=原异常不被吞。 assert any(isinstance(e, ReplyEndEvent) for e in events) if __name__ == "__main__": print("请用 pytest 跑(用例依赖 tmp_path/monkeypatch fixture)。") sys.exit(1)