Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .claude/rules/sdk-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,20 @@

- **3 点セットは provider ごとに対で書く**。Claude が `claude-adapter.ts` / `claude-parse.ts` /
`claude-errors.ts` なら、Codex は `codex-adapter.ts` / `codex-parse.ts` / `codex-errors.ts`
(+ JSONL の型と受理ガードだけを持つ `codex-events.ts`、実 I/O の `utils/codex.ts`)。
(+ JSONL の型と受理ガードだけを持つ `codex-events.ts`、rollout の形を知る
`codex-rollout.ts`、実 I/O の `utils/codex.ts`)。
**その CLI / SDK の形の知識をこの外へ漏らさない。**
- **その provider が「実際に動いているモデル」を報告しないなら、推測で埋めない。**
`codex exec --json` はモデル名を一切運ばない(実測 0.147.0)。`--model` を明示していない
セッションのモデル名は、CLI が書き残す rollout の `turn_context` からしか分からないので、
そこを読む(`core/codex-rollout.ts` = 純粋な抽出 / `utils/codex.ts` の
`resolveCodexRolloutModel` = 探索と読み出し。理由と実測は
[docs/TECH_NOTES.md](../../docs/TECH_NOTES.md))。**カタログの先頭を既定とみなす類の
当て推量はしない** — 設定で既定を変えているユーザーに嘘のモデル名を出すことになる。
取れなければモデル欄は空のままでよい(`permissions: false` と同じで、無いものは無いと出す)。
報告は **`model_resolved`**(`core/agent-events.ts`)で行う。答えはターンが終わったあとに
届くことがあり、`session_started` / `assistant_message` に相乗りさせると `status` が
`running` に巻き戻るため、**モデル欄だけを触る専用イベント**を通す。
- **CLI は同梱せず、ユーザーがインストールしたものを起動する**(`git` / `gh` と同じ扱い)。
provider の SDK パッケージを依存に足すと、その provider を使わないユーザーにまで
プラットフォーム別バイナリを配ることになる。認証もその CLI のログインに委ねる
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ CI(`.github/workflows/ci.yml`)は `lint → typecheck → test → build`。
| やりたいこと | 主なファイル |
|---|---|
| セッションの状態・遷移 | `core/types.ts`(union)/ `core/status-meta.ts`(性質の表)/ `core/status-reducer.ts`(純粋 reducer) |
| 別のエージェント(Codex / Grok)に対応させる | `core/agent-ports.ts`(`AgentAdapter` / `AgentCapabilities` / `PermissionDecision` = DI 境界)/ `core/agent-events.ts`(`AgentEvent` の語彙 + 全 provider 共通の畳み込み `applyAgentEvent`)/ `core/claude-adapter.ts`・`core/claude-parse.ts`・`core/claude-errors.ts`(Claude 実装の 3 点セット)/ `core/codex-adapter.ts`・`core/codex-parse.ts`・`core/codex-errors.ts` + `core/codex-events.ts`(JSONL の型)・`core/codex-models.ts`・`utils/codex.ts`(`codex exec` の起動 = 唯一の I/O)/ アダプタの登録は `bootstrap/build-manager.ts` の `buildAgents` |
| 別のエージェント(Codex / Grok)に対応させる | `core/agent-ports.ts`(`AgentAdapter` / `AgentCapabilities` / `PermissionDecision` = DI 境界)/ `core/agent-events.ts`(`AgentEvent` の語彙 + 全 provider 共通の畳み込み `applyAgentEvent`)/ `core/claude-adapter.ts`・`core/claude-parse.ts`・`core/claude-errors.ts`(Claude 実装の 3 点セット)/ `core/codex-adapter.ts`・`core/codex-parse.ts`・`core/codex-errors.ts` + `core/codex-events.ts`(JSONL の型)・`core/codex-models.ts`・`core/codex-rollout.ts`(rollout から解決済みモデル)・`utils/codex.ts`(`codex exec` の起動 = 唯一の I/O)/ アダプタの登録は `bootstrap/build-manager.ts` の `buildAgents` |
| SDK メッセージの解釈 | `core/claude-parse.ts` **のみ**(`parseClaudeMessage`: SDKMessage → `AgentEvent[]`)+ `core/__fixtures__/*.jsonl`。Codex は `core/codex-parse.ts`(`parseCodexEvent`: `codex exec --json` の JSONL → `AgentEvent[]`)+ `core/__fixtures__/codex-*.jsonl` |
| エージェントの切替(`/agent`)| `core/session-manager.ts`(一覧=既定: `getDefaultAgentId` / `setDefaultAgent`・詳細=切替: `listAgents` / `getSessionAgent` / `setSessionAgent`)/ `ui/agent-select.tsx`(`mode:'default'`=一覧 / `'session'`=詳細)/ `core/status-reducer.ts` の `agent_switched` |
| エージェントの導入・ログイン検出 | `core/agent-ports.ts` の `AgentAdapter.checkAvailability` / `AgentAvailability` / `core/agent-availability.ts`(`resolveDefaultAgentId` / `noAgentInstalled`・純粋)/ `utils/claude.ts` の `detectClaudeAvailability`・`utils/codex.ts` の `detectCodexAvailability`(実 I/O)/ `SessionManager.checkAgents`(集約・キャッシュ)/ `ui/hooks.ts` の `useAgentAvailability` |
Expand Down Expand Up @@ -87,6 +87,7 @@ CI(`.github/workflows/ci.yml`)は `lint → typecheck → test → build`。
| 通知 | `core/notify.ts`(判定・純粋)/ `utils/notify.ts`(OS I/O) |
| 学習データ利用の警告 | `core/privacy.ts`(判定・純粋)/ `utils/privacy.ts`(キャッシュ+非公開 API)/ `ui/banner.tsx` |
| モデル選択 | `core/models.ts` / `utils/model-catalog.ts`(Claude)/ `core/codex-models.ts` + `utils/codex.ts` の `fetchCodexModelCatalog`(Codex)/ `ui/model-select.tsx` |
| 一覧に出す「実際に動いているモデル」 | Claude は `system/init` が運ぶ。**Codex は JSONL に無い**ので `core/codex-rollout.ts`(純粋な抽出)+ `utils/codex.ts` の `resolveCodexRolloutModel`(rollout の `turn_context` を読む)→ `AgentEvent` の `model_resolved`(status を触らない専用イベント)。理由は docs/TECH_NOTES.md |
| 選択肢リストの表示(質問・モデル) | `core/choice-lines.ts`(折返し + クリック逆算 `choiceRowHeights`/`choiceIndexAtRow`・純粋)/ `ui/choice-row.tsx`(1件の描画) |
| アップデート通知・`/update` | `core/update.ts`(比較・判定・DI 境界)/ `utils/update.ts`(registry fetch・経路判定・`npm install`)/ `ui/update-dialog.tsx` |
| 子プロセスへ渡す env(`NODE_ENV` を漏らさない) | `core/child-env.ts`(`childEnv` = 純粋)/ `utils/child-env.ts`(`childProcessEnv` = 実 `process.env`)/ `utils/claude-query.ts`(SDK の `query` に `Options.env` を被せた入口)。**プロセスを起こすときは必ず通す**(issue #103) |
Expand Down
13 changes: 13 additions & 0 deletions docs/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1183,7 +1183,20 @@ zsh: abort codiva
resume の引き回し・systemPrompt の前置・中断・終端イベント無しの終了)/ `utils/codex.spec.ts`
(引数の組み立て)

