Skip to content

fix: エージェント切替まわりの不具合をまとめて修正(#133 の後追い) - #135

Merged
takecchi merged 5 commits into
mainfrom
fix/agent-switch-followups
Aug 22, 2026
Merged

fix: エージェント切替まわりの不具合をまとめて修正(#133 の後追い)#135
takecchi merged 5 commits into
mainfrom
fix/agent-switch-followups

Conversation

@takecchi

Copy link
Copy Markdown
Owner

概要

マージ済みの #133(エージェント切替時の会話引き継ぎ)を起点に、現在の main
切替まわり・provider アダプタ・一覧の描画の 4 系統でレビューし、実害のあるものを直した。
#133 自体の設計(AgentRunOptions.handoff で最初のユーザープロンプトに前置する)は妥当で、
ここで直しているのはその周辺の取りこぼしと、Phase D で入った切替機能そのものの穴。

なお #134#133 と同一内容の重複 PR で、マージ結果のツリー差分はゼロ(実害なし・
履歴に空コミットが 1 つ残っているだけ)。

重い順

1. 切替で畳んだ run のイベントを畳み込んでいた(resume id が混線する)

setAgent() は入力キューを閉じて this.run を捨てるが、for awaitそのイテレータを
掴んだまま
なので古い provider のイベントは切替後にも届く。一方 attribution はもう切替先。

  • 遅れて届いた session_started(Claude の system/init は起動に 1〜3 秒かかるので、その間に
    /agent を押せる)で 前任者の resume id が切替先の id として保存され、以後
    codex exec resume <claude の uuid> を投げ続ける。agentSessionsstate.json にも
    焼き付くので往復切替でも直らず、本物の Claude の id は失われる
  • 古いターンの turn_completed で切替済みのセッションが completed に戻り、auto-PR と
    完了通知まで走る
    run.interrupt() は best-effort なのでこの競走は普通に負ける)

→ 世代カウンタ(epoch)を持ち、世代が変わった run のイベントは畳まずに break
(= run.return() で古いストリームも閉じる)。catch の失敗記録と「最後の砦」も同じガードの内側へ。

2. 未使用の provider へ切り替えたセッションが state.json から消える

agent_switched は切替先が初めての provider だと sdkSessionId を undefined にする。
保存条件が「現在の id があること」だったため、/agent で切り替えて何も送らずに終了した
セッションが state.json から丸ごと消えていた
(戻るための agentSessions.claude は残っている
のに worktree だけが孤児になり、タイトル・コスト・PR 参照・TODO も失われる)。しかも
persistRelevantChangedagent / agentSessions / sdkSessionId の変化で即保存するので、
切替した時点で消える。

→ 条件を「どこかに resume 用の id がある」へ。読み込み側も同条件で受理し、
PersistedSession.sdkSessionId を optional に。

3. Grok の質問が既定モードで自動 allow されていた(ダイアログが一度も出ない)

質問の判定が ツール名AskUserQuestion)だったため、Grok の _x.ai/ask_user_question
auto(既定)で自動 allow。answers の無い allow は provider には「ユーザーは答えなかった」と
同じなので、質問がダイアログに出ないまま「承諾した」と返っていた

→ 判定を kind: 'question'(アダプタが正規化した種別)に統一(isQuestion の 1 箇所)。

4. 引き継ぎが「渡る前」に使い切られていた

Sessionopen() の時点で引き継ぎを捨てていたので、未ログインの CLI へ切り替えて失敗 →
ログインして送り直す
という普通の経路で 1 回きりの引き継ぎが黙って消えていた(Claude は run が
throw して終わるだけなので、アダプタ側で「渡るまで持つ」保険も効かない)。

AgentRunRequest.onHandoffDelivered() を新設し、渡ったと報告されるまで Session が持つ
報告点は Claude = プロンプトを yield した後 / Codex = thread.started / Grok =
session/prompt の応答が合成エラーでないとき(プロセス死で届かなかったターンを渡した扱いにしない)。

5. Codex の argv 上限(E2BIG)でセッションが詰む余地

MAX_HANDOFF_TRANSCRIPT_BYTES会話ぶんの予算で、systemPrompt(.codiva/prompt.md
無制限)とユーザーの指示文も同じ argv 1 本に載る。超えると spawnE2BIG で落ち、
thread.started が来ない = 引き継ぎが解除されないので以後どのターンも同じ理由で落ち続ける

