fix(codex): validate restored structured models

This commit is contained in:
Merge Sim
2026-09-12 00:53:39 -07:00
parent 719c4341a2
commit 99a878c899
3 changed files with 166 additions and 18 deletions
@@ -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<string, string>
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<Record<string, string>> | 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<CodexAppServerConnection, 'request'>
options: Readonly<Record<string, string>> | undefined
opened: CodexOpenedThread
timeoutMs: number | undefined
}): Promise<CodexModelCatalog> {
return readCodexStructuredSessionOptions({
connection: input.connection,
current: catalogCurrent(input),
timeoutMs: input.timeoutMs
}).catch(() => null)
}
export async function resolveCodexAcquiredSessionOptions(input: {
connection: Pick<CodexAppServerConnection, 'request'>
options: Readonly<Record<string, string>> | undefined
opened: CodexOpenedThread
timeoutMs: number | undefined
}): Promise<CodexAcquiredSessionOptions> {
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.`
}
}
@@ -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()
}
@@ -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<AgentSessionOptionsResult, 'models'> | null | undefined
export function restoredCodexSessionOptions(
options: Readonly<Record<string, string>> | undefined
options: Readonly<Record<string, string>> | undefined,
catalog: CodexModelCatalog
): Map<string, string> {
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<string, unknown> | 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 } : {})
}
}