"""#873 双 Claw 事件契约 · 单元哨兵(EngineEvent / ClawAdapter 层)。 为什么存在这个文件(#873 残留的「哨兵补」): R-DUAL 哨兵(test_dual_claw.py) 只验证了「Web/IM 双端能力一致 + OTel 标签」, 但从未直接断言 EngineEvent 工厂与两个 Adapter 的**线格式契约**。 这两份契约是单引擎 / 双 Claw 的物理基础 —— 一旦被破坏,前端静默不渲染, 且排查极难。本文件把它们锁死为确定性单测(无 DB / 无网络)。 验证范围: 1. EngineEvent 工厂:make_delta / reasoning / step / done / error 的线格式 2. to_wire() 与既有 ChatPage.tsx 逐键兼容(新增键非空才输出) 3. from_wire() 把遗留裸 dict 提升为 EngineEvent(迁移期双向兼容) 4. SseAdapter._thin() 默认剥离热帧元数据、终帧保留 trace_id 5. SseAdapter.frame() SSE 线格式 + frames() 生成器直到哨兵 6. BufferAdapter 累积 / 媒体结构化返回 / 诊断尾注 / 截断 7. 双 Claw 接线契约:SseAdapter.claw=="web" / BufferAdapter.claw=="im" + chat.py / im_llm.py 必须引用对应 Adapter(消灭第二份实现) """ import asyncio import json import os import sys import pytest _HERE = os.path.dirname(os.path.abspath(__file__)) APP_DIR = os.path.abspath(os.path.join(_HERE, "..", "app")) sys.path.insert(0, os.path.join(_HERE, "..")) from app.core.engine_event import ( # noqa: E402 CLAW_IM, CLAW_WEB, DELTA, DONE, ERROR, REASONING, STEP, SEV_ERROR, SEV_INFO, Diagnosis, EngineEvent, make_delta, make_done, make_error, make_reasoning, make_step, from_wire, ) from app.core.claw_adapters import ( # noqa: E402 SseAdapter, BufferAdapter, ) # ===================================================================== # 1. EngineEvent 工厂线格式 # ===================================================================== def test_make_delta_wire(): ev = make_delta("你好") assert ev.type == DELTA assert ev.to_wire() == {"type": "delta", "content": "你好"} def test_make_reasoning_wire(): ev = make_reasoning("让我想想") assert ev.to_wire() == {"type": "reasoning", "content": "让我想想"} def test_make_step_thinking(): ev = make_step("thinking", text="分析中") w = ev.to_wire() assert w["type"] == STEP assert w["step"] == {"type": "thinking", "text": "分析中", "args": {}, "result": ""} def test_make_step_skill_mcp(): ev = make_step("skill", name="天气查询", server="", args={"city": "北京"}, result="晴") w = ev.to_wire() step = w["step"] assert step["type"] == "skill" assert step["name"] == "天气查询" assert step["args"] == {"city": "北京"} assert step["result"] == "晴" ev2 = make_step("mcp", server="srv", name="tool_a", args={"x": 1}) step2 = ev2.to_wire()["step"] assert step2["server"] == "srv" assert step2["name"] == "tool_a" def test_make_step_index_and_node(): ev = make_step("skill", name="s", index=3, node={"id": "n1", "type": "llm", "label": "L"}) w = ev.to_wire() assert w["index"] == 3 assert w["step"]["node"] == {"id": "n1", "type": "llm", "label": "L"} def test_make_step_error_diagnosis_fallback_result(): ev = make_step( "mcp", server="srv", name="t", phase="fail", diagnosis={"code": "MCP_TIMEOUT", "severity": "error", "summary": "超时", "detail": "d", "hint": "重试"}, ) w = ev.to_wire() # 失败且 result 为空时,必须给可读 result 兜底,否则前端判定「运行中」 assert w["step"]["result"] == "❌ 超时" assert w["step"]["phase"] == "fail" assert w["step"]["diagnosis"]["code"] == "MCP_TIMEOUT" def test_make_done_full_fields(): ev = make_done( message_id="m1", conversation_id="c1", content="最终回复", process_steps=[{"type": "thinking", "text": "x"}], used_skills=["s"], used_mcp=["m"], generating=False, ) w = ev.to_wire() assert w["type"] == DONE assert w["message_id"] == "m1" assert w["conversation_id"] == "c1" assert w["content"] == "最终回复" assert w["process_steps"] == [{"type": "thinking", "text": "x"}] assert w["used_skills"] == ["s"] assert w["used_mcp"] == ["m"] assert w["generating"] is False def test_make_error_wire(): ev = make_error("崩了", diagnosis={"code": "BOOM", "severity": "error", "summary": "内部错误"}) w = ev.to_wire() assert w["type"] == ERROR assert w["message"] == "崩了" assert w["diagnosis"]["code"] == "BOOM" def test_ctx_strips_common_keys(): # 通用上下文键(seq/trace_id/tenant_id/claw) 必须从 kw 抽出,不污染 extra ev = make_delta("x", seq=5, trace_id="t1", tenant_id="ten", claw="web") assert ev.seq == 5 assert ev.trace_id == "t1" assert ev.tenant_id == "ten" assert ev.claw == "web" assert ev.to_wire() == {"type": "delta", "content": "x"} # 默认不输出元数据 # ===================================================================== # 2. to_wire 与既有前端逐键兼容(新增键非空才输出) # ===================================================================== def test_to_wire_omits_empty_meta_by_default(): ev = make_delta("hi") w = ev.to_wire() # 默认不输出 seq/claw/trace_id/tenant_id/diagnosis assert "seq" not in w assert "claw" not in w assert "trace_id" not in w assert "tenant_id" not in w assert "diagnosis" not in w def test_to_wire_emits_meta_when_present(): ev = make_done(message_id="m", conversation_id="c", content="ok", trace_id="tr") w = ev.to_wire() # done 终帧本身带 content/message_id 等,trace_id 因非空而输出 assert w["trace_id"] == "tr" assert w["message_id"] == "m" # ===================================================================== # 3. from_wire 把遗留裸 dict 提升为 EngineEvent # ===================================================================== def test_from_wire_delta_dict(): ev = from_wire({"type": "delta", "content": "hi"}) assert isinstance(ev, EngineEvent) assert ev.type == DELTA assert ev.content == "hi" def test_from_wire_passthrough_engineevent(): orig = make_delta("x") assert from_wire(orig) is orig def test_from_wire_rejects_malformed(): assert from_wire(None) is None assert from_wire("not a dict") is None assert from_wire({"content": "no type"}) is None def test_from_wire_done_roundtrip_content(): d = {"type": "done", "message_id": "m", "conversation_id": "c", "content": "fin", "process_steps": [], "used_skills": [], "used_mcp": [], "generating": False} ev = from_wire(d) w = ev.to_wire() # content 必须原样吐回,前端才能渲染终帧 assert w["content"] == "fin" assert w["message_id"] == "m" # ===================================================================== # 4. SseAdapter 热帧裁剪 + SSE 线格式 # ===================================================================== def test_sse_adapter_thins_meta_by_default(): sse = SseAdapter() ev = make_delta("热帧", seq=1, trace_id="t", tenant_id="ten", claw="web") wire = sse._thin(ev, ev.to_wire()) # 默认剥离 seq/claw/tenant_id;trace_id 在热帧也剥离 assert "seq" not in wire assert "claw" not in wire assert "tenant_id" not in wire assert "trace_id" not in wire assert wire == {"type": "delta", "content": "热帧"} def test_sse_adapter_keeps_trace_on_terminal(): sse = SseAdapter() ev = make_done(message_id="m", conversation_id="c", content="ok", trace_id="TRACE-1") wire = sse._thin(ev, ev.to_wire()) # 终帧保留 trace_id(排障唯一抓手),其余元数据仍剥离 assert wire["trace_id"] == "TRACE-1" assert "seq" not in wire assert "tenant_id" not in wire def test_sse_adapter_wire_meta_true_keeps_all(): sse = SseAdapter(wire_meta=True) ev = make_delta("x", seq=2, trace_id="t", tenant_id="ten", claw="web") wire = sse._thin(ev, ev.to_wire()) assert wire["seq"] == 2 assert wire["claw"] == "web" assert wire["trace_id"] == "t" assert wire["tenant_id"] == "ten" def test_sse_frame_format(): frame = SseAdapter.frame({"type": "delta", "content": "hi"}) assert frame == 'data: {"type": "delta", "content": "hi"}\n\n' def test_sse_frames_generator_until_sentinel(): async def _run(): sse = SseAdapter() await sse.emit(make_delta("一")) await sse.emit(make_done(message_id="m", conversation_id="c", content="终", trace_id="T")) await sse.close() frames = [f async for f in sse.frames()] return frames frames = asyncio.run(_run()) assert len(frames) == 2 assert frames[0] == 'data: {"type": "delta", "content": "一"}\n\n' assert frames[1].startswith('data: {"type": "done"') assert 'trace_id": "T"' in frames[1] assert not frames[1].endswith('"}\n\n') or '"}\n\n' in frames[1] def test_sse_emit_counts_and_swallow_exceptions(): async def _run(): sse = SseAdapter() async def boom(ev): raise RuntimeError("adapter must not crash engine") sse._handle = boom # type: ignore await sse.emit(make_delta("a")) await sse.emit(make_delta("b")) return dict(sse.counts) counts = asyncio.run(_run()) # 异常被吞掉,但计数仍记录(Adapter 永不反噬引擎) assert counts.get("delta") == 2 # ===================================================================== # 5. BufferAdapter 累积 / 媒体 / 诊断 # ===================================================================== def test_buffer_accumulates_delta_and_done_priority(): buf = BufferAdapter() asyncio.run(buf.emit(make_delta("第一句"))) asyncio.run(buf.emit(make_delta("第二句"))) # 还没 done 时,text 是累积的 delta assert buf.text == "第一句第二句" asyncio.run(buf.emit(make_done(message_id="m", conversation_id="c", content="终稿"))) # done 的 content 优先于累积 delta assert buf.text == "终稿" out = buf.assemble() assert out == "终稿" def test_buffer_drops_reasoning_by_default_keeps_when_flagged(): buf = BufferAdapter() asyncio.run(buf.emit(make_reasoning("偷偷想"))) asyncio.run(buf.emit(make_delta("公开内容"))) assert "偷偷想" not in buf.text buf2 = BufferAdapter(keep_reasoning=True) asyncio.run(buf2.emit(make_reasoning("可见思考"))) asyncio.run(buf2.emit(make_delta("正文"))) assert "可见思考" in buf2.text def test_buffer_extracts_media_struct(): buf = BufferAdapter() asyncio.run(buf.emit( make_done( message_id="m", conversation_id="c", content='图', ) )) out = buf.assemble() # 媒体必须返回结构化 dict(IM 内联发图依赖),禁止退化成纯文本 assert isinstance(out, dict) assert out.get("__media__") is True assert out.get("media_type") == "image" assert out.get("url") == "https://x/y.png" def test_buffer_diagnosis_tail_note(): buf = BufferAdapter() asyncio.run(buf.emit(make_delta("正文"))) asyncio.run(buf.emit( make_step("mcp", server="s", name="t", phase="fail", diagnosis={"code": "E1", "severity": "error", "summary": "工具挂了"}) )) asyncio.run(buf.emit(make_done(message_id="m", conversation_id="c", content="正文"))) out = buf.assemble() assert "工具挂了" in out # 失败步骤诊断降级成尾注 def test_buffer_error_render(): buf = BufferAdapter() asyncio.run(buf.emit(make_error("生成中断", diagnosis={"code": "X", "severity": "error", "summary": "失败"}))) out = buf.assemble() assert "失败" in out def test_buffer_truncates_oversized(): buf = BufferAdapter() big = "标题\n" + ("长内容" * 6000) asyncio.run(buf.emit(make_delta(big))) asyncio.run(buf.emit(make_done(message_id="m", conversation_id="c", content=big))) out = buf.assemble() assert len(out) <= 20000 + 40 # 截断上限 + 尾注余量 assert "已截断" in out # ===================================================================== # 6. Diagnosis 结构 # ===================================================================== def test_diagnosis_to_text_im_degradation(): d = Diagnosis(code="C", severity=SEV_ERROR, summary="坏了", detail="细节", hint="重试") txt = d.to_text() assert "坏了" in txt assert "细节" in txt assert "重试" in txt def test_diagnosis_roundtrip(): d = Diagnosis(code="C", severity=SEV_INFO, summary="s", detail="d", hint="h", retryable=True) dd = Diagnosis.from_dict(d.to_dict()) assert dd.code == "C" and dd.retryable is True # ===================================================================== # 7. 双 Claw 接线契约(消灭第二份实现) # ===================================================================== def test_dual_claw_channel_labels(): assert SseAdapter().claw == CLAW_WEB == "web" assert BufferAdapter().claw == CLAW_IM == "im" def test_dual_claw_wiring_source_guard(): """chat.py 必须引用 SseAdapter;im_llm.py 必须引用 BufferAdapter。 这是 #873 单引擎原则的硬约束:能力只在 EngineCore 实现一次, 两个 Claw 的差异只能收敛在 claw_adapters.py 的两个 Adapter 里, 禁止在 chat/im_llm 再写一份事件序列化逻辑。 """ api_dir = os.path.join(APP_DIR, "api") chat_src = "" im_src = "" for name in ("chat.py", "chat_service.py"): p = os.path.join(api_dir, name) if os.path.exists(p): chat_src = open(p, "r", encoding="utf-8").read() break im_path = os.path.join(api_dir, "im_llm.py") if os.path.exists(im_path): im_src = open(im_path, "r", encoding="utf-8").read() assert "SseAdapter" in chat_src, "chat.py 未引用 SseAdapter(Web Claw 事件序列化可能回退到内联实现)" assert "BufferAdapter" in im_src, "im_llm.py 未引用 BufferAdapter(IM Claw 可能又出现第二份实现,#866 类回归)"