docs+tests: #873 事件契约与双 Claw Adapter 文档及哨兵测试
- 新增 tests/test_engine_event.py (31 用例: EngineEvent 线格式/to_wire/from_wire, SseAdapter 裁剪+帧格式+frames, BufferAdapter 累积/媒体/诊断/截断, Diagnosis, test_dual_claw_wiring_source_guard 校验 chat.py/im_llm.py 接线) - 新增 docs/dual-claw-event-contract.md (#873 契约文档 7 节) - 全量 pytest 64 用例全绿 (含 test_dual_claw/test_node_registry 无回归) - 仅文档/测试,非运行时代码,不 push
This commit is contained in:
1 parent
33d0f9c32e
commit
c1f8b164bb
2 files changed
+536
No files matched your search
@@ -0,0 +1,396 @@
|
||||
"""#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='<img src="https://x/y.png" alt="图">',
|
||||
)
|
||||
))
|
||||
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 类回归)"
|
||||
Reference in new issue
Block a user