feat(routing): ordered backend candidates + real-probing doctor

- backends is now an ordered candidate list (first = preferred); channels
  report the backend actually serving via active_backend, surfaced in the
  doctor text report and --json
- new agent_reach/probe.py really executes upstream commands and tells
  apart missing / broken (stale venv shebang after a system Python
  upgrade) / timeout, with a reinstall prescription for broken installs
- all 13 channels migrated off which()-only checks: fixes bilibili
  false-positive "bili-cli 可用" on broken shims, misleading xiaohongshu
  "连接失败", rdt OSError crashing doctor, mcporter breakage masquerading
  as "未配置"
- twitter: 15s probe + 1 retry (flaky 10s timeout), broken twitter-cli
  now falls back to bird instead of aborting the check
- doctor survives per-channel exceptions; config supports per-channel
  backend override (<channel>_backend / <CHANNEL>_BACKEND env)
- fix skill install/uninstall crash on symlinked skill dirs (the
  "[Errno None] None" warning from shutil.rmtree on a symlink)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Pnant
2026-06-11 15:48:33 +08:00
parent 447dc4acc4
commit 762824c590
23 changed files with 988 additions and 177 deletions
+73
View File
@@ -1,10 +1,17 @@
# -*- coding: utf-8 -*-
"""Contract tests for channel adapters."""
import subprocess
from agent_reach.channels import get_all_channels
from agent_reach.config import Config
def _fake_run_ok(cmd, **kwargs):
"""Pretend any probed CLI executes fine and prints a version."""
return subprocess.CompletedProcess(cmd, 0, "2026.06.09", "")
def test_channel_registry_contract():
channels = get_all_channels()
assert channels, "channel registry must not be empty"
@@ -29,6 +36,67 @@ def test_channel_check_contract_with_minimal_runtime(monkeypatch, tmp_path):
assert isinstance(message, str) and message.strip()
def test_channel_active_backend_attribute_contract():
"""Every channel exposes active_backend (default None, or str once set)."""
for ch in get_all_channels():
assert hasattr(ch, "active_backend")
# Fresh instances must default to None / str (class attribute on base)
fresh = type(ch)()
assert fresh.active_backend is None or isinstance(fresh.active_backend, str)
def test_channel_active_backend_set_by_check(monkeypatch, tmp_path):
"""After check(), active_backend is None or a str — never anything else."""
monkeypatch.setattr("shutil.which", lambda _cmd: None)
# Keep the network-based channels (V2EX/Xueqiu/Bilibili API) deterministic.
import urllib.request
from urllib.error import URLError
def _no_net(*_a, **_k):
raise URLError("offline")
monkeypatch.setattr(urllib.request, "urlopen", _no_net)
import agent_reach.channels.xueqiu as xueqiu_mod
monkeypatch.setattr(xueqiu_mod, "_cookies_initialized", True)
monkeypatch.setattr(xueqiu_mod._opener, "open", _no_net)
config = Config(config_path=tmp_path / "config.yaml")
for ch in get_all_channels():
ch.check(config)
assert ch.active_backend is None or isinstance(ch.active_backend, str), (
f"{ch.name}: active_backend must be None or str after check()"
)
def test_ordered_backends_contract(tmp_path):
"""ordered_backends(config) is a reordering (same multiset) of backends."""
config = Config(config_path=tmp_path / "config.yaml")
for ch in get_all_channels():
ordered = ch.ordered_backends(config)
assert isinstance(ordered, list)
assert sorted(ordered) == sorted(ch.backends), (
f"{ch.name}: ordered_backends must be a permutation of backends"
)
# And without any config at all
ordered_none = ch.ordered_backends(None)
assert sorted(ordered_none) == sorted(ch.backends)
def test_ordered_backends_override_moves_backend_to_front():
"""Config key <channel>_backend promotes the named backend to front."""
from agent_reach.channels.twitter import TwitterChannel
ch = TwitterChannel()
ordered = ch.ordered_backends({"twitter_backend": "bird"})
assert ordered[0] == "bird CLI (legacy)"
assert sorted(ordered) == sorted(ch.backends)
# Unknown override is ignored — never hides working backends
ordered_unknown = ch.ordered_backends({"twitter_backend": "no-such-tool"})
assert ordered_unknown == list(ch.backends)
def test_youtube_warns_when_node_only_and_no_config(monkeypatch, tmp_path):
"""YouTube should warn when only Node.js is installed but no yt-dlp config exists."""
from agent_reach.channels.youtube import YouTubeChannel
@@ -41,6 +109,7 @@ def test_youtube_warns_when_node_only_and_no_config(monkeypatch, tmp_path):
return None # deno not installed
monkeypatch.setattr("shutil.which", fake_which)
monkeypatch.setattr("subprocess.run", _fake_run_ok) # yt-dlp probe really executes now
# Point to a non-existent config file
monkeypatch.setattr("os.path.expanduser", lambda p: str(tmp_path / ".config/yt-dlp/config"))
@@ -48,6 +117,7 @@ def test_youtube_warns_when_node_only_and_no_config(monkeypatch, tmp_path):
status, message = ch.check()
assert status == "warn"
assert "--js-runtimes" in message
assert ch.active_backend == "yt-dlp" # 本体活着,warn 只关乎 JS runtime
def test_youtube_warns_with_windows_specific_fix_command(monkeypatch, tmp_path):
@@ -62,6 +132,7 @@ def test_youtube_warns_with_windows_specific_fix_command(monkeypatch, tmp_path):
return None
monkeypatch.setattr("shutil.which", fake_which)
monkeypatch.setattr("subprocess.run", _fake_run_ok) # yt-dlp probe really executes now
monkeypatch.setattr("agent_reach.utils.paths.sys.platform", "win32")
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
@@ -84,10 +155,12 @@ def test_youtube_ok_when_deno_installed(monkeypatch):
return None
monkeypatch.setattr("shutil.which", fake_which)
monkeypatch.setattr("subprocess.run", _fake_run_ok) # yt-dlp probe really executes now
ch = YouTubeChannel()
status, _msg = ch.check()
assert status == "ok"
assert ch.active_backend == "yt-dlp"
def test_channel_can_handle_contract():