Skip to content

Commit 5c98aff

Browse files
authored
fix: モデル ID の綴り違いで現在のモデルが一致しない問題を修正 (#60)
#59 のフォローアップ(コードレビューで見つかった回帰の修正)。 ## 主題: 突き合わせが素の文字列比較だった 同一モデルが出所によって違う綴りで来るのに、`isCurrentModel` が素の `===` で比較していました。 | 出所 | 例 | |---|---| | セッションが報告する解決済みモデル(`system/init`) | `claude-opus-4-8` | | カタログの `resolvedModel` | `claude-opus-4-8[1m]` | | カタログの `resolvedModel`(日付付き) | `claude-haiku-4-5-20251001` | | 設定 / カタログの `value`(エイリアス) | `opus`, `opus[1m]` | 実 fixture(`src/core/__fixtures__/*.jsonl`)で確認: ``` 37 "model":"claude-opus-4-8" 1 "model":"claude-opus-4-8[1m]" ``` 一方、実カタログの Opus 行は `value: 'opus[1m]' / resolvedModel: 'claude-opus-4-8[1m]'`。**どの行にも `claude-opus-4-8` は無い** ため: - 詳細ビューで `/model` を開くと **✔ がどこにも付かず、カーソルが既定行に落ちる** - そのまま Enter(= 現在のモデルを確定する自然な操作。`model-select.tsx` のコメントも "Enter without moving is a no-op" と書いている)を押すと `setSessionModel(id, undefined)` になり、**ユーザーの選択が黙って破棄される** 旧実装は `MODELS` に `claude-opus-4-8` を直書きしていて偶然一致していたので、これは #59 で入れた回帰です。`~/.codiva/config.json` に旧 codiva が保存した `claude-haiku-4-5` も同様に一致していませんでした(カタログ側は `claude-haiku-4-5-20251001`)。 ### 修正 `normalizeModelId` でコンテキストタグ(`[1m]`)と末尾の日付スナップショット(`-20251001`)を落として比較します。ファミリー・バージョンの数字は残すので `claude-haiku-4-5` と `claude-haiku-3` は別物のままです。 テストは **RED を確認してから** 実装しました(3 failed → 29 passed)。実 SDK でも確認済み: ``` rows: default, opus[1m], claude-fable-5[1m], sonnet, haiku session 'claude-opus-4-8' -> row 1 (opus[1m]) check=true # 修正前は row 0 (default) unset -> row 0 (default) ``` `tests/app.test.tsx` のカタログ fixture も実測どおり `[1m]` 付きに直しました。タグ無しの形にしていたため、**テストが構造的にこの不一致を検出できない状態** でした(セッション側もタグ無しなので偶然一致していた)。 ## あわせて修正したレビュー指摘 - **取得中に `/exit` するとシェルのプロンプトが最大 10 秒返らない** タイムアウトのタイマーが `unref()` されておらず、サブプロセスもプロセスを生かし続けていました。TUI は消えているのにプロンプトが戻らない状態です。タイマーを `unref()` し、`opts.signal` で外から打ち切れるようにして合成ルートの終了処理で abort します。 - **タイムアウトが SDK の内部挙動に依存していた** abort 時に SDK が必ず reject することに頼っていたため、将来 SDK が abort を飲み込むと「モデル一覧を取得中…」で固まります。`Promise.race` で自前決着させました(遅れて届く rejection は unhandled にしない)。 - **`idlePrompt` が `.return()` を完了できない** 決して解決しない Promise で待っていたため、`for await … break` する消費者が来ると永久に待ちます。abort で待ちが解けるようにしました。 - **フォールバック一覧の「デフォルト」行が英語のままだった** モデル名は SDK 由来の英語をそのまま出す方針ですが、この行は codiva 自身の概念(= `--model` を渡さない)なので `model.defaultRow` としてカタログから引きます。オフライン時に日本語表示が保たれます。 - **doc drift**: `docs/ARCHITECTURE.md` が消えた `MODELS` を参照していたのを更新。 ## 既知の割り切り フォールバック一覧に **Fable は入れていません**。カタログでの `value` が `claude-fable-5[1m]` でありエイリアスが存在しないため、入れるとバージョン付き ID の直書き(= 今回避けたかった陳腐化)に戻ります。フォールバックは取得失敗時のみの経路なので、`default` / `opus` / `sonnet` / `haiku` のエイリアスだけに留めています。 ## テスト - `npm run lint` / `npm run typecheck` / `npm run build`: pass - `npm test`: **918 passed** - 追加: タグ/日付/エイリアスの突き合わせ表(`core/models.spec.ts`)、タイムアウト自前決着と外部 abort(`utils/model-catalog.spec.ts`) ### 手動確認 - [x] 実 SDK のカタログで `claude-opus-4-8` が Opus 行に一致する - [x] 取得後に node が自然終了する(サブプロセス・タイマーのリークなし) - [ ] TUI 上で Opus セッションの `/model` を開き ✔ が Opus に付くこと - [ ] 取得中に `/exit` してプロンプトが即座に返ること
1 parent ed324bd commit 5c98aff

10 files changed

Lines changed: 190 additions & 29 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ interface SessionState {
210210
- `dispose()`: 全セッションを **`stop()`(quiet)**(worktree は残す)。実行中でも resumable なまま。
211211
- `onTransition(prev,next)`: ステータス遷移ごとに発火(デスクトップ通知に配線)。
212212
- `onPersist()`: 永続対象が変わった合図(合成ルートで debounce 保存に配線)。`persistableState()` が state.json 用スナップショットを組み立てる。
213-
- **モデル切替(`/model`**: `SessionOptions` を可変フィールドとして保持し、`getModel()` / `setModel(model)` で公開。`setModel`**以降の新規セッション**に適用(実行中セッションは起動時のモデルを維持)し、`onModelChange(model)` で合成ルートに通知 → `~/.codiva/config.json``model` にマージ保存される。選択肢は `core/models.ts``MODELS`)、コマンド解析は `core/commands.ts``parseSlashCommand`)。
213+
- **モデル切替(`/model`**: `SessionOptions` を可変フィールドとして保持し、`getModel()` / `setModel(model)` で公開。`setModel`**以降の新規セッション**に適用(実行中セッションは起動時のモデルを維持)し、`onModelChange(model)` で合成ルートに通知 → `~/.codiva/config.json``model` にマージ保存される。選択肢は **Claude Code のカタログ**`Query.supportedModels()`)を唯一の出所にし、取得は `utils/model-catalog.ts``fetchModelCatalog`)・変換と突き合わせは `core/models.ts``toModelOptions` / `isCurrentModel`)が担う(詳細は [TECH_NOTES.md](./TECH_NOTES.md) の supportedModels 節)。コマンド解析は `core/commands.ts``parseSlashCommand`)。
214214
- **リポジトリ追加指示の編集(`/prompt`**: モデル切替と同じ形。`getRepoPrompt()` / `setRepoPrompt(text)``SessionOptions.appendSystemPrompt` を可変管理し、`setRepoPrompt`**以降の新規セッション**に適用(実行中セッションは起動時の指示を維持。systemPrompt は query 開始時に確定するため)、`onRepoPromptChange(text)` で合成ルートに通知 → `utils/saveRepoPrompt()``<repo>/.codiva/prompt.md` へ永続化(空なら削除)。UI は一覧の `/prompt``ui/repo-prompt-editor.tsx`(現在値をシードしたモーダル。Enter 保存 / Shift+Enter 改行 / Esc 取消。composer と同じ `input.ts` の chord モデル)を開く。起動時読込は従来どおり `loadRepoPrompt()`
215215
- `restore(persisted)`: 起動時に前回セッションを再構築(worktree meta を再配線し、`Session``resume`/`restored` を渡す。id/slug を予約して衝突回避)。
216216
- **責務分割**: SessionManager はライフサイクルと配線のファサードで、以下を委譲する:

src/core/i18n.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ export interface Messages {
103103
help: string;
104104
/** カタログ取得中のプレースホルダ */
105105
loading: string;
106+
/**
107+
* 「CLI 既定を使う」行のラベル。モデル名と違いこれは codiva 自身の概念
108+
* (= `--model` を渡さない)なので、SDK の英語ではなくカタログから引く。
109+
*/
110+
defaultRow: string;
106111
/** 選択確定後のフッタ通知(name は選んだモデルの表示名) */
107112
saved: (name: string) => string;
108113
};
@@ -269,6 +274,7 @@ const ja: Messages = {
269274
title: 'モデルを選択',
270275
help: '↑↓: 選択 ・ Enter: 決定 ・ Esc: キャンセル',
271276
loading: 'モデル一覧を取得中…',
277+
defaultRow: 'デフォルト(推奨)',
272278
saved: (name) => `モデルを ${name} に変更しました(以降の新規セッションに適用)`,
273279
},
274280
prompt: {
@@ -399,6 +405,7 @@ const en: Messages = {
399405
title: 'Select model',
400406
help: '↑↓: select · Enter: confirm · Esc: cancel',
401407
loading: 'Loading models…',
408+
defaultRow: 'Default (recommended)',
402409
saved: (name) => `Model set to ${name} (applies to new sessions)`,
403410
},
404411
prompt: {

src/core/models.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,28 @@ describe('isCurrentModel', () => {
148148
expect(isCurrentModel(row('sonnet'), 'claude-haiku-4-5')).toBe(false);
149149
});
150150

151+
// The SDK reports a session's resolved model WITHOUT the context tag
152+
// (`claude-opus-4-8`, per src/core/__fixtures__/*.jsonl) while the catalog rows
153+
// carry it (`claude-opus-4-8[1m]`). An exact compare misses, leaving the picker
154+
// with no ✔ and the caret on "Default" — so Enter would wipe the user's choice.
155+
it('matches a session-reported id against a catalog row carrying a [1m] tag', () => {
156+
expect(isCurrentModel(row('opus[1m]'), 'claude-opus-4-8')).toBe(true);
157+
});
158+
159+
it('matches a bare id against a dated-snapshot resolvedModel', () => {
160+
// Catalog: 'claude-haiku-4-5-20251001'; config/session: 'claude-haiku-4-5'.
161+
expect(isCurrentModel(row('haiku'), 'claude-haiku-4-5')).toBe(true);
162+
});
163+
164+
it('matches a tagged session id against an aliased row value', () => {
165+
expect(isCurrentModel(row('opus[1m]'), 'opus')).toBe(true);
166+
});
167+
168+
it('still rejects a different family sharing the tag shape', () => {
169+
expect(isCurrentModel(row('opus[1m]'), 'claude-sonnet-5')).toBe(false);
170+
expect(isCurrentModel(row('haiku'), 'claude-haiku-3')).toBe(false);
171+
});
172+
151173
it('never marks the default row for an explicitly configured model', () => {
152174
// The SDK gives the default row a resolvedModel too ('claude-opus-4-8[1m]').
153175
// Matching on it would show an explicit Opus choice as "Default".

src/core/models.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export interface ModelOption {
4040
* **バージョンを含む ID は置かない**(それが陳腐化の原因なので)。Claude Code が
4141
* カタログの `value` にも使うファミリーエイリアスだけを並べる。エイリアスは
4242
* 常に現行世代へ解決されるため、モデルが更新されても古びない。
43+
*
44+
* 既定行の `displayName` は表示に使われない(UI がカタログの `model.defaultRow` を
45+
* 引くため)。ここに置いてあるのは型を満たすためだけの不活性な値。
4346
*/
4447
export const FALLBACK_MODEL_OPTIONS: readonly ModelOption[] = [
4548
{ value: DEFAULT_MODEL_VALUE, displayName: 'Default' },
@@ -108,12 +111,38 @@ export function toConfigModel(value: string): string | undefined {
108111
}
109112

110113
/**
111-
* 選択肢が現在の設定モデルを指しているか。
114+
* モデル ID を突き合わせ用に正規化する。
115+
*
116+
* 同じモデルが出所によって違う綴りで来るため、素の文字列比較では一致しない:
117+
*
118+
* | 出所 | 例 |
119+
* |---|---|
120+
* | セッションが報告する解決済みモデル(`system/init`) | `claude-opus-4-8` |
121+
* | カタログの `resolvedModel` | `claude-opus-4-8[1m]` |
122+
* | カタログの `resolvedModel`(日付付き) | `claude-haiku-4-5-20251001` |
123+
* | 設定 / カタログの `value`(エイリアス) | `opus`, `opus[1m]` |
124+
*
125+
* コンテキストタグ(`[1m]`)と末尾の日付スナップショットを落として比較する。
126+
* ファミリーやバージョンの数字は残すので `claude-haiku-4-5` と `claude-haiku-3` は
127+
* 別物として扱われる。
128+
*/
129+
function normalizeModelId(id: string): string {
130+
return id
131+
.replace(/\[[^\]]*\]/g, '')
132+
.replace(/-\d{8}$/, '')
133+
.trim()
134+
.toLowerCase();
135+
}
136+
137+
/**
138+
* 選択肢が現在のモデル(設定値、またはセッションが報告した解決済みモデル)を
139+
* 指しているか。
112140
*
113-
* `value` だけでなく `resolvedModel` も見るのは、設定に明示 ID
114-
* (`'claude-sonnet-5'`)が保存されていてもカタログ側はエイリアス行
115-
* (`value: 'sonnet'`)で来ることがあるため。これで旧バージョンの codiva が
116-
* 直書き ID で保存した設定もそのまま現行の行に一致する。
141+
* `value` だけでなく `resolvedModel` も見るのは、明示 ID(`'claude-sonnet-5'`)が
142+
* 保存されていてもカタログ側はエイリアス行(`value: 'sonnet'`)で来ることがあるため。
143+
* 比較は `normalizeModelId` を通すので、`[1m]` タグや日付スナップショットの
144+
* 綴り違いでも一致する(ここを素の比較にすると ✔ が消え、カーソルが既定行に
145+
* 落ちて Enter がユーザーの選択を破棄する)。
117146
*/
118147
export function isCurrentModel(option: ModelOption, model: string | undefined): boolean {
119148
// 既定行は「未設定」専用。SDK は既定行にも `resolvedModel`(例 'claude-opus-4-8[1m]')
@@ -125,7 +154,14 @@ export function isCurrentModel(option: ModelOption, model: string | undefined):
125154
if (model === undefined) {
126155
return false;
127156
}
128-
return option.value === model || option.resolvedModel === model;
157+
const target = normalizeModelId(model);
158+
if (target.length === 0) {
159+
return false;
160+
}
161+
return (
162+
normalizeModelId(option.value) === target ||
163+
(option.resolvedModel !== undefined && normalizeModelId(option.resolvedModel) === target)
164+
);
129165
}
130166

131167
/**

src/index.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,13 @@ async function main(): Promise<void> {
7979
// 実測 0.3〜2 秒で、`/model` を開くまでにはほぼ確実に landing する(間に合わなければ
8080
// ダイアログが取得中を表示する)。失敗してもフォールバック一覧に落ちるだけで起動は
8181
// 妨げない(fetchModelCatalog は throw しない)。
82-
const modelCatalog = fetchModelCatalog(query, { cwd: repoRoot });
82+
// 終了時に取得を打ち切るためのハンドル(取得中に /exit されたときサブプロセスと
83+
// タイマーを残さない = シェルのプロンプトが返らない事故を防ぐ)。
84+
const catalogAbort = new AbortController();
85+
const modelCatalog = fetchModelCatalog(query, {
86+
cwd: repoRoot,
87+
signal: catalogAbort.signal,
88+
});
8389

8490
await restoreSessions(manager, statePath);
8591
const stopPrPolling = startPrPolling(manager);
@@ -108,6 +114,7 @@ async function main(): Promise<void> {
108114
// abort(), so in-flight sessions are still recorded as resumable), restore the
109115
// terminal (leave alt screen + mouse) so the shell history is intact.
110116
stopPrPolling();
117+
catalogAbort.abort();
111118
await persist.flushAsync();
112119
terminal.teardown();
113120
}

src/ui/model-select.spec.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ describe('ModelSelect', () => {
3333
);
3434
const frame = lastFrame() ?? '';
3535
expect(frame).toContain('モデルを選択');
36-
expect(frame).toContain('Default (recommended)');
36+
// Model names come from the SDK verbatim, but the "CLI default" row is
37+
// codiva's own concept and stays translated (see .claude/rules/i18n.md).
38+
expect(frame).toContain('デフォルト(推奨)');
39+
expect(frame).not.toContain('Default (recommended)');
3740
expect(frame).toContain('Opus');
3841
expect(frame).toContain('Sonnet');
3942
expect(frame).toContain('Haiku');

src/ui/model-select.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { Box, Text, useInput } from 'ink';
22
import { type FC, useState } from 'react';
3-
import { currentModelIndex, isCurrentModel, type ModelOption, toConfigModel } from '@/core';
3+
import {
4+
currentModelIndex,
5+
DEFAULT_MODEL_VALUE,
6+
isCurrentModel,
7+
type ModelOption,
8+
toConfigModel,
9+
} from '@/core';
410
import { useMessages } from './i18n-context';
511
import { glyph, theme } from './theme';
612

@@ -76,10 +82,15 @@ export const ModelSelect: FC<{
7682
) : (
7783
rows.map((choice, i) => {
7884
const active = i === cursor;
85+
// モデル名は SDK 由来(英語)をそのまま出すが、「CLI 既定」行は
86+
// codiva 自身の概念なのでカタログから引く(フォールバック一覧でも
87+
// 日本語表示が保たれる)。
88+
const label =
89+
choice.value === DEFAULT_MODEL_VALUE ? m.model.defaultRow : choice.displayName;
7990
return (
8091
<Box key={choice.value}>
8192
<Text color={active ? 'cyan' : undefined}>
82-
{active ? glyph.caret : ' '} {choice.displayName}
93+
{active ? glyph.caret : ' '} {label}
8394
{isCurrentModel(choice, current) ? ' ✔' : ''}
8495
</Text>
8596
{choice.description ? <Text dimColor>{choice.description}</Text> : null}

src/utils/model-catalog.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,43 @@ describe('fetchModelCatalog', () => {
6363
await expect(fetchModelCatalog(query, { cwd: '/repo' })).resolves.toEqual([]);
6464
});
6565

66+
it('gives up and returns [] when the SDK never answers (self-contained timeout)', async () => {
67+
vi.useFakeTimers();
68+
try {
69+
const spy = vi.fn();
70+
// Never resolves and never rejects — i.e. the SDK swallows the abort.
71+
const pending = fetchModelCatalog(
72+
fakeQuery(() => new Promise<unknown>(() => {}), spy),
73+
{ cwd: '/repo' },
74+
);
75+
await vi.advanceTimersByTimeAsync(10_000);
76+
await expect(pending).resolves.toEqual([]);
77+
expect(spy.mock.calls[0]?.[0]?.options.abortController?.signal.aborted).toBe(true);
78+
} finally {
79+
vi.useRealTimers();
80+
}
81+
});
82+
83+
it('stops early when the caller aborts (shutdown during the startup fetch)', async () => {
84+
const shutdown = new AbortController();
85+
const spy = vi.fn();
86+
const pending = fetchModelCatalog(
87+
fakeQuery(
88+
() =>
89+
new Promise<unknown>((_resolve, reject) => {
90+
// Mirror the SDK: aborting the query rejects the pending init.
91+
const signal = spy.mock.calls[0]?.[0]?.options.abortController?.signal;
92+
signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
93+
}),
94+
spy,
95+
),
96+
{ cwd: '/repo', signal: shutdown.signal },
97+
);
98+
shutdown.abort();
99+
await expect(pending).resolves.toEqual([]);
100+
expect(spy.mock.calls[0]?.[0]?.options.abortController?.signal.aborted).toBe(true);
101+
});
102+
66103
it('aborts even when the query throws', async () => {
67104
const spy = vi.fn();
68105
await fetchModelCatalog(

src/utils/model-catalog.ts

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,21 @@ export type CatalogQuery = (params: {
1616
supportedModels(): Promise<unknown>;
1717
};
1818

19-
/** 何も送らないプロンプト。カタログ取得は init のみで完結するため入力は不要。 */
20-
async function* idlePrompt(): AsyncGenerator<SDKUserMessage> {
21-
// 中断は abortController が行う。ここで解決しない Promise を待つことで
22-
// 「入力待ちのまま」= モデル推論を一切走らせない状態を保つ。
23-
await new Promise<never>(() => {});
19+
/**
20+
* 何も送らないプロンプト。カタログ取得は init のみで完結するため入力は不要で、
21+
* 待ち続けることで「入力待ちのまま」= モデル推論を一切走らせない状態を保つ。
22+
*
23+
* 待ちは abort で解ける。決して解決しない Promise にすると、この async generator は
24+
* `.return()` を完了できず、`for await … break` する消費者が永久に待つことになる。
25+
*/
26+
async function* idlePrompt(signal: AbortSignal): AsyncGenerator<SDKUserMessage> {
27+
await new Promise<void>((resolve) => {
28+
if (signal.aborted) {
29+
resolve();
30+
return;
31+
}
32+
signal.addEventListener('abort', () => resolve(), { once: true });
33+
});
2434
}
2535

2636
/**
@@ -30,18 +40,24 @@ async function* idlePrompt(): AsyncGenerator<SDKUserMessage> {
3040
* すぐ abort する。**モデル推論は走らないのでトークン消費もコストも無い**
3141
* (実測 0.3〜2 秒。設定・プラグインの読み込み量で変わる)。
3242
*
33-
* 失敗・タイムアウトでは投げずに空配列を返す。呼び出し側(合成ルート)が
34-
* `FALLBACK_MODEL_OPTIONS` へ落とすため、カタログが取れなくても /model は動く。
43+
* 失敗・タイムアウトでは投げずに空配列を返す。呼び出し側(`ui/hooks.ts` の
44+
* `useModelCatalog`)が `FALLBACK_MODEL_OPTIONS` へ落とすため、カタログが
45+
* 取れなくても /model は動く。
46+
*
47+
* `opts.signal` を渡すと取得を外から打ち切れる。合成ルートは終了時にこれを
48+
* abort する(取得中に終了されたときサブプロセスを残さないため)。
3549
*/
3650
export async function fetchModelCatalog(
3751
queryFn: CatalogQuery,
38-
opts: { cwd: string },
52+
opts: { cwd: string; signal?: AbortSignal },
3953
): Promise<ModelOption[]> {
4054
const abortController = new AbortController();
41-
const timer = setTimeout(() => abortController.abort(), CATALOG_TIMEOUT_MS);
55+
const abort = () => abortController.abort();
56+
opts.signal?.addEventListener('abort', abort, { once: true });
57+
let timer: ReturnType<typeof setTimeout> | undefined;
4258
try {
4359
const handle = queryFn({
44-
prompt: idlePrompt(),
60+
prompt: idlePrompt(abortController.signal),
4561
options: {
4662
cwd: opts.cwd,
4763
abortController,
@@ -50,12 +66,29 @@ export async function fetchModelCatalog(
5066
settingSources: ['project'],
5167
},
5268
});
53-
return toModelOptions(await handle.supportedModels());
69+
const catalog = handle.supportedModels();
70+
// タイムアウト勝ちの後に届いた rejection を unhandled にしない。
71+
catalog.catch(() => {});
72+
// タイムアウトは自前で決着させる(abort 時に SDK が必ず reject することに
73+
// 依存すると、SDK の内部挙動が変わったとき「取得中…」で固まる)。
74+
const timedOut = Symbol('timeout');
75+
const result = await Promise.race([
76+
catalog,
77+
new Promise<typeof timedOut>((resolve) => {
78+
timer = setTimeout(() => resolve(timedOut), CATALOG_TIMEOUT_MS);
79+
// 取得中に終了されても、このタイマーがプロセスを生かし続けないように。
80+
timer.unref?.();
81+
}),
82+
]);
83+
return result === timedOut ? [] : toModelOptions(result);
5484
} catch {
5585
return [];
5686
} finally {
57-
clearTimeout(timer);
58-
// 取得できたら即座にサブプロセスを畳む(常駐させない)。
87+
if (timer !== undefined) {
88+
clearTimeout(timer);
89+
}
90+
opts.signal?.removeEventListener('abort', abort);
91+
// どの経路でも即座にサブプロセスを畳む(常駐させない)。
5992
abortController.abort();
6093
}
6194
}

tests/app.test.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,13 @@ import {
2525
* picker is driven by injected data rather than a hardcoded list.
2626
*/
2727
const MODEL_CATALOG = [
28-
{ value: 'default', resolvedModel: 'claude-opus-4-8', displayName: 'Default (recommended)' },
29-
{ value: 'opus', resolvedModel: 'claude-opus-4-8', displayName: 'Opus' },
30-
{ value: 'claude-fable-5', resolvedModel: 'claude-fable-5', displayName: 'Fable' },
28+
{
29+
value: 'default',
30+
resolvedModel: 'claude-opus-4-8[1m]',
31+
displayName: 'Default (recommended)',
32+
},
33+
{ value: 'opus[1m]', resolvedModel: 'claude-opus-4-8[1m]', displayName: 'Opus' },
34+
{ value: 'claude-fable-5[1m]', resolvedModel: 'claude-fable-5', displayName: 'Fable' },
3135
];
3236

3337
describe('App fullscreen layout', () => {
@@ -776,8 +780,9 @@ describe('App detail view (in-app connection)', () => {
776780
await flush();
777781
expect(lastFrame()).toContain(messages.ja.model.title); // model picker open
778782

779-
// Rows come from the injected catalog (SDK display names), so the cursor starts
780-
// on the row whose resolvedModel matches the session's model (Opus).
783+
// Rows come from the injected catalog (SDK display names). The session reports
784+
// `claude-opus-4-8` while the catalog row is `claude-opus-4-8[1m]`, so this also
785+
// covers the tag-insensitive match — the cursor must start on Opus, not Default.
781786
stdin.write('\x1b[B'); // ↓ → Fable
782787
await flush();
783788
stdin.write('\r'); // confirm

0 commit comments

Comments
 (0)