diff --git a/changelog.d/3446.md b/changelog.d/3446.md new file mode 100644 index 000000000..9e6bd5132 --- /dev/null +++ b/changelog.d/3446.md @@ -0,0 +1,2 @@ + +- **fix(agent-health): エージェント自身の Stop の後に送信なしで届いた AskUserQuestion を質問として記録しない** (#3446): #3441 でターンを開かなくした会話外の `pre_tool_use(AskUserQuestion)` が、画面にピッカーが無いのに質問のレコードを残していた。直近のターンが自身の Stop で閉じたまま(プロンプトを報告するツールのみ。#3441 と同じ判定)なら記録せず `ask-user-question-outside-turn` を記録する。質問の記録・会話外の `pre_tool_use` のログには、送り主を後から特定できるよう session・agent・transcript・tool_use の識別子をハッシュの先頭 8 桁で残す(本文・パスは残さない)。ターンの中の質問の記録と応答は変わらない。 diff --git a/docs/module-reference.md b/docs/module-reference.md index d3bb9731d..a23a16290 100644 --- a/docs/module-reference.md +++ b/docs/module-reference.md @@ -271,7 +271,7 @@ const myState = getOrInitGlobal('__myState', () => new Map()); | `src/lib/tasks/task-transition-service.ts` | **`tasks.status` の唯一の writer**(Issue #1548)。applyTaskEvent(db,taskId,event,payload?)→{task,fromStatus,toStatus,eventId}\|null(null=タスク不在)。現 status 読み→transitionTask→**同一トランザクション**で task_events INSERT+tasks UPDATE(status だけ動いてイベントが無い/イベントだけあって status が動いていない、のどちらもログが表を説明できなくなる)。**拒否された遷移も to_status=null で記録**(起きた事実は残す。配線バグと競合を区別する唯一の手がかり)。`payload.runId` は `last_verification_run_id` にも書く(**受理時のみ**)。applyEventToActiveTask(db,worktreeId,cliToolId,instanceId,event,payload?) は getActiveTaskForInstance で解決し、**active task が無ければ何もしない**(契約なし運用への影響ゼロ)+**throw しない**(呼び出し元はポーラと API route で、本業はメッセージ配送。タスクログが原因でリクエストを落とさない) | | `src/lib/db/task-events-db.ts` | task_events 行 I/O(Issue #1548、migration v51)。listTaskEvents(db,taskId,limit=200)(`created_at ASC, id ASC`。**同一 ms は日常茶飯事**で、拒否された遷移とその直後のリトライが逆順に読めると話が反転する)/insertTaskEvent(空 payload は `'{}'` ではなく NULL)。**insertTaskEvent と updateTaskStatus は `@/lib/db` バレルから re-export しない** — 状態機械の判断を経ていない行が混ざるとログの意味が消えるため、writer は applyTaskEvent 1 つに閉じる。バレルから取れるのは reader だけ。テストは fixture 都合で直接パスから import する | | `src/app/api/worktrees/[id]/tasks/` + `src/app/api/tasks/[taskId]/` | 実行契約 API(Issue #1545)。`POST /worktrees/:id/tasks`(body `{contractPath, cliToolId?, instanceId?}`)は契約を**サーバ側で**パースし(パスは worktree 相対で、完了条件はその worktree の verify.yaml から解決するため)201 で `{task(pending), message}` を返す。契約不正は 400+`issues` 全件で **task 行は作らない**。`GET /worktrees/:id/tasks`(limit 既定20・1..100)、`GET /tasks/:taskId`→`{task, lastVerificationRun}`、`PATCH /tasks/:taskId` は **`running`/`failed`/`cancelled` のみ**受理する(`succeeded` は検証ランだけが与える判定で、クライアントの自己申告を受け入れると tasks 表の存在意義が消える)。終端 task の再オープンは 409 | -| `src/app/api/hooks/agent-event/` + `src/lib/hooks/agent-event-service.ts` + `agent-event-types.ts` + `agent-event-intake.ts` | 構造化エージェントイベント受け口(Issue #1549、**#1722 で instance 相関とネイティブ payload 対応**)。**2 形式を受ける**: (a) CommandMate 形式 `{tool, event, cwd, sessionId?, worktreeId?, instanceId?, detail?}`、(b) Claude の生 payload `{hook_event_name, session_id, cwd, …}`(`type:"http"` はボディを加工できないため。`tool`/`worktreeId`/`instanceId` はクエリパラメータ)。語彙は stop / notification / session_start / **user_prompt_submit / session_end**(`agent-event-types.ts` に分離=生成器と state が DB 依存なしに使える)。**相関の優先順は 明示 worktreeId > cwd 解決**、instanceId 明示があれば primary 固定を解除(#1722 が解消した route.ts:88)。**session_id は相関キーにしない** — `/clear` で変わる(#1721)。validateHookCwd は空/4096 超/NUL/相対/`..` セグメント(**percent-decode 後も検査**)を 400。`..` は正規化せず拒否する — resolve() で意味が変わる値を後段が使うのが traversal 検査の破られ方。**cwd は送られていれば常に検証する**が、worktreeId 明示時は必須ではない。resolveWorktreeByCwd は**両側 realpath**(macOS の /var↔/private/var、symlink された worktree ルート)で突合し、配下ディレクトリも一致、**入れ子は最長一致**。解決失敗も成功も **202 `{accepted:true}` で同一ボディ**(登録済みディレクトリの探索器にしないため)。stop の効果: active task に `agent_idle`(payload `{source:'hook'}`)→ 契約 `success.autoVerifyOnStop` が true なら startVerification(trigger='task')(**409 は正常な競合として握る**)→ lastStopEventAt 記録。**契約なし=task 無しでは 3 番目だけ起き、既存運用への影響ゼロ**。stop 以外は受理・記録のみ。**同一 (worktree, tool, instance, event, 細目, sessionId) は 3 秒以内 1 回**(間にターンの境目が適用されていれば通す。`stop` は #3289、ターンの開始は #3301)(自動注入と手動 hook の二重配送対策)。認証は middleware 任せ(AUTH_EXCLUDED_PATHS 非掲載が access control の全部)。**Issue #1726 で `pre_tool_use` / `post_tool_use` を追加(7 語)。****Issue #1759: どの綴りがどの語かは route が持たず `getAgentEventSource(tool).normalizeEvent()` に委ねる** — Claude/codex/copilot は `Stop`、gemini は `AfterAgent`、opencode は `session.idle`、antigravity は**イベント名フィールドが無い**。`agent-event-types.ts` に残るのは 7 語だけで、綴りは `hooks/sources/hook-event-vocabulary.ts` へ移設した(S1/S2)。`pre_tool_use` の質問 payload も `source.parseQuestion()` 経由 Issue #2246: `applyAgentStopEvent` が `recordAgentStopEvent` の直後に `captureTranscriptTurnOnStop()` を await し、結果を `AgentStopOutcome.structuredHistoryCaptured` で返す。task 検索より前に置くのは契約の無いセッションでも必ず走らせるため。route ではなく service に置くのは `/api/hooks/agent-event` と互換の `/api/hooks/claude-done` の両方が stop の受け口だから Issue #2398: `applyAgentStopEvent` は転写リーダーを **1 回だけ await** する(#2246 のコメントにあった「ターンは既に終わっているので待っても誰も困らない」は claude では真、codex では偽 — codex は本 hook の終了を待って `task_complete` を書く)。`AgentStopOutcome` に `structuredHistoryOutcome: 'captured' \| 'deferred' \| 'unavailable'` を追加。`structuredHistoryCaptured` は「**応答前に**書けたか」に狭まり、遅延読みで後から書かれたターンでは false(route が同名でログしているため boolean のまま残した) **#2874**: codex の `user_prompt_submit` で、通知先インスタンス(`instanceId` 無指定は `codex`)の tmux セッションが無いとき `agent-event-instance-not-running` を warn。`isSessionRunning` を待たずに実行し、例外は握りつぶす(応答・記録は変えない、振り分け直しもしない)。 **Issue #3311**: `agent-event-duplicate-dropped` に instanceId・session(SHA-256 先頭 8 文字)・sinceLastMs・detail。 **Issue #3330**: `user_prompt_submit` は `source.promptJoinsOpenTurn(payload)` を `joinsOpenTurn` として記録へ渡す。 **#3376**: `POST` の段(本文の読み取り・検証 `readAgentEventRequest`、worktree 解決 `resolveAgentEventWorktree`、detail `readEventDetail`、重複の窓 `dropDuplicateAgentEvent`、状態への適用 `applyAgentEventToState`、codex の通知 `warnIfCodexInstanceNotRunning`、質問の記録 `recordQuestionIfAsked`)を `src/lib/hooks/agent-event-intake.ts` へ切り出した(順序・応答・ログ・`await` は不変。stop の適用と応答の組み立ては route に残る)。 `agent-event-unresolved-target` のログは `worktreeId`(あれば)と `cwdHash`(`cwd` の SHA-256 先頭 16 文字。`cwd` 自体は出さない、#3312)。 | +| `src/app/api/hooks/agent-event/` + `src/lib/hooks/agent-event-service.ts` + `agent-event-types.ts` + `agent-event-intake.ts` | 構造化エージェントイベント受け口(Issue #1549、**#1722 で instance 相関とネイティブ payload 対応**)。**2 形式を受ける**: (a) CommandMate 形式 `{tool, event, cwd, sessionId?, worktreeId?, instanceId?, detail?}`、(b) Claude の生 payload `{hook_event_name, session_id, cwd, …}`(`type:"http"` はボディを加工できないため。`tool`/`worktreeId`/`instanceId` はクエリパラメータ)。語彙は stop / notification / session_start / **user_prompt_submit / session_end**(`agent-event-types.ts` に分離=生成器と state が DB 依存なしに使える)。**相関の優先順は 明示 worktreeId > cwd 解決**、instanceId 明示があれば primary 固定を解除(#1722 が解消した route.ts:88)。**session_id は相関キーにしない** — `/clear` で変わる(#1721)。validateHookCwd は空/4096 超/NUL/相対/`..` セグメント(**percent-decode 後も検査**)を 400。`..` は正規化せず拒否する — resolve() で意味が変わる値を後段が使うのが traversal 検査の破られ方。**cwd は送られていれば常に検証する**が、worktreeId 明示時は必須ではない。resolveWorktreeByCwd は**両側 realpath**(macOS の /var↔/private/var、symlink された worktree ルート)で突合し、配下ディレクトリも一致、**入れ子は最長一致**。解決失敗も成功も **202 `{accepted:true}` で同一ボディ**(登録済みディレクトリの探索器にしないため)。stop の効果: active task に `agent_idle`(payload `{source:'hook'}`)→ 契約 `success.autoVerifyOnStop` が true なら startVerification(trigger='task')(**409 は正常な競合として握る**)→ lastStopEventAt 記録。**契約なし=task 無しでは 3 番目だけ起き、既存運用への影響ゼロ**。stop 以外は受理・記録のみ。**同一 (worktree, tool, instance, event, 細目, sessionId) は 3 秒以内 1 回**(間にターンの境目が適用されていれば通す。`stop` は #3289、ターンの開始は #3301)(自動注入と手動 hook の二重配送対策)。認証は middleware 任せ(AUTH_EXCLUDED_PATHS 非掲載が access control の全部)。**Issue #1726 で `pre_tool_use` / `post_tool_use` を追加(7 語)。****Issue #1759: どの綴りがどの語かは route が持たず `getAgentEventSource(tool).normalizeEvent()` に委ねる** — Claude/codex/copilot は `Stop`、gemini は `AfterAgent`、opencode は `session.idle`、antigravity は**イベント名フィールドが無い**。`agent-event-types.ts` に残るのは 7 語だけで、綴りは `hooks/sources/hook-event-vocabulary.ts` へ移設した(S1/S2)。`pre_tool_use` の質問 payload も `source.parseQuestion()` 経由 Issue #2246: `applyAgentStopEvent` が `recordAgentStopEvent` の直後に `captureTranscriptTurnOnStop()` を await し、結果を `AgentStopOutcome.structuredHistoryCaptured` で返す。task 検索より前に置くのは契約の無いセッションでも必ず走らせるため。route ではなく service に置くのは `/api/hooks/agent-event` と互換の `/api/hooks/claude-done` の両方が stop の受け口だから Issue #2398: `applyAgentStopEvent` は転写リーダーを **1 回だけ await** する(#2246 のコメントにあった「ターンは既に終わっているので待っても誰も困らない」は claude では真、codex では偽 — codex は本 hook の終了を待って `task_complete` を書く)。`AgentStopOutcome` に `structuredHistoryOutcome: 'captured' \| 'deferred' \| 'unavailable'` を追加。`structuredHistoryCaptured` は「**応答前に**書けたか」に狭まり、遅延読みで後から書かれたターンでは false(route が同名でログしているため boolean のまま残した) **#2874**: codex の `user_prompt_submit` で、通知先インスタンス(`instanceId` 無指定は `codex`)の tmux セッションが無いとき `agent-event-instance-not-running` を warn。`isSessionRunning` を待たずに実行し、例外は握りつぶす(応答・記録は変えない、振り分け直しもしない)。 **Issue #3311**: `agent-event-duplicate-dropped` に instanceId・session(SHA-256 先頭 8 文字)・sinceLastMs・detail。 **Issue #3330**: `user_prompt_submit` は `source.promptJoinsOpenTurn(payload)` を `joinsOpenTurn` として記録へ渡す。 **#3376**: `POST` の段(本文の読み取り・検証 `readAgentEventRequest`、worktree 解決 `resolveAgentEventWorktree`、detail `readEventDetail`、重複の窓 `dropDuplicateAgentEvent`、状態への適用 `applyAgentEventToState`、codex の通知 `warnIfCodexInstanceNotRunning`、質問の記録 `recordQuestionIfAsked`)を `src/lib/hooks/agent-event-intake.ts` へ切り出した(順序・応答・ログ・`await` は不変。stop の適用と応答の組み立ては route に残る)。 `agent-event-unresolved-target` のログは `worktreeId`(あれば)と `cwdHash`(`cwd` の SHA-256 先頭 16 文字。`cwd` 自体は出さない、#3312)。 **Issue #3446**: `recordQuestionIfAsked` は会話外(自身の stop の後・送信なし、#3437 と同じ判定)の AskUserQuestion を記録せず `ask-user-question-outside-turn` を出す。`hookSenderTags(payload, sessionId)` が session/agent/transcript/toolUse をハッシュ 8 桁で返し、質問の記録と `agent-event-pre-tool-use-outside-turn` の行にだけ付く。 | | `src/lib/hooks/sources/` | **`AgentEventSource` 抽象**(Issue #1759 / Epic #1720 Phase 4-1)。生成層・受信層のツール固有部分を 1 ツール 1 ファイルに閉じ込める。`types.ts`=I/F(`transport:'push'|'pull'`/`noDecision`/`NormalizedAgentEvent`/`PendingDecision`/`Verdict`/`VerdictEncoding`/`SourceLiveness`/`AgentSourceCapabilities`)、`event-mapper.ts`=**述語つきマッパ**+未知イベント計数、`hook-event-vocabulary.ts`=CamelCase 綴り表と snake_case subtype 抽出(`agent-event-types.ts` から移設した S1/S2)、`define-source.ts`=`definePushHookSource`/`definePullEventSource`、`pending-decisions.ts`=裁定スロット+`answerPendingDecision`、`abstain.ts`=`describeAbstain`、`registry.ts`=`CLIToolType`→実装、`legacy-relay.ts`=未対応ツール用の互換ソース、`claude/`=第一実装。**設計根拠は実測**(`docs/design/agent-hooks-phase4-live-verification.md` §8 / `opencode-server-live-verification.md` §9): **`type:"http"` は Claude 専用**で他 4 ツールは中継スクリプト必須(codex は http を 1 つ書くと hooks.json 全体が死ぬ)/**7 語への写像は 1:1 でない**ので `Record` では opencode を写せない(`message.part.updated` が `part.state.status` で pre/post に割れる)/**fail 方向がソースごとに逆**(Claude/codex/copilot=fail-open、antigravity は空応答=拒否、opencode は無限待ち=10m19s 実測)ので `noDecision` を型として持ち `abstain` の意味を呼び出し側が読む/**未知 type で throw しない**(opencode の `server.heartbeat` は `/doc` に型が無く 10 秒ごとに来る)。ツール追加手順は `docs/design/agent-event-source-interface.md`。**Issue #1904: `copilot/hook-settings.ts` の machine-global 対策** — `~/.copilot/settings.json` はマシンに 1 本なので、相関キーに続き**ポート**も設定ファイルに焼けない。`CM_HOOK_PORT`(`launch-command.ts` の `HOOK_PORT_ENV_VAR`/一覧は `COMMANDMATE_HOOK_ENV_VARS`)。**Issue #1942: `COMMANDMATE_HOOK_ENV_VARS` の全要素は `CM_HOOK_` で始まること**が不変条件になった(`lib/security/env-sanitizer` はこの配列ではなく prefix で剥がすので、名前空間の外の名前を足すと宣言はされるのに子プロセスへ漏れる)。**この配列は「launch line に載る変数の全部」ではない** — codex は `CODEX_HOME` / `CM_AGENT_TOOL` / `CM_AGENT_WORKTREE_ID` / `CM_AGENT_INSTANCE_ID` / `CM_PERMISSION_HOOK_URL` も載せ、copilot は `CM_AGENT_WORKTREE_ID` / `CM_AGENT_INSTANCE_ID`、antigravity は `CM_PERMISSION_HOOK_URL` を載せる。これらは sanitizer の対象外で起動 env に載せ、生成 shell の冒頭で `case "$CM_HOOK_PORT" in ''|*[!0-9]*) … exit 0;; esac` の数値検証を通す。**scheme / host / relay の絶対パスは literal のまま**(宛先が定数であることが bearer 漏洩の防波堤/env の relay パスはコード実行の委譲)で、relay は `[ -x … ]` を発火時に見て inline `curl` へ落ちる。`${VAR:-既定}` の綴りは禁止(未設定なら発火しない)。`inspectCopilotConfigHooks()` は copilot が起動時に settings.json へ**移送・上書き**する `config.json` の `hooks` を検出し(実ファイルは `//` コメントつきなのでコメント除去後に解析)、あれば `CopilotConfigHooksShadowError` で注入を見送る。書き込みは temp + `rename` + `.cmate.lock`(`O_EXCL`/10s で stale 奪取)+ 1 世代バックアップ。`PreToolUse` の `curl` は `-fsS` で 4xx ボディを verdict にせず、失敗は理由コード 1 行を stderr に出す。根拠は `docs/design/multi-agent-state-architecture.md` §10.8 / §10.9 と `copilot-agent-hooks-injection.md` §7。**Issue #1783: `NormalizedAgentEvent.model`** を追加 — 抽出は `define-source.ts` の `buildNormalizedEvent` に一元化し、ツール差は spec の `modelFields?: readonly string[]`(フラットキー。claude/codex=`model`、antigravity=`modelName`)と `extractModel?: (payload)=>string|null`(フラットで届かない形。opencode の `properties.info.model.{modelID,id}`。**両方あれば関数が勝つ**)で宣言する。長さは `boundModel()` が `MAX_EVENT_DETAIL_LENGTH` で切る(値は UI のテキストノードまで行く)。gemini/copilot は payload に無いので常に null(gemini の `BeforeModel` は語彙に無く unknown-event として捨てられる)。**Issue #1846: #1759 が残した I/F 申し送り 5 件を裁定**(全裁定と理由は `docs/design/agent-event-source-interface.md` §3.3、テストは `tests/unit/hooks/sources/launch-contract-1846.test.ts`)。**採用 2 件**=①`prepareLaunch` の引数を `AgentLaunchContext{target, executablePath, worktreePath}` に変更(`worktreePath` は**必須**。per-worktree 設定を持つ gemini が別 export `injectGeminiHookSettings()` を要らなくなり、設定書き出しが 6 ソースとも `prepareLaunch` 1 箇所に揃う。`AgentInstanceRef` は**キー**なので 3 フィールドのまま — パスを持つキーは worktree 移動で自分自身と等しくなくなる)②`AgentLaunchPlan.env: Record`(**必須**)+ `launch-command.ts` の `renderAgentLaunchCommand`。codex/copilot/gemini/antigravity の**4 実装が独立に** `NAME=value ` を `command` へ前置していたのを剥がし、適用を 1 箇所に集約(出力バイト列は同一)。**不採用 3 件**=③`NoDecisionBehavior` に `denies` を足す(前提が失効。agy は #1779 の実測で既に `proceeds`。`denies` はどのソースも出さないワイヤ値を記述し、エンコード修正の代わりに宣言する逃げ道になる)④`supportedEvents` の `emittable`/`delivered` 分割(**「届く語」である**と型 doc に明記。分割すると消費層がどちらを見るか選ばされ、copilot の `pre_tool_use`・gemini の `BeforeTool` でちょうど永久に待つ)⑤`definePullEventSource` への turn-gate 内蔵(状態機械は汎用だがフレーム語彙は完全に opencode 固有。§4 手順 6′ と §5 の**規約**にした) **Issue #1783: `NormalizedAgentEvent.model`** を追加 — 抽出は `define-source.ts` の `buildNormalizedEvent` に一元化し、ツール差は spec の `modelFields?: readonly string[]`(フラットキー。claude/codex=`model`、antigravity=`modelName`)と `extractModel?: (payload)=>string|null`(フラットで届かない形。opencode の `properties.info.model.{modelID,id}`。**両方あれば関数が勝つ**)で宣言する。長さは `boundModel()` が `MAX_EVENT_DETAIL_LENGTH` で切る(値は UI のテキストノードまで行く)。gemini/copilot は payload に無いので常に null(gemini の `BeforeModel` は語彙に無く unknown-event として捨てられる)。**Issue #1846: #1759 が残した I/F 申し送り 5 件を裁定**(全裁定と理由は `docs/design/agent-event-source-interface.md` §3.3、テストは `tests/unit/hooks/sources/launch-contract-1846.test.ts`)。**採用 2 件**=①`prepareLaunch` の引数を `AgentLaunchContext{target, executablePath, worktreePath}` に変更(`worktreePath` は**必須**。per-worktree 設定を持つ gemini が別 export `injectGeminiHookSettings()` を要らなくなり、設定書き出しが 6 ソースとも `prepareLaunch` 1 箇所に揃う。`AgentInstanceRef` は**キー**なので 3 フィールドのまま — パスを持つキーは worktree 移動で自分自身と等しくなくなる)②`AgentLaunchPlan.env: Record`(**必須**)+ `launch-command.ts` の `renderAgentLaunchCommand`。codex/copilot/gemini/antigravity の**4 実装が独立に** `NAME=value ` を `command` へ前置していたのを剥がし、適用を 1 箇所に集約(出力バイト列は同一)。**不採用 3 件**=③`NoDecisionBehavior` に `denies` を足す(前提が失効。agy は #1779 の実測で既に `proceeds`。`denies` はどのソースも出さないワイヤ値を記述し、エンコード修正の代わりに宣言する逃げ道になる)④`supportedEvents` の `emittable`/`delivered` 分割(**「届く語」である**と型 doc に明記。分割すると消費層がどちらを見るか選ばされ、copilot の `pre_tool_use`・gemini の `BeforeTool` でちょうど永久に待つ)⑤`definePullEventSource` への turn-gate 内蔵(状態機械は汎用だがフレーム語彙は完全に opencode 固有。§4 手順 6′ と §5 の**規約**にした)。**Issue #1924: `AgentSourceCapabilities` に宣言値 5 項目を追加**(`permissionHookPredictsDialog` / `sessionStartMayArriveLate` / `permissionReplyReleasesPrompt` / `eventIdentity:'permission-id'|'tool-call-id'|'message-id'|null` / `resync:'none'|'session-status-poll'`)。**すべて JSON 直列化可能な宣言値で関数は置かない**(`capture --json` の `structuredEvents.source.capabilities` にそのまま載るため)。値は `docs/design/multi-agent-state-architecture.md` §4 D3 の 6×5 表が正で、`tests/unit/hooks/sources/capabilities.test.ts` が**件数ではなく値**を完全一致で pin する(grep 0 件は着手前から真=空虚な緑、DR3-006)。claude 行が「未計測セルの既定値」で、departure は copilot の `sessionStartMayArriveLate:true`(#1903 実測)と opencode の `permissionReplyReleasesPrompt:true` / `eventIdentity:'permission-id'` / `resync:'session-status-poll'`(#1898-#1900 実測)だけ。**読み手(状態機械)は Phase 3/4**(#1927 / #1901 / #1903)で、Phase 1 は型と宣言値のみ。**Issue #1899: `eventIdentity` の抽出側 `AgentEventSource.eventIdentityOf(payload)`** を I/F に追加(spec の `extractEventIdentity?`、既定は `() => null`)。capability は「**どの** id を使うか」の宣言だけで、**どこに入っているかは source しか知らない** — opencode 1 ツールの中で `permission.asked`=`properties.id` / `permission.replied`=`properties.requestID`(同じ値・別綴り)/ tool part=`part.callID` / prompt=`info.id` / `session.idle`=**無し** の 5 通りある。`event-mapper.ts` の `readEventIdentity()` が長さ(`MAX_EVENT_IDENTITY_LENGTH`=128)と文字種 allowlist で検証し、**外れた値は切り詰めずに破棄**する(DR4-001。切り詰めると prefix が同じ別 id と衝突して無関係なフレームが重複に見える)。**capability を宣言して `extractEventIdentity` を配線し忘れると「id を宣言しているのに誰も読めない」状態**になり全フレームが無言で時間窓へ落ちるので、2 つは必ず同時に変える **Issue #1996: `launch-command.ts` に `AGENT_CORRELATION_ENV_VARS`(6 個) と `AGENT_LAUNCH_CONFIG_ENV_VARS`(`CODEX_HOME` の 1 個) を追加**し、「`COMMANDMATE_HOOK_ENV_VARS` は launch line に載る変数の全部ではない」という上記の但し書きを**宣言に格上げ**した。`tests/unit/lib/agent-launch-plan-secrets-1933.test.ts` が 7 ソースの `prepareLaunch` を実際に組み、`env` のキーが 2 つの宣言の和と**両方向で完全一致**することを見る(片方向だと「ソースが新しい変数を書き始めた」と「宣言した変数を書かなくなった」の一方を見逃す)。`AGENT_LAUNCH_CONFIG_ENV_VARS` を実測の 1 個に絞ったのは、allowlist を広く取るとそのソースがそれを書き始めた変化を素通しするため(#1933 のローカル allowlist に在った `COPILOT_HOME` / `XDG_CONFIG_HOME` は `process.env` からの読み出し専用で `plan.env` には入らない)。2 定数は `sources/index.ts`(scope 外)から re-export していないので、消費側は `@/lib/hooks/sources/launch-command` から直接 import する。 **Issue #2054**: `types.ts` に `AgentEventSourceKind`(`sse` / `hooks` / `scraper`)・`AgentEventSourceLiveness`・`AgentEventSourceStatus` を追加。`define-source.ts` に `AGENT_SOURCE_STALE_AFTER_MS`(30,000 = opencode の実測 10 秒ハートビート 3 拍落ち。`OPENCODE_HEARTBEAT_TIMEOUT_MS` と同値であることをテストが assert。両モジュールは相互 import 不可)・`declaredAgentSourceKind()`(pull→`sse` / push かつ `supportedEvents` 空→`scraper` / それ以外→`hooks`。`createLegacyRelaySource` が空リストを「誰も計測していない」の意味で立てているのを根拠にしており、ツール名を書かない)・`describeAgentEventSource(source, liveness, now)` を追加。**後者が `structuredEvents.source.kind` と `CliToolSessionStatus.eventSource` を導出する唯一の場所**で、push ソースには `kind` しか publish しない(claude / codex の画面を #2054 以前と同一に保つのはこの非対称性)。`turn-body.ts`=4 リーダー共通のターン本文レイアウト(Issue #2234)。`separateTurnBody(blocks)` が prose / `aside`(Thinking) / `tool` の 3 種別を受け、prose を先頭・ツール呼び出しを末尾のラベル付き blockquote(`> **Tool calls (N)**`)に分離する。実測 400 転写 586 turn 中 141 件が本文冒頭をツールログに占領されていた。`
` はカードが `rehypeRaw` を使わないためタグごと消える(実測)ので blockquote。ツール 0 件の turn は分離前とバイト同一で、保存済み行は書き換えない **Issue #2251: `registry.ts` の登録は 7 件になった**(claude / copilot / gemini / antigravity / codex / opencode / **command-code**。未登録は `vibe-local` のみ)。`index.ts` は `COMMAND_CODE_CLI_TOOL_ID` も re-export する。 **Issue #2272: `turn-body.ts` に 3 つめの節** — ブロック種別に `reasoning`(**生テキスト**。引用は節側が付ける)を追加し、`separateTurnBody()` は本文 → `> **${TURN_REASONING_LABEL} (N)**` 節 → `> **${TURN_TOOL_LOG_LABEL} (N)**` 節の順に並べる。`reasoning` の N ブロックは**見出し 1 つの下**に `>` 空行区切りで入り、`SeparatedTurnBody` に `reasoningLog` / `reasoningBlocks` が増える。既存の `aside`(リーダー側が自分で引用済みのテキスト)は**従来どおり本文の流れに残す**ので、`aside` を使う 4 リーダー(claude / codex / antigravity / command-code)の出力は 1 バイトも動かない。`TURN_REASONING_LABEL = 'Thinking'` はリーダー側の `*_THINKING_LABEL` と同値であることが前提(行は `chat_messages.content` に焼かれるので後から翻訳できない) **Issue #2403: `launch-command.ts` が起動行に起動サーバ自身の `CM_PORT` を pin する。** `renderAgentLaunchCommand()` は `plan.env` をそのまま描画するのをやめ、`resolveAgentLaunchEnv(plan)`(新 export。`plan.env` + `SERVER_PORT_ENV_VAR`(=`'CM_PORT'`))を描画する。値は **hook URL と同じ `getServerPort()`**(`src/lib/env.ts`)で、**無条件**・**末尾に append**・**プランが自分で `CM_PORT` を宣言していれば上書きしない**。7 ソースの `prepareLaunch` ではなくレンダラに置いたのは、「どのサーバが動いているか」が 7 ソース共通の事実で 7 箇所に写すと必ず 1 つ忘れるため、かつ **claude(相関キーは `--settings` ファイルの中)と opencode(ポートは argv)は `env: {}` を返す** — つまり不具合が実測された当の 2 ソースが、ソース側実装だと新たに env を生やす必要があるため。**直った現象**: 同一マシンの 2 サーバが同じ tmux サーバを共有すると `tmux show-environment -g` に先に起動したサーバの `CM_PORT` が載り、pane の `-zsh` がそれを継ぐ。`src/cli/utils/server-url.ts` の `loadClientEnv()` は `process.env` > `~/.commandmate/.env`(#1743 の仕様)なので、tmux 由来の `CM_PORT=3000` が `.env` の 60301 に勝ち、**hooks は 60301/CLI は 3000 で割れる**(`whoami` だけ tmux セッション名から導出するので正答し、`ask` は 3000 の DB から読んで exit 0 を返すため呼び出し側が気づけない)。`AGENT_LAUNCH_CONFIG_ENV_VARS` は `CODEX_HOME` + `CM_PORT` の **2 個**になり、#1933 のドリフトガードの件数 pin は `7` → `8`(相関 6 + 設定 2)。同ガードの測定対象も `plan.env` から `resolveAgentLaunchEnv(plan)` に移した(`plan.env` を読むと #2403 以降は「起動行」を読んでいないため)。**起動行のバイト固定 6 ファイル**(`tests/unit/hooks/sources/{launch-contract-1846,opencode,opencode-config-scope-2053,command-code-source,copilot-hook-settings}.test.ts` と `tests/unit/hooks/codex-hooks-config-1760.test.ts`)は `CM_PORT` を固定値に stub して期待値を更新した。**「env が空なら素のコマンド」という #1846 の不変条件は失効**し、代わりに「1 個だけ付く」を完全一致で pin する(余計な区切りはそこで出る)。 **Issue #2614**: 宣言 `stopReportsSelfResume`(`agent-event-types.ts` の宣言マージ。語 `SELF_RESUME_PENDING_DETAIL = 'self_resume_pending'` と同居)を追加し、antigravity だけ `true`(他 6 ソースと legacy-relay は `false`。claude は `Stop` の `background_tasks` が候補だが空配列しか採取していないため未対応)。agy の `Stop` hook は `buildAntigravityStopHookCommand()` になり、payload の `"fullyIdle":false`(agy 自身の契約で「バックグラウンドタスクがすべて終わったら true」)を空白除去後に `case` で検出して relay に `--detail self_resume_pending` を渡す(それ以外は従来と同じ relay 呼び出し。stdout には何も出さない)。source の `extractAntigravityEventDetail()` も生 payload の `fullyIdle === false` を同じ語に読む。 **Issue #3330**: `AgentEventSource.promptJoinsOpenTurn?(payload)`(任意。`PushHookSourceSpec.promptJoinsOpenTurn` から配線、未宣言は false)。claude だけが宣言(`claude/queued-notice.ts`)。 **Issue #3360**: claude の `prepareLaunch` は `CM_UAT_ISOLATION=1` で `--setting-sources project,local` を末尾に足す(settingsPath の判定は足す前のコマンドで行う)。 claude の `prepareLaunch` は own-home(#3312)で hook 設定の dir とファイル(`CM_AGENT_HOOKS_DIR`)を確認し、外なら・書けなければ起動を拒否。 **Issue #3430**: claude も `stopReportsSelfResume: true`。`extractClaudeEventDetail`(`claude/source.ts`)が `stop` のとき `claude/self-resume` で transcript を読み、未通知のバックグラウンド作業があれば `SELF_RESUME_PENDING_DETAIL` を返す(他の event は従来の `extractSnakeCaseEventDetail`)。 | | `src/lib/hooks/sources/copilot/` | **copilot の `AgentEventSource`**(Issue #1761 / Phase 4-3)。`source.ts`=綴りは Claude と同一(`hook-event-vocabulary` の共有表を再利用)・`parseCopilotPermissionRequest` は **`hook_event_name:"PreToolUse"`** を読む(copilot に `PermissionRequest` は無い)・`encodeCopilotVerdict` は **`hookSpecificOutput.permissionDecision`**(Claude の `decision.behavior` ではない。deny も表現できる=Claude より広い)。`hook-settings.ts`=設定生成と起動コマンド。**書き先は `~/.copilot/settings.json`**(`COPILOT_HOME` 尊重)で、doc の言う `config.json` に書くと**次回起動で消える**(#1757 P2)。**置換ではなくマージ** — マーカー `cmate-copilot-agent-hooks` を含む自分のエントリだけ差し替え、他キー・他ハンドラ(grouped/flat 両形)は保持。パース不能な設定には触らず素の起動コマンド(`cli-tools/copilot.ts` が解決した実行体)で起動する。**`configScope:'global-singleton'`=マシン 1 本なので相関キーを URL に焼けない** → `AgentLaunchPlan.env` の `CM_AGENT_WORKTREE_ID` / `CM_AGENT_INSTANCE_ID` に載せ(#1846。それ以前は `command` への直接前置)、hook が fire 時に読む(hook がコマンドをシェル経由で実行し親 env を継承することを実測)。**全コマンドが `CM_AGENT_WORKTREE_ID` 未設定で無音終了する** — グローバル設定なので CommandMate 以外が起動した copilot でも発火し、cwd 解決で無関係な `wait` を返してしまうため。`PreToolUse` だけ permission receiver 宛(承認ゲートそのもの。stdout が裁定になる。往復実測 ≈110ms / 予算 ≈10s)で**イベントストアには入らない**ので `supportedEvents` から外してある。`notification` も**発火実績ゼロ**のため約束しない。`decisionTimeoutSeconds:10`(Claude は 600)。実測は `docs/design/copilot-agent-hooks-injection.md` **Issue #3391**: `CM_UAT_ISOLATION=1` では `writeCopilotHookSettings` が `~/.copilot/settings.json`・`.cmate-backup`・`.cmate.lock` を書かない。`inspectCopilotHookSettingsReadOnly` が「空のファイルに書く内容とバイト一致」のときだけ読むだけで使い、無い・違う・独自の hook/キー・`config.json` の `hooks`(または読めない)・`CM_AGENT_HOOKS_INJECT=0` は `UatIsolationLaunchRefusedError` で起動を拒否(`buildCopilotLaunchCommand` の bare への道もすべて拒否)。 own-home(#3312)では settings.json・バックアップ・ロック dir を確認後に書く。`config.json` の `hooks` は隔離中は素の起動ではなく拒否。 | | `src/lib/hooks/sources/gemini/` | **gemini の `AgentEventSource`**(Issue #1762 / Epic #1720 Phase 4-4)。`tool-id.ts`/`event-vocabulary.ts`=gemini 独自の綴り表(`BeforeAgent`→`user_prompt_submit`/`AfterAgent`→`stop`/`BeforeTool`・`AfterTool`。**共有 CamelCase 表には足さない**=`docs/design/agent-event-source-interface.md` §5)+ `notification_type:"ToolPermission"` → **`permission_prompt` へ翻訳**(消費層は Claude の subtype 文字列を直接比較するため、翻訳しないと gemini は永久に `waiting` にならない)/`settings-generator.ts`=`/.gemini/settings.json` への**マージ書き込み**(ユーザーの既存 hooks・全キーを保持。再書き込みは冪等で、自分の項目の識別は relay 名+`--tool gemini`+`--worktree-id ` の 3 点一致)+`buildGeminiLaunchCommand`/`shared-config-tree.ts`=`~/.gemini` 共有ツリー用の非破壊 JSON read/write(**antigravity 側も import する**)/`source.ts`=本体。**注入は `SessionStart`/`BeforeAgent`/`AfterAgent`/`Notification`/`SessionEnd` の 5 件のみ**(`BeforeTool`/`AfterTool` は写像だけ持ち登録しない — hooks は同期実行でツール呼び出しごとに 2 往復ブロックする一方、`running` は `BeforeAgent` で既に立っている)。**`noDecision:'proceeds'` は実装読み**(v0.55.1 バンドルの `DefaultHookOutput.isBlockingDecision()` は `decision==='block'|'deny'`、`shouldStopExecution()` は `continue===false` =空応答は何も止められない。#1757 §5.3.6 は項目 6/7 未計測)。**`encodeVerdict` は常に `{}`** — gemini の hook は**承認できない**(scheduler は hook →`checkPolicy` の順で、`hookDecision==='ask'` のときだけ `ASK_USER` へ**エスカレーション**する分岐しかない)ので承認は Policy Engine(`--approval-mode`/`--policy`/`-y`)専任。**Issue #1846 で `injectGeminiHookSettings()` は廃止** — `prepareLaunch` が `AgentLaunchContext.worktreePath` を受け取るようになったので、設定書き出しは `prepareGeminiLaunch()` の中で行い `settingsPath` に**実際に書いたファイル**を返す(`cli-tools/gemini.ts` の 2 回呼びも解消)。`CM_HOOK_URL` は `command` への前置ではなく `AgentLaunchPlan.env` で宣言する @@ -298,7 +298,7 @@ const myState = getOrInitGlobal('__myState', () => new Map()); | `src/lib/session/agent-event-record.ts` | `AgentEventRecord` 型のみ(Issue #3375 で agent-event-state から分離)。兄弟モジュールが本体を import せずに型を共有するため。`joinsOpenTurn`(#3330: 立っている `user_prompt_submit` は同じセッションの開いているターンを続ける) **Issue #3437**: `promptOpensTurns`(送信を報告するソース=`supportedEvents` に `user_prompt_submit` を持つソースの印。`pre_tool_use` だけが読む)。 | | `src/lib/session/agent-event-dedup.ts` | 配送の重複判定(3 秒窓・identity)と drop 件数。`MAX_RECENT_EVENT_KEYS` / `trimOldestEntries` を兄弟へ提供(#3375 分離)。`agentEventKeyClaimedAt()`(dedup キーを適用した配送の受信時刻)・`shortSessionTag()`(セッション id の SHA-256 先頭 8 文字)(#3311) | | `src/lib/session/agent-event-model.ts` | model / effort の latch(hook・frame・reported effort)、優先順位、model 変化の edge(#3375 分離) | -| `src/lib/session/agent-event-turn.ts` | ターンモデル(#1930): 世代フェンス、TurnRecord、遷移、ターンが持つ decision(#3375 分離)。`joinsOpenTurn` の記録は開いているターンを続ける(`turnId` / `openedAt` 不変)、`joinOpenTurnFromDuplicate`(印つきの配送が重複で捨てられたとき、先の配送が開き直したターンを戻す)(#3330)。`observeScraperCompletionEvidence(..., at, mayClose = true)`: false の間は数えるが閉じない(hooks を出している codex で Stop が来ないと分かる画面でないとき、#3337) **Issue #3437**: `promptOpensTurns` の `pre_tool_use` は、直近のターンがエージェント自身の `stop` で閉じていれば新しいターンを開かない(`endedByOwnStop`。`stale` / `scraper_evidence` 等の後と、`post_tool_use` は従来どおり開く)。 | +| `src/lib/session/agent-event-turn.ts` | ターンモデル(#1930): 世代フェンス、TurnRecord、遷移、ターンが持つ decision(#3375 分離)。`joinsOpenTurn` の記録は開いているターンを続ける(`turnId` / `openedAt` 不変)、`joinOpenTurnFromDuplicate`(印つきの配送が重複で捨てられたとき、先の配送が開き直したターンを戻す)(#3330)。`observeScraperCompletionEvidence(..., at, mayClose = true)`: false の間は数えるが閉じない(hooks を出している codex で Stop が来ないと分かる画面でないとき、#3337) **Issue #3437**: `promptOpensTurns` の `pre_tool_use` は、直近のターンがエージェント自身の `stop` で閉じていれば新しいターンを開かない(`endedByOwnStop`。`stale` / `scraper_evidence` 等の後と、`post_tool_use` は従来どおり開く)。 **Issue #3446**: `isPreToolUseOutsideTurn(worktreeId, cliToolId, instanceId, promptOpensTurns)`(`agent-event-state` から再 export)。 | | `src/lib/session/agent-event-structured-state.ts` | `getStructuredSessionState`(ターンから導く構造化 verdict)と `STRUCTURED_STATE_MAX_AGE_MS`(#3375 分離) | | `src/lib/session/agent-event-awaiting-instruction.ts` | エージェント自身の「指示待ち」フラグ(#1786)と遷移(#3375 分離) | | `src/lib/session/agent-event-ask-user-question.ts` | 進行中の `AskUserQuestion`(#1726)の記録と解放規則(#3375 分離) | diff --git a/src/lib/hooks/agent-event-intake.ts b/src/lib/hooks/agent-event-intake.ts index d060705b0..d3ce32884 100644 --- a/src/lib/hooks/agent-event-intake.ts +++ b/src/lib/hooks/agent-event-intake.ts @@ -24,6 +24,7 @@ import type { AgentEventSource, NormalizedAgentEvent } from '@/lib/hooks/sources import { agentEventKeyClaimedAt, isDuplicateAgentEvent, + isPreToolUseOutsideTurn, joinOpenTurnFromDuplicate, recordAgentEvent, recordAskUserQuestion, @@ -141,6 +142,47 @@ function readSessionId( return { sessionId }; } +/** An agent-type name a log line may carry as itself: short, and no path or prose. */ +const LOGGABLE_AGENT_TYPE = /^[A-Za-z0-9_.:-]{1,64}$/; + +/** + * Who sent a hook, in a form a log line may carry (Issue #3446). + * + * The payload's own identifiers — the agent session, the subagent (`agent_id` + * and `agent_type`, which Claude adds when a subagent's hook fires), the + * transcript and the tool call — each as {@link shortSessionTag}'s hash prefix, + * so the session and the transcript path are never written. A hash still lets + * an operator tell whether two lines came from the same sender, and check a + * line against a transcript by hashing its ids the same way. `agent_type` is a + * name, kept as itself only when it looks like one. + * + * Only for the lines that need it — a question recorded or set aside, a + * `pre_tool_use` that opened no turn — never for every delivery. + */ +export function hookSenderTags( + payload: Record, + sessionId: string | undefined +): Record { + const tag = (value: string | undefined): string | null => + value === undefined ? null : shortSessionTag(value); + const agentType = readString(payload, 'agent_type'); + return { + session: tag(sessionId), + agent: tag(readString(payload, 'agent_id')), + agentType: agentType !== undefined && LOGGABLE_AGENT_TYPE.test(agentType) ? agentType : null, + transcript: tag(readString(payload, 'transcript_path')), + toolUse: tag(readString(payload, 'tool_use_id')), + }; +} + +/** + * Whether the source opens its turns on its prompts (Issue #3437): read off the + * declaration, never the tool id. + */ +function promptOpensTurns(source: AgentEventSource): boolean { + return source.capabilities.supportedEvents.includes('user_prompt_submit'); +} + /** The instance, the worktree id and `cwd` the event names, or an error string. */ function readTarget( payload: Record, @@ -330,7 +372,7 @@ export function applyAgentEventToState( // Issue #3437: read off the declaration, never the tool id. A source that // reports its prompts opens its turns on them, so a `pre_tool_use` after // its own `Stop` with no prompt since does not begin one. - promptOpensTurns: source.capabilities.supportedEvents.includes('user_prompt_submit'), + promptOpensTurns: promptOpensTurns(source), }, { // Issue #1903: the declared value, read off the source this route already @@ -351,6 +393,24 @@ export function applyAgentEventToState( event, reason: recordOutcome.skipped, }); + return; + } + + // Issue #3446: the delivery #3437 keeps from opening a turn is the one whose + // sender nobody could name afterwards, so it is logged with the payload's + // identifiers. Asked after the event was applied: the turn is still closed by + // the agent's own `stop` exactly when the transition declined to open one. + if ( + event === 'pre_tool_use' && + isPreToolUseOutsideTurn(worktree.id, tool, instanceParam, promptOpensTurns(source)) + ) { + logger.info('agent-event-pre-tool-use-outside-turn', { + worktreeId: worktree.id, + tool, + instanceId: instanceParam, + detail, + ...hookSenderTags(payload, sessionId), + }); } } @@ -392,7 +452,7 @@ export function recordQuestionIfAsked( payload: Record, logger: AgentEventLogger ): void { - const { worktree, tool, source, instanceParam, event, receivedAt } = ctx; + const { worktree, tool, source, instanceParam, event, sessionId, receivedAt } = ctx; if (event === 'pre_tool_use') { // Issue #1726: the one event whose *body* is the point. The injected hook // carries `matcher: "AskUserQuestion"`, but `tool_name` is re-read here @@ -408,15 +468,32 @@ export function recordQuestionIfAsked( // (S7). opencode's `question.asked` carries structured choices in a shape // that shares no field name with Claude's `tool_input.questions`. const spec = source.parseQuestion(payload); - if (spec) { - recordAskUserQuestion(worktree.id, tool, instanceParam, spec, receivedAt); - logger.info('ask-user-question-recorded', { - worktreeId: worktree.id, - tool, - instanceId: instanceParam, - questionCount: spec.questions.length, - optionCounts: spec.questions.map((q) => q.choices.length), - }); + if (!spec) return; + + const fields = { + worktreeId: worktree.id, + tool, + instanceId: instanceParam, + questionCount: spec.questions.length, + optionCounts: spec.questions.map((q) => q.choices.length), + ...hookSenderTags(payload, sessionId), + }; + + // Issue #3446: a question asked outside any turn — after the agent's own + // `stop`, with no prompt since, the event #3437 keeps from opening a turn — + // is not filed. Measured on 2026-10-07: such a `PreToolUse(AskUserQuestion)` + // was nowhere in the session's transcript and the pane showed no picker, + // so a record of it is a question nobody is being asked. Not filing it, + // rather than filing it and teaching every reader to ignore it, keeps the + // episode's one meaning — "the picker on screen asks this". The `stop` + // before it has already released any earlier question, so nothing is left + // waiting. A question inside a turn is untouched: there the turn is open. + if (isPreToolUseOutsideTurn(worktree.id, tool, instanceParam, promptOpensTurns(source))) { + logger.info('ask-user-question-outside-turn', fields); + return; } + + recordAskUserQuestion(worktree.id, tool, instanceParam, spec, receivedAt); + logger.info('ask-user-question-recorded', fields); } } diff --git a/src/lib/session/agent-event-state.ts b/src/lib/session/agent-event-state.ts index f5e04a26a..b0696afbe 100644 --- a/src/lib/session/agent-event-state.ts +++ b/src/lib/session/agent-event-state.ts @@ -130,6 +130,7 @@ export { getAgentTurn, getPendingDecisions, getPublishedAgentTurn, + isPreToolUseOutsideTurn, joinOpenTurnFromDuplicate, observeScraperCompletionEvidence, } from '@/lib/session/agent-event-turn'; diff --git a/src/lib/session/agent-event-turn.ts b/src/lib/session/agent-event-turn.ts index e518f66b9..da35846a6 100644 --- a/src/lib/session/agent-event-turn.ts +++ b/src/lib/session/agent-event-turn.ts @@ -532,6 +532,26 @@ function endedByOwnStop(key: string): boolean { return turn !== null && turn.closedAt !== null && turn.closedBy === 'stop'; } +/** + * Whether a `pre_tool_use` from a source that reports its prompts falls outside + * any turn: the instance's last turn was ended by the agent's own `stop` and + * nothing has opened one since (Issue #3446). + * + * The same reading {@link applyTurnTransition} uses to leave such an event's + * turn closed (#3437), exposed so the intake can decide the question that event + * carries on the same facts. Asked after the event was applied, it still holds, + * because the transition did not open a turn; for a source that does not report + * its prompts it is always false, because that event opened one. + */ +export function isPreToolUseOutsideTurn( + worktreeId: string, + cliToolId: CLIToolType, + instanceId: string | undefined, + promptOpensTurns: boolean +): boolean { + return promptOpensTurns && endedByOwnStop(buildCompositeKey(worktreeId, cliToolId, instanceId)); +} + /** The three fields the turn keeps from a record. */ function displayOf(record: AgentEventRecord): TurnRecord['displayEvent'] { return { event: record.event, at: record.at, detail: record.detail }; diff --git a/tests/unit/lib/hooks/agent-event-intake-question-3446.test.ts b/tests/unit/lib/hooks/agent-event-intake-question-3446.test.ts new file mode 100644 index 000000000..26226164a --- /dev/null +++ b/tests/unit/lib/hooks/agent-event-intake-question-3446.test.ts @@ -0,0 +1,209 @@ +/** + * A question asked outside any turn is not filed, and the lines that decide it + * name the sender (Issue #3446). + * + * The measured sequence is #3437's (production, 2026-10-07): `UserPromptSubmit` + * 23:40:24, `Stop` 23:41:32.759, then `PreToolUse(AskUserQuestion)` + * 23:41:35.219 with no prompt between — absent from the session's transcript, + * no picker on the pane. #3441 stopped it opening a turn; it still filed a + * question. Every case below is a positive or a negative control on the same + * condition #3437 reads, and the question inside a turn (#1726) is unchanged. + * + * @vitest-environment node + */ + +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearAgentStopEvents, + clearAskUserQuestion, + getAskUserQuestion, + shortSessionTag, +} from '@/lib/session/agent-event-state'; +import { + applyAgentEventToState, + hookSenderTags, + recordQuestionIfAsked, + type AgentEventLogger, + type ResolvedAgentEvent, +} from '@/lib/hooks/agent-event-intake'; +import { getAgentEventSource } from '@/lib/hooks/sources'; +import type { AgentEventType } from '@/lib/hooks/agent-event-types'; +import type { Worktree } from '@/types/models'; + +const WT = 'wt-3446'; +const SESSION = '07a5ddd9-46df-42c1-be1c-a6e35209c4a3'; +const AGENT_ID = 'a1b2c3d4e5f6'; +const TRANSCRIPT = '/Users/someone/.claude/projects/x/07a5ddd9.jsonl'; +const TOOL_USE_ID = 'toolu_01ABCDEF'; +const PROMPT_AT = 1_791_416_424_052; +const STOP_AT = 1_791_416_492_759; +const PRE_AT = 1_791_416_495_219; + +/** The captured `PreToolUse(AskUserQuestion)` payload (#1721), with this test's ids. */ +function questionPayload(extra: Record = {}): Record { + const captured = JSON.parse( + readFileSync( + join(process.cwd(), 'tests/fixtures/hooks/claude/pre-tool-use-ask-user-question.json'), + 'utf8', + ), + ) as Record; + return { + ...captured, + session_id: SESSION, + transcript_path: TRANSCRIPT, + tool_use_id: TOOL_USE_ID, + ...extra, + }; +} + +function makeLogger(): AgentEventLogger & { info: ReturnType } { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never; +} + +function ctxOf(event: AgentEventType, at: number, detail: string | null): ResolvedAgentEvent { + return { + worktree: { id: WT } as Worktree, + tool: 'claude', + source: getAgentEventSource('claude'), + instanceParam: 'claude', + event, + sessionId: SESSION, + receivedAt: at, + detail, + normalized: { model: null }, + } as unknown as ResolvedAgentEvent; +} + +/** What the route does with one delivery, minus the HTTP. */ +function deliver( + event: AgentEventType, + at: number, + detail: string | null, + payload: Record = {}, + logger: AgentEventLogger = makeLogger(), +): void { + const ctx = ctxOf(event, at, detail); + applyAgentEventToState(ctx, payload, false, logger); + recordQuestionIfAsked(ctx, payload, logger); +} + +function lines(logger: ReturnType, name: string): Record[] { + return logger.info.mock.calls.filter(([n]) => n === name).map(([, data]) => data); +} + +beforeEach(() => { + clearAgentStopEvents(); + clearAskUserQuestion(WT, 'claude', 'claude'); +}); + +describe('[#3446] AskUserQuestion after the agent\'s own stop', () => { + it('is not filed as a question (the measured sequence)', () => { + deliver('user_prompt_submit', PROMPT_AT, null); + deliver('stop', STOP_AT, null); + + const logger = makeLogger(); + deliver('pre_tool_use', PRE_AT, 'AskUserQuestion', questionPayload(), logger); + + expect(getAskUserQuestion(WT, 'claude', 'claude', PRE_AT + 1)).toBeNull(); + expect(lines(logger, 'ask-user-question-recorded')).toHaveLength(0); + expect(lines(logger, 'ask-user-question-outside-turn')).toEqual([ + expect.objectContaining({ + worktreeId: WT, + questionCount: 2, + session: shortSessionTag(SESSION), + transcript: shortSessionTag(TRANSCRIPT), + toolUse: shortSessionTag(TOOL_USE_ID), + }), + ]); + expect(lines(logger, 'agent-event-pre-tool-use-outside-turn')).toEqual([ + expect.objectContaining({ detail: 'AskUserQuestion', session: shortSessionTag(SESSION) }), + ]); + }); + + it('is filed when a post_tool_use reopened the turn after the stop (negative control)', () => { + // A Stop hook that blocks keeps the agent working, and its tool call + // reports `post_tool_use`, which opens the turn (#3437). + deliver('user_prompt_submit', PROMPT_AT, null); + deliver('stop', STOP_AT, null); + deliver('post_tool_use', STOP_AT + 500, 'Bash'); + + const logger = makeLogger(); + deliver('pre_tool_use', PRE_AT, 'AskUserQuestion', questionPayload(), logger); + + expect(getAskUserQuestion(WT, 'claude', 'claude', PRE_AT + 1)?.spec.questions).toHaveLength(2); + expect(lines(logger, 'ask-user-question-outside-turn')).toHaveLength(0); + expect(lines(logger, 'agent-event-pre-tool-use-outside-turn')).toHaveLength(0); + }); + + it('is filed when a new prompt opened a turn after the stop (negative control)', () => { + deliver('user_prompt_submit', PROMPT_AT, null); + deliver('stop', STOP_AT, null); + deliver('user_prompt_submit', STOP_AT + 1000, null); + + deliver('pre_tool_use', PRE_AT, 'AskUserQuestion', questionPayload()); + + expect(getAskUserQuestion(WT, 'claude', 'claude', PRE_AT + 1)).not.toBeNull(); + }); +}); + +describe('[#3446] AskUserQuestion inside a turn (#1726, unchanged)', () => { + it('is filed and logged with the sender tags', () => { + deliver('user_prompt_submit', PROMPT_AT, null); + + const logger = makeLogger(); + deliver( + 'pre_tool_use', + PRE_AT, + 'AskUserQuestion', + questionPayload({ agent_id: AGENT_ID, agent_type: 'general-purpose' }), + logger, + ); + + expect(getAskUserQuestion(WT, 'claude', 'claude', PRE_AT + 1)?.at).toBe(PRE_AT); + expect(lines(logger, 'ask-user-question-recorded')).toEqual([ + expect.objectContaining({ + questionCount: 2, + optionCounts: [3, 2], + session: shortSessionTag(SESSION), + agent: shortSessionTag(AGENT_ID), + agentType: 'general-purpose', + }), + ]); + expect(lines(logger, 'agent-event-pre-tool-use-outside-turn')).toHaveLength(0); + }); + + it('is released by the stop that ends the turn, as before', () => { + deliver('user_prompt_submit', PROMPT_AT, null); + deliver('pre_tool_use', PRE_AT - 10_000, 'AskUserQuestion', questionPayload()); + deliver('stop', STOP_AT + 10_000, null); + + expect(getAskUserQuestion(WT, 'claude', 'claude', STOP_AT + 10_001)).toBeNull(); + }); +}); + +describe('[#3446] hookSenderTags', () => { + it('never carries the session id, the transcript path or the tool call id as themselves', () => { + const tags = hookSenderTags( + { agent_id: AGENT_ID, transcript_path: TRANSCRIPT, tool_use_id: TOOL_USE_ID }, + SESSION, + ); + const written = JSON.stringify(tags); + + for (const secret of [SESSION, TRANSCRIPT, TOOL_USE_ID, AGENT_ID]) { + expect(written).not.toContain(secret); + } + expect(Object.values(tags).every((v) => v === null || /^[0-9a-f]{8}$/.test(v))).toBe(true); + }); + + it('gives null for fields the payload does not carry, and drops an agent_type that is not a name', () => { + expect(hookSenderTags({ agent_type: 'not a name / with a path' }, undefined)).toEqual({ + session: null, + agent: null, + agentType: null, + transcript: null, + toolUse: null, + }); + }); +});