- [x] **解決済みモデルの表示**(一覧のモデル欄): `codex exec --json` はモデル名を運ばないので、
`--model` を明示していないセッション(= CLI の既定)のモデル欄が空のままだった。
rollout(`$CODEX_HOME/sessions/<年>/<月>/<日>/rollout-<時刻>-<thread_id>.jsonl`)の
`turn_context.model` が唯一の出所なのでそこを読む(`core/codex-rollout.ts` = 純粋な抽出 /
`utils/codex.ts` の `resolveCodexRolloutModel` = 探索と読み出し)。報告は専用の
`model_resolved`(status を触らない中立イベント)

> 実績メモ:
> - **解決済みモデルは JSONL に無い**(実測 0.147.0)。`codex debug models` に既定の印は無く、
> `codex doctor --json` も `"<default>"` としか答えない。rollout の `turn_context` だけが
> 実際の slug(`-m` 無しで `gpt-5.6-sol`)を持つ。**カタログ先頭を既定とみなす当て推量は
> 採らない** — `~/.codex/config.toml` で既定を変えているユーザーに嘘のモデル名を出すため。
> 問い合わせの答えは**ターンが終わったあとに届くことがある**ので、`session_started` /
> `assistant_message` に相乗りさせず(あれは `status` を `running` へ戻す)専用イベントにした。
> - セルフレビューで塞いだ取りこぼし: 初回のターンが `thread.started` 前に落ちると
> systemPrompt を二度と渡せなくなる(latch をやめて `threadId` から導出)/ 捨てられた run が
> `codex exec` を孤児として残す(Rust は `SIGPIPE` を無視するので明示的に kill)/ `stderr` の
Expand Down
46 changes: 46 additions & 0 deletions docs/TECH_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,52 @@ codex exec --json --skip-git-repo-check
エイリアスが無い。したがって取得に失敗したときのフォールバックは**「デフォルト」1 行だけ**
(`DEFAULT_ONLY_MODEL_OPTIONS`)。ここに推測でモデル名を並べると必ず陳腐化する。