→ 送る直前に fitHandoff(handoff, 残りバイト) で会話の古い側から削る。1 ターンも載らなければ
引き継ぎを諦めて notice を 1 行残す(黙って捨てない)。

6. 一覧のバッジ列が溢れて行のクリックがズレる(英語 UI)

行のセルはどれも wrap="truncate-end" を持つのに ProgressBadge だけ持っていなかった。
英語の Awaiting permission(19 セル)は 12 セル固定の列に入らないので1 セッションが 2 行に
折り返し
、「1 セッション = 1 行」前提の rowLineAtPoint 以降のクリックが 1 行ずつズレて
(別セッションが選ばれ、PR セルのクリックも他人の PR を開く)、最後の行はクリック自体が
捨てられていた。日本語はちょうど 12 セルだったので気付けなかった。

truncate-end + 英語文言を 12 セル以内(Permission / Login needed)+ 列幅を
BADGE_COLUMN_WIDTH として core/layout.ts へ + i18n.spec.ts に幅の番人を追加。

7. その他

  • 切替直後の Ctrl+C が効かない(畳んだループの finally が新しいエージェントを起こす
    予約を持っているため)→ 予約を取り消して積み残しを捨てる(Grok アダプタが立ち上げ中の
    中断で指示を捨てるのと同じ考え方)
  • stripHandoff最後の境目で切る(会話ログに同じ見出しが混ざると残骸がユーザー発言として
    復元され、lastUserInstruction 経由で次の切替に入れ子で写る)
  • Grok: 拒否の選択肢が見つからないとき options[0](実データでは allow-once)に落ちて
    拒否が実行に化けるのを cancelled に倒す / 未応答要求の待ち行列を接続ごとに持つ
    (死んだプロセスの後片付けが次のプロセスの initialize を失敗させ、健全なプロセスを殺していた)/
    readLoop が異常終了したら子を畳む(同じ worktree に 2 本目が立つ)/ プロセス死の判定を
    JSON-RPC の code ではなく接続のフラグで(-32000 は Grok 自身も使う)/ GROK_HOME=""
    auth.json を相対パスで読んでいた
  • Codex: スレッド id が分かっているクラッシュ(panic = exit 101 / SIGKILL = code null)を
    failed(終端 = 再開の導線が消える)ではなく resumable な connection
  • 受理ガードに、パーサが無条件に読むフィールドを追加(Codex の reasoning / agent_message
    textcommand_execution.command、Grok の content / plan の要素)— 通すと TypeError が
    ターンのストリームごと殺す
  • 準備中(worktree 作成中)のセッションに既定エージェントを載せる(Claude と嘘の名前が出て、
    エージェント列が一瞬現れて一覧が組み直される)
  • 詳細の /agent で同じエージェントを選び直したときにエラーを出さない / /login の未対応
    メッセージにエージェント名を差し込む / /agent のヒントの全角区切りを theme.ts の記号へ /
    ログインダイアログ表示中のフッタヒント / ダイアログのアンマウントでログインプロセスを畳む

直していない既知の制約(ドキュメントに明記した)

Claude では切替直前に送った追加指示が古い CLI 側のキューに残る。 Claude Agent SDK は
プロンプトの AsyncIterable を先読みするので、ターン中の追加指示はその場で CLI の stdin へ
渡り、AsyncQueue.pending は 0 のまま(= drain() で移せない)。SDK の interrupt()
still_queued として uuid を返すが、取り消すには送信時に uuid を打って cancel_async_message
送る必要があり、実装していない。Codex / Grok は消費が遅延評価なので移し替えが効く。
docs/ARCHITECTURE.md.claude/rules/session-domain.md に限界として書いた。

テスト

追加した番人(いずれも修正前は落ちる):

  • session.spec.ts: 畳んだ run の遅延イベントを無視する / 渡る前に死んだ run の引き継ぎを保つ /
    切替直後の Ctrl+C で予約された再起動を取り消す / provider が何と呼んでいても質問は上げる
  • persistence.spec.ts: 未使用 provider へ切り替えたセッションを保存・復元できる
  • agent-handoff.spec.ts: fitHandoff の 4 ケース / 境目が本文に混ざっても最後で切る
  • codex-adapter.spec.ts: argv 上限で引き継ぎを削る / クラッシュを resumable にする
  • grok-adapter.spec.ts: 拒否できる選択肢が無ければ cancelled / 死んだプロセスの後片付けが
    次のプロセスを巻き込まない
  • codex-events.spec.ts / grok-events.spec.ts: 受理ガードの追加ケース
  • i18n.spec.ts: バッジは一覧の列幅(12 セル)に収まる

