"""Dual-Claw 回归哨兵 —— R-DUAL-*(设计书 leagent_accel_belt_design.md §6,任务 #873)。 硬约束:Web Claw 与 IM Bot Claw 必须消费**同一份**能力清单与**同一套**工具命名。 本文件是「活体哨兵」:任何把 IM Claw 能力改回退(如 #866 覆盖 #764/#765 导致 mcp=[]/agents=[] 写死)的提交,会在 pre-push 阶段立即变红。 历史教训(见设计书 §2):双端对齐若靠「在第二份实现里再写一遍」必然被下一次覆盖 回退。**唯一根治法是消灭第二份实现**——即 im_llm 必须复用统一的 capability_discovery。 测试分两层: A. 始终运行(确定性、无网络):源码契约守卫 + DB 级能力一致性 + 命名同源守卫。 B. 可选运行(需联网 MCP/LLM):置 RUN_DUAL_INTEGRATION=1 时执行真实工具调用, 作为持续哨兵;默认 skip,避免 CI 网关因外部网络抖动而误杀正常提交。 """ import os import re import glob import sys import pytest _HERE = os.path.dirname(os.path.abspath(__file__)) APP_DIR = os.path.abspath(os.path.join(_HERE, "..", "app")) API_DIR = os.path.join(APP_DIR, "api") sys.path.insert(0, os.path.join(_HERE, "..")) from app.database import SessionLocal # noqa: E402 from app.core.capability_discovery import ( # noqa: E402 discover_skills, discover_mcp_servers, _safe_name, ) def _read_api_sources(): parts = [] for p in sorted(glob.glob(os.path.join(API_DIR, "chat*.py"))) + \ [os.path.join(API_DIR, "im_llm.py")]: if os.path.exists(p): with open(p, "r", encoding="utf-8") as f: parts.append(f.read()) return "\n".join(parts) @pytest.fixture(scope="module") def db(): session = SessionLocal() yield session session.close() # ───────────────────────────────────────────────────────────────────────────── # R-DUAL-2 (源码守卫):IM Claw 必须复用统一能力发现,禁止写死 mcp=[] # ───────────────────────────────────────────────────────────────────────────── def test_rdual2_im_reuses_unified_discovery(): """IM Bot 引擎必须从 capability_discovery 取能力,且不得残留 mcp=[] 写死。 这是 #866 覆盖 #764/#765 的精确回归签名:当时 im_llm 把 mcp=[] 硬编码, 导致 IM 永远拿不到 MCP 工具。本测试捕获该字面量复现。 """ im_src = "" im_path = os.path.join(API_DIR, "im_llm.py") with open(im_path, "r", encoding="utf-8") as f: im_src = f.read() # 1) 必须导入统一发现层 assert "from app.core.capability_discovery import" in im_src, \ "im_llm 未从 capability_discovery 导入——IM 可能又退化成第二份实现" assert "discover_skills" in im_src, "im_llm 未使用 discover_skills" assert "discover_mcp_servers" in im_src, "im_llm 未使用 discover_mcp_servers" # 2) 禁止写死 mcp=[](agents=[] 是允许的:当前无已发布 agent) assert "mcp=[]" not in im_src, \ "检测到 im_llm 写死 mcp=[]——IM Claw 的 MCP 能力已回退(#866 类回归)" # 3) _build_system_prompt 调用必须传入真实 mcp(而非空列表) assert "mcp=mcp_servers" in im_src or "mcp=mcp_servers," in im_src, \ "im_llm 未把发现的 MCP 服务器注入 system prompt" # ───────────────────────────────────────────────────────────────────────────── # R-DUAL-1 (DB 级一致性):双 Claw 返回同一份能力集合 # ───────────────────────────────────────────────────────────────────────────── def test_rdual1_skill_set_parity(db): """同一用户,Web 与 IM 必须发现相同的技能集合(统一发现层 cliff 保证)。""" web = discover_skills(db, "dual-claw-sentinel", claw="web") im = discover_skills(db, "dual-claw-sentinel", claw="im") assert len(web) > 0, "Web Claw 未发现任何技能(published fallback 可能失效)" web_names = {s.get("name") for s in web} im_names = {s.get("name") for s in im} assert web_names == im_names, ( f"Web 与 IM 技能集合不一致:\n web={sorted(web_names)}\n im={sorted(im_names)}" ) def test_rdual1_mcp_server_parity(db): """双 Claw 必须挂载相同的 MCP 服务器集合(IM 不再丢 MCP)。""" web = discover_mcp_servers(db, None, claw="web") im = discover_mcp_servers(db, None, claw="im") assert len(web) > 0, "未发现有启用的 MCP 服务器" web_names = sorted(s.get("name") for s in web) im_names = sorted(s.get("name") for s in im) assert web_names == im_names, ( f"Web 与 IM 的 MCP 服务器不一致:\n web={web_names}\n im={im_names}" ) # ───────────────────────────────────────────────────────────────────────────── # R-DUAL-4 (命名同源):双 Claw 工具命名必须同源,杜绝第二份实现发散 # ───────────────────────────────────────────────────────────────────────────── def test_rdual4_tool_naming_single_source(): """capability_discovery._safe_name 必须与 Web Claw 的命名公式完全一致。 Web Claw (chat_service.py:3428) 用: _sname = re.sub(r'[^A-Za-z0-9_-]', '_', name[:30]) _tname = re.sub(r'[^A-Za-z0-9_-]', '_', name[:50]) _fname = f"mcp__{_sname}__{_tname}__{mcp_idx}" 统一层必须用同一个公式,否则两端工具名对不上 → 工具调用在 IM 端失效。 """ # 统一层的公式 for raw, maxlen in [("My Server!", 30), ("some tool.name", 50), ("中文-工具", 50)]: expected = re.sub(r"[^A-Za-z0-9_-]", "_", raw[:maxlen]) assert _safe_name(raw, maxlen) == expected, \ f"_safe_name 公式与 Web Claw 不一致: {_safe_name(raw, maxlen)!r} != {expected!r}" # Web Claw 源码必须仍使用同一公式([:30] 给 server,[:50] 给 tool) cs_path = os.path.join(API_DIR, "chat_service.py") with open(cs_path, "r", encoding="utf-8") as f: cs_src = f.read() assert "re.sub(r'[^A-Za-z0-9_-]', '_', " in cs_src, \ "chat_service.py 工具命名公式缺失/被改写" assert "[:30]" in cs_src and "[:50]" in cs_src, \ "chat_service.py 的 server/tool 截断长度与统一层不匹配(应为 30/50)" # ───────────────────────────────────────────────────────────────────────────── # R-DUAL-5 (媒体结构契约):IM 必须保留 __media__ 结构化返回 # ───────────────────────────────────────────────────────────────────────────── def test_rdual5_im_preserves_media_struct(): """微信内联图片依赖 __media__ 结构化返回,禁止在 IM 端退化成纯文本。""" im_path = os.path.join(API_DIR, "im_llm.py") with open(im_path, "r", encoding="utf-8") as f: im_src = f.read() assert "__media__" in im_src, \ "im_llm 缺失 __media__ 结构化返回——微信内联图片将失效(R-DUAL-5)" # ───────────────────────────────────────────────────────────────────────────── # R-DUAL-2 / R-DUAL-3(真实集成,需联网,默认 skip) # ───────────────────────────────────────────────────────────────────────────── _RUN_LIVE = os.environ.get("RUN_DUAL_INTEGRATION") == "1" @pytest.mark.skipif(not _RUN_LIVE, reason="set RUN_DUAL_INTEGRATION=1 to run live MCP/LLM integration") @pytest.mark.asyncio async def test_rdual2_live_im_mcp_tools_reachable(db): """真实拉取启用 MCP 服务器的工具列表,证明 IM 端代理绕过已生效(对照 #P0 修复)。""" from app.mcp_client import list_mcp_tools servers = discover_mcp_servers(db, None, claw="im") assert servers, "没有启用 MCP 服务器可供实时验证" total = 0 for srv in servers: try: tools = await list_mcp_tools(srv, timeout=30, raise_on_error=False) except Exception: tools = [] total += len(tools or []) assert total > 0, "实时拉取 MCP 工具为 0——IM 端 MCP 代理可能再次被代理劫持" @pytest.mark.skipif(not _RUN_LIVE, reason="set RUN_DUAL_INTEGRATION=1 to run live agent integration") def test_rdual3_im_agent_capability(db): """若已发布 agent,IM 端必须能纳入;当前无已发布 agent 则 skip(待 #873 薄壳化)。""" from app.models import MarketApp agent_count = db.query(MarketApp).filter( MarketApp.type == "agent", MarketApp.status == "published" ).count() if agent_count == 0: pytest.skip("尚无已发布 agent(market_apps.type='agent'=0),IM agent 能力待 #873 薄壳化后激活") # 待 agent 接入后补充:断言 im_llm 经 EngineCore 获得 agent 编排能力 # ───────────────────────────────────────────────────────────────────────────── # R-DUAL-6(OTel 标签,#876 已完成):双 Claw span 须携带 tenant_id + claw_type # ───────────────────────────────────────────────────────────────────────────── def test_rdual6_spans_carry_tenant_and_claw(): """双 Claw 链路的 span 必须带有 tenant_id 与 claw_type 标签(双端横向对比看板依赖)。 #876 在 otel.current_span_cm 层强制注入 tenant_id + claw_type + trace_id, 入口经 set_otel_ctx / set_route_ctx 注入一次,全部 span 自动带维度(零签名侵入)。 本测试以确定性方式验证该强制注入对 Web / IM 双 Claw 均生效。 """ from app.core.otel import set_otel_ctx, current_span_cm, get_otel_ctx # 模拟 Web Claw 入口注入 set_otel_ctx(tenant_id="dual-claw-sentinel", claw_type="web") assert get_otel_ctx()["claw_type"] == "web" cm = current_span_cm("test_rdual6_web_span", {"model": "deepseek"}) if cm is None: pytest.skip("OTel tracer 不可用(OTEL_ENABLED=0 或 init 失败)—— 标签注入代码存在但未在本题环境演练") span = cm.__enter__() try: attrs = dict(span.attributes) assert attrs.get("tenant_id") == "dual-claw-sentinel", \ f"Web Claw span 缺失 tenant_id 标签: {attrs}" assert attrs.get("claw_type") == "web", \ f"Web Claw span 缺失 claw_type 标签: {attrs}" assert attrs.get("model") == "deepseek", \ f"业务字段 model 未透传: {attrs}" finally: cm.__exit__(None, None, None) # 模拟 IM Claw 入口注入 set_otel_ctx(tenant_id="dual-claw-sentinel", claw_type="im_wechat") cm2 = current_span_cm("test_rdual6_im_span") if cm2 is None: pytest.skip("OTel tracer 不可用,跳过 IM 断言") span2 = cm2.__enter__() try: attrs2 = dict(span2.attributes) assert attrs2.get("claw_type") == "im_wechat", \ f"IM Claw span 缺失 claw_type=im_wechat 标签: {attrs2}" assert attrs2.get("tenant_id") == "dual-claw-sentinel", \ f"IM Claw span 缺失 tenant_id 标签: {attrs2}" finally: cm2.__exit__(None, None, None)