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
5 changes: 5 additions & 0 deletions .claude/rules/sdk-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@
報告は **`model_resolved`**(`core/agent-events.ts`)で行う。答えはターンが終わったあとに
届くことがあり、`session_started` / `assistant_message` に相乗りさせると `status` が
`running` に巻き戻るため、**モデル欄だけを触る専用イベント**を通す。
**その答えが「いつ書かれるか」も実測で確かめる。** Codex は `thread.started` から
約 3 秒遅れて rollout に書くので、直後に少し待つだけの問い合わせは必ず空振りする。
だから「ターン中に長い猶予で 1 本(誰も await しない)+ **ターンが終わってから短い猶予で
引き直す**(そこでは必ず書かれている)」の 2 段にする — 前者だけだと、一度指示して結果を
待つだけのセッション(いちばん普通の使い方)のモデル欄が永久に埋まらない。
- **CLI は同梱せず、ユーザーがインストールしたものを起動する**(`git` / `gh` と同じ扱い)。
provider の SDK パッケージを依存に足すと、その provider を使わないユーザーにまで
プラットフォーム別バイナリを配ることになる。認証もその CLI のログインに委ねる
Expand Down
28 changes: 24 additions & 4 deletions docs/TECH_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -1063,16 +1063,36 @@ Claude の `system/init` に当たるもの(**実際に動いているモデ
`thread.started` の `thread_id`** なので、時刻部分(ローカル時刻)は当てにせず id で突き合わせる。
- 1 ファイルは実測**平均 1MB 超**(`session_meta` が `base_instructions` 全文を 1 行で運ぶ)。
`turn_context` はその数行あとに来るので**先頭 512KB だけ**読む。
- `turn_context` は**ターン開始時**に書かれるので、`thread.started` の直後にはまだ無いことが
ある。数回だけ間を置いて読み直す(実測では当日ディレクトリに当たって **4ms** で解決した)。
一度見つけたパスは使い回す(追記されるだけで移動しないので、リトライは同じ 1 ファイルの
- **`turn_context` は「ターン開始時」ではなく数秒遅れて書かれる**。実バイナリを走らせて
計測した順序(0.147.0、`Reply with just the word OK.` の 1 ターン):

| 時刻(`thread.started` 基準) | 起きること |
|---|---|
| +0.0s | stdout に `thread.started` |
| ~+0.2s | rollout ファイルが作られる(`session_meta` = `base_instructions` 全文が先に書かれる) |
| **~+3.0s** | **`turn_context`(モデル slug)が書かれる**(入力 `response_item` と `world_state` のあと) |
| +7.2s | `turn.completed` |

当初は「200ms × 6 回 = 最長 1.2 秒」(しかもファイル探索の上限で実質 0.6 秒)で諦めていたため、
**既定モデルで動く Codex セッションのモデル欄はほぼ必ず空のまま**だった(#121 で入れた仕組みが
実際には一度も答えを拾えていなかった)。読み直しの猶予は実測の数倍取り(既定 20 秒・間隔は
指数バックオフ)、**探索回数の上限は置かない**(猶予そのものが上限。ファイルが作られる前に
張った問い合わせを、出現を待てずに諦めさせないため)。
一度見つけたパスは使い回す(追記されるだけで移動しないので、読み直しは同じ 1 ファイルの
読み直しで済み、走査を待ち時間のたびに繰り返さない)。
- **本命は「ターンが終わったあとの引き直し」**。上の遅れがあるため、ターン中に張った問い合わせは
短いターンだと空振りしうる。一方**ターンが終わった時点なら必ず書かれている**ので、終端イベントを
流したあとに短い猶予(1.5 秒)で聞き直す(`core/codex-adapter.ts`)。ここが無いと 1 ターンで
idle になったセッション(= 一度指示して結果を待つ、いちばん普通の使い方)のモデル欄が永久に
空のままになる。ターン中の問い合わせは**誰も await しない**ので猶予を長く取れる代わりに、
終了後の引き直しだけは**待つ**ので短くする(取れない環境でターンの合間を引き止めない)。
- **探索を日数で打ち切らない。** `codex exec resume` は**スレッドを開始した日**の rollout へ
追記し続ける一方、codiva のセッションは `state.json` に無期限で残って何日も後に復元される。
「新しい日付から N 個まで」で切ると、その間に別セッションを作っただけで対象が範囲から外れ、
モデル欄が二度と埋まらない(暦日ではなく**存在する日付ディレクトリ数**なので、使うほど早く
当たる)。代わりに**新しい順に見て最初に当たった時点で止める**ことで実費を抑え、全日付を
舐めるのは「本当に無い」ときだけにする(その繰り返しは探索回数の上限で抑える)。
舐めるのは「本当に無い」ときだけにする(その繰り返しは猶予とバックオフ、および
「空振りしてよい回数」の予算で抑える)。
- **カタログ先頭(priority 1)を既定とみなす手は採らない。** 実測では確かに
priority 1 = `gpt-5.6-sol` = 実際の既定だったが、`~/.codex/config.toml` で `model` を
設定しているユーザーには**嘘のモデル名**を出すことになる。
Expand Down
3 changes: 2 additions & 1 deletion src/bootstrap/build-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ export function buildAgents(
checkAvailability: () => detectCodexAvailability(),
// `codex exec --json` は解決済みモデルを運ばない。既定モデルで動いている
// セッションのモデル名は rollout の `turn_context` からしか分からない。
resolveModel: (threadId) => resolveCodexRolloutModel(threadId),
// `waitMs` はアダプタが決める(ターン中は長く、ターン終了後の引き直しは短く)。
resolveModel: (threadId, waitMs) => resolveCodexRolloutModel(threadId, { waitMs }),
spawnLogin,
}),
};
Expand Down
55 changes: 45 additions & 10 deletions src/core/codex-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,8 @@ describe('createCodexAdapter resolveModel', () => {
});
const adapter = createCodexAdapter({
spawn: codex.spawn,
resolveModel: (threadId) => {
seen.push(threadId);
resolveModel: (threadId, waitMs) => {
seen.push(`${threadId}:${waitMs > 5_000 ? 'long' : 'short'}`);
return pending;
},
});
Expand All @@ -414,7 +414,9 @@ describe('createCodexAdapter resolveModel', () => {

answer('gpt-5.6-sol');
await waitFor(() => events.some((e) => e.kind === 'model_resolved'), 'the resolved model');
expect(seen).toEqual(['th-1']);
// ターン中は長い猶予で聞き(誰も待たない)、ターン終了後は短い猶予で引き直す
// (そこでは既に書かれているので当たれば即返る)。
expect(seen).toEqual(['th-1:long', 'th-1:short']);
expect(events.find((e) => e.kind === 'model_resolved')).toEqual({
kind: 'model_resolved',
model: 'gpt-5.6-sol',
Expand Down Expand Up @@ -553,11 +555,41 @@ describe('createCodexAdapter resolveModel', () => {
expect(events.some((e) => e.kind === 'model_resolved')).toBe(false);
});

// **これが本命の回帰テスト。** CLI が解決済みモデルを書き出すのは `thread.started` から
// 約 3 秒後(実測 0.147.0)なので、ターン中に張った問い合わせは空振りしうる。ターンが
// 終わった時点なら必ず書かれているので、そこで引き直せば当たる。これが無いと
// 「Codex でセッションを始めてもモデル欄が空のまま」になる(実際にそうなっていた)。
it('re-asks at the end of the turn when the in-turn lookup was too early', async () => {
const codex = makeFakeCodex();
const answers: (string | undefined)[] = [undefined, 'gpt-5.6-sol'];
let asked = 0;
const adapter = createCodexAdapter({
spawn: codex.spawn,
resolveModel: async () => answers[asked++],
});
const { prompts, events, done } = drive(adapter);

// 1 ターンで終わって idle になるセッション(次の指示は来ない)。
prompts.push('only turn');
await waitFor(() => codex.requests.length === 1, 'the spawn');
codex.at(0).emit(threadStarted('th-1'));
codex.at(0).emit(turnCompleted);
codex.at(0).end();
await waitFor(() => events.some((e) => e.kind === 'model_resolved'), 'the tail lookup');
expect(asked).toBe(2);
const kinds = events.map((e) => e.kind);
expect(kinds.indexOf('model_resolved')).toBeGreaterThan(kinds.indexOf('turn_completed'));

prompts.close();
await done;
});

// 空振り(`turn_context` がまだ書かれていない)を記憶してしまうと、次のターンなら
// すぐ読めるのに二度と引き直さないセッションになる。
it('retries on the next turn when the rollout was not readable yet', async () => {
const codex = makeFakeCodex();
const answers: (string | undefined)[] = [undefined, 'gpt-5.6-sol'];
// ターン 1 は 2 回(ターン中 + 終了後)とも空振りし、ターン 2 で読めるようになる。
const answers: (string | undefined)[] = [undefined, undefined, 'gpt-5.6-sol'];
let asked = 0;
const adapter = createCodexAdapter({
spawn: codex.spawn,
Expand All @@ -579,7 +611,7 @@ describe('createCodexAdapter resolveModel', () => {
codex.at(1).emit(turnCompleted);
codex.at(1).end();
await waitFor(() => events.some((e) => e.kind === 'model_resolved'), 'the retry to answer');
expect(asked).toBe(2);
expect(asked).toBe(3);

prompts.close();
await done;
Expand Down Expand Up @@ -609,7 +641,9 @@ describe('createCodexAdapter resolveModel', () => {
`turn ${turn} to end`,
);
}
expect(asked).toBe(3);
// 予算は 4 回ぶん(1 ターンにつきターン中 + 終了後の 2 回)。5 ターン回しても
// それ以上は探しに行かない。
expect(asked).toBe(4);

prompts.close();
await done;
Expand All @@ -626,8 +660,8 @@ describe('createCodexAdapter resolveModel', () => {
spawn: codex.spawn,
resolveModel: async () => {
asked += 1;
// 最初の 3 回(= 上限ぶん)は空振り、そのあとは読める。
return asked > 3 ? 'gpt-5.6-sol' : undefined;
// 最初の 4 回(= 上限ぶん)は空振り、そのあとは読める。
return asked > 4 ? 'gpt-5.6-sol' : undefined;
},
});
const { run, prompts, events, done } = drive(adapter);
Expand All @@ -644,18 +678,19 @@ describe('createCodexAdapter resolveModel', () => {
);
};

// 2 ターン(= 4 回の問い合わせ)で予算を使い切る。3 ターン目は探しに行かない。
for (let i = 0; i < 3; i += 1) {
await turn(i);
}
expect(asked).toBe(3);
expect(asked).toBe(4);
expect(events.some((e) => e.kind === 'model_resolved')).toBe(false);

// 明示モデルを選び、また既定へ戻す。
await run.setModel?.('gpt-5.4-mini');
await run.setModel?.(undefined);
await turn(3);

expect(asked).toBe(4);
expect(asked).toBe(5);
expect(events.find((e) => e.kind === 'model_resolved')).toEqual({
kind: 'model_resolved',
model: 'gpt-5.6-sol',
Expand Down
Loading
Loading