### 解決済みモデルは JSONL に無い — rollout の `turn_context` が唯一の出所(実測 0.147.0)

Claude の `system/init` に当たるもの(**実際に動いているモデル名**)が Codex には無い。
実バイナリで確かめた結果は次のとおり:

| 調べた先 | 結果 |
|---|---|
| `codex exec --json` の stdout | **モデル名を一切運ばない**(`thread.started` は `thread_id` だけ、`turn.started` は空、`turn.completed` は usage だけ) |
| `codex debug models` | 既定を示す印が**無い**(`priority` はあるが「CLI の既定」とは別物) |
| `codex doctor --json` | `config.load.details.model` は `"<default>"` としか答えない |
| rollout の `session_meta` | `model_provider`(`"openai"`)だけで **slug は無い** |
| rollout の **`turn_context`** | **`model: "gpt-5.6-sol"`** ← ここだけが解決済み slug を持つ |

そのため `--model` を明示していないセッション(= CLI の既定に任せている)のモデル欄が
一覧で空のままだった。`--model` を渡した場合は `Session.consume()` が渡した値をそのまま
表示する(#109)ので、埋まらないのは**既定で動いているとき**だけ。

対策は rollout を読むこと(`core/codex-rollout.ts` = 純粋な抽出 / `utils/codex.ts` の
`resolveCodexRolloutModel` = 探索と読み出し):

- 置き場所は `$CODEX_HOME`(既定 `~/.codex`)の
`sessions/<年>/<月>/<日>/rollout-<時刻>-<thread_id>.jsonl`。ファイル名の**末尾が
`thread.started` の `thread_id`** なので、時刻部分(ローカル時刻)は当てにせず id で突き合わせる。
- 1 ファイルは実測**平均 1MB 超**(`session_meta` が `base_instructions` 全文を 1 行で運ぶ)。
`turn_context` はその数行あとに来るので**先頭 512KB だけ**読む。
- `turn_context` は**ターン開始時**に書かれるので、`thread.started` の直後にはまだ無いことが
ある。数回だけ間を置いて読み直す(実測では当日ディレクトリに当たって **4ms** で解決した)。
一度見つけたパスは使い回す(追記されるだけで移動しないので、リトライは同じ 1 ファイルの
読み直しで済み、走査を待ち時間のたびに繰り返さない)。
- **探索を日数で打ち切らない。** `codex exec resume` は**スレッドを開始した日**の rollout へ
追記し続ける一方、codiva のセッションは `state.json` に無期限で残って何日も後に復元される。
「新しい日付から N 個まで」で切ると、その間に別セッションを作っただけで対象が範囲から外れ、
モデル欄が二度と埋まらない(暦日ではなく**存在する日付ディレクトリ数**なので、使うほど早く
当たる)。代わりに**新しい順に見て最初に当たった時点で止める**ことで実費を抑え、全日付を
舐めるのは「本当に無い」ときだけにする(その繰り返しは探索回数の上限で抑える)。
- **カタログ先頭(priority 1)を既定とみなす手は採らない。** 実測では確かに
priority 1 = `gpt-5.6-sol` = 実際の既定だったが、`~/.codex/config.toml` で `model` を
設定しているユーザーには**嘘のモデル名**を出すことになる。
- 読めなければ黙って諦める(モデル欄が空のままになるだけ)。`--ephemeral` 実行や
レイアウト変更で取れなくてもセッションは壊さない。

報告は専用の中立イベント **`model_resolved`**(`core/agent-events.ts`)で行う。
`session_started` / `assistant_message` にも `model` は載るが、あちらは「ターンが動いている」
区切りでもあり `status` を `running` へ戻す。問い合わせの答えは**ターンが終わったあとに
届くことがある**ので、あれに相乗りさせると完了したセッションが `running` に巻き戻る。

### フィクスチャの採取: モック Responses API を立てて実バイナリを走らせる

Codex の JSONL には上流に採取済みのフィクスチャが無く、実アカウントで走らせると
Expand Down
4 changes: 4 additions & 0 deletions src/bootstrap/build-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
lookupPrs,
markPrReady,
notify,
resolveCodexRolloutModel,
saveRepoPrompt,
spawnCodex,
spawnLogin,
Expand Down Expand Up @@ -89,6 +90,9 @@ export function buildAgents(
networkAccess: config.codexNetworkAccess,
generateTitle,
checkAvailability: () => detectCodexAvailability(),
// `codex exec --json` は解決済みモデルを運ばない。既定モデルで動いている
// セッションのモデル名は rollout の `turn_context` からしか分からない。
resolveModel: (threadId) => resolveCodexRolloutModel(threadId),
spawnLogin,
}),
};
Expand Down
34 changes: 34 additions & 0 deletions src/core/agent-events.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,40 @@ describe('applyAgentEvent / session_started', () => {
});
});

