diff --git a/apps/cli/ai/sessions/context.ts b/apps/cli/ai/sessions/context.ts index b88e101f84..4eb2fa0ed4 100644 --- a/apps/cli/ai/sessions/context.ts +++ b/apps/cli/ai/sessions/context.ts @@ -1,4 +1,4 @@ -import { isAiModelId, type AiModelId } from '@studio/common/ai/models'; +import { resolveSessionModel, type AiModelId } from '@studio/common/ai/models'; import { isStudioCustomEntryOfType } from '@studio/common/ai/sessions/entry-types'; import { AI_PROVIDERS, type AiProviderId } from 'cli/ai/providers'; import type { LoadedAiSession } from '@studio/common/ai/sessions/types'; @@ -27,31 +27,21 @@ export function resolveResumeSessionContext( context.sessionId = resumeSession.summary.id; } + // Shared resolution: the most recent recorded model wins, and a removed + // model auto-switches to the default so resumed sessions never pin a + // model we no longer offer. + context.model = resolveSessionModel( resumeSession.entries ); + for ( let index = resumeSession.entries.length - 1; index >= 0; index -= 1 ) { const entry = resumeSession.entries[ index ]; - if ( ! context.model && entry.type === 'model_change' ) { - const modelId = ( entry as { modelId?: unknown } ).modelId; - if ( typeof modelId === 'string' && isAiModelId( modelId ) ) { - context.model = modelId; - } - } - if ( isStudioCustomEntryOfType( entry, 'studio.session_context' ) ) { const data = entry.data; - if ( data ) { - if ( ! context.provider && isAiProviderId( data.provider ) ) { - context.provider = data.provider; - } - if ( ! context.model && isAiModelId( data.model ) ) { - context.model = data.model; - } + if ( data && isAiProviderId( data.provider ) ) { + context.provider = data.provider; + break; } } - - if ( context.sessionId && context.provider && context.model ) { - break; - } } return context; diff --git a/tools/common/ai/models.ts b/tools/common/ai/models.ts index 4cd20b8d86..8c8e7cd72a 100644 --- a/tools/common/ai/models.ts +++ b/tools/common/ai/models.ts @@ -20,8 +20,8 @@ export interface AiModel { // reasoning turns. export const AI_MODELS = [ { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', family: 'anthropic' }, - { id: 'claude-opus-4-6', label: 'Opus 4.6', family: 'anthropic' }, { id: 'claude-opus-4-7', label: 'Opus 4.7', family: 'anthropic' }, + { id: 'claude-opus-4-8', label: 'Opus 4.8', family: 'anthropic' }, { id: 'gpt-5.5', label: 'GPT 5.5', family: 'openai' }, ] as const satisfies readonly AiModel[]; @@ -58,26 +58,40 @@ export function getAiModelLabel( id: AiModelId ): string { return getAiModel( id ).label; } +/** + * Read the raw model id recorded on a single session entry, if any. + * + * Looks at the `model_change` entry the UI writes when the user picks a model + * and the `studio.session_context` payload the CLI records per turn. Returns + * the stored string verbatim (no validation) so callers can distinguish "no + * model recorded" from "a model we no longer offer". + */ +function readEntryModelId( entry: SessionEntry ): string | undefined { + if ( entry.type === 'model_change' ) { + const modelId = ( entry as { modelId?: unknown } ).modelId; + return typeof modelId === 'string' ? modelId : undefined; + } + if ( isStudioCustomEntryOfType( entry, 'studio.session_context' ) ) { + const model = entry.data?.model; + return typeof model === 'string' ? model : undefined; + } + return undefined; +} + /** * Derive the current model for a session from its pi entries. * - * Prefers the most recent `model_change` entry (written when the user picks - * a model from the UI) over the `studio.session_context` `customType` - * payload that the CLI records per turn. Falls back to `DEFAULT_MODEL` for - * sessions that have neither — e.g. a brand-new session before the first - * turn runs. + * The most recently recorded model wins. If it names a model we no longer + * offer (e.g. one that was removed from `AI_MODELS`), the session + * auto-switches to `DEFAULT_MODEL` rather than pinning a dead id. Sessions + * that recorded no model — e.g. a brand-new session before the first turn + * runs — also fall back to `DEFAULT_MODEL`. */ export function resolveSessionModel( entries: SessionEntry[] ): AiModelId { for ( let index = entries.length - 1; index >= 0; index -= 1 ) { - const entry = entries[ index ]; - if ( entry.type === 'model_change' ) { - const modelId = ( entry as { modelId?: unknown } ).modelId; - if ( typeof modelId === 'string' && isAiModelId( modelId ) ) return modelId; - } - if ( isStudioCustomEntryOfType( entry, 'studio.session_context' ) ) { - const model = entry.data?.model; - if ( typeof model === 'string' && isAiModelId( model ) ) return model; - } + const recordedModel = readEntryModelId( entries[ index ] ); + if ( recordedModel === undefined ) continue; + return isAiModelId( recordedModel ) ? recordedModel : DEFAULT_MODEL; } return DEFAULT_MODEL; }