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
28 changes: 9 additions & 19 deletions apps/cli/ai/sessions/context.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down
44 changes: 29 additions & 15 deletions tools/common/ai/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

Expand Down Expand Up @@ -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;
}