diff --git a/src/main/codex/codex-structured-model-restoration.ts b/src/main/codex/codex-structured-model-restoration.ts new file mode 100644 index 00000000000..e5f7fa2ebee --- /dev/null +++ b/src/main/codex/codex-structured-model-restoration.ts @@ -0,0 +1,115 @@ +import type { CodexAppServerConnection } from './codex-app-server-connection' +import { + codexCatalogAdmitsModel, + readCodexStructuredSessionOptions, + reportedCodexThreadOptions, + restoredCodexSessionOptions, + type CodexModelCatalog +} from './codex-structured-session-options' +import type { CodexSession } from './codex-structured-session-state' +import type { CodexOpenedThread } from './codex-structured-thread-open' + +export type CodexAcquiredSessionOptions = { + options: Map + reportedOptions: CodexSession['reportedOptions'] + notice?: string +} + +const NOTICE_MODEL_ID_LIMIT = 160 + +function displayedModelId(modelId: string): string { + const bounded = + modelId.length > NOTICE_MODEL_ID_LIMIT + ? `${modelId.slice(0, NOTICE_MODEL_ID_LIMIT - 1)}…` + : modelId + return JSON.stringify(bounded) +} + +function rejectedModelDescription(input: { + restored: string | undefined + reported: string | undefined +}): string { + if (input.restored && input.reported && input.restored !== input.reported) { + return `restored model ${displayedModelId(input.restored)} and provider-reported model ${displayedModelId(input.reported)}` + } + return `model ${displayedModelId(input.restored ?? input.reported ?? 'unknown')}` +} + +function catalogCurrent(input: { + options: Readonly> | undefined + opened: CodexOpenedThread +}): { model?: string; effort?: string } { + const restoredModel = input.options?.model || undefined + const restoredEffort = input.options?.effort || undefined + const model = restoredModel ?? input.opened.model + const effort = restoredEffort ?? input.opened.effort + return { ...(model ? { model } : {}), ...(effort ? { effort } : {}) } +} + +async function readCatalog(input: { + connection: Pick + options: Readonly> | undefined + opened: CodexOpenedThread + timeoutMs: number | undefined +}): Promise { + return readCodexStructuredSessionOptions({ + connection: input.connection, + current: catalogCurrent(input), + timeoutMs: input.timeoutMs + }).catch(() => null) +} + +export async function resolveCodexAcquiredSessionOptions(input: { + connection: Pick + options: Readonly> | undefined + opened: CodexOpenedThread + timeoutMs: number | undefined +}): Promise { + const catalog = await readCatalog(input) + const restoredModel = input.options?.model || undefined + const reportedModel = input.opened.model + const restoredRefused = Boolean(restoredModel && !codexCatalogAdmitsModel(catalog, restoredModel)) + const reportedRefused = Boolean(reportedModel && !codexCatalogAdmitsModel(catalog, reportedModel)) + const options = restoredCodexSessionOptions(input.options, catalog) + const reportedOptions = reportedCodexThreadOptions(input.opened, catalog) + + if (!restoredRefused && !reportedRefused) { + return { options, reportedOptions } + } + + const rejected = rejectedModelDescription({ + restored: restoredRefused ? restoredModel : undefined, + reported: reportedRefused ? reportedModel : undefined + }) + if (restoredModel && !restoredRefused && reportedRefused) { + return { + options, + reportedOptions, + notice: `Codex no longer lists ${rejected}. Orca will use the existing restored choice ${displayedModelId(restoredModel)} for this session.` + } + } + + const defaults = catalog?.models.filter((model) => model.isDefault) ?? [] + if (defaults.length !== 1) { + return { + options: restoredCodexSessionOptions(input.options, null), + reportedOptions: reportedCodexThreadOptions(input.opened, null), + notice: `Codex no longer lists ${rejected}, but did not identify a unique provider default. Orca left the model unchanged; choose an available model to continue.` + } + } + + const replacement = defaults[0] + options.set('model', replacement.id) + options.delete('effort') + if ( + replacement.defaultEffort && + replacement.efforts.some((effort) => effort.value === replacement.defaultEffort) + ) { + options.set('effort', replacement.defaultEffort) + } + return { + options, + reportedOptions, + notice: `Codex no longer lists ${rejected}. Orca selected the provider-listed default ${displayedModelId(replacement.id)} for this session.` + } +} diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index c9a306128b6..80f5ba28cee 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -19,10 +19,7 @@ import { closeCodexPublishedSession, handleCodexSessionExit } from './codex-structured-session-close' -import { - reportedCodexThreadOptions, - restoredCodexSessionOptions -} from './codex-structured-session-options' +import { resolveCodexAcquiredSessionOptions } from './codex-structured-model-restoration' import { codexSessionLifecycle, mintCodexAcquisitionGeneration, @@ -169,6 +166,13 @@ export async function acquireCodexStructuredSession(input: { acquisitions.assertCurrent(sessionId, attempt) const opened = await openCodexThread(connection, launch, deps.requestTimeoutMs) acquisitions.assertCurrent(sessionId, attempt) + const resolvedOptions = await resolveCodexAcquiredSessionOptions({ + connection, + options: acquireInput.options, + opened, + timeoutMs: deps.requestTimeoutMs + }) + acquisitions.assertCurrent(sessionId, attempt) primaryThreadId = opened.threadId const restoreAdmission = translator?.restoreThread(opened.threadId, opened.thread ?? {}) if (restoreAdmission && !restoreAdmission.accepted) { @@ -205,8 +209,8 @@ export async function acquireCodexStructuredSession(input: { historyMode: opened.historyMode, activeTurnIds: new Set(), prompts: acquisition.prompts, - options: restoredCodexSessionOptions(acquireInput.options), - reportedOptions: reportedCodexThreadOptions(opened), + options: resolvedOptions.options, + reportedOptions: resolvedOptions.reportedOptions, turnIdWaiters: [], translator, backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions), @@ -221,6 +225,16 @@ export async function acquireCodexStructuredSession(input: { } turnCancellation.register(session) sessions.set(sessionId, session) + if (resolvedOptions.notice && acquireInput.events) { + acquireInput.events.appendItem( + { + provider: 'orca', + clientMessageId: `codex-model-restoration:${acquired.acquisitionGeneration}` + }, + { kind: 'status', text: resolvedOptions.notice, tone: 'warning' } + ) + acquireInput.events.publish() + } for (const event of acquisition.drain()) { event() } diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts index e7ff155625c..f69892c764a 100644 --- a/src/main/codex/codex-structured-session-options.ts +++ b/src/main/codex/codex-structured-session-options.ts @@ -12,10 +12,23 @@ import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wi const MODEL_PAGE_LIMIT = 100 const MAX_MODEL_PAGES = 20 +export type CodexModelCatalog = Pick | null | undefined + export function restoredCodexSessionOptions( - options: Readonly> | undefined + options: Readonly> | undefined, + catalog: CodexModelCatalog ): Map { - return new Map(Object.entries(options ?? {}).filter(([key]) => isCodexTurnOptionKey(key))) + const restored = new Map( + Object.entries(options ?? {}).filter( + ([key, value]) => isCodexTurnOptionKey(key) && (key !== 'model' || value.length > 0) + ) + ) + const model = restored.get('model') + if (model && !codexCatalogAdmitsModel(catalog, model)) { + restored.delete('model') + restored.delete('effort') + } + return restored } function record(value: unknown): Record | null { @@ -102,13 +115,8 @@ export async function readCodexStructuredSessionOptions(input: { break } } - if (input.current.model && !models.some((model) => model.id === input.current.model)) { - models.push({ - id: input.current.model, - label: input.current.model, - isDefault: false, - efforts: [] - }) + if (cursor) { + throw new Error(`codex app-server model enumeration exceeded ${MAX_MODEL_PAGES} pages`) } const model = input.current.model ?? models.find((entry) => entry.isDefault)?.id ?? models[0]?.id if (!model) { @@ -120,12 +128,23 @@ export async function readCodexStructuredSessionOptions(input: { } } +/** Returns the final admission decision, including unavailable evidence's permissive case. */ +export function codexCatalogAdmitsModel(catalog: CodexModelCatalog, modelId: string): boolean { + const models = catalog?.models ?? [] + // An empty list identifies no model, so it is not evidence against one — a + // CLI unable to answer model/list must not have every model refused under it. + // Do not turn this into a refusal. + return models.length === 0 || models.some((model) => model.id === modelId) +} + export function reportedCodexThreadOptions( - opened: CodexOpenedThread + opened: CodexOpenedThread, + catalog: CodexModelCatalog ): CodexSession['reportedOptions'] { + const modelAdmitted = !opened.model || codexCatalogAdmitsModel(catalog, opened.model) return { - ...(opened.model ? { model: opened.model } : {}), - ...(opened.effort ? { effort: opened.effort } : {}) + ...(opened.model && modelAdmitted ? { model: opened.model } : {}), + ...(opened.effort && modelAdmitted ? { effort: opened.effort } : {}) } }