diff --git a/adapter/claude/hooks-settings.md b/adapter/claude/hooks-settings.md index 7002f90..6da7c11 100644 --- a/adapter/claude/hooks-settings.md +++ b/adapter/claude/hooks-settings.md @@ -138,7 +138,7 @@ Target: `{workspace_root}/.claude/settings.json` Real files, copied verbatim into `{workspace_root}/.claude/hooks/` on bootstrap (with `{LI_PLUS_TAG}` placeholder replaced by the resolved target tag): -- `adapter/claude/hooks/on-user-prompt.sh` — per-turn Trigger Check Gate re-arm + webhook check (Character_Instance is loaded via output-styles, not per-turn re-notify) +- `adapter/claude/hooks/on-user-prompt.sh` — per-turn Trigger Check Gate re-arm + webhook re-arm (the call half is `poll`-only; the handling half is emitted in every delivery mode — see the mcp_tool entry behavior section below). Character_Instance is loaded via output-styles, not per-turn re-notify - `adapter/claude/hooks/on-session-start.sh` — Cold-start Synthesis material emitter (matcher-aware: `startup` runs diff-only against `{workspace_root}/.claude/state/last-cold-start-emit.json`; `resume` / `clear` / `compact` / `fork` re-anchor only the cold-start rule anchor — see `rules/evolution/cold-start-synthesis.md` for the emission-state table) All five documented SessionStart matchers are registered. An unregistered @@ -174,10 +174,21 @@ Preconditions for the entry to actually deliver webhook context to the AI: side, so the wrapped output reaches the AI prompt context. `Li+config.md`'s `LI_PLUS_WEBHOOK_DELIVERY` setting controls the *bash hook's* -reminder text behavior (poll / channel / mcp_hook) independently. The -`mcp_tool` entry itself fires unconditionally; setting -`LI_PLUS_WEBHOOK_DELIVERY=mcp_hook` suppresses the bash hook's reminder text so -the wrap delivery is the single source of webhook context. +call half (poll / channel / mcp_hook) independently. The `mcp_tool` entry itself +fires unconditionally; setting `LI_PLUS_WEBHOOK_DELIVERY=mcp_hook` suppresses the +bash hook's "call the tool yourself" line, so the wrap delivery is the single +source of webhook context. + +It does not suppress the handling half. The webhook block carries two separable +things — who calls the tool, and what to do with what arrives — and the delivery +mode selects only the first. `channel` and `mcp_hook` both replace the call and +neither replaces the handling, so the report filter and the `mark_processed` +re-arm are emitted in every mode. They have to be emitted by the hook rather than +left to the always-on canonical +(`rules/operations/main-agent-procedures.md` Foreground webhook notification +intake): its firing moment is `each user turn start`, and residency is a load +guarantee, not a firing one. Dropping both halves together is #1798 — events were +delivered into context and no surface said what to do with them. If `github-webhook-mcp` is **not connected**: Claude Code's mcp_tool resolver returns a `not connected` error per turn. The error is surfaced as plain text diff --git a/adapter/claude/hooks/on-user-prompt.sh b/adapter/claude/hooks/on-user-prompt.sh index 6009aca..47cb3a9 100755 --- a/adapter/claude/hooks/on-user-prompt.sh +++ b/adapter/claude/hooks/on-user-prompt.sh @@ -10,12 +10,24 @@ export PATH="$HOME/.local/bin:$PATH" PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-.}" -# --- Webhook notification reminder --- -# Read LI_PLUS_WEBHOOK_DELIVERY from Li+config.md -# poll (default / unset) = emit reminder text so Claude calls the MCP tool -# channel = MCP channel delivers events directly; skip reminder -# mcp_hook = a sibling type=mcp_tool hook entry invokes the MCP -# tool directly without going through Claude; skip reminder +# --- Webhook notification re-arm --- +# The block carries two separable halves, and LI_PLUS_WEBHOOK_DELIVERY selects +# only the first: who calls the tool, never who handles what arrives. +# call half = "Run ... get_pending_status silently", emitted under poll +# (default / unset) alone. channel delivers events over the MCP +# channel and mcp_hook has a sibling type=mcp_tool entry invoke +# the tool directly, so both already replace this half; emitting +# it there is the double delivery described below. +# handling half = the report filter and mark_processed. Nothing replaces it in +# any mode. Its firing moment is `each user turn start` +# (rules/operations/main-agent-procedures.md Foreground webhook +# notification intake), and a per-turn hook is the only surface +# that can fire a turn boundary - always-on residency is a load +# guarantee, not a firing one. Suppressing it alongside the call +# half is #1798: events arrived and no surface said what to do +# with them. +# The re-arm stays terse and points at the canonical instead of copying it, the +# same shape the Trigger Check Gate re-arm below uses. # # `tr -d '\r'` normalises a CRLF-saved Li+config.md, matching both codex ports # (.sh does the same, .ps1 uses .Trim()). Without it the extracted value is @@ -25,13 +37,15 @@ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-.}" # Latent rather than live: on a Windows/Git-Bash host both gawk and MSYS command # substitution drop the CR on their own, so the miss needs a POSIX host. WEBHOOK_DELIVERY=$(awk -F= '/^LI_PLUS_WEBHOOK_DELIVERY=/{print $2}' "$PROJECT_ROOT/Li+config.md" 2>/dev/null | tr -d '\r') +echo "" +echo "━━━ Webhook: check pending notifications ━━━" if [ "$WEBHOOK_DELIVERY" != "channel" ] && [ "$WEBHOOK_DELIVERY" != "mcp_hook" ]; then - echo "" - echo "━━━ Webhook: check pending notifications ━━━" echo "Run mcp__github-webhook-mcp__get_pending_status silently." - echo "Report only foreground-relevant or notable items." - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" fi +echo "Report only foreground-relevant or notable items." +echo "mark_processed every consumed event; own-operation arrivals promptly." +echo "Intake detail: rules/operations/main-agent-procedures.md Foreground webhook notification intake; mark_processed mandate: rules/operations/operations.md Operations Rules (both always-on)." +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" # --- Trigger Check Gate re-arm (every turn) --- echo "" diff --git a/adapter/codex/hooks-config.md b/adapter/codex/hooks-config.md index cf81a4f..a4ecbec 100644 --- a/adapter/codex/hooks-config.md +++ b/adapter/codex/hooks-config.md @@ -207,8 +207,9 @@ is `.ps1` (Windows native, primary on the verified Codex Windows env) + `.sh` On `resume` / `clear` / `compact`: rules re-injection + language contract marker + cold-start anchor only. - `adapter/codex/hooks/on-user-prompt.{ps1,sh}` — per-turn Trigger Check Gate - re-arm + webhook reminder (Character_Instance lives in AGENTS.md, not re-notified - per turn). + re-arm + webhook re-arm, whose call half is `poll`-only and whose handling half + is emitted in every delivery mode (Character_Instance lives in AGENTS.md, not + re-notified per turn). - `adapter/codex/hooks/post-tool-use.{ps1,sh}` — sub-issue refs auto-append on `gh pr create`. @@ -237,7 +238,10 @@ tag-tracking anchor. Bootstrap's tag-mismatch check reads this line. The Claude template adds a sibling `type: "mcp_tool"` UserPromptSubmit entry that calls `get_pending_status` on `github-webhook-mcp`. The Codex hooks schema documents only `type: "command"` handlers. Therefore the Codex webhook intake stays on the -**poll** path: the `on-user-prompt` hook emits the reminder text and the AI calls -the MCP tool itself. `LI_PLUS_WEBHOOK_DELIVERY=channel` / `mcp_hook` suppress the -reminder, but a Codex host without an mcp_tool hook substrate falls back to `poll` +**poll** path: the `on-user-prompt` hook emits the call line and the AI calls the +MCP tool itself. `LI_PLUS_WEBHOOK_DELIVERY=channel` / `mcp_hook` suppress that call +half only — the handling half (report filter + `mark_processed`) is emitted in every +mode, because nothing in either mode replaces it and its firing moment is `each user +turn start`, which only a per-turn hook can fire (#1798). A Codex host without an +mcp_tool hook substrate falls back to `poll` (see `adapter/codex/AGENTS.md` Optional Webhook Notification Flow). diff --git a/adapter/codex/hooks/on-user-prompt.ps1 b/adapter/codex/hooks/on-user-prompt.ps1 index 235242c..c8ba9d0 100644 --- a/adapter/codex/hooks/on-user-prompt.ps1 +++ b/adapter/codex/hooks/on-user-prompt.ps1 @@ -14,7 +14,8 @@ # # Webhook delivery: the Claude version emits a reminder so the AI calls the # github-webhook-mcp tool. The poll/channel/mcp_hook switch is read from -# Li+config.md LI_PLUS_WEBHOOK_DELIVERY (default = poll = emit reminder). +# Li+config.md LI_PLUS_WEBHOOK_DELIVERY (default = poll) and gates the call half +# of the webhook block only; the handling half is emitted in every mode. $ErrorActionPreference = 'SilentlyContinue' # Read stdin payload (Codex passes JSON: session_id, cwd, hook_event_name, ...). @@ -38,13 +39,17 @@ if (Test-Path -LiteralPath $configFile) { $sb = [System.Text.StringBuilder]::new() +# The call half is poll-only; the handling half is unconditional. Rationale +# and the #1798 failure it repairs are in the claude port this one mirrors. +[void]$sb.AppendLine('') +[void]$sb.AppendLine('━━━ Webhook: check pending notifications ━━━') if ($webhookDelivery -ne 'channel' -and $webhookDelivery -ne 'mcp_hook') { - [void]$sb.AppendLine('') - [void]$sb.AppendLine('━━━ Webhook: check pending notifications ━━━') [void]$sb.AppendLine('Run mcp__github-webhook-mcp__get_pending_status silently.') - [void]$sb.AppendLine('Report only foreground-relevant or notable items.') - [void]$sb.AppendLine('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') } +[void]$sb.AppendLine('Report only foreground-relevant or notable items.') +[void]$sb.AppendLine('mark_processed every consumed event; own-operation arrivals promptly.') +[void]$sb.AppendLine('Intake detail: rules/operations/main-agent-procedures.md Foreground webhook notification intake; mark_processed mandate: rules/operations/operations.md Operations Rules (both always-on).') +[void]$sb.AppendLine('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') # --- Trigger Check Gate re-arm (every turn) --- [void]$sb.AppendLine('') diff --git a/adapter/codex/hooks/on-user-prompt.sh b/adapter/codex/hooks/on-user-prompt.sh index 7538776..a5458ec 100644 --- a/adapter/codex/hooks/on-user-prompt.sh +++ b/adapter/codex/hooks/on-user-prompt.sh @@ -36,13 +36,17 @@ CONTEXT="" append() { CONTEXT="${CONTEXT}$1 "; } +# The call half is poll-only; the handling half is unconditional. Rationale +# and the #1798 failure it repairs are in the claude port this one mirrors. +append "" +append "━━━ Webhook: check pending notifications ━━━" if [ "$WEBHOOK_DELIVERY" != "channel" ] && [ "$WEBHOOK_DELIVERY" != "mcp_hook" ]; then - append "" - append "━━━ Webhook: check pending notifications ━━━" append "Run mcp__github-webhook-mcp__get_pending_status silently." - append "Report only foreground-relevant or notable items." - append "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" fi +append "Report only foreground-relevant or notable items." +append "mark_processed every consumed event; own-operation arrivals promptly." +append "Intake detail: rules/operations/main-agent-procedures.md Foreground webhook notification intake; mark_processed mandate: rules/operations/operations.md Operations Rules (both always-on)." +append "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" # --- Trigger Check Gate re-arm (every turn) --- append "" diff --git a/docs/6.-Adapter.md b/docs/6.-Adapter.md index 755e52a..fa883ae 100644 --- a/docs/6.-Adapter.md +++ b/docs/6.-Adapter.md @@ -135,14 +135,14 @@ self-evolution observation surface も同じく比較セット外だが、外す トリガー:`UserPromptSubmit` — ユーザーがメッセージを送信するたび(Claude の処理開始前)。 -動作:通知取り込みリマインダーのみ。Character Instance は output-styles 機構で system prompt に常駐するため、毎ターン hook 再通知は不要(v1.16.11 で撤廃)。 +動作:Trigger Check Gate の毎ターン re-arm と、webhook 取り込みの毎ターン re-arm。Character Instance は output-styles 機構で system prompt に常駐するため、毎ターン hook 再通知は不要(v1.16.11 で撤廃)。 -通知取り込みリマインダー:Li+config.md の `LI_PLUS_WEBHOOK_DELIVERY` を読み、配信モードに応じて挙動を切り替える。 -- 未設定 / `poll`:リマインダーテキストを stdout へ出力し、AI に MCP ツールを呼び出させる(既定、後方互換) -- `channel`:MCP channel がリアルタイム配信を担うためリマインダーをスキップする -- `mcp_hook`:別途 `UserPromptSubmit` に追加された `type: "mcp_tool"` hook が MCP ツールを直接呼び出すためリマインダーをスキップする(settings.json template の既定) +webhook 取り込み re-arm は**分離可能な 2 つ**を運ぶ。Li+config.md の `LI_PLUS_WEBHOOK_DELIVERY` が切り替えるのは下の**呼び出し半分**だけであり、届いたものをどう扱うかの面は切り替えない。 -関連性判定と destructive consume の正本は [5. Notifications](5.-Notifications) に従う。 +- **呼び出し半分**(未設定 / `poll` のみ):`mcp__github-webhook-mcp__get_pending_status` を AI 自身に呼ばせる指示を stdout へ出力する。`channel` は MCP channel が、`mcp_hook` は `UserPromptSubmit` の `type: "mcp_tool"` hook(settings.json template の既定)が同じ呼び出しを代替するため、両モードではこの半分を出さない。出せば二重配送になる。 +- **取り扱い半分**(全モード):前景報告フィルタと `mark_processed` の re-arm。これを代替するものはどのモードにも無い。発火時刻は `each user turn start` であり、always-on 常駐はロード保証であって発火保証ではないため、ターン境界を発火できる hook だけがこの面を担える。両半分を一括で落としていたのが #1798 であり、イベントは届いているのに扱いを述べる面が無い状態になっていた。 + +hook が運ぶのは terse な re-arm であって正本の写しではない。関連性判定と destructive consume の正本は [5. Notifications](5.-Notifications)、前景取り込み手続きの正本は `rules/operations/main-agent-procedures.md` の `## Foreground webhook notification intake` に従う。 ### post-tool-use.sh @@ -191,7 +191,7 @@ node 不在時の挙動:node は jq より確度の高い前提だが保証で │ └── /SKILL.md # skill auto-invocation(flat 命名、例:operations-on-commit, model-agentic-search, evolution-loop, model-pair-review 等) ├── hooks/ │ ├── on-session-start.sh # Cold-start Synthesis 素材収集(diff-only 出力) - │ ├── on-user-prompt.sh # 通知取り込みリマインダーのみ(Character は output-styles で常駐) + │ ├── on-user-prompt.sh # Gate re-arm + 通知取り込み re-arm(Character は output-styles で常駐) │ └── post-tool-use.sh # PR 作成時のサブ issue 参照自動補完のみ └── state/ ├── .gitignore # `*\n!.gitignore`(hook runtime state をバージョン管理から除外) diff --git a/docs/B.-Configuration.md b/docs/B.-Configuration.md index 7e2f71e..486e5da 100644 --- a/docs/B.-Configuration.md +++ b/docs/B.-Configuration.md @@ -114,13 +114,14 @@ webhook 通知がセッションへ届く方法を指定します。`mcp__github | 値 | 動作 | |----|------| -| 未設定 / `poll` | 毎ターン開始時に on-user-prompt hook がポーリングリマインダーを出力する(既定、後方互換) | -| `channel` | MCP channel がリアルタイムにイベントを配信するため、hook のポーリングリマインダーをスキップする | -| `mcp_hook` | UserPromptSubmit の `type: "mcp_tool"` hook が `mcp__github-webhook-mcp__get_pending_status` を直接呼び出し、結果を prompt context に注入する。bash hook のポーリングリマインダーはスキップされる(`github-webhook-mcp >= v0.11.3` が前提) | +| 未設定 / `poll` | 毎ターン開始時に on-user-prompt hook が呼び出し指示を出力し、AI 自身に MCP ツールを呼ばせる(既定、後方互換) | +| `channel` | MCP channel がリアルタイムにイベントを配信するため、hook の呼び出し指示をスキップする | +| `mcp_hook` | UserPromptSubmit の `type: "mcp_tool"` hook が `mcp__github-webhook-mcp__get_pending_status` を直接呼び出し、結果を prompt context に注入する。hook の呼び出し指示はスキップされる(`github-webhook-mcp >= v0.11.3` が前提) | 注意: - 値を切り替えても webhook 通知の前景判定ルールは変わりません。transport(呼び出し主体)が変わるだけです +- したがってスキップされるのは**呼び出し指示だけ**です。取り扱い指示(前景報告フィルタと `mark_processed` の re-arm)は全モードで毎ターン出力されます。これを代替するものはどのモードにも無く、発火時刻がターン境界である以上、hook 以外に担える面が無いためです(#1798) - この設定は on-user-prompt hook が実行時に Li+config.md から読み取ります。bootstrap での追加アクションは不要です - `mcp_tool` の hook entry は `adapter/claude/hooks-settings.md` の default テンプレートに含まれており、bootstrap によって `.claude/settings.json` に自動配置されます。**手動追加は不要**になりました(旧仕様では opt-in に手動編集が必要でした) - 配信が実際に AI 文脈へ届く前提条件: diff --git a/tests/test_on_user_prompt_webhook_rearm.py b/tests/test_on_user_prompt_webhook_rearm.py new file mode 100644 index 0000000..903eb2b --- /dev/null +++ b/tests/test_on_user_prompt_webhook_rearm.py @@ -0,0 +1,334 @@ +"""Behavioural coverage for the per-turn webhook re-arm. + +Target = the three `adapter/*/hooks/on-user-prompt.*` implementations +(claude bash / codex bash / codex PowerShell). Issue #1798. + +The defect this pins: the webhook block carries two separable things — a *call* +half telling the AI to run `get_pending_status` itself, and a *handling* half +saying what to do with whatever arrived. `LI_PLUS_WEBHOOK_DELIVERY=channel` and +`=mcp_hook` replace the call half only; nothing in either mode replaces the +handling half. The condition dropped the whole block, so under those two modes +events were delivered into context and no surface said what to do with them — +observed on 2026-08-24 with nine pending events injected at turn 1 and neither +inspect nor `mark_processed` running until a later step named the procedure. + +The handling half cannot be left to its always-on canonical +(`rules/operations/main-agent-procedures.md` Foreground webhook notification +intake). That section fixes its own firing moment at `each user turn start`, and +always-on residency is a load guarantee, not a firing one; a per-turn hook is +the only surface that fires a turn boundary. Same reasoning, same file, as +`rules/model/trigger-check-gate.md` Trigger firing. + +What is pinned and what is not +------------------------------ +The assertions read the *obligations* out of the emission, not its wording. The +call half is located by the MCP tool name, which is fixed by the server; the +handling half by `mark_processed`, which `rules/operations/operations.md` +Operations Rules states as a mandatory word, and by the foreground filter's +subject. The banner text, the phrasing of each line and their order are adapter +presentation and are deliberately not matched. + +The pointer is held to resolving both obligations, not merely to being present. +A re-arm that points instead of copying is only as good as what the pointer +reaches, and the two obligations it asserts have two homes — see CANONICAL_TOKENS. + +The other failure direction is pinned too, and it is not symmetric noise: the +call half reaching a `channel` / `mcp_hook` workspace is the double delivery +that `LI_PLUS_WEBHOOK_DELIVERY` exists to prevent (#1632 F7), so a repair that +simply emits the block unconditionally must fail here. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +from test_on_session_start_observation_surface import ( + ADAPTERS, + BASH, + PWSH, + emitted_sections, + posix_path, + require_runtime, + slash_path, +) + + +ROOT = Path(__file__).resolve().parents[1] + +HOOKS = { + "claude_sh": ROOT / "adapter" / "claude" / "hooks" / "on-user-prompt.sh", + "codex_sh": ROOT / "adapter" / "codex" / "hooks" / "on-user-prompt.sh", + "codex_ps1": ROOT / "adapter" / "codex" / "hooks" / "on-user-prompt.ps1", +} + +HOOK_TIMEOUT = 120 + +DELIVERY_KEY = "LI_PLUS_WEBHOOK_DELIVERY" + +# Modes that replace the call half. Both are spelled here rather than derived, +# because "which modes have a substitute" is the judgment under test. +REPLACING_MODES = ("channel", "mcp_hook") + +# Config states that leave the AI as the caller. `None` = the key is absent from +# an otherwise present Li+config.md; `MISSING_CONFIG` = no config file at all. +# Both are the documented default (`docs/B.-Configuration.md`: 未設定 / poll). +MISSING_CONFIG = object() +CALLING_STATES = ("poll", None, MISSING_CONFIG) + +# The MCP tool the call half names. Fixed by the server, not by the adapter. +CALL_TOKEN = "get_pending_status" + +# The two handling obligations. `mark_processed` is stated as mandatory in +# `rules/operations/operations.md` Operations Rules; `foreground` is the subject +# of the report filter in `rules/operations/main-agent-procedures.md`. +CONSUME_TOKEN = "mark_processed" +FILTER_TOKEN = "foreground" + +# Where each asserted obligation resolves. The re-arm points instead of copying, +# the shape the Trigger Check Gate re-arm in the same file already uses — and a +# pointer earns that shape only by reaching what the re-arm claims. The two +# claims have two homes: the intake procedure and the report filter are in +# `main-agent-procedures.md` Foreground webhook notification intake, whose only +# `mark_processed` is scoped to own-operation events (`:493`), while the general +# "every consumed event" mandate is stated in `operations.md` Operations Rules +# (`:91`). Naming the first alone left the general half unreachable by a reader +# who followed the pointer — brake 1 finding 1 on PR #1802, 3/3. +CANONICAL_TOKENS = ( + "rules/operations/main-agent-procedures.md", + "rules/operations/operations.md", +) + +# A terse re-arm, not a transplanted procedure. The budget is deliberately loose +# — it catches the canonical being copied into the hook, not a line added. +MAX_REARM_LINES = 6 + + +def state_name(state: object) -> str: + if state is MISSING_CONFIG: + return "" + if state is None: + return "" + return str(state) + + +class Fixture: + """A workspace root holding one `Li+config.md` state.""" + + def __init__(self, state: object) -> None: + self.root = Path(tempfile.mkdtemp(prefix="liplus-prompt-hook-")) + self.workspace = self.root / "ws" + self.workspace.mkdir(parents=True) + if state is not MISSING_CONFIG: + (self.workspace / "Li+config.md").write_text( + self._config_text(state), encoding="utf-8" + ) + + @staticmethod + def _config_text(mode: str | None) -> str: + lines = [ + "# Li+ Config", + "", + "LI_PLUS_REPO=https://github.com/Liplus-Project/liplus-language", + "LI_PLUS_MODE=clone", + ] + if mode is not None: + lines.append(f"{DELIVERY_KEY}={mode}") + return "\n".join(lines) + "\n" + + def cleanup(self) -> None: + shutil.rmtree(self.root, ignore_errors=True) + + # -- hook execution ------------------------------------------------------- + + def _env_for(self, adapter: str) -> dict[str, str]: + env = dict(os.environ) + env.pop("CLAUDE_PROJECT_DIR", None) + env.pop("CODEX_PROJECT_DIR", None) + if adapter == "claude_sh": + env["CLAUDE_PROJECT_DIR"] = posix_path(self.workspace) + elif adapter == "codex_sh": + # The payload `cwd` is the production path and is set below; this is + # the hook's own documented fallback for a host without `jq`, and + # both resolve to the same fixture. Root resolution is not the axis + # under test here, so neither branch is allowed to decide the case. + env["CODEX_PROJECT_DIR"] = posix_path(self.workspace) + else: + env["CODEX_PROJECT_DIR"] = slash_path(self.workspace) + return env + + def _command_and_stdin(self, adapter: str) -> tuple[list[str], str]: + hook = HOOKS[adapter] + payload = { + "session_id": "test-session", + "hook_event_name": "UserPromptSubmit", + "prompt": "test prompt", + } + if adapter == "codex_ps1": + payload["cwd"] = slash_path(self.workspace) + command = [PWSH, "-NoProfile", "-NonInteractive", "-File", str(hook)] + else: + payload["cwd"] = posix_path(self.workspace) + command = [BASH, posix_path(hook)] + return command, json.dumps(payload) + + def run(self, adapter: str) -> str: + """Run one hook and return the context text it injects.""" + if adapter in ("claude_sh", "codex_sh") and not BASH: + require_runtime("bash", "claude / codex shell hooks") + if adapter == "codex_ps1" and not PWSH: + require_runtime("pwsh", "codex PowerShell hook") + + command, stdin_payload = self._command_and_stdin(adapter) + completed = subprocess.run( + command, + input=stdin_payload.encode("utf-8"), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=self._env_for(adapter), + timeout=HOOK_TIMEOUT, + ) + stdout = completed.stdout.decode("utf-8", errors="replace") + if adapter == "claude_sh": + # Claude accepts plain text on UserPromptSubmit stdout. + return stdout + try: + envelope = json.loads(stdout) + except json.JSONDecodeError as error: + raise AssertionError( + f"{adapter} did not emit JSON: {error}\nstdout={stdout!r}\n" + f"stderr={completed.stderr.decode('utf-8', errors='replace')!r}" + ) from error + return envelope["hookSpecificOutput"]["additionalContext"] + + +def webhook_section(hook_output: str) -> str | None: + """Body of the webhook re-arm section, or None when it was not emitted. + + Located by topic rather than by exact banner text, for the reason the + session-start suite gives: the banner is an adapter choice, and pinning it + would make every assertion here depend on one string. + """ + for banner, body in emitted_sections(hook_output): + if "webhook" in banner.lower(): + return body + return None + + +class WebhookRearmTestCase(unittest.TestCase): + def section_for(self, adapter: str, state: object) -> str: + fixture = Fixture(state) + self.addCleanup(fixture.cleanup) + output = fixture.run(adapter) + section = webhook_section(output) + if section is None: + banners = [banner for banner, _body in emitted_sections(output)] + self.fail( + f"{adapter} emitted no webhook section for " + f"{state_name(state)}; banners seen: {banners}" + ) + return section + + +class HandlingHalfTest(WebhookRearmTestCase): + """The half #1798 lost: it must survive every delivery mode.""" + + def test_handling_half_is_emitted_in_every_delivery_mode(self) -> None: + for adapter in ADAPTERS: + for state in CALLING_STATES + REPLACING_MODES: + with self.subTest(adapter=adapter, delivery=state_name(state)): + section = self.section_for(adapter, state) + self.assertIn( + FILTER_TOKEN, + section, + f"{adapter} dropped the report filter under " + f"{state_name(state)}", + ) + self.assertIn( + CONSUME_TOKEN, + section, + f"{adapter} dropped the {CONSUME_TOKEN} re-arm under " + f"{state_name(state)}; omission accumulates backlog", + ) + + def test_the_rearm_points_at_the_canonical_instead_of_copying_it(self) -> None: + for adapter in ADAPTERS: + for state in CALLING_STATES + REPLACING_MODES: + with self.subTest(adapter=adapter, delivery=state_name(state)): + section = self.section_for(adapter, state) + for token in CANONICAL_TOKENS: + self.assertIn( + token, + section, + f"{adapter} carries no pointer to {token} under " + f"{state_name(state)}; the re-arm asserts an " + "obligation whose literal lives there", + ) + lines = [line for line in section.split("\n") if line.strip()] + self.assertLessEqual( + len(lines), + MAX_REARM_LINES, + f"{adapter} emits {len(lines)} lines under " + f"{state_name(state)}; the hook carries a terse re-arm, " + "and the procedure body belongs to the canonical", + ) + + +class CallHalfTest(WebhookRearmTestCase): + """The half the delivery mode does select.""" + + def test_call_half_is_emitted_where_the_ai_is_the_caller(self) -> None: + for adapter in ADAPTERS: + for state in CALLING_STATES: + with self.subTest(adapter=adapter, delivery=state_name(state)): + self.assertIn( + CALL_TOKEN, + self.section_for(adapter, state), + f"{adapter} never tells the AI to call the tool under " + f"{state_name(state)}, and nothing else does either", + ) + + def test_call_half_is_suppressed_where_something_replaces_it(self) -> None: + for adapter in ADAPTERS: + for mode in REPLACING_MODES: + with self.subTest(adapter=adapter, delivery=mode): + self.assertNotIn( + CALL_TOKEN, + self.section_for(adapter, mode), + f"{adapter} still asks the AI to call the tool under " + f"{mode}; that is the double delivery the setting " + "exists to prevent", + ) + + +class PortParityTest(WebhookRearmTestCase): + """Strict equality across the three hand-written ports. + + Containment assertions pass on any superset, so they would not report a port + that gained or kept a line the others do not have. This is the assertion that + fails when one port is edited and the others are not. + """ + + def test_every_port_emits_the_same_section(self) -> None: + for state in CALLING_STATES + REPLACING_MODES: + sections = { + adapter: self.section_for(adapter, state) for adapter in ADAPTERS + } + reference = sections["claude_sh"] + for adapter, section in sections.items(): + with self.subTest(delivery=state_name(state), adapter=adapter): + self.assertEqual( + section, + reference, + f"{adapter} disagrees with claude_sh under " + f"{state_name(state)}", + ) + + +if __name__ == "__main__": + unittest.main()