diff --git a/agent_reach/backends/opencli.py b/agent_reach/backends/opencli.py index 816d33a..e03ba9e 100644 --- a/agent_reach/backends/opencli.py +++ b/agent_reach/backends/opencli.py @@ -9,20 +9,51 @@ Probing notes (verified live): - `opencli doctor` AUTO-STARTS the daemon — a side effect, so health checks must use `opencli daemon status` (pure query) instead. - Exit codes are always 0; status must be parsed from text output. - - Extension connectivity is volatile (drops when Chrome restarts and - reconnects on demand) — report it, but treat "installed + daemon - reachable" as the stable signal. + - "Extension: disconnected" does NOT mean unusable: the extension's + service worker sleeps and any real opencli command wakes it up + (verified: status flips disconnected→connected after one call). + Since daemon status can't tell "sleeping" from "never installed", + we check Chrome's Extensions directory on disk to disambiguate. """ +import glob +import os from dataclasses import dataclass from agent_reach.probe import probe_command OPENCLI_PACKAGE = "@jackwener/opencli" +OPENCLI_EXTENSION_ID = "ildkmabpimmkaediidaifkhjpohdnifk" OPENCLI_EXTENSION_URL = ( - "https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk" + f"https://chromewebstore.google.com/detail/opencli/{OPENCLI_EXTENSION_ID}" ) +#: Chrome-family profile roots that contain /Extensions// +_CHROME_PROFILE_ROOTS = ( + "~/Library/Application Support/Google/Chrome", # macOS Chrome + "~/Library/Application Support/Chromium", # macOS Chromium + "~/.config/google-chrome", # Linux Chrome + "~/.config/chromium", # Linux Chromium +) + + +def _extension_installed_on_disk() -> bool: + """True if the OpenCLI extension exists in any Chrome profile. + + Store-installed extensions always live under + /Extensions// — this disambiguates a sleeping + service worker from a never-installed extension. Dev installs via + "Load unpacked" are not covered (those users can read `opencli doctor`). + """ + roots = [os.path.expanduser(p) for p in _CHROME_PROFILE_ROOTS] + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: # Windows + roots.append(os.path.join(local_app_data, "Google", "Chrome", "User Data")) + for root in roots: + if glob.glob(os.path.join(root, "*", "Extensions", OPENCLI_EXTENSION_ID)): + return True + return False + @dataclass class OpenCLIStatus: @@ -30,13 +61,20 @@ class OpenCLIStatus: broken: bool = False daemon_running: bool = False extension_connected: bool = False + extension_installed: bool = False version: str = "" hint: str = "" @property def ready(self) -> bool: - """Fully usable right now: extension connected to the daemon.""" - return self.installed and not self.broken and self.extension_connected + """Usable now or on first call. + + A live connection counts, and so does an installed-but-sleeping + extension: its service worker wakes on the first real command. + """ + return self.installed and not self.broken and ( + self.extension_connected or self.extension_installed + ) def opencli_status(timeout: int = 10) -> OpenCLIStatus: @@ -73,11 +111,13 @@ def opencli_status(timeout: int = 10) -> OpenCLIStatus: st.extension_connected = "disconnected" not in line and "connected" in line if not st.extension_connected: - st.hint = ( - "OpenCLI 已安装,但 Chrome 扩展未连接。\n" - f" 1. 安装扩展(需手动点一次):{OPENCLI_EXTENSION_URL}\n" - " 2. 保持 Chrome 打开,运行 `opencli doctor` 验证" - ) + st.extension_installed = _extension_installed_on_disk() + if not st.extension_installed: + st.hint = ( + "OpenCLI 已安装,但 Chrome 扩展未安装。\n" + f" 1. 安装扩展(需手动点一次):{OPENCLI_EXTENSION_URL}\n" + " 2. 保持 Chrome 打开,运行 `opencli doctor` 验证" + ) return st @@ -87,8 +127,10 @@ def opencli_summary(st: OpenCLIStatus) -> str: return "OpenCLI 未安装" if st.broken: return "OpenCLI 无法执行(node 环境损坏)" - if st.ready: + if st.extension_connected: return f"OpenCLI 可用(浏览器登录态,v{st.version})" + if st.ready: + return "OpenCLI 可用(扩展睡眠中,调用时自动唤醒)" if st.daemon_running: - return "OpenCLI 已安装,等待 Chrome 扩展连接" + return "OpenCLI 已安装,等待 Chrome 扩展安装" return "OpenCLI 已安装(daemon 未运行,使用时自动启动;需 Chrome 扩展)" diff --git a/agent_reach/channels/xiaohongshu.py b/agent_reach/channels/xiaohongshu.py index 5549f19..f60ab80 100644 --- a/agent_reach/channels/xiaohongshu.py +++ b/agent_reach/channels/xiaohongshu.py @@ -1,10 +1,41 @@ # -*- coding: utf-8 -*- -"""XiaoHongShu — check if xhs-cli (xiaohongshu-cli) is available.""" +"""XiaoHongShu — multi-backend: OpenCLI / xiaohongshu-mcp / xhs-cli. + +Backend order encodes the recommendation, and probing order makes the +environment split automatic: OpenCLI needs a desktop Chrome so it simply +never probes alive on a server, where xiaohongshu-mcp (self-contained +headless browser) takes over. xhs-cli (upstream unmaintained since +2026-03) keeps working for existing installs as the last candidate. +""" + +import urllib.error +import urllib.request from agent_reach.probe import probe_command from .base import Channel +_MCP_ENDPOINT = "http://localhost:18060/mcp" +_MCP_INSTALL_URL = "https://github.com/xpzouying/xiaohongshu-mcp" + + +def _mcp_service_reachable(timeout: int = 3) -> bool: + """True if the xiaohongshu-mcp HTTP service answers on localhost. + + Any HTTP response counts (the MCP endpoint replies 405 to GET) — + we only care that the service is up. Proxies are bypassed explicitly: + localhost must never be routed through HTTP_PROXY. + """ + req = urllib.request.Request(_MCP_ENDPOINT, method="GET") + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + try: + opener.open(req, timeout=timeout) + return True + except urllib.error.HTTPError: + return True # 405/404 etc. — service is alive + except Exception: + return False + def format_xhs_result(data): """Clean XHS API response, keeping only useful fields. @@ -118,7 +149,7 @@ def _clean_comment(comment): class XiaoHongShuChannel(Channel): name = "xiaohongshu" description = "小红书笔记" - backends = ["xhs-cli (xiaohongshu-cli)"] + backends = ["OpenCLI", "xiaohongshu-mcp", "xhs-cli (xiaohongshu-cli)"] tier = 1 def can_handle(self, url: str) -> bool: @@ -127,19 +158,83 @@ class XiaoHongShuChannel(Channel): return "xiaohongshu.com" in d or "xhslink.com" in d def check(self, config=None): + """Probe candidates in order; first fully-usable backend wins. + + If none is fully usable, the first fixable candidate (warn) is + reported, so the user gets one actionable prescription instead + of three half-relevant ones. + """ self.active_backend = None + findings = [] # (backend, status, message) + + for backend in self.ordered_backends(config): + if backend == "OpenCLI": + result = self._check_opencli() + elif backend == "xiaohongshu-mcp": + result = self._check_mcp() + else: + result = self._check_xhs_cli() + if result is None: + continue # not installed — not a candidate right now + findings.append((backend, *result)) + + for wanted in ("ok", "warn"): + for backend, status, message in findings: + if status == wanted: + self.active_backend = backend + return status, message + + if findings: # only broken candidates left + return "error", "\n".join(m for _, _, m in findings) + + return "off", ( + "未安装任何小红书后端。推荐:\n" + " 桌面:agent-reach install --channels opencli\n" + " (复用 Chrome 登录态,刷过小红书即零配置可用)\n" + f" 服务器:xiaohongshu-mcp(自带无头浏览器+扫码登录):{_MCP_INSTALL_URL}" + ) + + def _check_opencli(self): + """OpenCLI candidate. None = not installed.""" + from agent_reach.backends import opencli_status + + st = opencli_status() + if not st.installed: + return None + if st.broken: + return "error", st.hint + if st.ready: + return "ok", ( + "OpenCLI 可用(复用浏览器登录态)。用法:" + "opencli xiaohongshu search/note/comments/feed -f yaml" + ) + return "warn", st.hint + + def _check_mcp(self): + """xiaohongshu-mcp candidate. None = service not running.""" + if not _mcp_service_reachable(): + return None + mcporter = probe_command( + "mcporter", ["config", "list"], timeout=10, package="mcporter" + ) + if mcporter.ok and "xiaohongshu" in mcporter.output: + return "ok", ( + "xiaohongshu-mcp 服务运行中" + "(mcporter call 'xiaohongshu.search_feeds(keyword: \"...\")')。" + "若未登录,让 agent 调 get_login_qrcode 扫码" + ) + return "warn", ( + "xiaohongshu-mcp 服务在跑但 mcporter 未接入。运行:\n" + f" mcporter config add xiaohongshu {_MCP_ENDPOINT}" + ) + + def _check_xhs_cli(self): + """Legacy xhs-cli candidate. None = not installed.""" probe = probe_command( "xhs", ["status"], timeout=10, package="xiaohongshu-cli" ) - if probe.status == "missing": - return "off", ( - "需要安装 xhs-cli:\n" - " pipx install xiaohongshu-cli\n" - "或:\n" - " uv tool install xiaohongshu-cli\n" - "安装后运行 `xhs login` 登录" - ) + return None if probe.status == "broken": return "error", "xhs 命令存在但无法执行\n" + probe.hint if probe.status == "timeout": @@ -147,19 +242,16 @@ class XiaoHongShuChannel(Channel): # 进程是活的(执行成功或运行后非零退出)——按输出内容分类 if probe.ok and "ok: true" in probe.output: - self.active_backend = self.backends[0] return "ok", ( - "完整可用(搜索、阅读、评论、发帖、热门、" - "收藏、关注、用户查询)" + "xhs-cli 可用(搜索、阅读、评论、热门;上游 2026-03 起停更," + "桌面用户建议迁移到 OpenCLI)" ) if "not_authenticated" in probe.output or "expired" in probe.output: - self.active_backend = self.backends[0] return "warn", ( "xhs-cli 已安装但未登录。运行:\n" " xhs login\n" "(自动从浏览器提取 Cookie,或扫码登录)" ) - self.active_backend = self.backends[0] return "warn", ( "xhs-cli 已安装但状态异常。运行:\n" " xhs -v status 查看详细信息" diff --git a/agent_reach/cli.py b/agent_reach/cli.py index 99db8b8..e9e8bec 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -699,26 +699,29 @@ def _install_twitter_deps(): def _install_xhs_deps(): - """Install xhs-cli (xiaohongshu-cli) for XiaoHongShu.""" - import shutil - import subprocess + """Set up XiaoHongShu — backend depends on environment. - print("Setting up XiaoHongShu (xhs-cli)...") - if shutil.which("xhs"): - print(" ✅ xhs-cli already installed") + Desktop: OpenCLI (reuses the browser session, zero config). + Server: xiaohongshu-mcp guide (self-contained headless browser + QR + login; we don't manage long-running services, so guide only). + xhs-cli is no longer installed by default — upstream unmaintained + since 2026-03; existing installs keep working as a fallback backend. + """ + import shutil + + print("Setting up XiaoHongShu...") + if _detect_environment() == "server": + print(" 服务器环境推荐 xiaohongshu-mcp(自带无头浏览器,扫码登录):") + print(" 1. 下载 binary:https://github.com/xpzouying/xiaohongshu-mcp/releases") + print(" (建议放到 ~/.agent-reach/tools/ 下)") + print(" 2. 启动服务(首次运行会下载约 150MB 浏览器,请等待完成)") + print(" 3. 扫码登录后接入:mcporter config add xiaohongshu http://localhost:18060/mcp") + print(" 4. 验证:agent-reach doctor") return - for tool, cmd in [("pipx", ["pipx", "install", "xiaohongshu-cli"]), - ("uv", ["uv", "tool", "install", "xiaohongshu-cli"])]: - if shutil.which(tool): - try: - subprocess.run(cmd, capture_output=True, encoding="utf-8", - errors="replace", timeout=120) - if shutil.which("xhs"): - print(" ✅ xhs-cli installed (run `xhs login` to authenticate)") - return - except Exception: - pass - print(" [!] xhs-cli install failed. Run: pipx install xiaohongshu-cli") + + _install_opencli_deps() + if shutil.which("xhs"): + print(" ✅ 检测到存量 xhs-cli,将作为备选后端继续可用") def _install_opencli_deps(): diff --git a/agent_reach/skill/references/social.md b/agent_reach/skill/references/social.md index f0affab..66bdfdc 100644 --- a/agent_reach/skill/references/social.md +++ b/agent_reach/skill/references/social.md @@ -2,45 +2,66 @@ 小红书、Twitter/X、B站、V2EX、Reddit。 -## 小红书 / XiaoHongShu (xhs-cli) +## 小红书 / XiaoHongShu(多后端) -### 稳定可用的命令 +小红书有三个后端,**先跑 `agent-reach doctor --json` 看 xiaohongshu 的 `active_backend` 是哪个**,再用对应命令组。 + +### 后端 A:OpenCLI(桌面首选,复用浏览器登录态) ```bash -# 搜索笔记(推荐入口) -xhs search "query" +# 搜索笔记 +opencli xiaohongshu search "query" -f yaml -# 阅读笔记详情(必须用搜索结果中的 URL 或 ID,不能裸 note_id) -xhs read NOTE_ID_OR_URL +# 读笔记正文+互动数据(用搜索结果里的完整 URL,含 xsec_token) +opencli xiaohongshu note "NOTE_URL" -f yaml -# 查看评论 -xhs comments NOTE_ID_OR_URL +# 评论(支持楼中楼) +opencli xiaohongshu comments NOTE_ID -f yaml -# 浏览热门 -xhs hot +# 首页推荐 feed +opencli xiaohongshu feed -f yaml -# 推荐 feed -xhs feed +# 用户主页公开笔记 +opencli xiaohongshu user USER_ID -f yaml ``` -### 已知不稳定的命令(v0.6.4) +> 要求 Chrome 打开且装了 OpenCLI 扩展。报 AUTH_REQUIRED 说明浏览器里没登录小红书,让用户在 Chrome 里登录一次即可。 + +### 后端 B:xiaohongshu-mcp(服务器场景) ```bash -# 以下命令当前可能返回 API error,谨慎使用: -xhs user USER_ID # 可能返回 {code: -1} -xhs user-posts USER_ID # 可能返回 {code: -1} -xhs favorites # 可能返回 API error +# 未登录时:先查状态,再取二维码给用户扫 +mcporter call 'xiaohongshu.check_login_status()' --timeout 120000 +mcporter call 'xiaohongshu.get_login_qrcode()' --timeout 120000 + +# 搜索 +mcporter call 'xiaohongshu.search_feeds(keyword: "query")' --timeout 120000 + +# 笔记详情+评论(feed_id 和 xsec_token 从搜索结果取) +mcporter call 'xiaohongshu.get_feed_detail(feed_id: "...", xsec_token: "...")' --timeout 120000 ``` -### 重要注意事项 +> 首次调用会自动下载约 150MB 无头浏览器,务必带 `--timeout 120000`。未登录时 search 会挂死,先 check_login_status。 -> **安装**: `pipx install xiaohongshu-cli`,然后 `xhs login`(自动从浏览器提取 Cookie)。 +### 后端 C:xhs-cli(存量备选,上游 2026-03 起停更) + +```bash +xhs search "query" # 搜索 +xhs read NOTE_ID_OR_URL # 读笔记(必须用搜索结果中的 URL/ID,不能裸 note_id) +xhs comments NOTE_ID_OR_URL # 评论 +xhs hot # 热门 +xhs feed # 推荐 +``` + +> 已知不稳定:`xhs user` / `xhs user-posts` / `xhs favorites` 可能返回 API error(上游停更无人修)。新装用户建议直接走后端 A/B。 + +### 通用注意事项 + +> **xsec_token 限制**: 小红书强制 xsec_token 机制,**不能直接用裸 note_id 去读**。正确流程:先搜索/feed 拿结果,再用结果中的完整 URL/ID 去读。三个后端都一样。 > -> **xsec_token 限制**: 小红书强制 xsec_token 机制,**不能直接用裸 note_id 去读**。正确流程是:先 `xhs search` 或 `xhs feed` 获取结果,再用结果中的 URL/ID 去 `xhs read`。直接构造 note_id 会被拦截。 +> **频率控制**: 高频请求(批量搜索、深翻评论)会触发验证码,平台限制无法绕过。每次操作间隔 2-3 秒。 > -> **频率控制**: 高频请求(批量搜索、深翻评论)会触发验证码,这是平台限制无法绕过。建议每次操作间隔 2-3 秒。 -> -> **POST 操作风险**: 发帖(post)、评论(comment)、点赞(like) 等写操作在 v0.6.x 可能因签名问题返回 406。如需使用,建议降级到 v0.3.5 (`pipx install xiaohongshu-cli==0.3.5`)。 +> **写操作(发帖/评论/点赞)**: 建议只读。xhs-cli v0.6.x 写操作可能因签名问题返回 406。 ## Twitter/X (twitter-cli) diff --git a/tests/test_channels.py b/tests/test_channels.py index 88d79a2..249d26f 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -756,7 +756,25 @@ class TestRedditChannel: class TestXiaoHongShuChannel: + """多后端选择逻辑:OpenCLI > xiaohongshu-mcp > xhs-cli,第一个完整可用者获胜。""" + + @staticmethod + def _isolate(monkeypatch, opencli=None, mcp_reachable=False): + """隔离 OpenCLI / mcp 候选,让测试聚焦目标后端。 + + opencli: None 表示未安装;否则传入 (status, message) 二元组。 + """ + import agent_reach.channels.xiaohongshu as xhs_mod + + monkeypatch.setattr( + XiaoHongShuChannel, "_check_opencli", lambda self: opencli + ) + monkeypatch.setattr( + xhs_mod, "_mcp_service_reachable", lambda timeout=3: mcp_reachable + ) + def test_reports_ok_when_cli_authenticated(self, monkeypatch): + self._isolate(monkeypatch) monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs") def fake_run(cmd, **kwargs): @@ -767,10 +785,11 @@ class TestXiaoHongShuChannel: ch = XiaoHongShuChannel() status, msg = ch.check() assert status == "ok" - assert "完整可用" in msg + assert "xhs-cli 可用" in msg assert ch.active_backend == "xhs-cli (xiaohongshu-cli)" def test_reports_warn_when_not_authenticated(self, monkeypatch): + self._isolate(monkeypatch) monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs") def fake_run(cmd, **kwargs): @@ -785,16 +804,20 @@ class TestXiaoHongShuChannel: # 未登录是业务态:工具进程活着,后端仍可用 assert ch.active_backend == "xhs-cli (xiaohongshu-cli)" - def test_reports_off_when_not_installed(self, monkeypatch): + def test_reports_off_when_nothing_installed(self, monkeypatch): + self._isolate(monkeypatch) monkeypatch.setattr(shutil, "which", lambda _: None) ch = XiaoHongShuChannel() status, msg = ch.check() assert status == "off" - assert "xiaohongshu-cli" in msg + # off 指引推荐当代后端,而非停更的 xhs-cli + assert "opencli" in msg + assert "xiaohongshu-mcp" in msg assert ch.active_backend is None def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch): """which 命中但 exec 抛 FileNotFoundError(venv 断链)→ error + 重装处方。""" + self._isolate(monkeypatch) monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs") def fake_run(cmd, **kwargs): @@ -810,6 +833,94 @@ class TestXiaoHongShuChannel: assert "pipx reinstall xiaohongshu-cli" in msg assert ch.active_backend is None + def test_opencli_ready_wins_over_cli(self, monkeypatch): + """OpenCLI 完整可用时按序获胜,即使 xhs-cli 也完整可用。""" + self._isolate(monkeypatch, opencli=("ok", "OpenCLI 可用(复用浏览器登录态)")) + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs") + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, "ok: true\n", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + + ch = XiaoHongShuChannel() + status, msg = ch.check() + assert status == "ok" + assert ch.active_backend == "OpenCLI" + + def test_opencli_warn_loses_to_usable_cli(self, monkeypatch): + """OpenCLI 装了但扩展未连(warn)时,完整可用的 xhs-cli 获胜。""" + self._isolate(monkeypatch, opencli=("warn", "扩展未连接")) + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs") + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, "ok: true\n", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + + ch = XiaoHongShuChannel() + status, msg = ch.check() + assert status == "ok" + assert ch.active_backend == "xhs-cli (xiaohongshu-cli)" + + def test_mcp_service_wins_when_opencli_absent(self, monkeypatch): + """服务器场景:OpenCLI 未装、mcp 服务可达且 mcporter 已接入 → mcp 获胜。""" + self._isolate(monkeypatch, mcp_reachable=True) + + def fake_which(name): + return "/usr/local/bin/mcporter" if name == "mcporter" else None + + monkeypatch.setattr(shutil, "which", fake_which) + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, "exa\nxiaohongshu\n", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + + ch = XiaoHongShuChannel() + status, msg = ch.check() + assert status == "ok" + assert ch.active_backend == "xiaohongshu-mcp" + assert "search_feeds" in msg + + def test_mcp_reachable_but_mcporter_unconfigured_warns(self, monkeypatch): + self._isolate(monkeypatch, mcp_reachable=True) + + def fake_which(name): + return "/usr/local/bin/mcporter" if name == "mcporter" else None + + monkeypatch.setattr(shutil, "which", fake_which) + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, "exa\n", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + + ch = XiaoHongShuChannel() + status, msg = ch.check() + assert status == "warn" + assert "mcporter config add xiaohongshu" in msg + assert ch.active_backend == "xiaohongshu-mcp" + + def test_backend_override_prefers_cli(self, monkeypatch): + """config xiaohongshu_backend=xhs-cli 时,即使 OpenCLI ready 也用 xhs-cli。""" + self._isolate(monkeypatch, opencli=("ok", "OpenCLI 可用")) + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs") + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, "ok: true\n", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + + class _Cfg: + def get(self, key, default=None): + return "xhs-cli" if key == "xiaohongshu_backend" else default + + ch = XiaoHongShuChannel() + status, _ = ch.check(_Cfg()) + assert status == "ok" + assert ch.active_backend == "xhs-cli (xiaohongshu-cli)" + class TestBilibiliChannel: def test_reports_error_with_reinstall_hint_when_ytdlp_broken(self, monkeypatch): diff --git a/tests/test_opencli_backend.py b/tests/test_opencli_backend.py index 787129f..3714ecc 100644 --- a/tests/test_opencli_backend.py +++ b/tests/test_opencli_backend.py @@ -7,8 +7,8 @@ from agent_reach.backends import opencli_status, opencli_summary from agent_reach.probe import ProbeResult -def _status_with(version_probe, daemon_probe=None): - """Run opencli_status with probe_command patched per call.""" +def _status_with(version_probe, daemon_probe=None, ext_on_disk=False): + """Run opencli_status with probe_command and disk check patched.""" calls = [] def fake_probe(cmd, args=("--version",), **kwargs): @@ -17,7 +17,11 @@ def _status_with(version_probe, daemon_probe=None): return version_probe return daemon_probe or ProbeResult("missing") - with patch("agent_reach.backends.opencli.probe_command", side_effect=fake_probe): + with patch("agent_reach.backends.opencli.probe_command", side_effect=fake_probe), \ + patch( + "agent_reach.backends.opencli._extension_installed_on_disk", + return_value=ext_on_disk, + ): return opencli_status(), calls @@ -46,17 +50,34 @@ def test_daemon_running_extension_connected_is_ready(): assert "1.8.3" in opencli_summary(st) -def test_extension_disconnected_not_ready_with_store_guide(): +def test_extension_never_installed_not_ready_with_store_guide(): daemon_out = "Daemon: running (PID 1)\nExtension: disconnected\n" st, _ = _status_with( ProbeResult("ok", output="1.8.3"), ProbeResult("ok", output=daemon_out), + ext_on_disk=False, ) assert st.daemon_running and not st.extension_connected assert not st.ready assert "chromewebstore.google.com" in st.hint +def test_sleeping_extension_counts_as_ready(): + """实测:扩展 service worker 睡眠时 daemon status 报 disconnected, + 但任何真实命令会唤醒它——装在磁盘上即视为可用。""" + daemon_out = "Daemon: running (PID 1)\nExtension: disconnected\n" + st, _ = _status_with( + ProbeResult("ok", output="1.8.3"), + ProbeResult("ok", output=daemon_out), + ext_on_disk=True, + ) + assert not st.extension_connected + assert st.extension_installed + assert st.ready + assert "唤醒" in opencli_summary(st) + assert st.hint == "" + + def test_daemon_not_running_parsed_correctly(): st, _ = _status_with( ProbeResult("ok", output="1.8.3"),