CI と同じ 4 点をローカルで通した:

  • lint(biome、268 ファイル)
  • typecheck
  • test — 118 files / 3021 tests passed
  • build

体感確認(実際に /agent で切り替える・英語 UI で行をクリックする)は TTY と各 CLI の認証が
要るため未実施。

🤖 Generated with Claude Code

`/agent` の切替まわりを通しでレビューして見つけた実害のあるものを直す。

- **切替で畳んだ run のイベントを畳み込まない**(`Session` の世代カウンタ `epoch`)。
  `for await` は捨てたイテレータを掴んだままなので古い provider のイベントが切替後にも
  届き、帰属は既に切替先。そのため遅れて来た `session_started`(Claude の `system/init` は
  起動に 1〜3 秒)で**前任者の resume id が切替先の id として保存**され、以後
  `codex exec resume <claude の uuid>` を投げ続けていた(`agentSessions` と state.json にも
  焼き付くので往復切替でも直らない)。古いターンの `turn_completed` で completed に戻って
  auto-PR / 完了通知まで走る経路も同時に消える。
- **引き継ぎは「provider へ渡った」と報告されたときだけ落とす**
  (`AgentRunRequest.onHandoffDelivered`)。`Session` が `open()` の時点で捨てていたため、
  未ログインの CLI へ切り替えて失敗 → ログインして送り直す、という普通の経路で 1 回きりの
  引き継ぎが黙って消えていた(Claude は run が throw で終わるだけなのでアダプタ側の保険も
  効かない)。Grok は「プロセス死で届かなかったターン」を渡した扱いにしていたのも直す。
- **Codex の argv 上限に収める**(`fitHandoff`)。会話ぶんの予算に systemPrompt
  (`.codiva/prompt.md` は無制限)と指示文を足すと `MAX_ARG_STRLEN` を超えうる。超えると
  `spawn` が E2BIG で落ち、`thread.started` が来ない = 引き継ぎが解除されないので毎ターン
  落ち続けてセッションが詰む。収まらないぶんは会話の古い側から削り、1 ターンも載らなければ
  引き継ぎを諦めて `notice` を 1 行残す。
- **`stripHandoff` は最後の境目で切る**。会話ログに境目と同じ見出しが混ざると引き継ぎの
  残骸がユーザー発言として復元され、`lastUserInstruction` 経由で次の切替に入れ子で写る。
- **切替直後の `Ctrl+C` を効かせる**。畳んだループの `finally` が新しいエージェントを
  起こす予約を持っているので、取り消さないと切替後の最初の指示だけ中断できなかった。
- **質問の判定を `kind` に統一**(`isQuestion`)。ツール名で見ていたので Grok の
  `_x.ai/ask_user_question` が既定(auto)モードで自動 allow され、`answers` の無い「承諾」を
  返して**質問が一度もダイアログに出ていなかった**。
- Grok: 拒否の選択肢が見つからないときは `cancelled` に倒す(`options[0]` は実データでは
  `allow-once` なので、拒否が実行に化ける)。未応答要求の待ち行列を接続ごとに持ち、
  readLoop が異常終了したら子プロセスを畳む。プロセス死の判定は JSON-RPC の code ではなく
  接続のフラグで行う(`-32000` は Grok 自身も使う)。
- Codex: スレッド id が分かっているクラッシュ(panic / SIGKILL)を `failed`(終端)ではなく
  resumable な `connection` に倒す。
`agent_switched` は切替先が初めての provider だと `sdkSessionId` を undefined にする
(そこにはまだ会話が無い)。保存条件が「現在の id があること」だったため、`/agent` で
切り替えて何も送らずに終了したセッションが **state.json から丸ごと消えていた** —
戻るための id(`agentSessions.claude`)は残っているのに、worktree だけが孤児になり
タイトル・コスト・PR 参照・TODO も失われる。しかも `persistRelevantChanged` は
`agent` / `agentSessions` / `sdkSessionId` の変化で即保存するので、切替した時点で消える。