describe('applyAgentEvent / model_resolved', () => {
// Codex は解決済みモデルをストリームで運ばないので、別経路で調べた結果が
// **ターンが終わったあとに**届くことがある。だから status を触らせない。
it('fills in the model without touching the status of a finished session', () => {
const finished = running({ status: 'completed', finishedAt: 5 });
const s = applyAgentEvent(finished, { kind: 'model_resolved', model: 'gpt-5.6-sol' }, 9);
expect(s.model).toBe('gpt-5.6-sol');
expect(s.status).toBe('completed');
expect(s.finishedAt).toBe(5);
});

it.each(['running', 'awaiting_input', 'interrupted', 'failed'] as const)(
'leaves %s alone',
(status) => {
const s = applyAgentEvent(running({ status }), { kind: 'model_resolved', model: 'm' }, 1);
expect(s.status).toBe(status);
expect(s.model).toBe('m');
},
);

it('returns the same object when the model is already known (no needless repaint)', () => {
const known = running({ model: 'gpt-5.6-sol' });
expect(applyAgentEvent(known, { kind: 'model_resolved', model: 'gpt-5.6-sol' }, 1)).toBe(known);
});

it('lets a later explicit choice win (last write, not first)', () => {
const s = fold(running(), [
{ kind: 'model_resolved', model: 'gpt-5.6-sol' },
{ kind: 'assistant_message', model: 'gpt-5.4-mini' },
]);
expect(s.model).toBe('gpt-5.4-mini');
});
});

describe('applyAgentEvent / log attribution', () => {
it('stamps the agent on log lines only when one is supplied', () => {
const withAgent = fold(running(), [{ kind: 'assistant_text', text: 'hi' }], 1, 'codex');
Expand Down
14 changes: 14 additions & 0 deletions src/core/agent-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ export type AgentEvent =
* `assistant_text` / `tool_use` が運ぶ。
*/
| { kind: 'assistant_message'; model?: string }
/**
* 解決済みモデルが**あとから**分かった。ストリームがモデル名を運ばない provider
* (Codex)が、別経路で調べた結果を報告するための専用イベント。
*
* `session_started` / `assistant_message` にも `model` は載るが、あちらは
* 「ターンが動いている」ことを表す区切りでもあるため `status` を `running` に
* 戻してしまう。到着順が読めない非同期の問い合わせ結果をあれに相乗りさせると、
* 完了したセッションが `running` に巻き戻る。こちらは**モデル欄だけ**を触る。
*/
| { kind: 'model_resolved'; model: string }
| { kind: 'assistant_text'; text: string; timestamp?: number }
| {
kind: 'tool_use';
Expand Down Expand Up @@ -270,6 +280,10 @@ export function applyAgentEvent(
};
}

// 状態は動かさない(順序に依存しないので、ターンが終わったあとに届いても安全)。
case 'model_resolved':
return state.model === event.model ? state : { ...state, model: event.model };

case 'assistant_message': {
const model = event.model ?? state.model;
const status = state.pendingPermission ? state.status : 'running';
Expand Down
Loading
Loading