Skip to content

feat: エージェント切替時に会話履歴を引き継ぐ - #133

Merged
takecchi merged 3 commits into
mainfrom
fix/persist-session-model
Aug 21, 2026
Merged

feat: エージェント切替時に会話履歴を引き継ぐ#133
takecchi merged 3 commits into
mainfrom
fix/persist-session-model

Conversation

@takecchi

@takecchi takecchi commented Aug 21, 2026

Copy link
Copy Markdown
Owner

概要

/agent で Claude / Codex / Grok を切り替えるとき、codiva が保持しているユーザー・アシスタント双方の会話を切替先へ引き継ぐ。

  • provider 固有のセッションはまたげない(各 CLI が自分のトランスクリプトを持つ)ので、codiva 側のログ(user / assistant_text)を写して渡す
  • 渡し方は AgentRunOptions.handoffsystemPrompt には載せないcodex exec resume のように再開時に systemPrompt を読み直さない provider があり、往復切替でだけ引き継ぎが消えるため。各アダプタが attachHandoff で切替後の最初のユーザープロンプトに前置する
  • ツール実行・system / error 行は除外(量が大きく、作業ツリーを見れば分かる)
  • 予算は UTF-8 バイトMAX_HANDOFF_TRANSCRIPT_BYTES = 64,000)。新しい会話から詰め、省略したことは明示する
  • worktree 確認を促す既存の引き継ぎ情報(ブランチ・最初の指示・直前の指示)も維持

レビューで見つけて直したもの

初版(16cf34b / 06819e7)に対するレビューで出た問題への対処が ac1b156

内容
予算の単位 文字数で 80,000 だと日本語(1 文字 3 バイト)で最大 240 KiB になる。Codex は指示文を argv で渡すので Linux の MAX_ARG_STRLEN(131,072 バイト)に当たり、codex exec の起動が E2BIG で落ちる。解除点が thread.started なので以後どのターンも同じ理由で落ち続けセッションが詰む。macOS には per-arg 上限が無く手元では再現しない → UTF-8 バイト予算へ変更
Grok の取りこぼし 立ち上げ中に Ctrl+C されたターンは runTurn が丸ごと捨てるのに、引き継ぎだけ無条件に消費していた。1 回きりなので切替の文脈が黙って失われる → runTurn が「実際に投げたか」を返し、投げたときだけ落とす
復元で二重に出る 引き継ぎは provider にはユーザーメッセージとして届くので CLI のトランスクリプトにもそう残る。復元するとログに巨大な引き継ぎが「ユーザー発言」として並び、lastUserInstruction もそれを拾って次の切替で入れ子になる → 復元の入口で stripHandoff を通す
前置きが嘘になる 会話を載せられなかったとき(ログの無い復元セッション)にも「会話を下に写した」と名乗っていた → 条件分岐
UI 文言が逆のまま /agent ダイアログの注意書きが「会話の文脈は引き継がれません」のままだった。ユーザーが実際に読む唯一の説明 → ja / en とも更新
死にコード composeSystemPrompthandoff 引数が呼ばれなくなっていた → 削除
テストが無い アダプタ 3 本の引き継ぎ受け渡し(この PR の眼目)にテストが 1 件も無かった → Claude / Codex / Grok ぶんと、中断・スレッド未開始の経路を追加
ドキュメント CLAUDE.md / docs/ARCHITECTURE.md / docs/TASKS.md / .claude/rules/sdk-integration.md が「systemPrompt に載る」のままだった → 更新

実測(codex exec 0.148.0)

codex exec resume <id>thread.started同じ thread_id出すことを実バイナリで確認した(Codex アダプタの引き継ぎ解除点がこれに依存しているため)。docs/TECH_NOTES.md に追記。

設計上の判断

往復切替では重複を許す。 切替先が自分のスレッドを resume できるときは、そのぶん文脈が二重になる。それでも全部渡すのは、resume が失敗した・圧縮で落ちた場合に「渡しすぎ」より「足りない」方が害が大きいため。重複が新しい指示ではないことは引き継ぎ文の中で断ってある。

テスト

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

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

実セッションでの体感確認(実際に /agent で切り替えて引き継ぎが効くか)は TTY と各 CLI の認証が要るため未実施。

@takecchi
takecchi force-pushed the fix/persist-session-model branch from b00f564 to 16cf34b Compare August 21, 2026 08:31
@takecchi takecchi changed the title fix: セッション単位のモデル選択を再起動後も引き継ぐ feat: エージェント切替時に会話履歴を引き継ぐ Aug 21, 2026
エージェント切替の引き継ぎ(#133)をレビューして見つかった問題を直す。

- 引き継ぎの予算を**文字数から UTF-8 バイト**へ(`MAX_HANDOFF_TRANSCRIPT_BYTES`)。
  Codex は指示文を argv で渡すので、日本語(1 文字 3 バイト)の会話が 80,000 文字
  まで載ると最大 240 KiB になり、Linux の `MAX_ARG_STRLEN`(131,072 バイト)に
  当たって `codex exec` の起動そのものが `E2BIG` で落ちる。しかも解除点が
  `thread.started` なので、以後どのターンも同じ理由で落ち続けてセッションが詰む。
  macOS には per-arg 上限が無いため手元では再現しない。
- Grok: 立ち上げ中に `Ctrl+C` されたターンは `runTurn` が丸ごと捨てるのに、引き継ぎだけ
  無条件に消費していた(1 回きりなので切替の文脈が黙って失われる)。`runTurn` が
  「実際に投げたか」を返し、投げたときだけ落とす。
- 引き継ぎは provider にはユーザーメッセージとして届くので CLI のトランスクリプトにも
  そう残る。復元で `stripHandoff` を通し、詳細ビューと `lastUserInstruction` に
  漏れないようにする(漏れると次の切替で引き継ぎが入れ子に写る)。
- 会話を載せられなかったときに「会話を下に写した」と名乗らないようにする
  (ログの無い復元セッションで嘘になる)。
- `/agent` ダイアログの注意書きが「会話の文脈は引き継がれません」のままだった
  (ja/en とも更新)。ユーザーが実際に読む唯一の説明。
- `composeSystemPrompt` の `handoff` 引数が呼ばれなくなっていたので削除。
- 実測を追記: `codex exec resume` も `thread.started` を出す(0.148.0)。
- アダプタ 3 本の引き継ぎ受け渡しにテストが 1 件も無かったので追加
  (Claude / Codex / Grok + 中断・スレッド未開始の経路)。
- docs / rules / CLAUDE.md を実装に合わせて更新。
@takecchi
takecchi merged commit 6e24be6 into main Aug 21, 2026
2 checks passed
@takecchi
takecchi deleted the fix/persist-session-model branch August 21, 2026 14:24
takecchi added a commit that referenced this pull request Aug 22, 2026
## 概要

マージ済みの #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>` を投げ続ける。`agentSessions` と
`state.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 も失われる)。しかも
`persistRelevantChanged` は `agent` / `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. 引き継ぎが「渡る前」に使い切られていた

`Session` が `open()` の時点で引き継ぎを捨てていたので、**未ログインの 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 本に載る。超えると `spawn` が `E2BIG` で落ち、
`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` の
`text`、`command_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 点をローカルで通した:

- [x] `lint`(biome、268 ファイル)
- [x] `typecheck`
- [x] `test` — 118 files / 3021 tests passed
- [x] `build`

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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