条件を「**どこかに** resume 用の id がある」に変え、読み込み側(`toPersistedSessionJson`)も
同じ条件で受理する。`PersistedSession.sdkSessionId` は optional になったので、トランスクリプト
復元はそれがあるときだけ試みる(控えの id は別 provider のものなので代わりに読まない)。
- Codex: `reasoning` / `agent_message` は `parseCodexEvent` が `text.trim()` として無条件に
  読むので、`text` の型まで見る(欠けた行を通すと TypeError がアダプタの generator を
  突き抜けてターンのストリームごと死ぬ → `codex exec` が孤児として残る)。
  `command_execution.command` も表示に使うので文字列であることを要求する
  (`"$ undefined"` というログ行を出さない)。
- Grok: `tool_call` / `tool_call_update` の `content` は `toolOutputText` が for-of で回すので
  「オブジェクトの配列」まで見る。`plan` の各要素も `e.content` / `e.status` として無条件に
  読むので要素の型を見る。
- `GROK_HOME=""` のとき `auth.json` を**相対パス**で読んでいた(リポジトリに同名のファイルが
  あるだけでログイン済みと誤判定する)。`codexHome()` と同じく空文字は未設定として扱う。
一覧の行のセルはどれも `wrap="truncate-end"` を持っているが `ProgressBadge` だけ持って
いなかった。英語の `Awaiting permission`(19 セル)は 12 セル固定の列に収まらないので
**1 セッションが 2 行に折り返し**、「1 セッション = 1 行」を前提にした `rowLineAtPoint` 以降の
クリックが 1 行ずつズレて(別のセッションが選ばれ、PR セルのクリックも他人の PR を開く)、
最後の行はクリック自体が捨てられていた。日本語はちょうど 12 セルに収まっていたので
気付けなかった。

- `ProgressBadge` に `truncate-end`(他のセルと同じ)
- 列幅を `BADGE_COLUMN_WIDTH` として `core/layout.ts` に出し、英語の文言を 12 セル以内へ
  (`Awaiting permission` → `Permission` / `Login required` → `Login needed`)。
  番人として `i18n.spec.ts` に「バッジは一覧の列幅に収まる」を追加
- 準備中(worktree 作成中)のセッションに既定エージェントを載せる。無いと
  `sessionAgentId` の既定(`'claude'`)で表示されるので、既定が Codex / Grok のとき
  「準備中 / Claude」と嘘の名前が出て、他の行と食い違うために**エージェント列が一瞬現れて
  一覧全体が組み直される**
- 詳細の `/agent` で同じエージェントを選び直したときにエラーを出さない(カーソルは今の
  エージェントから始まるので `/agent` → Enter が最も打ちやすい操作)
- `/login` の未対応メッセージにエージェント名を差し込む(空だと先頭に空白が残るだけ)
- `/agent` のヒントの全角区切りを `theme.ts` の記号へ(英語 UI に `・` が混ざっていた)
- ログインダイアログ表示中のフッタヒントを `m.login.help` に(Esc しか効かないのに
  「Enter: 送信 / Tab: 一覧」と案内していた)
- ログインダイアログのアンマウントでログインプロセスを畳む(開いたまま codiva を終了すると
  `<cli> login --device-auth` が孤児として残り、自前のタイムアウトまでポーリングし続ける)
実装に合わせてドキュメントを更新する。

- `docs/ARCHITECTURE.md`: 畳んだ run のイベントを畳み込まない理由(resume id の混線・
  古いターンの完了で auto-PR)、引き継ぎを落とす条件、Codex の argv 予算、
  「積み残しを移せるのは誰にも渡していないぶんだけ」という Claude の制約
- `.claude/rules/session-domain.md`: 世代ガード / 保存条件(どこかに resume 用の id)
- `.claude/rules/sdk-integration.md`: `onHandoffDelivered` の契約、`fitHandoff`、
  質問の判定をツール名でやらない
- `.claude/rules/ink-components.md`: 一覧のセルは必ず truncate-end / 固定幅列の文言は
  カタログ側でも幅に収める
- `docs/TASKS.md`: Phase D の後追い修正を一覧に
@takecchi
takecchi merged commit c9475fa into main Aug 22, 2026
1 check passed
@takecchi
takecchi deleted the fix/agent-switch-followups branch August 22, 2026 02:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant