import type { AgentSessionOptionCatalog, CatalogModel } from './agent-session-option-catalog' import { discoveredModel } from './structured-agent-session-discovered-model' import { buildNativeChatSessionOptionSnapshot, resolveEffectiveNativeChatModelId, withTrackedNativeChatModel } from './native-chat-session-option-snapshot' import { applyNativeChatReportedSessionOptions, clearTrackedSessionOption, createNativeChatSessionOptionRecord, setTrackedSessionOption, type NativeChatSessionOptionRecord } from './native-chat-session-option-state' import { STRUCTURED_LAUNCH_SEED_OPTION_IDS } from './native-chat-session-option-defaults' import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options' import type { AgentSessionModelCatalogResult, AgentSessionOptionsResult } from './agent-session-wire' import { decodeStructuredAgentSessionOptionValue, encodeStructuredAgentSessionOptionValue } from './structured-agent-session-option-codec' export function structuredAgentSessionOptionCatalog( seed: AgentSessionOptionCatalog, result: AgentSessionOptionsResult ): AgentSessionOptionCatalog { const models: CatalogModel[] = result.models.map((model) => discoveredModel(model, result.fastModeSupport?.supported === true) ) if (result.current.model && !models.some((model) => model.id === result.current.model)) { models.push({ id: result.current.model, label: result.current.model, options: seed.unknownModelOptions ?? [] }) } return { ...seed, models, defaultModelIsCliDefault: true } } export type StructuredAgentSessionOptionState = { catalog: AgentSessionOptionCatalog | null /** What produced `catalog`; a weaker source never replaces a stronger one. `seed` is the * built-in list before the host answered (shown as the quiet placeholder); `builtin` is the * same list once the host said it has none, usable but naming nothing the host may replace. */ catalogSource: 'seed' | 'builtin' | 'host' | 'live' | null record: NativeChatSessionOptionRecord pendingId: string | null } /** With `seedCatalog`, the state holds the static seed from the first frame, but the picker shows * the quiet placeholder until the host answers; every later source only upgrades it. */ export function createStructuredAgentSessionOptionState( agent = 'codex', seedCatalog?: AgentSessionOptionCatalog | null ): StructuredAgentSessionOptionState { return { catalog: seedCatalog ?? null, catalogSource: seedCatalog ? 'seed' : null, record: createNativeChatSessionOptionRecord(agent), pendingId: null } } /** The host answered with no list (none saved, a failed read, or an older host): the built-in * list becomes usable, still naming nothing until a host or session list does. */ export function settleStructuredAgentSessionBuiltinCatalog( state: StructuredAgentSessionOptionState ): StructuredAgentSessionOptionState { return state.catalogSource === 'seed' ? { ...state, catalogSource: 'builtin' } : state } /** * Applies the host's stored model catalog: models only, no current selection * and no record writes, so nothing here reads as a committed value — the pick * stays provisional until a live options result confirms it. A live catalog * is never downgraded by this. */ export function applyStructuredAgentSessionModelCatalog( state: StructuredAgentSessionOptionState, seed: AgentSessionOptionCatalog, catalog: AgentSessionModelCatalogResult, options: { newLaunch: boolean } ): StructuredAgentSessionOptionState { if (state.catalogSource === 'live') { return state } const models = catalog.origin === 'unknown' ? [] : catalog.models.map((model) => discoveredModel(model, catalog.fastModeSupport?.supported === true) ) if (catalog.origin === 'unknown' || models.length === 0) { return settleStructuredAgentSessionBuiltinCatalog(state) } // The host says whether its listed default is what this launch runs; an older host never says, // so the client's own knowledge of the agent stands in. A reopened chat may keep its own model. const namesDefault = options.newLaunch && (catalog.listingNamesConfiguredModel ?? seed.hostListingNamesConfiguredModel === true) return { ...state, // `isDefault` came from a real listing, so a launch's CLI default is nameable — // as a provisional `default`-source value, never a confirmed one. catalog: { ...seed, models, ...(namesDefault ? { defaultModelIsCliDefault: true } : {}) }, catalogSource: 'host' } } export function applyStructuredAgentSessionOptions( state: StructuredAgentSessionOptionState, seed: AgentSessionOptionCatalog, result: AgentSessionOptionsResult ): StructuredAgentSessionOptionState { if (result.current.fastMode === undefined) { clearTrackedSessionOption(state.record, result.current.model ?? null, 'fastMode') } if (result.current.serviceTier === undefined) { clearTrackedSessionOption(state.record, result.current.model ?? null, 'serviceTier') } applyNativeChatReportedSessionOptions( state.record, { ...(result.current.model ? { model: result.current.model } : {}), ...(result.current.effort ? { effort: result.current.effort } : {}), ...(result.current.fastMode !== undefined ? { fastMode: result.current.fastMode } : {}), ...(result.current.serviceTier ? { serviceTier: result.current.serviceTier } : {}) }, result.current.confirmed ?? [] ) return { ...state, catalog: structuredAgentSessionOptionCatalog(seed, result), catalogSource: 'live' } } /** Before the host answers, or an agent with no list reports, the quiet model pill: never * pickable, so displaying it can never become a launch pick. */ const PROVIDER_DEFAULT_MODEL_PLACEHOLDER: SessionOptionDescriptor = { id: 'model', label: 'Model', category: 'model', kind: { type: 'select', choices: [] }, valueSource: 'unknown', transport: 'agent-session', settable: false, disabledReason: 'available-after-session-start' } export function structuredAgentSessionOptionSnapshot( state: StructuredAgentSessionOptionState ): SessionOptionDescriptor[] { if (!state.catalog) { return [] } // Until the host answers, the built-in list would paint a label its list may replace. if (state.catalogSource === 'seed') { return [PROVIDER_DEFAULT_MODEL_PLACEHOLDER] } const snapshot = buildNativeChatSessionOptionSnapshot({ catalog: state.catalog, // A seeded default can name a model the static seed does not list yet. models: withTrackedNativeChatModel(state.catalog, state.catalog.models, state.record), record: state.record, mode: 'live', modelLabel: 'Model', liveTransport: 'agent-session' }) return snapshot.length > 0 || state.catalogSource === 'live' ? snapshot : [PROVIDER_DEFAULT_MODEL_PLACEHOLDER] } /** No launch holds a pick and no fence can carry one yet, so the picker only shows. */ export function lockedStructuredAgentSessionOptionSnapshot( snapshot: readonly SessionOptionDescriptor[] ): SessionOptionDescriptor[] { return snapshot.map((descriptor) => ({ ...descriptor, settable: false, disabledReason: 'available-after-session-start' })) } export function canSetStructuredAgentSessionOption( state: StructuredAgentSessionOptionState, id: string, value: SessionOptionValue ): boolean { const descriptor = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === id) return Boolean( state.catalog && state.pendingId === null && ((typeof value === 'string' && descriptor?.kind.type === 'select' && descriptor.kind.choices.some((choice) => choice.value === value)) || (typeof value === 'boolean' && descriptor?.kind.type === 'boolean')) ) } export function commitStructuredAgentSessionOption( state: StructuredAgentSessionOptionState, id: string, value: string ): StructuredAgentSessionOptionState { if (!state.catalog) { return state } const effectiveModel = resolveEffectiveNativeChatModelId( state.catalog, state.catalog.models, state.record ) const decoded = decodeStructuredAgentSessionOptionValue(id, value) if (decoded === null) { return { ...state, pendingId: null } } setTrackedSessionOption(state.record, id, decoded, 'dispatched', effectiveModel) return { ...state, pendingId: null } } export function commitStructuredAgentSessionOptionValues( state: StructuredAgentSessionOptionState, values: Readonly> ): StructuredAgentSessionOptionState { let next = state for (const id of STRUCTURED_LAUNCH_SEED_OPTION_IDS) { const value = values[id] if (value !== undefined) { next = commitStructuredAgentSessionOption(next, id, value) } } return next } export type StructuredSessionOptionPick = { modelId: string optionId: string value: SessionOptionValue } /** * The picks a mutation must remember so the next launch starts where the user left off. * Keyed off the same ids the launch seed reads back, so a pick this surface cannot * re-seed is never written. * * Model and effort travel as a pair: a launch resolves a stored effort only under a * stored model, so an effort-only pick adopts the model it was chosen against. Values * come from what the provider committed, not what was requested — it reconciles an * effort the newly selected model cannot run before reporting back. * * `state` may still be pre-commit: a changed model arrives in `committed`, and an * unchanged one is already what the record tracks, so neither reading depends on the * commit having landed. */ export function structuredAgentSessionOptionPicks( state: StructuredAgentSessionOptionState, committed: Readonly> ): StructuredSessionOptionPick[] { if (!state.catalog) { return [] } const committedModel = committed.model const modelId = typeof committedModel === 'string' && committedModel.trim() ? committedModel : resolveEffectiveNativeChatModelId(state.catalog, state.catalog.models, state.record) if (!modelId) { return [] } return STRUCTURED_LAUNCH_SEED_OPTION_IDS.flatMap((optionId) => { const value = committed[optionId] if (value === undefined) { return [] } const decoded = decodeStructuredAgentSessionOptionValue(optionId, value) return decoded === null || (typeof decoded === 'string' && !decoded.trim()) || encodeStructuredAgentSessionOptionValue(optionId, decoded) === null ? [] : [{ modelId, optionId, value: decoded }] }) }