feat: エージェントとして Grok (xAI) に対応する - #124
Conversation
行区切り JSON は Codex の `codex exec --json` だけのものではない(次に足す Grok の ACP も JSON-RPC を 1 行 1 通で流す)。framing 自体は provider 固有の知識を持たない 純粋な関数なので、codex-events.ts から中立の core/jsonl.ts へ移す。振る舞いは不変。
closes #107 `grok` CLI(Grok Build 1.0.0)を 3 つ目の provider として追加する。一覧・詳細の `/agent` で選べ、`/model` で Grok 自身のモデル一覧から選択でき、セッション一覧には 実際に動いているモデル名が出る。 **ACP(JSON-RPC over stdio)を選んだ理由**: `grok` の非対話モードは 2 つあり、 headless(`-p --output-format streaming-json`)は一方向で許可・質問を上げられない。 `grok agent stdio` は双方向なので、`session/request_permission` と `_x.ai/ask_user_question` をそのまま codiva の許可・質問ダイアログへ繋げる (`permissions: true`。Codex では諦めていた機能)。 構成は Claude / Codex と同じ 3 点セット + 型と I/O: - `core/grok-events.ts` … ACP メッセージの型と受理ガード(通知が `session/update` と `_x.ai/session_notification` の 2 本のレールで来ることを吸収) - `core/grok-parse.ts` … 通知 → `AgentEvent`。本文の区切りが通知に無いので、 ここだけは状態(溜めた本文・思考)を持ち、ツール呼び出しとターン終了で確定させる - `core/grok-errors.ts` … `retry_state.error_type`(CLI 自身の分類)を優先し、 `api` だけは文言判定へ落とす(401 を素の failed に格下げしないため) - `core/grok-adapter.ts` … 1 セッション 1 プロセス + 要求 id の対応表 - `core/grok-models.ts` / `utils/grok.ts` … カタログは `initialize` の `_meta.modelState` から取る(`grok models` は人間向けテキストのみ) 実測で分かった要点(詳細は docs/TECH_NOTES.md): - `session/cancel` は**通知**。id を付けると `-32601` になる - エージェントからの要求は**必ず答える**(放置するとターンが永久に止まる)ので、 知らない要求にも `-32601` を返す - 継続は `session/load` ではなく `session/resume`(load は会話を全部流し直す) - systemPrompt は `session/new` の `_meta.rules`(`systemPromptOverride` は Grok 自身の system prompt を潰すので使わない) - 解決済みモデルは provider が自分で報告するので、Codex のような rollout 探索は不要 テストは実 `grok` 1.0.0 の通信を採取したフィクスチャ(`__fixtures__/grok-*.jsonl`)で 駆動する。モデルカタログの受け渡しは `modelsByAgent` の表に一般化し、ビュー側の provider ごとの分岐をやめた。
- README: 対応エージェント・動作要件・`/agent` の手順・`agent` 設定値・Grok セッションの制約(許可と質問は届く / コストと使用状況は出ない / ログ復元なし) - TECH_NOTES: `grok agent stdio`(ACP)の実測。メソッド表・2 本の通知レール・ 要求への応答形・error_type による分類・モデルの取得元・モックバックエンドを 使ったフィクスチャ採取の手順 - ARCHITECTURE: core/ のファイル一覧、parse→fold 図、capability 表、 「1 セッション 1 プロセス + 対応表」にした理由 - CLAUDE.md / rules: 地図と不変条件に Grok の 3 点セットと core/jsonl.ts を追加。 「要求には必ず答える」「要求と通知を取り違えない」を規約に足す - TASKS: Phase C を完了にして実績メモを残す
コードレビューで見つかった、セッションが嘘の状態に落ちる 4 件: - **中断が完了に化ける**。`Ctrl+C` と `end_turn` が競ると `turn_completed` を出して しまい、`interrupted` が `completed` に化けて auto-PR まで走っていた。終端イベントの 手前で `interrupted` / abort を見るようにする(Codex 側と同じ守り)。 - **セッション確立前の `Ctrl+C` が無視される**。`session/new` の最中は `sessionId` が 無いので `session/cancel` を送れず、UI は「中断した」と言うのにエージェントは走り 切っていた。確立直後に送り直す予約を持つ。 - **プロセスが黙って死ぬと `failed`(再開不可)になる**。合成文言(`grok agent exited`) では分類が効かないため。`GrokProcess.result()` の stderr と終了コードから理由を 組み立て、それでも読み取れないものは `connection`(再開可能)へ倒す。 - **`retry_state.error_type` が分類に使われていなかった**(docs は使うと書いていた)。 CLI 自身の分類を覚えて `grokStopCause` に渡す。文言では読めないレート制限が `failed` にならなくなる。 ほか、未処理 rejection で TUI を落としうる箇所を塞ぐ: `.finally()` の後ろへ catch を移す、許可・質問の応答が例外で飛んだら `cancelled` で 必ず閉じる(放置するとターンが永久に終わらない)、`drive()` の失敗を**キューを 閉じる前に**積む(閉じたあとの push は黙って捨てられていた)。`errorText` は `message` と `data` の両方を残す(`Internal error` だけでは分類も説明もできない)。
takecchi
left a comment
There was a problem hiding this comment.
1件、修正が必要な問題があります。
[High] session/new 中の Ctrl+C は、cancelを送ったあとに中断対象のpromptを新規開始してしまいます。
interrupt() は sessionId がまだ無いと cancelPending = true にし、start() は session/new の応答後に flushPendingCancel() で session/cancel を送ります。しかし start() が戻ると、同じ drive() は無条件で runTurn(text) を呼び、直後に session/prompt を送ります。つまり実際の順序は次のとおりです。
session/newを送信- ユーザーがCtrl+C(UIは
interrupted) session/new応答を受信session/cancel通知を送信(この時点ではprompt/ターンはまだ存在しない)session/promptを送信して、中断したはずの作業を開始
cancelが未来のpromptへ予約される保証はなく、通常は「現在走っているターン」を止める通知なので、4は空振りし、5の作業がそのままworktreeを変更し得ます。interruptedフラグにより後の完了イベントだけは抑止されるため、UIは中断済みのまま、裏でエージェントだけが動くさらに危険な状態になります。
既存テストはcancel通知が送られたことまでしか見ていません。PR headの同テストへ、数tick後も session/prompt が送られていないことを追加で期待すると失敗し、実際にid 3の session/prompt が記録されることを確認しました。
対処としては、確立前に中断されたターンではセッション確立だけ済ませて元の text を捨て、runTurn()へ進まないのが自然です(またはprompt送信後にcancelし、cancelの対象となるターンが確実に存在する順序にする必要があります)。少なくとも次を固定する回帰テストが必要です。
session/new待ち中にinterrupt- new応答後も中断対象の
session/promptを送らない - 次に新しい指示を送ったときだけ、その新しいtextでpromptが始まる
確認結果:
- 追加回帰テスト以外のGrok関連185テストは成功
npm run typecheck: 成功- GitHub CI check: 成功
レビュー指摘。`session/new` の応答待ちで `Ctrl+C` を押すと、cancel を送ったあとに **その中断したはずの `session/prompt` をそのまま投げていた**。 `session/cancel` が止められるのは「今走っているターン」だけなので、prompt を出す前に 送っても空振りする。しかも完了イベントは `interrupted` フラグで抑止されるため、 UI は「中断済み」のまま**エージェントだけが worktree を書き換え続ける**(誰も 気付けない)という、握り潰しより悪い状態になっていた。 前の修正で入れた「確立後に cancel を送り直す」予約をやめ、`runTurn` の先頭で 中断・abort を見て**そのターンを始めない**(指示ごと捨てる。やり直しはユーザーが 改めて送る)。同じ穴は resume 経路(`sessionId` は既にあるがターンは未開始)にも あったので、両方を回帰テストで固定する。`interrupt()` も、止める対象が無いときは 空振りの通知を送らないようにした。 1 プロセスが複数ターンを跨ぐ provider では「プロセスを殺せば止まる」(Codex)が 成り立たない、という一般的な教訓なので docs/ARCHITECTURE.md と .claude/rules/sdk-integration.md にも残す。
|
レビューありがとうございます。ご指摘のとおりで、修正しました(2b1aae7)。 何が起きていたかおっしゃる順序 4→5 が完全に正しく、 修正予約の仕掛け( 同じ穴が resume 経路にもありました( 回帰テスト(ご提示の 3 点をそのまま固定)
「長寿命プロセスの provider では『プロセスを殺せば止まる』が成り立たないので、中断は指示を捨てることで表現する」という教訓は、次の provider でも踏みそうなので 確認: lint / typecheck / test(2906 passed)/ build いずれも成功しています。 |
takecchi
left a comment
There was a problem hiding this comment.
再レビューしました。前回指摘した、session/new / session/resume の処理中に Ctrl+C で中断しても、その後 session/prompt が送信されてしまう問題は解消されています。
runTurn() が起動処理後の中断状態を確認してプロンプト送信を打ち切るようになり、実際にプロンプトが送られないことと、その次の入力では正常にターンを開始できることも回帰テストで確認されています。
追加の指摘事項はありません。LGTM です。
確認結果:
- 全テスト成功: 115 test files / 2,906 tests
npm run typecheck成功npm run build成功- GitHub CI 成功
補足: ローカルの npm run lint は共有 node_modules に macOS ARM64 用 Biome バイナリがなく起動できなかったため、lint のローカル再実行のみ未確認です。
closes #107
grokCLI(xAI の Grok Build 1.0.0)を 3 つ目の provider として追加します。一覧・詳細の/agentで選べ、/modelで Grok 自身のモデル一覧から選択でき、セッション一覧には実際に動いているモデル名が出ます。許可・質問ダイアログも効きます(Codex では諦めていた機能)。なぜ ACP(JSON-RPC over stdio)なのか
grokの非対話モードは 2 つあります。grok -p … --output-format streaming-jsongrok agent stdiosession/request_permission/_x.ai/ask_user_questionが飛んでくるCodex に倣うなら前者ですが、それだと Codex と同じく
permissions: falseに落ちます。後者ならエージェント側からの要求を codiva のダイアログにそのまま繋げるので、ACP を選びました。構成(Claude / Codex と同じ 3 点セット + 型と I/O)
core/grok-events.tssession/updateと_x.ai/session_notificationの2 本のレールで来るのをここで吸収core/grok-parse.tsAgentEvent。本文の区切りが通知に無いので、ここだけは状態(溜めた本文・思考)を持ち、ツール呼び出しとターン終了で確定させるcore/grok-errors.tsretry_state.error_type(CLI 自身の分類)を優先し、apiだけ文言判定へ落とす(401 を素のfailedに格下げしないため)core/grok-adapter.tscore/grok-models.ts/utils/grok.tsinitializeの_meta.modelStateから(grok modelsは人間向けテキストのみ)createJsonlSplitterは provider 非依存なのでcore/jsonl.tsへ切り出して Codex と共用(振る舞いは不変)。modelsByAgentの表に一般化し、ビュー側のagent === 'codex' ? …を廃止しました(4 つ目の provider は 1 行で足せます)。agent: "grok"を受理し、自動選択の順にgrokを足しただけです。実測で分かった要点(詳細は docs/TECH_NOTES.md)
session/cancelは通知。idを付けると-32601 Method not foundになる-32601を返すsession/loadではなくsession/resume(load は過去の会話を全部流し直す)session/newの_meta.rules(systemPromptOverrideは Grok 自身の system prompt を潰す)grok modelsは未認証でも終了コード 0 なので、ログイン判定には使えない(~/.grok/auth.json/XAI_API_KEYの有無で見る)authenticateを勝手に呼ぶと応答が返らないままブラウザ OAuth を待ち続けるので、ログインは必ずユーザー操作(/login=grok login --device-auth)からcapability
テスト
src/core/__fixtures__/grok-*.jsonl)。実grok1.0.0 を、モデル側だけローカルのモック Responses API に差し替えて走らせた実出力です(ACP のフレーミング・ツール実行・許可要求・質問要求はすべて本物)。採取手順は docs/TECH_NOTES.md に残してあります。completed、モデル欄はgrok-4.5、resume id も取れています。test plan
npm run lint(この worktree の node_modules に biome のネイティブバイナリが無いため、同バージョンを別の場所へ展開して実行)npm run typechecknpm test(2905 passed / カバレッジ閾値も維持)npm run buildgrok1.0.0 での E2E(許可ダイアログ・ツール実行・完了・モデル表示)XAI_API_KEY)での手動確認 — 認証が要るためエージェントセッションでは実行できません。/agentで Grok を選び、1 ターン流して「モデル名が一覧に出る」「許可ダイアログが出る」「Ctrl+Cで中断できる」「再開できる」あたりをご確認ください