diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a175f5e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,122 @@ +# Contributing to RiNG + +謝謝你願意幫 RiNG 變好。這份文件整理本 repo 的開發流程、測試方式與送 PR 前的檢查項目。 + +## 開發環境 + +RiNG 需要 Python 3.13+,專案使用 `uv` 管理依賴與執行環境。 + +```sh +uv sync --all-groups +uv run ring +uv run ring --watch +``` + +PyPI 發佈名是 `ring-cli`,但 Python module 與 CLI 指令都叫 `ring`。 + +## 常用命令 + +```sh +uv run poe format # ruff check --fix + ruff format +uv run poe lint # ruff check + mypy +uv run poe test # pytest +uv run poe cover # pytest + coverage report +uv run poe all # format + lint + coverage +uv run poe ci # pre-commit hooks + coverage +``` + +測試預設會用 `pytest-xdist` 平行執行;要跑單一測試時可以直接用 pytest: + +```sh +uv run pytest tests/test_cli.py +uv run pytest tests/test_cli.py::test_name +``` + +## Pre-commit Hooks + +建議安裝 hooks,讓格式化、lint、lockfile、commit message 檢查在本機先跑過。 + +```sh +uv run poe setup-pre-commit +``` + +目前 hooks 會檢查 TOML/YAML、private key、拼字、`uv.lock`、Ruff、Mypy、Commitizen 等項目。 +`no-commit-to-branch` 會擋直接 commit 到受保護分支;必要時請在 feature branch 上工作。 + +## 程式風格 + +- Python 程式碼放在 `src/ring/`,測試放在 `tests/`。 +- Ruff 目標版本是 Python 3.13,行寬 120。 +- Mypy 使用 strict mode;新增 API 時請補齊型別。 +- CLI 行為、輸出格式、hook protocol、JSON keys 會被使用者腳本依賴;改動時請優先維持相容,必要 breaking change 要清楚標示。 +- 專案刻意允許台灣漢語註解與 UI 字串;新增英文 UI 時也要注意 i18n。 + +## 測試準則 + +請依改動範圍補測試: + +- CLI 參數與輸出:`tests/test_cli.py` +- TUI / render 行為:`tests/test_tui.py`、`tests/test_render.py` +- source discovery:`tests/test_sources.py`、`tests/test_discover.py` +- hook / registry / IPC:`tests/test_hook.py`、`tests/test_registry.py`、`tests/test_ipc.py` +- notifier / focus / permission reply:對應 `tests/test_notify.py`、`tests/test_focus.py`、`tests/test_permission.py` + +送 PR 前至少跑: + +```sh +uv run poe lint +uv run poe test +``` + +若改到跨模組行為、輸出格式或發佈相關設定,請跑: + +```sh +uv run poe all +``` + +## i18n + +改到使用者可見字串時,請更新翻譯檔。 + +```sh +uv run poe i18n-extract +uv run poe i18n-check +uv run poe i18n-compile +``` + +注意:不要用 `pybabel update`。這會把空 `msgstr` 塞進 `.po`,可能弄壞 i18n 測試。新字串請手動把已翻譯條目加進各語言 `.po`,再重新 compile 並 commit `.po` 與 `.mo`。 + +## Commit 與 Changelog + +本專案使用 Commitizen / Conventional Commits,commit message 例如: + +```text +feat: add webhook retry backoff +fix: keep stale codex sessions hidden +docs: document hook setup +test: cover tmux permission parsing +``` + +版本與 changelog 由 Commitizen 管理。一般 PR 不需要手動改 `CHANGELOG.md`,除非正在做 release / bump。 + +## PR Checklist + +送 PR 前請確認: + +- 變更範圍聚焦,沒有混入無關格式化或產物。 +- 新功能或修 bug 有對應測試,或在 PR 說明中交代為什麼無法測。 +- `uv.lock` 與依賴設定一致。 +- 使用者可見字串已處理 i18n。 +- README / docs 已同步更新 CLI 旗標、設定鍵、輸出格式或行為變更。 +- 有 breaking change 時,PR 說明清楚列出影響與遷移方式。 + +## 回報 Issue + +請盡量附上: + +- 作業系統與終端環境,例如 macOS + iTerm2、tmux、Linux X11 / Wayland。 +- `ring --version` 與安裝方式。 +- 相關命令與實際輸出。 +- 若和 hook / focus / permission reply 有關,請附 `ring doctor` 的相關段落。 + +請不要貼出含有 token、private key、完整私有 transcript 或其他敏感資訊的內容。 diff --git a/README.en.md b/README.en.md index f10a222..4aaf857 100644 --- a/README.en.md +++ b/README.en.md @@ -301,9 +301,31 @@ Claude Code events: | `PermissionRequest` / `PreToolUse` with `AskUserQuestion` | 🔴 waiting | | `SessionEnd` | removed from the board | -Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, and `Stop`. Codex -also emits `PermissionRequest` before an existing policy auto-approves the call; a bare event therefore stays -🟢 working, and it only turns 🔴 waiting when the payload explicitly carries `requires_action` / `waiting_for`. +`Stop` has one more exception: an agent sometimes doesn't use `AskUserQuestion` or trigger a permission +request, and just asks a plain-text question before stopping (e.g. "want me to fix B too?"). RiNG looks at +the **end** of the last assistant message for this: if it ends in a question mark (`?` / `?`, with any +trailing fenced code block stripped first) the session is promoted to 🔴 waiting (`waiting_kind="question"`), +with the question as its detail; a question that only appears mid-message, with a statement at the end, +does not count — conservative on purpose, biased toward missing a case rather than a false positive. This +applies to both Claude Code and Codex (`Stop` payloads from both carry `last_assistant_message`); the +`detect_stop_questions` config key can turn it off (on by default). + +Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, `PostToolUse`, +and `Stop`. Codex also emits `PermissionRequest` before an existing policy auto-approves the call, so a bare +event stays 🟢 working at the hook level. Codex hooks have no "user approved" event and no heartbeat — while +an approval prompt is pending, the hook channel is **completely silent**. RiNG therefore treats the silence +itself as the signal: when the last hook event is a `PermissionRequest` and nothing has followed for more than +`codex_permission_wait_seconds` (default 10s), the board marks the session 🔴 waiting with the pending command +as its detail; any subsequent event (the next tool call, `Stop`) naturally clears it. + +Known Codex-side limitations (verified against 0.144.4; the hook payload has no field that could distinguish +these): + +- **Approve and deny are indistinguishable**: neither emits a hook event. After you approve a long-running + command, 🔴 lingers until the command finishes (`PostToolUse`); after a deny with no immediate follow-up + action, 🔴 lingers until the next event or `Stop`. +- The system notification for this timeout-promoted 🔴 is sent by the TUI's alert scheduler (there is no hook + event to notify from); headless `--watch` does not send this particular notification. Verify that hooks are writing: @@ -384,6 +406,8 @@ legend = true active_window_seconds = 21600 working_threshold_seconds = 90 waiting_window_seconds = 1800 +codex_permission_wait_seconds = 10 # bare Codex PermissionRequest + hook silence beyond this → 🔴 waiting; 0 = off +detect_stop_questions = true # promote Stop to 🔴 waiting when it ends in a plain-text question; false = Stop always 🟡 notify_sound = true notify_sound_name = "Glass" notify_ignore_dnd = false diff --git a/README.md b/README.md index aabaea8..f425a64 100644 --- a/README.md +++ b/README.md @@ -312,9 +312,27 @@ Claude Code 註冊這幾個事件,對應到狀態: | `PermissionRequest` / `PreToolUse` 的 `AskUserQuestion` | 🔴 等你(權限 / 選項需要你決策) | | `SessionEnd` | 從看板消失 | -Codex 目前註冊 Codex 支援的互動事件:`PreToolUse`、`PermissionRequest`、`Stop`。Codex 的 -`PermissionRequest` 也會在既有 policy 自動放行前送出;因此裸事件維持 🟢 工作中,只有 payload -明確標示 `requires_action` / `waiting_for` 時才轉成 🔴 等你。 +`Stop` 還有一種例外:agent 有時不用 `AskUserQuestion`、不觸發權限請求,只在文字裡問了一句話 +就停下來(例如「要不要順便修 B?」)。這種情況下 RiNG 會看最後一則 assistant 訊息的**結尾**—— +結尾是問句(`?` / `?`,圍欄程式碼區塊先剝掉再看)就升級成 🔴 等你(`waiting_kind="question"`), +detail 帶那句問句;問句只出現在訊息中段、結尾是陳述句則不算,保守判斷、寧可漏報不要誤報。 +Claude Code 與 Codex 兩邊都適用(兩者的 `Stop` payload 都帶 `last_assistant_message`); +`detect_stop_questions` 設定鍵可關閉這個行為(預設開)。 + +Codex 目前註冊 Codex 支援的互動事件:`PreToolUse`、`PermissionRequest`、`PostToolUse`、`Stop`。 +Codex 的 `PermissionRequest` 也會在既有 policy 自動放行前送出,所以裸事件在 hook 層維持 🟢 +工作中。Codex 的 hook 沒有「使用者已核可」事件、沒有心跳,等核可期間 hook 通道**完全靜默** +——RiNG 因此把靜默本身當訊號:最後一個 hook 事件是 `PermissionRequest`、又超過 +`codex_permission_wait_seconds`(預設 10 秒)沒有任何後續事件,看板就把它標成 🔴 等你, +detail 帶當初要跑的指令摘要;任何後續事件(下一個工具、`Stop`)到了就自然轉回。 + +Codex 側的已知限制(0.144.4 實證,hook payload 無任何欄位可區分): + +- **分不出核可還是拒絕**:你按下 allow 或 deny 時,hook 都不會收到事件。allow 之後若那條指令 + 本身跑很久,🔴 會多亮到指令跑完(`PostToolUse`)為止;deny 之後若 agent 沒有立即的後續動作, + 🔴 會亮到下一個事件或 `Stop` 才清。 +- 核可等待轉 🔴 的系統通知由 TUI 的提醒排程器代發(沒有 hook 事件可在當下發);headless + `--watch` 不發這一種通知。 hook 只對**新開的 session** 生效,所以裝完要重開。確認方法: @@ -411,6 +429,8 @@ legend = true # 是否預設顯示圖例 active_window_seconds = 21600 # 只看最近這段時間動過的 session(預設 6h) working_threshold_seconds = 90 # 多久沒動 → 🟢 工作中 變 🟡 閒置 waiting_window_seconds = 1800 # 跑完停著升等你的時間窗上限(預設 30 分) +codex_permission_wait_seconds = 10 # Codex 裸 PermissionRequest 後 hook 靜默超過這秒數 → 🔴 等你;0 = 關閉 +detect_stop_questions = true # Stop 結尾是純文字提問時升級成 🔴 等你;false = Stop 一律 🟡 notify_sound = true # 系統通知是否播放聲音 notify_sound_name = "Glass" # macOS / terminal-notifier sound name notify_ignore_dnd = false # macOS terminal-notifier 是否穿透勿擾 / Focus diff --git a/src/ring/cli.py b/src/ring/cli.py index 451a7d5..7483c11 100644 --- a/src/ring/cli.py +++ b/src/ring/cli.py @@ -402,6 +402,8 @@ def run_config(args: list[str]) -> int: def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int: # 系統通知由 ``ring hook`` 在 session 轉 🔴 等你的當下就地發出(見 hook._ring_waiting_now); # watch 只負責顯示看板,不再輪詢發通知——這樣關掉看板也照樣 ring 你。 + # 例外:codex 核可等待是讀取側的靜默逾時判定,沒有 hook 事件可發通知,由 TUI 的 + # 提醒排程器代發(tui._ring_on_waiting_alerts);headless watch 仍不發,是已知限制。 frames = 0 footer_text = _("每 {interval}s 刷新 · Ctrl-C 離場", interval=int(interval)) if not HAVE_RICH: diff --git a/src/ring/config.py b/src/ring/config.py index 70fd3c8..1707e26 100644 --- a/src/ring/config.py +++ b/src/ring/config.py @@ -9,6 +9,11 @@ active_window_seconds = 21600 # 只看最近這段時間動過的 session(預設 6h) working_threshold_seconds = 90 # 多久沒動就從 🟢 工作中 變 🟡 閒置 waiting_window_seconds = 1800 # 跑完停著升等你的時間窗上限(預設 30 分) + codex_permission_wait_seconds = 10 # Codex 裸 PermissionRequest 後 hook 靜默超過這秒數 + # → 看板判定真的停下來等核可(🔴 等你);0 = 關閉 + detect_stop_questions = true # Stop 事件時,若最後一則 assistant 訊息「結尾」是純文字 + # 提問(沒用 AskUserQuestion、沒有權限請求)→ 🔴 等你 + # (waiting_kind="question");關閉則 Stop 一律 🟡 notify_sound = true # 系統通知帶聲音 notify_sound_name = "Glass" # macOS / terminal-notifier sound name notify_ignore_dnd = false # terminal-notifier 是否加 -ignoreDnD(穿透勿擾 / Focus) @@ -64,6 +69,13 @@ class Config: active_window_seconds: int = 6 * 60 * 60 working_threshold_seconds: int = 90 waiting_window_seconds: int = 1800 # 跑完停著升等你的時間窗上限(預設 30 分) + # Codex 的 hook 沒有「使用者已核可」事件也沒有心跳(0.144.4 實證):policy 自動放行時 + # 下一個事件幾秒內就到;真的停下來等人時 hook 通道完全靜默。所以「最後一個事件是 + # PermissionRequest 且已靜默超過這個門檻」就判定在等核可。0 = 關閉這個判定。 + codex_permission_wait_seconds: int = 10 + # Stop 事件時,若最後一則 assistant 訊息結尾是純文字提問(見 question_detect.py) + # → 升級成 🔴 等你(waiting_kind="question")。預設開;關閉則 Stop 一律維持 🟡。 + detect_stop_questions: bool = True notify_sound: bool = True notify_sound_name: str = "Glass" notify_ignore_dnd: bool = False @@ -139,6 +151,10 @@ def load(path: Path | None = None) -> Config: active_window_seconds=_as_int(raw.get("active_window_seconds"), d.active_window_seconds), working_threshold_seconds=_as_int(raw.get("working_threshold_seconds"), d.working_threshold_seconds), waiting_window_seconds=_as_int(raw.get("waiting_window_seconds"), d.waiting_window_seconds), + codex_permission_wait_seconds=_as_int( + raw.get("codex_permission_wait_seconds"), d.codex_permission_wait_seconds + ), + detect_stop_questions=_as_bool(raw.get("detect_stop_questions"), d.detect_stop_questions), notify_sound=_as_bool(raw.get("notify_sound"), d.notify_sound), notify_sound_name=( raw["notify_sound_name"] if isinstance(raw.get("notify_sound_name"), str) else d.notify_sound_name @@ -212,6 +228,8 @@ def _coerce_str_list(s: str) -> list[str]: "active_window_seconds": _coerce_int, "working_threshold_seconds": _coerce_int, "waiting_window_seconds": _coerce_int, + "codex_permission_wait_seconds": _coerce_int, + "detect_stop_questions": _coerce_bool, "notify_sound": _coerce_bool, "notify_sound_name": str, "notify_ignore_dnd": _coerce_bool, diff --git a/src/ring/hook.py b/src/ring/hook.py index 84b2098..04e8d7c 100644 --- a/src/ring/hook.py +++ b/src/ring/hook.py @@ -9,8 +9,15 @@ 事件 → 狀態: SessionStart / UserPromptSubmit → 🟢 工作中(剛開始 / 你剛回話,台上在跑) PreToolUse(非 action)/ PostToolUse → 🟢 工作中(工具在動,順便清掉剛答完的等你) - Stop → 🟡 跑完停著(回完一輪,不代表需要你回應) - PermissionRequest / actionable Notification / AskUserQuestion → 🔴 等你(需要你決策) + Stop → 🟡 跑完停著(回完一輪,不代表需要你回應);但最後一則 + assistant 訊息「結尾」像純文字提問時 → 🔴 等你 + (waiting_kind="question",見 question_detect.py; + config 鍵 detect_stop_questions 可關閉,預設開) + PermissionRequest(裸) → 🟢 工作中(權限判定中,多半瞬間自動放行;Claude Code + 真的停下來等人由後續的 permission_prompt Notification + 兜底轉 🔴;Codex 沒有這種事件,改由讀取側的靜默逾時 + 判定補上,見 registry._promote_codex_permission_wait) + actionable Notification / AskUserQuestion → 🔴 等你(需要你決策) SessionEnd → 刪檔(乾淨離場) """ @@ -22,16 +29,17 @@ import subprocess import sys import time -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import Any from urllib.parse import quote from ring.config import get_config -from ring.hook_protocol import HOOK_EVENTS, adapter_for, provider_from_payload +from ring.hook_protocol import HOOK_EVENTS, NormalizedHookEvent, 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.question_detect import stop_last_assistant_text, trailing_question_detail from ring.registry import ( RING_REGISTRY, Session, @@ -46,9 +54,10 @@ # Codex 的 hooks.json 用跟 Claude 同樣的 PascalCase 事件名,但只支援其中一小撮。 # 保守取有實證可用的:PermissionRequest(裸事件只代表權限準備判定;明確需互動才 → 🔴)、 -# PreToolUse(→ 動作/清除)、Stop(→ 🟡 回合結束、清掉 waiting)。多裝 Codex 不認的事件 -# 有風險,故不照搬 Claude 全套。 -_CODEX_HOOK_EVENTS = ["PreToolUse", "PermissionRequest", "Stop"] +# PreToolUse(→ 動作/清除)、PostToolUse(工具跑完 → 清除;0.144.4 binary 內嵌 schema +# 證實存在——它是「核可後最早的下一個事件」,能盡早清掉讀取側的核可等待判定)、 +# Stop(→ 🟡 回合結束、清掉 waiting)。多裝 Codex 不認的事件有風險,故不照搬 Claude 全套。 +_CODEX_HOOK_EVENTS = ["PreToolUse", "PermissionRequest", "PostToolUse", "Stop"] # hook command 的 timeout(秒)。給足,因為 notify_backend="agent-hooks" 時權限 modal 會 # block 到使用者作答。install 用它判斷「既有條目要不要更新」——舊版裝的 timeout=10 會被升上來。 @@ -171,6 +180,7 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None: event = adapter.normalize(data) if event is None: return + event = _maybe_flag_trailing_question(event, data) unhide_session(event.session_id) path = RING_REGISTRY / f"{quote(event.session_id, safe=':')}.json" @@ -197,6 +207,9 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None: "cwd": event.cwd, "origin_cwd": event.cwd, "status": event.status.value, + # 最後一個 hook 事件名:讀取側據此做 codex 的核可等待判定(最後事件是 + # PermissionRequest 且靜默逾時 → 🔴),任何後續事件覆寫它就自然清紅。 + "last_event": event.event, "last_active": now, "heartbeat_at": now, "last_action": last_action, @@ -211,10 +224,26 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None: payload["todo"] = list(todo) if event.waiting_for: payload["waiting_for"] = event.waiting_for + + # 裸 PermissionRequest(→ WORKING)帶著最具體的 tool/指令摘要;真的停下來等人時, + # Claude Code 只補發籠統的 permission_prompt Notification("Claude needs your + # permission",不含 tool/指令)。所以把摘要暫存進 row,Notification 轉 WAITING 時 + # (還新鮮的話)拿它當 waiting_detail,看板與通知才看得到具體是哪條指令在等核可。 + # Codex 沒有 Notification:這份暫存改由讀取側的靜默逾時判定拿去當 waiting_detail + # (見 registry._promote_codex_permission_wait)。 + pending_detail, pending_at = _pending_permission_detail(event, prev_row, now) + if pending_detail: + payload["pending_permission_detail"] = pending_detail + payload["pending_permission_detail_at"] = pending_at + if event.status is Status.WAITING and event.waiting_kind: payload["waiting_kind"] = event.waiting_kind - if event.status is Status.WAITING and event.detail: - payload["waiting_detail"] = event.detail + if event.status is Status.WAITING: + waiting_detail = event.detail + if event.event == "Notification" and event.waiting_kind == "permission" and pending_detail: + waiting_detail = pending_detail + if waiting_detail: + payload["waiting_detail"] = waiting_detail tty = event.tty or _controlling_tty() or _session_tty(adapter.process_names) if tty: payload["tty"] = tty @@ -244,6 +273,26 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None: _ring_waiting_now(event, payload, last_action) +def _maybe_flag_trailing_question(event: NormalizedHookEvent, data: dict[str, Any]) -> NormalizedHookEvent: + """Stop 事件、目前判定 🟡 跑完停著、且訊息結尾像純文字提問時,升級成 🔴 等你。 + + 這是 hook_protocol._ALWAYS_STATUS 的 Stop→IDLE 之外唯一會讀 assistant 文字的地方—— + 寫死的映射本身測不到「agent 用文字問了問題但沒用 AskUserQuestion」這種情況(見 + ring.question_detect 模組 docstring)。保守判準:只看訊息結尾,中段有問句、結尾是 + 陳述句一律不算,寧可漏報不要誤報。config 關閉(detect_stop_questions=false)或非 + Stop / 非 IDLE 一律原樣放行;抓不到文字或結尾不是問句也放行。 + """ + if event.event != "Stop" or event.status is not Status.IDLE: + return event + if not get_config().detect_stop_questions: + return event + text = stop_last_assistant_text(data) + detail = trailing_question_detail(text) + if not detail: + return event + return replace(event, status=Status.WAITING, waiting_kind="question", detail=detail) + + def _previous_row(path: Path) -> dict[str, Any]: """讀 registry 檔目前的內容(給轉換偵測與通知冷卻用);沒檔 / 壞檔回空 dict。""" try: @@ -258,6 +307,32 @@ def _as_epoch(v: Any) -> float | None: return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None +# 暫存的權限 detail 新鮮期(秒):permission_prompt Notification 通常在裸 PermissionRequest +# 後 ~6 秒到;超過這個窗口的暫存視為過期(大概率屬於已解決的舊請求),不再拿來當 detail。 +_PENDING_DETAIL_TTL = 120.0 + +# 這些事件代表權限已放行(工具跑完)或回合已推進(新輸入 / 回合結束)→ 清掉暫存。 +_PENDING_DETAIL_CLEAR_EVENTS = {"PostToolUse", "UserPromptSubmit", "Stop"} + + +def _pending_permission_detail(event: NormalizedHookEvent, prev_row: dict[str, Any], now: float) -> tuple[str, float]: + """決定這次要寫進 row 的「暫存權限 detail」:(detail, 暫存時間戳);不留就回 ("", 0.0)。 + + - 裸 PermissionRequest(→ WORKING)帶 detail → 以這次的 detail 起新暫存 + - PostToolUse / UserPromptSubmit / Stop → 清掉(已放行或回合推進,舊暫存失效) + - 其餘事件 → 沿用 prev_row 的暫存(row 每次重建,不帶會遺失),但過期就丟 + """ + if event.event == "PermissionRequest" and event.status is Status.WORKING and event.detail: + return event.detail, now + if event.event in _PENDING_DETAIL_CLEAR_EVENTS: + return "", 0.0 + prev_detail = prev_row.get("pending_permission_detail") + prev_at = _as_epoch(prev_row.get("pending_permission_detail_at")) + if isinstance(prev_detail, str) and prev_detail and prev_at is not None and now - prev_at <= _PENDING_DETAIL_TTL: + return prev_detail, prev_at + return "", 0.0 + + def _waiting_ring_allowed(last_notified: float | None, now: float) -> bool: """hook 的 waiting 通知是否放行:距上次通知未滿 ``waiting_cooldown_seconds`` 就不發。 @@ -274,9 +349,11 @@ def _waiting_ring_allowed(last_notified: float | None, now: float) -> bool: def _ring_waiting_now(event: Any, payload: dict[str, Any], last_action: str) -> None: """session 轉 🔴 等你的當下,就地由 hook 發系統通知——不必等 RiNG 看板輪詢。 - WAITING 只會從 hook 來(scan 模式永不標 WAITING),所以 hook 是第一手知道「等你」 + WAITING 是 hook 資料驅動的(scan 模式永不標 WAITING),hook 是第一手知道「等你」 的地方;在事件當下通知最即時,也不依賴看板有沒有開著(看板沒開時舊版根本不會 ring - 你)。backend=none / agent-hooks 由 notify_waiting → _select_notifier 自動短路 + 你)。唯一的例外是 codex 的核可等待:那是讀取側對 hook row 的靜默逾時判定,沒有 + 對應的 hook 事件可在這裡發通知,由 TUI 的提醒排程器代發(見 tui._ring_on_waiting_alerts)。 + backend=none / agent-hooks 由 notify_waiting → _select_notifier 自動短路 (agent-hooks 改走 _delegate_to_agent_hooks 的 modal 委派),不會重複發。 失敗安靜吞掉,絕不擋住 session。 """ diff --git a/src/ring/hook_protocol.py b/src/ring/hook_protocol.py index 01c05e5..8139c81 100644 --- a/src/ring/hook_protocol.py +++ b/src/ring/hook_protocol.py @@ -44,7 +44,6 @@ class NormalizedHookEvent: "UserPromptSubmit": Status.WORKING, "Stop": Status.IDLE, "SessionEnd": Status.ENDED, - "PermissionRequest": Status.WAITING, } _ACTION_REQUIRED_NOTIFICATION_TYPES = { @@ -105,13 +104,17 @@ def normalize(self, data: Mapping[str, Any]) -> NormalizedHookEvent | None: status = Status.WORKING elif explicit_requires_action is not None: status = Status.WAITING if explicit_requires_action else Status.IDLE - elif event == "PermissionRequest" and self.provider == "codex": - # Codex 會在權限「準備判定」時送 PermissionRequest;即使既有 policy - # 隨後自動放行、畫面從未停下來等人,也會經過這個 hook。裸事件因此只能 - # 證明 agent 還在處理工具呼叫,不能當成使用者需要回應。若 payload 有 - # requires_action / waiting_for 等明確訊號,已由上面的分支判成 WAITING; - # 明確的 requires_action=false 也在上面判成 IDLE,不會落到這裡的 WORKING。 - status = Status.WORKING + elif event == "PermissionRequest": + # PermissionRequest 在權限「準備判定」時就發,不分 provider:Claude Code + # 連 subagent 的唯讀工具呼叫都會經過(帶 agent_id/agent_type),Codex 則在 + # policy 判定前發。多數在幾秒內被 policy 自動放行、畫面從未停下來等人—— + # 裸事件因此只能證明 agent 還在處理工具呼叫,不能當成使用者需要回應 + # → WORKING。payload 本身就要求互動(AskUserQuestion / questions / options) + # 才直接判 WAITING。Claude Code 真的停下來等人時,會在數秒後補發 + # notification_type=permission_prompt 的 Notification,由下面的 Notification + # 分支兜底轉 WAITING(內建 debounce)。顯式 requires_action / waiting_for + # 訊號已由上面的分支先處理(true → WAITING、false → IDLE),不會落到這裡。 + status = Status.WAITING if _is_action_required_payload(data) else Status.WORKING elif event == "Notification": status = Status.WAITING if _is_action_required_notification(data) else Status.IDLE elif event == "PreToolUse": @@ -238,12 +241,14 @@ def _waiting_kind(data: Mapping[str, Any], event: str, status: Status) -> str: if "plan" in waiting_for or "plan" in tool_name or "plan" in detail: return "plan" + if tool_name == "askuserquestion": + # 先於 PermissionRequest 判斷:AskUserQuestion 也會以 PermissionRequest 事件 + # 進來(權限判定包著問題),但使用者要回的是「問題」不是「權限」。 + return "question" if event == "PermissionRequest" or notification_type == "permission_prompt": return "permission" if waiting_for in {"approval", "permission"}: return "permission" - if tool_name == "askuserquestion": - return "question" if waiting_for in { "choice", "choices", diff --git a/src/ring/question_detect.py b/src/ring/question_detect.py new file mode 100644 index 0000000..b24e5e9 --- /dev/null +++ b/src/ring/question_detect.py @@ -0,0 +1,93 @@ +"""偵測 Stop 事件裡「回合結尾純文字提問」。 + +agent 有時不用 ``AskUserQuestion``、不觸發權限請求,只是在文字裡問了一句話就停下來 +(例如「要不要順便修 B?」)。既有的 Stop → 🟡 跑完停著是寫死的(見 +``hook_protocol._ALWAYS_STATUS``),從不讀 assistant 文字,這類提問因此測不到、使用者 +以為沒東西要回。 + +設計原則:保守,寧可漏報也不要誤報。只看**訊息結尾**: + +- 問句出現在訊息中段、結尾是陳述句 → 不算(避免把「先確認一下:你要 A 還是 B?我會用 + A 繼續做」這種訊息誤判——它結尾是陳述句,不該轉紅)。 +- 結尾若是圍欄程式碼區塊(```` ``` ... ``` ````),先把整個區塊剝掉再看區塊之前的最後一行 + ——常見於「這樣可以嗎?\\n\\n```bash\\nls -la\\n```」,圍欄本身不是問句,但它前面那句是。 +""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from ring.transcript import _last_assistant_text, _tail_records + +_DETAIL_MAX = 160 + +# 收尾判斷前,先剝掉這些包裹字元(markdown 強調記號、引號、括號), +# 例如「...要繼續嗎?**」「...要繼續嗎?」」。 +_TRAILING_WRAPPERS = "*_`\"' )]>" + +_QUESTION_MARKS = ("?", "?") + + +def stop_last_assistant_text(data: Mapping[str, Any]) -> str: + """取得 Stop payload 對應的最後一則 assistant 訊息文字。 + + 優先讀 payload 本身的 ``last_assistant_message``(Claude Code、Codex 的實際 Stop + payload 都帶這欄——見 ``~/.config/ring/hook_payloads.jsonl`` 實錄);缺欄位或空字串 + 才退回從 ``transcript_path`` 尾端讀(有界讀取,缺檔/壞格式安全回空字串,不炸)。 + """ + text = data.get("last_assistant_message") + if isinstance(text, str) and text.strip(): + return text + tp = data.get("transcript_path") or data.get("transcriptPath") + if isinstance(tp, str) and tp: + try: + records = _tail_records(Path(tp)) + except Exception: + return "" + return _last_assistant_text(records) + return "" + + +def _strip_trailing_code_fence(text: str) -> str: + """剝掉文字尾端的圍欄程式碼區塊(可能不只一個),回傳剝除後的文字。""" + lines = text.splitlines() + changed = True + while changed and lines: + changed = False + while lines and not lines[-1].strip(): + lines.pop() + if lines and lines[-1].strip().startswith("```"): + closing = len(lines) - 1 + opening = None + for i in range(closing - 1, -1, -1): + if lines[i].strip().startswith("```"): + opening = i + break + if opening is not None: + lines = lines[:opening] + changed = True + return "\n".join(lines) + + +def _strip_trailing_wrappers(line: str) -> str: + line = line.rstrip() + while line and line[-1] in _TRAILING_WRAPPERS: + line = line[:-1].rstrip() + return line + + +def trailing_question_detail(text: str) -> str: + """``text`` 的結尾若是問句,回傳該行摘要(截斷合理長度);否則回傳空字串。""" + if not text: + return "" + stripped = _strip_trailing_code_fence(text.rstrip()) + lines = [ln for ln in stripped.splitlines() if ln.strip()] + if not lines: + return "" + last_line = _strip_trailing_wrappers(lines[-1]) + if not last_line or last_line[-1] not in _QUESTION_MARKS: + return "" + detail = " ".join(last_line.split()) + return detail if len(detail) <= _DETAIL_MAX else detail[: _DETAIL_MAX - 1] + "…" diff --git a/src/ring/registry.py b/src/ring/registry.py index a9715d1..ad6788f 100644 --- a/src/ring/registry.py +++ b/src/ring/registry.py @@ -8,7 +8,9 @@ 2. zero-config fallback:直接掃 ``~/.claude/projects/**/*.jsonl``,用檔案 mtime 推活躍度,從記錄裡的 ``cwd`` 欄位還原真實路徑(避開目錄名以 ``-`` 編碼 造成的 hyphen 還原歧義)。scan 模式不把「回完一輪」當成 🔴 WAITING; - WAITING 只保留給 hook 可確認的權限 / 選項等互動。 + WAITING 一律由 hook 資料驅動:hook 事件直接標的權限 / 選項互動,加上 + codex 的核可等待靜默逾時判定(``_promote_codex_permission_wait``——它讀的 + 也是 hook row,是推遲判定而非 scan 猜測)。 額外富化: - ``tmux_target``:靠 tmux pane 的 current_path 對 cwd,給你「去哪」的座標。 @@ -53,6 +55,8 @@ ACTIVE_WINDOW_SECONDS = _CFG.active_window_seconds # 只看最近這段時間動過的 session(預設 6h) WORKING_THRESHOLD_SECONDS = _CFG.working_threshold_seconds # 多久沒動 → 🟢 工作中 變 🟡 閒置 WAITING_WINDOW_SECONDS = _CFG.waiting_window_seconds # IDLE 升 WAITING 的時間窗上限(預設 30 分) +# codex 裸 PermissionRequest 後 hook 靜默超過這秒數 → 判定真的停下來等核可(0 = 關閉) +CODEX_PERMISSION_WAIT_SECONDS = _CFG.codex_permission_wait_seconds _SUBPROCESS_CACHE_TTL = 1.0 # ps / tmux 結果的短快取,省掉同一次刷新內的重複呼叫 # Claude Code SessionStart payload 的 source 值(不是 provider)。舊版 bug 曾把它誤當 @@ -349,7 +353,9 @@ def _apply_waiting( """對話尾是 end_turn 且在時間窗內時,將 live/idle scan row 收斂為 IDLE。 純函式、可單測,不依賴 module-level 常數。 - 不把回合結束升成 WAITING;WAITING 只保留給 hook 確認的權限 / 選項互動: + 不把回合結束升成 WAITING;WAITING 一律由 hook 資料驅動(hook 直接標的權限 / + 選項互動,或 ``_promote_codex_permission_wait`` 對 hook row 的靜默逾時判定), + scan 猜測永遠不標: - WORKING(< 90s):若尾端已是 end_turn,代表回合其實結束了,收斂成 IDLE。 - ENDED:超過活躍窗,不升。 """ @@ -358,6 +364,34 @@ def _apply_waiting( return status +def _promote_codex_permission_wait( + provider: str, + status: Status, + last_event: str, + age_seconds: float, + threshold: float, +) -> bool: + """codex hook row 的核可等待判定:裸 PermissionRequest 後靜默逾時 → 該升 🔴 嗎。 + + Codex(0.144.4 實證)的 hook 是封閉的 10 事件枚舉:沒有「使用者已核可」事件、沒有 + 心跳,rollout 檔在等核可期間也完全靜默。policy 自動放行時,下一個事件(PostToolUse / + 下一個 PreToolUse / Stop)幾秒內就會到;真的停下來等人時 hook 通道只會一直沉默。 + 所以「最後一個 hook 事件是 PermissionRequest 且已靜默超過門檻」本身就是可靠的等待 + 訊號——這是對 hook 資料的推遲判定,不是 scan 猜測。任何後續 hook 事件會覆寫 + last_event,自然清紅。 + + 只對 codex 啟用:claude-code 真的停下來等人時會補發 permission_prompt Notification + (hook 直接標 🔴),不需要、也不該重複走這條路。純函式、可單測。 + """ + return ( + provider == "codex" + and status is Status.WORKING + and last_event == "PermissionRequest" + and threshold > 0 + and age_seconds > threshold + ) + + def _hook_heartbeat_stale( source_path: str, heartbeat_at: float, @@ -1329,26 +1363,40 @@ def _hook_sessions( if purge_session_start_phantoms: f.unlink(missing_ok=True) continue - out.append( - Session( - session_id=str(data["session_id"]), - cwd=str(data.get("cwd", "")), - status=Status(data.get("status", "idle")), - last_active=float(data.get("last_active", 0.0)), - last_action=str(data.get("last_action", "—")), - source="hook", - tmux_pane=str(data.get("tmux_pane", "")) or None, - tty=str(data.get("tty", "")) or None, - hook_pid=int(data["hook_pid"]) if str(data.get("hook_pid", "")).isdigit() else None, - heartbeat_at=float(data.get("heartbeat_at", data.get("last_active", 0.0))), - source_path=str(data.get("source_path", "")), - todo=tuple(todo) if isinstance(todo, list) and len(todo) == 2 else None, - provider=provider, - waiting_kind=str(data.get("waiting_kind", "")), - waiting_detail=str(data.get("waiting_detail", "")), - origin_cwd=str(data.get("origin_cwd", "")), - ) + row = Session( + session_id=str(data["session_id"]), + cwd=str(data.get("cwd", "")), + status=Status(data.get("status", "idle")), + last_active=float(data.get("last_active", 0.0)), + last_action=str(data.get("last_action", "—")), + source="hook", + tmux_pane=str(data.get("tmux_pane", "")) or None, + tty=str(data.get("tty", "")) or None, + hook_pid=int(data["hook_pid"]) if str(data.get("hook_pid", "")).isdigit() else None, + heartbeat_at=float(data.get("heartbeat_at", data.get("last_active", 0.0))), + source_path=str(data.get("source_path", "")), + todo=tuple(todo) if isinstance(todo, list) and len(todo) == 2 else None, + provider=provider, + waiting_kind=str(data.get("waiting_kind", "")), + waiting_detail=str(data.get("waiting_detail", "")), + origin_cwd=str(data.get("origin_cwd", "")), ) + if _promote_codex_permission_wait( + _canonical_provider(provider), + row.status, + str(data.get("last_event", "")), + time.time() - row.last_active, + CODEX_PERMISSION_WAIT_SECONDS, + ): + row.status = Status.WAITING + row.waiting_kind = "permission" + pending_detail = data.get("pending_permission_detail") + if isinstance(pending_detail, str) and pending_detail: + # hook 在裸 PermissionRequest 當下暫存的指令摘要(120s TTL 只管 + # 「下一個事件來時還新不新鮮」;這裡 hook 靜默 = 同一筆請求還掛著, + # 摘要必然還是它,直接沿用)。 + row.waiting_detail = pending_detail + out.append(row) except (KeyError, ValueError): continue for s in out: diff --git a/src/ring/transcript.py b/src/ring/transcript.py index 4102650..dc517ce 100644 --- a/src/ring/transcript.py +++ b/src/ring/transcript.py @@ -131,6 +131,26 @@ def _latest_action(records: list[dict[str, Any]]) -> str: return "—" +def _last_assistant_text(records: list[dict[str, Any]]) -> str: + """從新到舊找最後一筆帶文字的 assistant 記錄,回傳其 text block 合併後的完整文字。 + + 跳過沒有 text block 的 assistant 記錄(例如純 tool_use);找不到回空字串。 + 給 Stop 事件「回合結尾純文字提問」偵測用——跟 ``_latest_action`` 不同,這裡要完整 + 文字(不截斷、不退回 tool_use 摘要),才能判斷訊息結尾是不是問句。 + """ + for record in reversed(records): + if record.get("type") != "assistant": + continue + texts = [ + str(b.get("text", "")) + for b in _blocks(record) + if isinstance(b, dict) and b.get("type") == "text" and b.get("text") + ] + if texts: + return "\n".join(texts) + return "" + + def _extract_todo(records: list[dict[str, Any]]) -> tuple[int, int] | None: """從新到舊找最新的 TodoWrite,回傳 (done, total)。真進度訊號。""" for record in reversed(records): diff --git a/src/ring/tui.py b/src/ring/tui.py index db5a56c..fbd58bd 100644 --- a/src/ring/tui.py +++ b/src/ring/tui.py @@ -289,11 +289,29 @@ def _display_name(self, s: Session) -> str: return labeled_project(s.project, get_label(s.session_id)) def _ring_on_waiting_alerts(self, alerts: list[Session]) -> None: - """有 session 需要提醒 → RiNG 真的「ring」你(響鈴 + toast 通知)。""" - if alerts: - self.bell() - names = ", ".join(sorted(self._display_name(s) for s in alerts)) - self.notify(_("🔔 {names} 在等你回話", names=names), timeout=8) + """有 session 需要提醒 → RiNG 真的「ring」你(響鈴 + toast 通知)。 + + 系統通知原則上由 ``ring hook`` 在轉 🔴 的事件當下發(這裡不重複發,只響 + in-app 鈴)。唯一的例外是 codex 的核可等待:它是讀取側的靜默逾時判定 + (registry._promote_codex_permission_wait),沒有對應 hook 事件可發通知, + 所以由這裡代發系統通知——初次與重複提醒都走 WaitingAlertScheduler 同一條路。 + codex + waiting_kind="permission" 只可能來自逾時判定(hook 對 codex 裸 + PermissionRequest 一律記 working;AskUserQuestion 形狀的是 "question"), + 不會跟 hook 端的通知重複。失敗安靜吞,不影響看板。 + """ + if not alerts: + return + self.bell() + names = ", ".join(sorted(self._display_name(s) for s in alerts)) + self.notify(_("🔔 {names} 在等你回話", names=names), timeout=8) + promoted = [s for s in alerts if s.provider == "codex" and s.waiting_kind == "permission"] + if promoted: + try: + from ring.notify import notify_waiting + + notify_waiting(promoted) + except Exception: + pass def _activate_own_window(self) -> None: """把 RiNG 自己的終端視窗帶到前景(best-effort,失敗安靜吞)。 diff --git a/tests/test_config.py b/tests/test_config.py index e7a81dd..5c26bc4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -153,3 +153,17 @@ def test_dump_toml_roundtrips_via_tomllib() -> None: "colors": {"waiting": "bold red"}, } assert tomllib.loads(dump_toml(data)) == data + + +def test_codex_permission_wait_seconds_parses_and_defaults(tmp_path: Path) -> None: + p = tmp_path / "config.toml" + p.write_text("codex_permission_wait_seconds = 3\n") + assert load(p).codex_permission_wait_seconds == 3 + assert load(tmp_path / "nope.toml").codex_permission_wait_seconds == 10 + + +def test_detect_stop_questions_parses_and_defaults(tmp_path: Path) -> None: + p = tmp_path / "config.toml" + p.write_text("detect_stop_questions = false\n") + assert load(p).detect_stop_questions is False + assert load(tmp_path / "nope.toml").detect_stop_questions is True diff --git a/tests/test_hook.py b/tests/test_hook.py index 6f7bf5a..cd59e79 100644 --- a/tests/test_hook.py +++ b/tests/test_hook.py @@ -165,14 +165,94 @@ def test_regular_notification_writes_idle(monkeypatch: pytest.MonkeyPatch, tmp_p assert data["status"] == Status.IDLE.value -def test_permission_request_writes_waiting(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def _bare_permission_request(session_id: str = "s1", *, subagent: bool = False) -> dict[str, Any]: + """真實 Claude Code 裸 PermissionRequest 的欄位形狀(取自 hook_payloads.jsonl 實錄)。 + + 權限「判定」時就發、多數瞬間被 policy 自動放行——subagent 觸發的帶 agent_id/agent_type, + 主執行緒的不帶。兩種都沒有 requires_action / waiting_for 這類顯式訊號。 + """ + payload: dict[str, Any] = { + "session_id": session_id, + "transcript_path": f"/nonexistent/{session_id}.jsonl", + "cwd": "/x", + "prompt_id": "f6faddb7-473f-4345-9518-4e4c3ea58554", + "permission_mode": "acceptEdits", + "effort": {"level": "high"}, + "hook_event_name": "PermissionRequest", + "tool_name": "Bash", + "tool_input": {"command": "grep -n foo src/*.py", "description": "Search for foo"}, + "permission_suggestions": [], + } + if subagent: + payload["agent_id"] = "a490599d8a4bbc07c" + payload["agent_type"] = "general-purpose" + return payload + + +def test_claude_bare_permission_request_writes_working(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """主執行緒的裸 PermissionRequest(無 agent_id)→ 🟢 WORKING:權限判定不等於停下來等人。""" + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + _feed(monkeypatch, _bare_permission_request()) + + assert hook.run_hook() == 0 + + data = json.loads((tmp_path / "s1.json").read_text()) + assert data["status"] == Status.WORKING.value + assert "waiting_detail" not in data + + +def test_claude_subagent_bare_permission_request_writes_working( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """subagent 觸發的裸 PermissionRequest(帶 agent_id/agent_type)→ 🟢 WORKING。 + + 這是 48 小時內單一 session 翻轉 105 次的假「等你」來源:subagent 跑唯讀工具, + 每次呼叫都發 PermissionRequest、幾秒內自動放行,不該閃紅。 + """ monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) - _feed(monkeypatch, {"session_id": "s1", "hook_event_name": "PermissionRequest", "cwd": "/x"}) + _feed(monkeypatch, _bare_permission_request(subagent=True)) + + assert hook.run_hook() == 0 + + data = json.loads((tmp_path / "s1.json").read_text()) + assert data["status"] == Status.WORKING.value + assert "waiting_detail" not in data + + +def test_ask_user_question_permission_request_writes_waiting(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """AskUserQuestion 包在 PermissionRequest 裡 → 🔴 WAITING,detail 帶問題內容。""" + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + _feed( + monkeypatch, + { + "session_id": "s1", + "cwd": "/x", + "permission_mode": "acceptEdits", + "hook_event_name": "PermissionRequest", + "tool_name": "AskUserQuestion", + "tool_input": {"questions": [{"question": "要用哪個 auth 方案?", "options": [{"label": "A"}]}]}, + }, + ) assert hook.run_hook() == 0 data = json.loads((tmp_path / "s1.json").read_text()) assert data["status"] == Status.WAITING.value + assert "要用哪個 auth 方案" in data["waiting_detail"] + + +def _permission_prompt_notification(session_id: str = "s1", cwd: str = "/proj") -> dict[str, Any]: + """真實 Claude Code permission_prompt Notification 的欄位形狀(取自 hook_payloads.jsonl 實錄)。 + + 真的停下來等人時,裸 PermissionRequest 後 ~6 秒必有這個事件——它才是「等你」的訊號。 + """ + return { + "session_id": session_id, + "cwd": cwd, + "hook_event_name": "Notification", + "notification_type": "permission_prompt", + "message": "Claude needs your permission", + } class _SpyNotifier: @@ -198,7 +278,7 @@ def test_waiting_event_delivers_notification_in_hook(monkeypatch: pytest.MonkeyP monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) spy = _SpyNotifier() monkeypatch.setattr("ring.notify._NOTIFIERS", [spy]) - _feed(monkeypatch, {"session_id": "s1", "hook_event_name": "PermissionRequest", "cwd": "/proj"}) + _feed(monkeypatch, _permission_prompt_notification()) assert hook.run_hook() == 0 @@ -232,18 +312,18 @@ def test_waiting_flap_within_cooldown_suppresses_second_hook_notification( spy = _SpyNotifier() monkeypatch.setattr("ring.notify._NOTIFIERS", [spy]) - _feed(monkeypatch, {"session_id": "s1", "hook_event_name": "PermissionRequest", "cwd": "/proj"}) + _feed(monkeypatch, _permission_prompt_notification()) assert hook.run_hook() == 0 assert len(spy.sent) == 1 - # 翻轉:權限請求自動解決 → 離開 waiting(Stop → idle)。時間戳要跟著帶進新 row。 + # 翻轉:權限請求解決 → 離開 waiting(Stop → idle)。時間戳要跟著帶進新 row。 _feed(monkeypatch, {"session_id": "s1", "hook_event_name": "Stop", "cwd": "/proj"}) assert hook.run_hook() == 0 row = json.loads((tmp_path / "s1.json").read_text()) assert "waiting_notified_at" in row, "離開 waiting 時要保留上次通知時間戳,否則冷卻判斷失憶" # 幾秒內又翻回 waiting(遠小於預設 180 秒冷卻期)→ 不再發 - _feed(monkeypatch, {"session_id": "s1", "hook_event_name": "PermissionRequest", "cwd": "/proj"}) + _feed(monkeypatch, _permission_prompt_notification()) assert hook.run_hook() == 0 assert len(spy.sent) == 1, "冷卻期內再轉 waiting 不該再發一則系統通知" @@ -255,7 +335,7 @@ def test_waiting_realerts_after_cooldown_expired(monkeypatch: pytest.MonkeyPatch spy = _SpyNotifier() monkeypatch.setattr("ring.notify._NOTIFIERS", [spy]) - _feed(monkeypatch, {"session_id": "s1", "hook_event_name": "PermissionRequest", "cwd": "/proj"}) + _feed(monkeypatch, _permission_prompt_notification()) assert hook.run_hook() == 0 assert len(spy.sent) == 1 @@ -265,7 +345,7 @@ def test_waiting_realerts_after_cooldown_expired(monkeypatch: pytest.MonkeyPatch row["waiting_notified_at"] = row["waiting_notified_at"] - 999.0 row_path.write_text(json.dumps(row)) - _feed(monkeypatch, {"session_id": "s1", "hook_event_name": "PermissionRequest", "cwd": "/proj"}) + _feed(monkeypatch, _permission_prompt_notification()) assert hook.run_hook() == 0 assert len(spy.sent) == 2, "冷卻期滿後再轉 waiting 要照常通知" @@ -281,11 +361,11 @@ def test_waiting_cooldown_zero_notifies_every_transition(monkeypatch: pytest.Mon spy = _SpyNotifier() monkeypatch.setattr("ring.notify._NOTIFIERS", [spy]) - _feed(monkeypatch, {"session_id": "s1", "hook_event_name": "PermissionRequest", "cwd": "/proj"}) + _feed(monkeypatch, _permission_prompt_notification()) assert hook.run_hook() == 0 _feed(monkeypatch, {"session_id": "s1", "hook_event_name": "Stop", "cwd": "/proj"}) assert hook.run_hook() == 0 - _feed(monkeypatch, {"session_id": "s1", "hook_event_name": "PermissionRequest", "cwd": "/proj"}) + _feed(monkeypatch, _permission_prompt_notification()) assert hook.run_hook() == 0 assert len(spy.sent) == 2 @@ -302,7 +382,7 @@ def test_waiting_notifies_when_legacy_row_lacks_timestamp_field( json.dumps({"session_id": "s1", "provider": "claude-code", "cwd": "/proj", "status": "working"}) ) - _feed(monkeypatch, {"session_id": "s1", "hook_event_name": "PermissionRequest", "cwd": "/proj"}) + _feed(monkeypatch, _permission_prompt_notification()) assert hook.run_hook() == 0 assert len(spy.sent) == 1 @@ -349,7 +429,7 @@ def test_codex_provider_writes_qualified_session(monkeypatch: pytest.MonkeyPatch def test_codex_bare_permission_request_stays_working(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Codex 權限 policy 自動放行時也會送 hook;裸事件不代表真的停下來等人。""" + """Codex 權限 policy 自動放行時也會送 hook;裸事件不代表真的停下來等人(規則不分 provider)。""" monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) _feed(monkeypatch, {"session_id": "thread-1", "event": "PermissionRequest", "cwd": "/repo"}) @@ -452,7 +532,8 @@ def fake_run(cmd: list[str], **kw: object) -> object: assert calls[0][0][:2] == ["agent-hooks", "callback"] assert "--provider" in calls[0][0] assert calls[0][1] is not None and "PermissionRequest" in calls[0][1] # 原始 payload 被透傳 - assert json.loads((tmp_path / "s1.json").read_text())["status"] == Status.WAITING.value # 狀態照寫 + # 狀態照寫(裸 PermissionRequest 現在是 🟢 WORKING;委派與狀態記錄互不影響) + assert json.loads((tmp_path / "s1.json").read_text())["status"] == Status.WORKING.value def test_no_delegation_when_backend_not_agent_hooks(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -855,19 +936,21 @@ def test_uninstall_hooks_invalid_json(monkeypatch: pytest.MonkeyPatch, tmp_path: # --------------------------------------------------------------------------- -def test_waiting_detail_captures_bash_command(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """PermissionRequest 帶 tool_input.command → registry 存 `Bash: <指令>`。""" +def test_permission_prompt_notification_uses_stashed_permission_detail( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """裸 PermissionRequest 的指令摘要暫存起來;後續 permission_prompt Notification + 轉 WAITING 時拿它當 waiting_detail,取代籠統的「Claude needs your permission」。""" monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) - _feed( - monkeypatch, - { - "session_id": "s1", - "hook_event_name": "PermissionRequest", - "cwd": "/x", - "tool_name": "Bash", - "tool_input": {"command": "rm -rf node_modules"}, - }, - ) + payload = _bare_permission_request() + payload["tool_input"] = {"command": "rm -rf node_modules"} + _feed(monkeypatch, payload) + assert hook.run_hook() == 0 + data = json.loads((tmp_path / "s1.json").read_text()) + assert data["status"] == Status.WORKING.value + assert data["pending_permission_detail"] == "Bash: rm -rf node_modules" + + _feed(monkeypatch, _permission_prompt_notification(cwd="/x")) assert hook.run_hook() == 0 data = json.loads((tmp_path / "s1.json").read_text()) assert data["status"] == Status.WAITING.value @@ -875,6 +958,51 @@ def test_waiting_detail_captures_bash_command(monkeypatch: pytest.MonkeyPatch, t assert data["waiting_kind"] == "permission" +def test_permission_prompt_notification_after_post_tool_use_falls_back_to_message( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """PostToolUse 代表權限已放行 → 清掉暫存;之後的 permission_prompt Notification + 屬於新的等待,detail 回到 Notification 自己的籠統訊息。""" + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + payload = _bare_permission_request() + payload["tool_input"] = {"command": "rm -rf node_modules"} + _feed(monkeypatch, payload) + assert hook.run_hook() == 0 + + _feed(monkeypatch, {"session_id": "s1", "hook_event_name": "PostToolUse", "tool_name": "Bash", "cwd": "/x"}) + assert hook.run_hook() == 0 + data = json.loads((tmp_path / "s1.json").read_text()) + assert "pending_permission_detail" not in data + + _feed(monkeypatch, _permission_prompt_notification(cwd="/x")) + assert hook.run_hook() == 0 + data = json.loads((tmp_path / "s1.json").read_text()) + assert data["status"] == Status.WAITING.value + assert data["waiting_detail"] == "Claude needs your permission" + assert data["waiting_kind"] == "permission" + + +def test_stale_stashed_permission_detail_not_used(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """暫存超過新鮮期(120 秒)→ 不拿來當 waiting_detail,回籠統訊息。""" + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + payload = _bare_permission_request() + payload["tool_input"] = {"command": "rm -rf node_modules"} + _feed(monkeypatch, payload) + assert hook.run_hook() == 0 + + # 把暫存時間戳改成遠早於新鮮期(模擬時間流逝,不 patch 時鐘) + row_path = tmp_path / "s1.json" + row = json.loads(row_path.read_text()) + row["pending_permission_detail_at"] -= 999.0 + row_path.write_text(json.dumps(row)) + + _feed(monkeypatch, _permission_prompt_notification(cwd="/x")) + assert hook.run_hook() == 0 + data = json.loads((tmp_path / "s1.json").read_text()) + assert data["status"] == Status.WAITING.value + assert data["waiting_detail"] == "Claude needs your permission" + + def test_waiting_detail_captures_question_text(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """AskUserQuestion → 存第一個問題的內容。""" monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) @@ -916,18 +1044,14 @@ def test_waiting_kind_detects_plan_approval(monkeypatch: pytest.MonkeyPatch, tmp def test_waiting_detail_collapses_multiline_and_truncates(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """暫存的指令摘要沿用 _action_detail 的整形:多行壓單行、超長截斷。""" monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) - long_cmd = "echo start\n" + "x" * 400 - _feed( - monkeypatch, - { - "session_id": "s1", - "hook_event_name": "PermissionRequest", - "cwd": "/x", - "tool_name": "Bash", - "tool_input": {"command": long_cmd}, - }, - ) + payload = _bare_permission_request() + payload["tool_input"] = {"command": "echo start\n" + "x" * 400} + _feed(monkeypatch, payload) + assert hook.run_hook() == 0 + + _feed(monkeypatch, _permission_prompt_notification(cwd="/x")) assert hook.run_hook() == 0 detail = json.loads((tmp_path / "s1.json").read_text())["waiting_detail"] assert "\n" not in detail @@ -971,3 +1095,243 @@ def test_notification_message_used_as_waiting_detail(monkeypatch: pytest.MonkeyP assert data["status"] == Status.WAITING.value assert data["waiting_detail"] == "Claude needs your permission to use Edit" assert data["waiting_kind"] == "permission" + + +# --------------------------------------------------------------------------- +# codex 核可等待:hook 側寫入 last_event 與暫存 detail(讀取側判定的原料) +# --------------------------------------------------------------------------- + + +def _codex_bare_permission_request(session_id: str = "thread-1") -> dict[str, Any]: + """真實 Codex 0.144.4 裸 PermissionRequest 的欄位形狀(取自 hook_payloads.jsonl 實錄)。 + + schema 固定欄位(additionalProperties=false):無 requires_action / waiting_for, + tool_name / tool_input 必有。 + """ + return { + "session_id": session_id, + "turn_id": "019f63f6-5f51-7792-aedf-dedbbcd0251d", + "transcript_path": f"/nonexistent/rollout-{session_id}.jsonl", + "cwd": "/repo", + "hook_event_name": "PermissionRequest", + "model": "gpt-5.6-sol", + "permission_mode": "default", + "tool_name": "Bash", + "tool_input": {"command": "cp /tmp/fix.py pelicanconf.py", "description": "修 CSS 路徑"}, + } + + +def test_codex_bare_permission_request_writes_last_event_and_stash( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """codex 裸 PermissionRequest → 🟢 working,但 row 要留下讀取側逾時判定的原料: + last_event(判定「還停在權限請求」)+ pending_permission_detail(升紅時的 detail)。""" + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + _feed(monkeypatch, _codex_bare_permission_request()) + + assert hook.run_hook(provider="codex") == 0 + + data = json.loads((tmp_path / "codex:thread-1.json").read_text()) + assert data["status"] == Status.WORKING.value + assert data["last_event"] == "PermissionRequest" + assert data["pending_permission_detail"] == "Bash: cp /tmp/fix.py pelicanconf.py" + assert "waiting_detail" not in data + + +def test_codex_subsequent_event_overwrites_last_event_and_clears_stash( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """核可後的 PostToolUse 覆寫 last_event 並清掉暫存 → 讀取側的逾時判定自然失效。""" + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + _feed(monkeypatch, _codex_bare_permission_request()) + assert hook.run_hook(provider="codex") == 0 + + _feed( + monkeypatch, + {"session_id": "thread-1", "hook_event_name": "PostToolUse", "tool_name": "Bash", "cwd": "/repo"}, + ) + assert hook.run_hook(provider="codex") == 0 + + data = json.loads((tmp_path / "codex:thread-1.json").read_text()) + assert data["status"] == Status.WORKING.value + assert data["last_event"] == "PostToolUse" + assert "pending_permission_detail" not in data + + +# --------------------------------------------------------------------------- +# Stop 事件的「回合結尾純文字提問」偵測(question_detect.py) +# --------------------------------------------------------------------------- + + +def test_stop_trailing_question_writes_waiting(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """claude-code Stop、結尾是問句 → 🔴 等你,kind=question,detail 含問句本文。""" + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + _feed( + monkeypatch, + { + "session_id": "s1", + "hook_event_name": "Stop", + "cwd": "/x", + "last_assistant_message": "先做了 A。\n\n要不要順便修 B?", + }, + ) + assert hook.run_hook() == 0 + data = json.loads((tmp_path / "s1.json").read_text()) + assert data["status"] == Status.WAITING.value + assert data["waiting_kind"] == "question" + assert "要不要順便修 B?" in data["waiting_detail"] + + +def test_stop_trailing_statement_writes_idle(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """結尾是陳述句 → 維持 🟡,不誤報。""" + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + _feed( + monkeypatch, + { + "session_id": "s1", + "hook_event_name": "Stop", + "cwd": "/x", + "last_assistant_message": "已經修好了,測試都綠了。", + }, + ) + assert hook.run_hook() == 0 + data = json.loads((tmp_path / "s1.json").read_text()) + assert data["status"] == Status.IDLE.value + assert "waiting_kind" not in data + + +def test_stop_question_mid_message_statement_at_end_writes_idle( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """問句在訊息中段、結尾是陳述句 → 不容誤報,維持 🟡。""" + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + _feed( + monkeypatch, + { + "session_id": "s1", + "hook_event_name": "Stop", + "cwd": "/x", + "last_assistant_message": "先確認一下:你要 A 還是 B?我會用 A 繼續做。", + }, + ) + assert hook.run_hook() == 0 + data = json.loads((tmp_path / "s1.json").read_text()) + assert data["status"] == Status.IDLE.value + + +def test_stop_missing_transcript_falls_back_idle(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """沒有 last_assistant_message、transcript_path 指向不存在的檔 → 安全退回 🟡,不炸。""" + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + _feed( + monkeypatch, + { + "session_id": "s1", + "hook_event_name": "Stop", + "cwd": "/x", + "transcript_path": str(tmp_path / "missing.jsonl"), + }, + ) + assert hook.run_hook() == 0 + data = json.loads((tmp_path / "s1.json").read_text()) + assert data["status"] == Status.IDLE.value + + +def test_stop_malformed_transcript_falls_back_idle(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """transcript 檔存在但不是合法 JSONL → 安全退回 🟡,不炸。""" + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + transcript = tmp_path / "session.jsonl" + transcript.write_text("not json at all\n{{{broken\n", encoding="utf-8") + _feed( + monkeypatch, + { + "session_id": "s1", + "hook_event_name": "Stop", + "cwd": "/x", + "transcript_path": str(transcript), + }, + ) + assert hook.run_hook() == 0 + data = json.loads((tmp_path / "s1.json").read_text()) + assert data["status"] == Status.IDLE.value + + +def test_stop_transcript_fallback_detects_trailing_question(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """payload 沒帶 last_assistant_message 時,退回讀 transcript 尾端的 assistant 文字。""" + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + transcript = tmp_path / "session.jsonl" + transcript.write_text( + '{"type": "assistant", "message": {"content": [{"type": "text", "text": "要繼續嗎?"}]}}\n', + encoding="utf-8", + ) + _feed( + monkeypatch, + { + "session_id": "s1", + "hook_event_name": "Stop", + "cwd": "/x", + "transcript_path": str(transcript), + }, + ) + assert hook.run_hook() == 0 + data = json.loads((tmp_path / "s1.json").read_text()) + assert data["status"] == Status.WAITING.value + assert data["waiting_kind"] == "question" + assert "要繼續嗎?" in data["waiting_detail"] + + +def test_codex_stop_trailing_question_writes_waiting(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """codex Stop payload 也帶 last_assistant_message(見 hook_payloads.jsonl 實錄), + 結尾是問句時同樣升 🔴,行為與 claude-code 一致。""" + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + _feed( + monkeypatch, + { + "session_id": "thread-1", + "hook_event_name": "Stop", + "cwd": "/repo", + "last_assistant_message": "修完了 footer。要不要一起看看手機版?", + }, + ) + assert hook.run_hook(provider="codex") == 0 + data = json.loads((tmp_path / "codex:thread-1.json").read_text()) + assert data["status"] == Status.WAITING.value + assert data["waiting_kind"] == "question" + assert "要不要一起看看手機版?" in data["waiting_detail"] + + +def test_stop_trailing_question_with_trailing_code_fence(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """問句後面跟著程式碼區塊收尾 → 圍欄先剝掉,還是要偵測到問句。""" + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + _feed( + monkeypatch, + { + "session_id": "s1", + "hook_event_name": "Stop", + "cwd": "/x", + "last_assistant_message": "這樣可以嗎?\n\n```bash\nls -la\n```", + }, + ) + assert hook.run_hook() == 0 + data = json.loads((tmp_path / "s1.json").read_text()) + assert data["status"] == Status.WAITING.value + assert data["waiting_kind"] == "question" + assert "這樣可以嗎?" in data["waiting_detail"] + + +def test_stop_trailing_question_disabled_by_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """detect_stop_questions=false → 就算結尾是問句也維持 🟡。""" + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + monkeypatch.setattr(hook, "get_config", lambda: Config(detect_stop_questions=False)) + _feed( + monkeypatch, + { + "session_id": "s1", + "hook_event_name": "Stop", + "cwd": "/x", + "last_assistant_message": "要不要順便修 B?", + }, + ) + assert hook.run_hook() == 0 + data = json.loads((tmp_path / "s1.json").read_text()) + assert data["status"] == Status.IDLE.value + assert "waiting_kind" not in data diff --git a/tests/test_question_detect.py b/tests/test_question_detect.py new file mode 100644 index 0000000..6ae32bf --- /dev/null +++ b/tests/test_question_detect.py @@ -0,0 +1,87 @@ +from pathlib import Path + +from ring.question_detect import ( + _strip_trailing_code_fence, + stop_last_assistant_text, + trailing_question_detail, +) + + +def test_trailing_question_detected() -> None: + text = "先做了 A。\n\n要不要順便修 B?" + assert trailing_question_detail(text) == "要不要順便修 B?" + + +def test_trailing_statement_not_flagged() -> None: + text = "先確認一下:你要 A 還是 B?我會用 A 繼續做。" + assert trailing_question_detail(text) == "" + + +def test_question_mid_message_statement_at_end_not_flagged() -> None: + text = "要用哪個方案?我選了方案一,正在套用。" + assert trailing_question_detail(text) == "" + + +def test_empty_text_not_flagged() -> None: + assert trailing_question_detail("") == "" + + +def test_trailing_code_fence_stripped_before_check() -> None: + text = "這樣可以嗎?\n\n```bash\nls -la\n```" + assert trailing_question_detail(text) == "這樣可以嗎?" + + +def test_trailing_code_fence_with_statement_still_not_flagged() -> None: + text = "我先跑一下這個指令。\n\n```bash\nls -la\n```" + assert trailing_question_detail(text) == "" + + +def test_ascii_question_mark_detected() -> None: + assert trailing_question_detail("Should I proceed with the deploy?") == "Should I proceed with the deploy?" + + +def test_trailing_wrapper_chars_stripped() -> None: + assert trailing_question_detail("要繼續嗎?**") == "要繼續嗎?" + + +def test_detail_truncated_when_too_long() -> None: + long_line = "要不要" + "很長" * 100 + "?" + detail = trailing_question_detail(long_line) + assert len(detail) == 160 + assert detail.endswith("…") + + +def test_strip_trailing_code_fence_removes_multiple_blocks() -> None: + text = "問句?\n```\na\n```\n\n```\nb\n```" + assert _strip_trailing_code_fence(text) == "問句?" + + +def test_stop_last_assistant_text_prefers_payload_field() -> None: + data = {"last_assistant_message": "要繼續嗎?", "transcript_path": "/does/not/exist.jsonl"} + assert stop_last_assistant_text(data) == "要繼續嗎?" + + +def test_stop_last_assistant_text_falls_back_to_transcript(tmp_path: Path) -> None: + transcript = tmp_path / "session.jsonl" + transcript.write_text( + '{"type": "assistant", "message": {"content": [{"type": "text", "text": "要繼續嗎?"}]}}\n', + encoding="utf-8", + ) + data = {"transcript_path": str(transcript)} + assert stop_last_assistant_text(data) == "要繼續嗎?" + + +def test_stop_last_assistant_text_missing_transcript_returns_empty(tmp_path: Path) -> None: + data = {"transcript_path": str(tmp_path / "missing.jsonl")} + assert stop_last_assistant_text(data) == "" + + +def test_stop_last_assistant_text_malformed_transcript_returns_empty(tmp_path: Path) -> None: + transcript = tmp_path / "session.jsonl" + transcript.write_text("not json at all\n", encoding="utf-8") + data = {"transcript_path": str(transcript)} + assert stop_last_assistant_text(data) == "" + + +def test_stop_last_assistant_text_no_fields_returns_empty() -> None: + assert stop_last_assistant_text({}) == "" diff --git a/tests/test_registry.py b/tests/test_registry.py index 68f6b5e..b1116d5 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -1401,3 +1401,191 @@ def test_hook_sessions_loads_waiting_detail(monkeypatch: pytest.MonkeyPatch, tmp assert by_id["s2"].waiting_kind == "" assert by_id["s2"].waiting_icon == "" assert by_id["s2"].waiting_detail == "" + + +# --------------------------------------------------------------------------- +# codex 核可等待:hook 靜默逾時判定(_promote_codex_permission_wait) +# --------------------------------------------------------------------------- + + +def _write_codex_permission_row( + registry_dir: Path, + sid: str = "codex:thread-1", + cwd: str = "/work/app", + *, + age_seconds: float, + last_event: str = "PermissionRequest", + status: str = "working", + pending_detail: str = "Bash: cp /tmp/fix.py pelicanconf.py", + provider: str = "codex", +) -> None: + """寫一筆「hook 收過裸 PermissionRequest」形狀的 registry row(欄位取自 hook 實寫格式)。""" + registry_dir.mkdir(parents=True, exist_ok=True) + row: dict[str, Any] = { + "session_id": sid, + "provider": provider, + "cwd": cwd, + "status": status, + "last_event": last_event, + "last_active": time.time() - age_seconds, + "last_action": "—", + } + if pending_detail: + row["pending_permission_detail"] = pending_detail + row["pending_permission_detail_at"] = row["last_active"] + (registry_dir / f"{sid}.json").write_text(json.dumps(row)) + + +def test_hook_sessions_promotes_codex_permission_wait_after_threshold( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """codex 裸 PermissionRequest 後 hook 靜默超過門檻 → 讀取側判定真的在等核可(🔴)。 + + Codex(0.144.4)等核可期間 hook 通道完全靜默,policy 自動放行則幾秒內必有後續事件; + 「最後事件是 PermissionRequest + 逾時」因此是可靠訊號。detail 帶 hook 暫存的指令摘要。 + """ + monkeypatch.setattr("ring.registry.RING_REGISTRY", tmp_path) + monkeypatch.setattr("ring.registry.CODEX_PERMISSION_WAIT_SECONDS", 10) + _write_codex_permission_row(tmp_path, age_seconds=30.0) + + sessions = _hook_sessions([("/work/app", "")]) + + assert sessions[0].status is Status.WAITING + assert sessions[0].waiting_kind == "permission" + assert sessions[0].waiting_detail == "Bash: cp /tmp/fix.py pelicanconf.py" + + +def test_hook_sessions_keeps_codex_permission_request_working_within_threshold( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """門檻內的裸 PermissionRequest 維持 🟢:多半是 policy 正要自動放行,別閃紅。""" + monkeypatch.setattr("ring.registry.RING_REGISTRY", tmp_path) + monkeypatch.setattr("ring.registry.CODEX_PERMISSION_WAIT_SECONDS", 10) + _write_codex_permission_row(tmp_path, age_seconds=2.0) + + sessions = _hook_sessions([("/work/app", "")]) + + assert sessions[0].status is Status.WORKING + assert sessions[0].waiting_kind == "" + + +def test_hook_sessions_codex_promotion_threshold_configurable(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """門檻由 config 的 codex_permission_wait_seconds 決定:調大 → 同樣的靜默不再升紅。""" + monkeypatch.setattr("ring.registry.RING_REGISTRY", tmp_path) + _write_codex_permission_row(tmp_path, age_seconds=30.0) + + monkeypatch.setattr("ring.registry.CODEX_PERMISSION_WAIT_SECONDS", 60) + assert _hook_sessions([("/work/app", "")])[0].status is Status.WORKING + + monkeypatch.setattr("ring.registry.CODEX_PERMISSION_WAIT_SECONDS", 5) + assert _hook_sessions([("/work/app", "")])[0].status is Status.WAITING + + +def test_hook_sessions_codex_promotion_disabled_when_threshold_zero( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """codex_permission_wait_seconds = 0 = 關閉判定:再久的靜默也不升紅。""" + monkeypatch.setattr("ring.registry.RING_REGISTRY", tmp_path) + monkeypatch.setattr("ring.registry.CODEX_PERMISSION_WAIT_SECONDS", 0) + _write_codex_permission_row(tmp_path, age_seconds=3600.0) + + assert _hook_sessions([("/work/app", "")])[0].status is Status.WORKING + + +def test_hook_sessions_claude_permission_request_not_promoted(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """claude-code 不走逾時判定:它真的停下來等人時有 permission_prompt Notification 兜底。 + + subagent 的唯讀工具呼叫常留下裸 PermissionRequest 且之後長時間沒事件(工具在跑), + 對 claude 升紅會把 flapping 假象重新引進來。 + """ + monkeypatch.setattr("ring.registry.RING_REGISTRY", tmp_path) + monkeypatch.setattr("ring.registry.CODEX_PERMISSION_WAIT_SECONDS", 10) + _write_codex_permission_row(tmp_path, sid="s1", age_seconds=30.0, provider="claude-code") + + sessions = _hook_sessions([("/work/app", "")]) + + assert sessions[0].status is Status.WORKING + + +def test_hook_sessions_codex_promotion_cleared_by_subsequent_event( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """核可後的任何後續事件(PostToolUse / PreToolUse / Stop)覆寫 last_event → 不再升紅。""" + monkeypatch.setattr("ring.registry.RING_REGISTRY", tmp_path) + monkeypatch.setattr("ring.registry.CODEX_PERMISSION_WAIT_SECONDS", 10) + _write_codex_permission_row(tmp_path, age_seconds=30.0, last_event="PostToolUse") + + sessions = _hook_sessions([("/work/app", "")]) + + assert sessions[0].status is Status.WORKING + assert sessions[0].waiting_kind == "" + + +def test_hook_sessions_codex_promotion_without_stashed_detail(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """沒有暫存 detail(理論上 codex payload 必有 tool_input,防衛性)→ 照樣升紅,detail 留空。""" + monkeypatch.setattr("ring.registry.RING_REGISTRY", tmp_path) + monkeypatch.setattr("ring.registry.CODEX_PERMISSION_WAIT_SECONDS", 10) + _write_codex_permission_row(tmp_path, age_seconds=30.0, pending_detail="") + + sessions = _hook_sessions([("/work/app", "")]) + + assert sessions[0].status is Status.WAITING + assert sessions[0].waiting_detail == "" + + +def test_hook_sessions_old_row_without_last_event_not_promoted(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """舊版 hook 寫的 row 沒有 last_event 欄位 → 缺資料不判定,維持原狀(fail-safe)。""" + monkeypatch.setattr("ring.registry.RING_REGISTRY", tmp_path) + monkeypatch.setattr("ring.registry.CODEX_PERMISSION_WAIT_SECONDS", 10) + tmp_path.mkdir(parents=True, exist_ok=True) + (tmp_path / "codex:old.json").write_text( + json.dumps( + { + "session_id": "codex:old", + "provider": "codex", + "cwd": "/work/app", + "status": "working", + "last_active": time.time() - 300.0, + "last_action": "—", + } + ) + ) + + assert _hook_sessions([("/work/app", "")])[0].status is Status.WORKING + + +def test_codex_permission_wait_end_to_end_from_real_hook_write(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """端到端:真實 schema 形狀的 codex 裸 PermissionRequest 走 ``ring hook`` 實寫 row, + 讀取側靜默逾時後升 🔴 帶指令摘要——不依賴測試手寫的 row 格式。""" + import io + + import ring.hook as hook_mod + from ring.config import Config + + monkeypatch.setattr(hook_mod, "RING_REGISTRY", tmp_path) + monkeypatch.setattr("ring.registry.RING_REGISTRY", tmp_path) + monkeypatch.setattr("ring.hook.get_config", lambda: Config()) + monkeypatch.setattr("ring.notify._NOTIFIERS", []) + monkeypatch.setattr("ring.stats.EVENTS_PATH", tmp_path / "events.jsonl") + payload = { + "session_id": "thread-1", + "turn_id": "019f63f6-5f51-7792-aedf-dedbbcd0251d", + "transcript_path": "/nonexistent/rollout.jsonl", + "cwd": "/repo", + "hook_event_name": "PermissionRequest", + "model": "gpt-5.6-sol", + "permission_mode": "default", + "tool_name": "Bash", + "tool_input": {"command": "cp /tmp/fix.py pelicanconf.py", "description": "修 CSS 路徑"}, + } + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(payload))) + assert hook_mod.run_hook(provider="codex") == 0 + + monkeypatch.setattr("ring.registry.CODEX_PERMISSION_WAIT_SECONDS", 0.05) + time.sleep(0.1) + sessions = _hook_sessions([("/repo", "")]) + + assert sessions[0].session_id == "codex:thread-1" + assert sessions[0].status is Status.WAITING + assert sessions[0].waiting_kind == "permission" + assert sessions[0].waiting_detail == "Bash: cp /tmp/fix.py pelicanconf.py" diff --git a/tests/test_stats.py b/tests/test_stats.py index ccf46c4..60db64f 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -129,11 +129,18 @@ def test_hook_logs_only_transitions(monkeypatch: pytest.MonkeyPatch, tmp_path: P events = tmp_path / "events.jsonl" monkeypatch.setattr("ring.stats.EVENTS_PATH", events) - for event, n in (("UserPromptSubmit", 1), ("PostToolUse", 1), ("PermissionRequest", 2), ("Stop", 3)): - _feed(monkeypatch, {"session_id": "s1", "hook_event_name": event, "cwd": "/x/maigo"}) + steps: list[tuple[dict[str, Any], int]] = [ + ({"hook_event_name": "UserPromptSubmit"}, 1), + ({"hook_event_name": "PostToolUse"}, 1), # 維持 working → 不記 + ({"hook_event_name": "PermissionRequest"}, 1), # 裸事件 → working,不記 + ({"hook_event_name": "Notification", "notification_type": "permission_prompt"}, 2), # → waiting + ({"hook_event_name": "Stop"}, 3), # → idle + ] + for extra, n in steps: + _feed(monkeypatch, {"session_id": "s1", "cwd": "/x/maigo", **extra}) assert hook.run_hook() == 0 lines = events.read_text().splitlines() if events.exists() else [] - assert len(lines) == n # PostToolUse 維持 working → 不記 + assert len(lines) == n statuses = [json.loads(line)["status"] for line in events.read_text().splitlines()] assert statuses == ["working", "waiting", "idle"] diff --git a/tests/test_tui.py b/tests/test_tui.py index efbc41f..c62acdf 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -868,3 +868,48 @@ def fresh_board(show_all: bool) -> list[Session]: app._reload() assert app._sessions[0].status.value == "waiting" assert "a" not in app._permission_acks + + +@pytest.mark.asyncio +async def test_codex_promoted_waiting_sends_system_notification(monkeypatch: pytest.MonkeyPatch) -> None: + """codex 核可等待(讀取側逾時判定)沒有 hook 事件可發通知 → TUI 提醒排程器代發系統通知; + claude 的等待照舊只響 in-app 鈴(系統通知由 hook 發過了,不重複)。""" + state: dict[str, list[Session]] = { + "sessions": [ + Session("codex:t1", "/x/p", Status.WORKING, 0.0, "-", "hook", provider="codex"), + Session("c1", "/y/q", Status.WORKING, 0.0, "-", "hook", provider="claude-code"), + ] + } + monkeypatch.setattr(tui, "board", lambda show_all: state["sessions"]) + monkeypatch.setattr(tui, "running_agent_pids", lambda: [1]) + sent: list[list[Session]] = [] + monkeypatch.setattr("ring.notify.notify_waiting", lambda sessions: sent.append(sessions)) + + app = tui.RingApp(lang="en") + async with app.run_test(): + state["sessions"] = [ + Session( + "codex:t1", + "/x/p", + Status.WAITING, + 0.0, + "-", + "hook", + provider="codex", + waiting_kind="permission", + waiting_detail="Bash: cp /tmp/fix.py pelicanconf.py", + ), + Session( + "c1", + "/y/q", + Status.WAITING, + 0.0, + "-", + "hook", + provider="claude-code", + waiting_kind="permission", + ), + ] + app._reload() + assert len(sent) == 1, "只有 codex 的逾時升紅要代發系統通知" + assert [s.session_id for s in sent[0]] == ["codex:t1"]