diff --git a/README.en.md b/README.en.md index 9609af3..f10a222 100644 --- a/README.en.md +++ b/README.en.md @@ -243,11 +243,18 @@ RiNG collects sessions from registered sources. Built-ins: |--------|------------|-----------| | **Claude Code zero-config** | `~/.claude/projects/**/*.jsonl`, mtimes, and `cwd` fields | no setup; detects recent activity and turn completion. Precise user-action prompts require hooks | | **Codex zero-config** | `~/.codex/state_5.sqlite`, rollout JSONL, and live `codex` processes | no setup; detects live / ended / turn completion. Use hooks for precise jumps when multiple sessions share a cwd | +| **Ollama zero-config** | interactive `ollama run` processes with a controlling terminal | process-liveness only; shows cwd, TTY, and model, and excludes `ollama serve` | +| **llama.cpp zero-config** | interactive `llama-cli` processes with a controlling terminal | process-liveness only; shows cwd, TTY, and model, and excludes `llama-server` | | **hook registry** | `~/.config/ring/sessions/`, written by `ring hook` | precise: 🔴 waiting / 🟢 working / 🟡 idle / ⚫ ended | Zero-config needs no setup. For precise “who needs me”, install hooks so provider events feed the RiNG registry. RiNG includes installers for Claude Code and Codex; other tools can use the provider-neutral `ring hook` protocol. +Ollama and llama.cpp do not expose a session transcript or interaction hooks that RiNG can read, so their +zero-config rows stay 🟡: the row means the interactive CLI is alive, not that RiNG can distinguish generation +from waiting for the next prompt. The row disappears when the CLI exits. An outer agent that can emit lifecycle +events can use the provider-neutral hook protocol for precise states. + ## States RiNG reduces every session to four user-facing states. 🔴 waiting is sorted first. @@ -406,7 +413,7 @@ RiNG is not tied to a specific tool or terminal. | Extension Point | Purpose | Built-ins | |-----------------|---------|-----------| -| `SessionSource` | find sessions | Claude Code, Codex, hook registry | +| `SessionSource` | find sessions | Claude Code, Codex, Ollama, llama.cpp, hook registry | | `Focuser` | jump to terminals | tmux, iTerm2, Terminal.app, Linux X11 (wmctrl) | | `Notifier` | notify when sessions are waiting | terminal-notifier, osascript, notify-send, ntfy, webhook | diff --git a/README.md b/README.md index 71ef224..aabaea8 100644 --- a/README.md +++ b/README.md @@ -244,11 +244,17 @@ RiNG 會從已註冊的 source 收集 session;目前內建這幾種: |------|------|----------| | **Claude Code zero-config**(預設) | 掃 `~/.claude/projects/**/*.jsonl` 的 mtime + 記錄裡的 `cwd` 欄位 | 免設定;可辨識近期活動與回合結束。需要回應的通知要靠 hook 才精準 | | **Codex zero-config**(預設) | 讀 `~/.codex/state_5.sqlite` threads + rollout JSONL,並用 live `codex` process 配 tty | 免設定;可辨識 live / ended / 回合結束。同 cwd 多 session 建議裝 hook 取得精準跳轉 | +| **Ollama zero-config**(預設) | 偵測有控制終端的 `ollama run` process | 行程存活層級;顯示 cwd、TTY 與模型,不把 `ollama serve` 當 session | +| **llama.cpp zero-config**(預設) | 偵測有控制終端的 `llama-cli` process | 行程存活層級;顯示 cwd、TTY 與模型,不把 `llama-server` 當 session | | **hook registry**(opt-in,精準) | RiNG hook 在 `Notification` / `UserPromptSubmit` / `Stop` / `SessionEnd` 即時寫 `~/.config/ring/sessions/` | 準(🔴 等你 / 🟢 工作中 / 🟡 跑完停著 / ⚫ 已離場) | zero-config 不必設定就能用;想要精準的「誰在等你」,就讓 provider 的 hook 餵進 RiNG registry。 RiNG 內建 Claude Code / Codex hook 安裝器;其他工具可直接走 provider-neutral `ring hook` protocol。 +Ollama 與 llama.cpp 本身沒有 RiNG 可讀的 session transcript 或互動 hook,因此 zero-config 列固定以 +🟡 顯示:它代表互動式 CLI 還活著,不代表 RiNG 能分辨它正在生成或等下一個 prompt。CLI 結束後列 +就會移除。若外層 agent 能送出生命週期事件,可用下方的中立 hook protocol 取得精準狀態。 + ## 狀態機 RiNG 把每個 session 壓成四種狀態。看板排序時,🔴 等你永遠排最上面並 highlight。 @@ -448,7 +454,7 @@ core 不綁死任何特定工具或終端。三個維度都可插拔,每個都 | 維度 | 在做什麼 | 內建 | |------|----------|------| -| `SessionSource` | 從哪裡找到 session | Claude Code、Codex、RiNG hook registry | +| `SessionSource` | 從哪裡找到 session | Claude Code、Codex、Ollama、llama.cpp、RiNG hook registry | | `Focuser` | 跳轉時把焦點帶去哪個終端 | tmux、iTerm2、Terminal.app、Linux X11(wmctrl)| | `Notifier` | 等你時怎麼發系統通知 | terminal-notifier、osascript、notify-send、ntfy、webhook | @@ -475,7 +481,8 @@ core 不綁死任何特定工具或終端。三個維度都可插拔,每個都 ### 其他 agent CLI(`SessionSource`) 內建 `HookRegistrySource`(讀 `~/.config/ring/sessions/`)、`ClaudeCodeSource`(掃 -`~/.claude`)與 `CodexSource`(讀 `~/.codex/state_5.sqlite`)。要監測其他工具, +`~/.claude`)、`CodexSource`(讀 `~/.codex/state_5.sqlite`),以及 Ollama / llama.cpp +互動式 CLI 的 process source。要監測其他工具, 可以優先餵 `ring hook`;若工具沒有 hook,再寫一個 source 吐出 `Session`、註冊即可: ```python diff --git a/pyproject.toml b/pyproject.toml index 74e3c18..aad4b2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,8 @@ authors = [{ name = "Wei Lee", email = "weilee.rx@gmail.com" }] keywords = [ "claude-code", "codex", + "ollama", + "llama-cpp", "tui", "dashboard", "session", diff --git a/src/ring/config.py b/src/ring/config.py index b44e8a3..70fd3c8 100644 --- a/src/ring/config.py +++ b/src/ring/config.py @@ -27,6 +27,10 @@ notify_also = ["ntfy"] # 主後端之外「加發」的後端(例如桌面通知+手機各一份) focusers = ["Neovim", "tmux", "iTerm2", "Terminal", "linux-wm"] # 跳轉嘗試順序;省略=內建預設 plugins = ["my_ring_plugin"] # 啟動時 import 的外部 plugin 模組(自行 register_*) + debug_payload_log = false # 診斷用:記錄 hook 收到的原始 payload + # (見 payload_log.py,寫到 ~/.config/ring/hook_payloads.jsonl); + # 預設關閉,payload 可能含使用者輸入才要開; + # 環境變數 RING_DEBUG_PAYLOAD_LOG 可覆寫(優先於本鍵) """ from __future__ import annotations @@ -77,6 +81,7 @@ class Config: notify_also: tuple[str, ...] = () # 主後端之外加發的後端名(如 ["ntfy"]) focusers: tuple[str, ...] = () # 空=用內建預設順序 plugins: tuple[str, ...] = () # 啟動時 import 的外部 plugin 模組(entry point 之外的本機路) + debug_payload_log: bool = False # 診斷用:記錄 hook 收到的原始 payload;預設關閉 colors: dict[str, str] = field(default_factory=lambda: dict(_DEFAULT_COLORS)) @@ -148,6 +153,7 @@ def load(path: Path | None = None) -> Config: notify_also=_as_str_tuple(raw.get("notify_also")), focusers=_as_str_tuple(raw.get("focusers")), plugins=_as_str_tuple(raw.get("plugins")), + debug_payload_log=_as_bool(raw.get("debug_payload_log"), d.debug_payload_log), colors=_parse_colors(raw.get("colors")), ) @@ -218,6 +224,7 @@ def _coerce_str_list(s: str) -> list[str]: "notify_also": _coerce_str_list, "focusers": _coerce_str_list, "plugins": _coerce_str_list, + "debug_payload_log": _coerce_bool, } diff --git a/src/ring/hook.py b/src/ring/hook.py index 38dc0e5..84b2098 100644 --- a/src/ring/hook.py +++ b/src/ring/hook.py @@ -31,6 +31,7 @@ from ring.hook_protocol import HOOK_EVENTS, adapter_for, provider_from_payload from ring.i18n import gettext as _ from ring.i18n import set_lang +from ring.payload_log import maybe_log_raw_payload from ring.registry import ( RING_REGISTRY, Session, @@ -157,6 +158,9 @@ def run_hook(provider: str = "claude-code") -> int: return 0 selected_provider = provider_from_payload(data, fallback=provider) + # 取證 log:診斷用、預設關閉(見 payload_log.py)。故意放在任何狀態判定/改寫之前, + # 記下 hook 實際收到的原始資料——不影響、不參與下面的正規化流程。 + maybe_log_raw_payload(selected_provider, data) _record_session_state(data, selected_provider) return _delegate_to_agent_hooks(raw, selected_provider) diff --git a/src/ring/payload_log.py b/src/ring/payload_log.py new file mode 100644 index 0000000..15d7f03 --- /dev/null +++ b/src/ring/payload_log.py @@ -0,0 +1,110 @@ +"""原始 hook payload 取證 logger——診斷用,預設關閉。 + +診斷「claude-code 裸 PermissionRequest 被誤判 WAITING」與「codex 裸 PermissionRequest +永遠判 WORKING」這類問題時,需要看到 hook 實際收到的原始 event payload——現有的 +registry(``~/.config/ring/sessions/*.json``)與 ``events.jsonl`` 都只留正規化後的 +status,原始資料在 ``hook_protocol.normalize`` 跑完就遺失了。 + +這支 logger 在 hook 收到 stdin 的當下、任何狀態判定/改寫之前,把原始 payload 整段 +append 一行,供事後分析用。設計原則跟 ``stats.log_transition`` 一致: + +- append-only、JSONL,一行一事件 +- 檔案超過上限自動砍半保新,不無限成長 +- 任何錯誤(含讀 config 失敗、payload 無法序列化)一律安靜吞掉,絕不影響 hook 主流程 + +開關:``debug_payload_log`` config 鍵,或環境變數 ``RING_DEBUG_PAYLOAD_LOG`` +(``1``/``true``/``yes``/``on`` 開,``0``/``false``/``no``/``off`` 關;env 優先於 config)。 +**預設關閉**——payload 可能含使用者輸入/檔案內容,開了才寫。 +""" + +from __future__ import annotations + +import json +import os +import time +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +PAYLOAD_LOG_PATH = Path.home() / ".config" / "ring" / "hook_payloads.jsonl" + +# log 檔上限;超過就砍半保新(append 前檢查)。原始 payload 比 events.jsonl 的轉換記錄 +# 大很多(可能含完整 tool_input/transcript 片段),給足空間再砍半。 +_MAX_BYTES = 20 * 1024 * 1024 + +_ENV_FLAG = "RING_DEBUG_PAYLOAD_LOG" + +_TRUE = {"1", "true", "yes", "on"} +_FALSE = {"0", "false", "no", "off"} + + +def payload_log_enabled() -> bool: + """是否要記原始 payload:env var 優先,其次 config 鍵,預設關閉。""" + env = os.environ.get(_ENV_FLAG, "").strip().lower() + if env in _TRUE: + return True + if env in _FALSE: + return False + try: + from ring.config import get_config + + return get_config().debug_payload_log + except Exception: + return False + + +def _raw_event_name(data: Mapping[str, Any]) -> str: + """從原始 payload 猜事件名(只為了記錄好讀,不影響任何判定邏輯)。""" + for key in ("event", "event_name", "hook_event_name", "hookEventName"): + v = data.get(key) + if isinstance(v, str) and v: + return v + return "" + + +def maybe_log_raw_payload( + provider: str, + data: Mapping[str, Any], + *, + path: Path | None = None, + now: float | None = None, +) -> None: + """開關開啟時,把 hook 收到的原始 payload 整段 append 一行(診斷用)。 + + 呼叫端應放在 hook 處理最前端,早於 ``hook_protocol`` 的任何狀態判定/改寫。 + 開關預設關閉(見模組 docstring);任何錯誤一律安靜吞掉,呼叫端不需要自己包 try。 + """ + try: + if not payload_log_enabled(): + return + p = path or PAYLOAD_LOG_PATH + line = json.dumps( + { + "ts": now if now is not None else time.time(), + "provider": provider, + "event": _raw_event_name(data), + "payload": data, + }, + ensure_ascii=False, + default=str, + ) + p.parent.mkdir(parents=True, exist_ok=True) + _trim_if_oversized(p) + with p.open("a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception: + pass + + +def _trim_if_oversized(p: Path) -> None: + """log 超過 ``_MAX_BYTES`` 時砍半保新,避免無上限成長。失敗安靜放棄(不擋 append)。""" + try: + if p.stat().st_size <= _MAX_BYTES: + return + lines = p.read_text(encoding="utf-8").splitlines() + keep = lines[len(lines) // 2 :] + tmp = p.with_suffix(".jsonl.tmp") + tmp.write_text("\n".join(keep) + "\n", encoding="utf-8") + tmp.replace(p) # atomic + except Exception: + pass diff --git a/src/ring/registry.py b/src/ring/registry.py index 522e028..a9715d1 100644 --- a/src/ring/registry.py +++ b/src/ring/registry.py @@ -872,7 +872,10 @@ def running_agent_pids() -> list[int]: 顯示用途,不是存活判定;真正的 ENDED 判定路徑(``_hook_sessions``)用的是 未攤平的 ``running_claude_pids`` / ``running_codex_pids`` 原始回傳值。 """ - return [*(running_claude_pids() or []), *(running_codex_pids() or [])] + # 延後 import,避免 registry(Session model)與 sources package 初始化時循環相依。 + from ring.sources.local_llm import running_pids as running_local_llm_pids + + return [*(running_claude_pids() or []), *(running_codex_pids() or []), *running_local_llm_pids()] def _pids_cwd(pids: list[int]) -> dict[int, str] | None: diff --git a/src/ring/sources/__init__.py b/src/ring/sources/__init__.py index 6b16151..a925136 100644 --- a/src/ring/sources/__init__.py +++ b/src/ring/sources/__init__.py @@ -13,9 +13,11 @@ from ring.sources.claude_code import source as _claude_code from ring.sources.codex import source as _codex from ring.sources.hook_registry import source as _hook_registry +from ring.sources.local_llm import llama_cpp_source as _llama_cpp +from ring.sources.local_llm import ollama_source as _ollama # 註冊表(順序=彙整順序)。hook registry 先於 zero-config source,精準事件優先。 -_SOURCES: list[SessionSource] = [_hook_registry, _claude_code, _codex] +_SOURCES: list[SessionSource] = [_hook_registry, _claude_code, _codex, _ollama, _llama_cpp] def register_source(source: SessionSource, *, first: bool = False) -> None: diff --git a/src/ring/sources/local_llm.py b/src/ring/sources/local_llm.py new file mode 100644 index 0000000..c72b308 --- /dev/null +++ b/src/ring/sources/local_llm.py @@ -0,0 +1,157 @@ +"""Ollama 與 llama.cpp 互動式 CLI 的 zero-config 行程來源。 + +兩者都沒有可供 RiNG 讀取的 session transcript;這個來源因此只承諾行程層級的 +存活資訊。只收有控制終端的 ``ollama run`` / ``llama-cli``,刻意排除長駐 API +server,避免把基礎設施誤當成「需要使用者回去處理」的 session。 +""" + +from __future__ import annotations + +import os +import subprocess +import time +from dataclasses import dataclass + +import ring.registry as registry +from ring.registry import Session, Status + +_CACHE_TTL = 1.0 + + +@dataclass(frozen=True) +class LocalLLMProcess: + pid: int + provider: str + cwd: str + tty: str + started_at: float + model: str + + +_process_cache: tuple[float, list[LocalLLMProcess] | None] = (-1.0, []) + + +def _elapsed_seconds(value: str) -> int | None: + """解析 ps ``etime``(``[[dd-]hh:]mm:ss``)。""" + try: + day_part, clock = value.split("-", 1) if "-" in value else ("0", value) + fields = [int(part) for part in clock.split(":")] + if len(fields) == 2: + hours, minutes, seconds = 0, fields[0], fields[1] + elif len(fields) == 3: + hours, minutes, seconds = fields + else: + return None + return int(day_part) * 86400 + hours * 3600 + minutes * 60 + seconds + except ValueError: + return None + + +def _command_tokens(comm: str, args: str) -> tuple[str, list[str]]: + """找出實際 executable 與其後參數;ps args 不保證保留 shell quoting。""" + tokens = args.split() + executable = os.path.basename(comm.strip()) + for index, token in enumerate(tokens): + if os.path.basename(token) == executable: + return executable, tokens[index + 1 :] + return executable, tokens[1:] if tokens else [] + + +def _classify(comm: str, args: str) -> tuple[str, str] | None: + executable, argv = _command_tokens(comm, args) + if executable == "ollama": + if len(argv) < 2 or argv[0] != "run": + return None + return "ollama", argv[1] + if executable == "llama-cli": + model_flags = {"-m", "--model", "-hf", "-hfr", "--hf-repo", "-mu", "--model-url"} + for index, token in enumerate(argv[:-1]): + if token in model_flags: + return "llama.cpp", argv[index + 1] + return "llama.cpp", "llama-cli" + return None + + +def _scan_processes() -> list[LocalLLMProcess] | None: + """一次 ps + 一次批次 lsof 找出兩種本機互動 CLI;失敗以 ``None`` 表示未知。""" + global _process_cache + monotonic_now = time.monotonic() + if 0 <= monotonic_now - _process_cache[0] <= _CACHE_TTL: + return _process_cache[1] + + try: + result = subprocess.run( + ["ps", "-Ao", "pid=,tty=,etime=,comm=,args="], + capture_output=True, + text=True, + timeout=3, + ) + except (OSError, subprocess.SubprocessError): + return None + if result.returncode != 0: + return None + + now = time.time() + candidates: list[tuple[int, str, float, str, str]] = [] + for line in result.stdout.splitlines(): + parts = line.split(None, 4) + if len(parts) < 4: + continue + tty = registry._normalize_tty(parts[1]) + if not tty: + continue + classified = _classify(parts[3], parts[4] if len(parts) == 5 else "") + elapsed = _elapsed_seconds(parts[2]) + if classified is None or elapsed is None: + continue + try: + pid = int(parts[0]) + except ValueError: + continue + provider, model = classified + candidates.append((pid, tty, now - elapsed, provider, model)) + + cwd_by_pid = registry._pids_cwd([pid for pid, *_rest in candidates]) + if cwd_by_pid is None: + return None + processes = [ + LocalLLMProcess(pid, provider, cwd, tty, started_at, model) + for pid, tty, started_at, provider, model in candidates + if (cwd := cwd_by_pid.get(pid, "")) + ] + _process_cache = (monotonic_now, processes) + return processes + + +def running_pids() -> list[int]: + """顯示用途的 live local-LLM CLI pid;掃描未知時回空清單。""" + return [process.pid for process in (_scan_processes() or [])] + + +class LocalLLMSource: + def __init__(self, provider: str) -> None: + self.name = provider + + def discover(self) -> list[Session]: + processes = _scan_processes() + if processes is None: + return [] + return [ + Session( + session_id=f"{self.name}:pid-{process.pid}", + cwd=process.cwd, + status=Status.IDLE, + last_active=process.started_at, + last_action=process.model, + source=self.name, + tty=process.tty, + provider=self.name, + origin_cwd=process.cwd, + ) + for process in processes + if process.provider == self.name + ] + + +ollama_source = LocalLLMSource("ollama") +llama_cpp_source = LocalLLMSource("llama.cpp") diff --git a/tests/conftest.py b/tests/conftest.py index ac279a2..7d51328 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,11 @@ from collections.abc import Iterator from pathlib import Path +from types import SimpleNamespace import pytest import ring.registry as registry +import ring.sources.local_llm as local_llm from ring.i18n import set_lang @@ -21,3 +23,24 @@ def _reset_lang(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[Non set_lang(None) yield set_lang(None) + + +@pytest.fixture(autouse=True) +def _no_real_local_llm_scan(monkeypatch: pytest.MonkeyPatch) -> None: + """local-LLM 來源會真的呼叫 ``ps`` 掃這台機器上的行程;預設回空,讓舊測試不受 + 這台機器當下是否真的跑著 ``ollama run`` / ``llama-cli`` 影響(同 ``CODEX_STATE`` + 指向不存在路徑的隔離手法)。 + + 只換掉 ``ring.sources.local_llm`` 模組自己名字空間裡的 ``subprocess`` 綁定 + (``monkeypatch.setattr(local_llm, "subprocess", ...)``),不是改 + ``subprocess.run`` 這個共用模組屬性本身——後者會讓行程裡任何其他呼叫 + ``subprocess.run`` 的程式碼(例如 ``tests/test_focus.py`` 真的呼叫 ps/nvim) + 也被攔截,汙染整個測試行程。``tests/test_local_llm.py`` 會在各自測試裡用同一個 + ``monkeypatch`` 再次對這個(此時已是 fake 的)``subprocess`` 物件設定 ``.run``, + 行為不受影響。 + """ + monkeypatch.setattr( + local_llm, + "subprocess", + SimpleNamespace(run=lambda *_args, **_kwargs: SimpleNamespace(stdout="", returncode=0)), + ) diff --git a/tests/test_local_llm.py b/tests/test_local_llm.py new file mode 100644 index 0000000..5129a1b --- /dev/null +++ b/tests/test_local_llm.py @@ -0,0 +1,93 @@ +from types import SimpleNamespace +from typing import Any + +import pytest + +from ring.registry import Status +from ring.sources import local_llm + + +@pytest.fixture(autouse=True) +def _clear_cache(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(local_llm, "_process_cache", (-1.0, [])) + + +def _fake_process_scan(monkeypatch: pytest.MonkeyPatch, snapshot: str, cwds: dict[int, str]) -> list[list[str]]: + calls: list[list[str]] = [] + + def fake_run(cmd: list[str], **_kwargs: object) -> Any: + calls.append(cmd) + return SimpleNamespace(stdout=snapshot, returncode=0) + + monkeypatch.setattr("ring.sources.local_llm.subprocess.run", fake_run) + monkeypatch.setattr("ring.registry._pids_cwd", lambda pids: {pid: cwds[pid] for pid in pids if pid in cwds}) + return calls + + +def test_discovers_ollama_run_and_llama_cli(monkeypatch: pytest.MonkeyPatch) -> None: + snapshot = "\n".join( + [ + "101 ttys001 00:05 ollama ollama run qwen3:8b", + "202 pts/2 01:02 llama-cli /opt/bin/llama-cli -m /models/gemma.gguf -cnv", + ] + ) + calls = _fake_process_scan(monkeypatch, snapshot, {101: "/work/a", 202: "/work/b"}) + + ollama = local_llm.ollama_source.discover() + llama = local_llm.llama_cpp_source.discover() + + assert len([call for call in calls if call[0] == "ps"]) == 1 + assert [(s.session_id, s.cwd, s.tty, s.last_action) for s in ollama] == [ + ("ollama:pid-101", "/work/a", "/dev/ttys001", "qwen3:8b") + ] + assert [(s.session_id, s.cwd, s.tty, s.last_action) for s in llama] == [ + ("llama.cpp:pid-202", "/work/b", "/dev/pts/2", "/models/gemma.gguf") + ] + assert ollama[0].status is Status.IDLE + assert llama[0].provider == "llama.cpp" + + +def test_excludes_servers_and_non_tty_processes(monkeypatch: pytest.MonkeyPatch) -> None: + snapshot = "\n".join( + [ + "101 ?? 2-00:00:00 ollama ollama serve", + "102 ttys001 00:03 llama-server llama-server -m model.gguf", + "103 ?? 00:04 ollama ollama run hidden:latest", + ] + ) + calls = _fake_process_scan(monkeypatch, snapshot, {}) + + assert local_llm.ollama_source.discover() == [] + assert local_llm.llama_cpp_source.discover() == [] + assert not any(call[0] == "lsof" for call in calls) + + +def test_scan_failure_is_unknown_and_contributes_no_rows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "ring.sources.local_llm.subprocess.run", + lambda *_args, **_kwargs: SimpleNamespace(stdout="", returncode=1), + ) + + assert local_llm.ollama_source.discover() == [] + assert local_llm.llama_cpp_source.discover() == [] + + +@pytest.mark.parametrize( + ("comm", "args", "expected"), + [ + ("/opt/homebrew/bin/ollama", "/opt/homebrew/bin/ollama run qwen3:8b", ("ollama", "qwen3:8b")), + ("ollama", "ollama serve", None), + ("llama-cli", "llama-cli -hf ggml-org/gemma-GGUF:Q4_K_M", ("llama.cpp", "ggml-org/gemma-GGUF:Q4_K_M")), + ("llama-server", "llama-server -m model.gguf", None), + ], +) +def test_classify_supported_commands(comm: str, args: str, expected: tuple[str, str] | None) -> None: + assert local_llm._classify(comm, args) == expected + + +@pytest.mark.parametrize( + ("value", "seconds"), + [("00:07", 7), ("01:02:03", 3723), ("2-03:04:05", 183845), ("bad", None)], +) +def test_elapsed_seconds(value: str, seconds: int | None) -> None: + assert local_llm._elapsed_seconds(value) == seconds diff --git a/tests/test_payload_log.py b/tests/test_payload_log.py new file mode 100644 index 0000000..6f89dff --- /dev/null +++ b/tests/test_payload_log.py @@ -0,0 +1,167 @@ +"""ring.payload_log——診斷用原始 hook payload 取證 logger(預設關閉)。""" + +from __future__ import annotations + +import io +import json +from pathlib import Path +from typing import Any + +import pytest + +import ring.hook as hook +import ring.payload_log as payload_log +from ring.config import Config +from ring.payload_log import maybe_log_raw_payload, payload_log_enabled + + +@pytest.fixture(autouse=True) +def _hermetic(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr("ring.hook.get_config", lambda: Config()) + monkeypatch.setattr("ring.notify._NOTIFIERS", []) + monkeypatch.setattr("ring.stats.EVENTS_PATH", tmp_path / "events.jsonl") + + +def _feed(monkeypatch: pytest.MonkeyPatch, payload: dict[str, Any]) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(payload))) + + +# --------------------------------------------------------------------------- +# 開關 +# --------------------------------------------------------------------------- + + +def test_disabled_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("ring.config.get_config", lambda: Config(debug_payload_log=False)) + monkeypatch.delenv("RING_DEBUG_PAYLOAD_LOG", raising=False) + assert payload_log_enabled() is False + + +def test_env_var_overrides_config_on(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("ring.config.get_config", lambda: Config(debug_payload_log=False)) + monkeypatch.setenv("RING_DEBUG_PAYLOAD_LOG", "1") + assert payload_log_enabled() is True + + +def test_env_var_overrides_config_off(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("ring.config.get_config", lambda: Config(debug_payload_log=True)) + monkeypatch.setenv("RING_DEBUG_PAYLOAD_LOG", "off") + assert payload_log_enabled() is False + + +def test_config_key_enables(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("ring.config.get_config", lambda: Config(debug_payload_log=True)) + monkeypatch.delenv("RING_DEBUG_PAYLOAD_LOG", raising=False) + assert payload_log_enabled() is True + + +# --------------------------------------------------------------------------- +# maybe_log_raw_payload:開啟時寫、關閉時不寫、壞 payload 不炸 +# --------------------------------------------------------------------------- + + +def test_writes_full_raw_payload_when_enabled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("RING_DEBUG_PAYLOAD_LOG", "1") + p = tmp_path / "hook_payloads.jsonl" + payload = { + "session_id": "s1", + "hook_event_name": "PermissionRequest", + "cwd": "/x", + "tool_input": {"nested": {"a": [1, 2, 3]}}, + } + maybe_log_raw_payload("claude-code", payload, path=p, now=123.0) + lines = [json.loads(line) for line in p.read_text().splitlines()] + assert len(lines) == 1 + assert lines[0]["ts"] == 123.0 + assert lines[0]["provider"] == "claude-code" + assert lines[0]["event"] == "PermissionRequest" + assert lines[0]["payload"] == payload # 完整原文,不裁切 + + +def test_does_not_write_when_disabled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("RING_DEBUG_PAYLOAD_LOG", "0") + p = tmp_path / "hook_payloads.jsonl" + maybe_log_raw_payload("claude-code", {"session_id": "s1"}, path=p, now=1.0) + assert not p.exists() + + +def test_bad_payload_does_not_raise(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """payload 含不可序列化物件時安靜吞掉,不得炸出例外(json.dumps default=str 兜底)。""" + monkeypatch.setenv("RING_DEBUG_PAYLOAD_LOG", "1") + p = tmp_path / "hook_payloads.jsonl" + + class Weird: + def __repr__(self) -> str: + return "" + + maybe_log_raw_payload("claude-code", {"session_id": "s1", "obj": Weird()}, path=p, now=1.0) + # 不炸;因為 default=str,實際上還是寫得出來 + assert p.exists() + line = json.loads(p.read_text().splitlines()[0]) + assert "weird" in line["payload"]["obj"] + + +def test_write_failure_is_swallowed(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """目標路徑不可寫(例如指向一個目錄)時,不得拋例外。""" + monkeypatch.setenv("RING_DEBUG_PAYLOAD_LOG", "1") + p = tmp_path / "is_a_dir.jsonl" + p.mkdir() + maybe_log_raw_payload("claude-code", {"session_id": "s1"}, path=p, now=1.0) # 不炸就算過 + + +def test_get_config_failure_defaults_to_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + def _boom() -> Config: + raise RuntimeError("config 讀壞了") + + monkeypatch.setattr("ring.config.get_config", _boom) + monkeypatch.delenv("RING_DEBUG_PAYLOAD_LOG", raising=False) + assert payload_log_enabled() is False + + +# --------------------------------------------------------------------------- +# 上限砍半 +# --------------------------------------------------------------------------- + + +def test_trims_oversized_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(payload_log, "_MAX_BYTES", 200) + monkeypatch.setenv("RING_DEBUG_PAYLOAD_LOG", "1") + p = tmp_path / "hook_payloads.jsonl" + for i in range(20): + maybe_log_raw_payload("claude-code", {"session_id": "s1", "i": i}, path=p, now=float(i)) + lines = [json.loads(line) for line in p.read_text().splitlines()] + assert len(lines) < 20 # 有砍半過 + assert lines[-1]["payload"]["i"] == 19 # 保留最新 + + +# --------------------------------------------------------------------------- +# 透過 hook.run_hook 端到端驗證(開/關兩態) +# --------------------------------------------------------------------------- + + +def test_run_hook_writes_raw_payload_when_enabled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path / "sessions") + log_path = tmp_path / "hook_payloads.jsonl" + monkeypatch.setattr("ring.payload_log.PAYLOAD_LOG_PATH", log_path) + monkeypatch.setenv("RING_DEBUG_PAYLOAD_LOG", "1") + payload = {"session_id": "s1", "hook_event_name": "PermissionRequest", "cwd": "/x"} + _feed(monkeypatch, payload) + + assert hook.run_hook() == 0 + + lines = [json.loads(line) for line in log_path.read_text().splitlines()] + assert len(lines) == 1 + assert lines[0]["payload"] == payload + assert lines[0]["event"] == "PermissionRequest" + + +def test_run_hook_skips_raw_payload_when_disabled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path / "sessions") + log_path = tmp_path / "hook_payloads.jsonl" + monkeypatch.setattr("ring.payload_log.PAYLOAD_LOG_PATH", log_path) + monkeypatch.setenv("RING_DEBUG_PAYLOAD_LOG", "0") + _feed(monkeypatch, {"session_id": "s1", "hook_event_name": "PermissionRequest", "cwd": "/x"}) + + assert hook.run_hook() == 0 + + assert not log_path.exists() diff --git a/tests/test_sources.py b/tests/test_sources.py index 23dbfce..433af50 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -33,9 +33,8 @@ def test_register_source_appends(monkeypatch: pytest.MonkeyPatch) -> None: assert sources.sources()[-1].name == "mytool" -def test_default_sources_include_claude_code_and_codex() -> None: - assert any(s.name == "claude-code" for s in sources.sources()) - assert any(s.name == "codex" for s in sources.sources()) +def test_default_sources_include_supported_clis() -> None: + assert {s.name for s in sources.sources()} >= {"claude-code", "codex", "ollama", "llama.cpp"} class _StaticSource: