Files
orca/src/main/codex/codex-structured-session-options.test.ts
T
Merge Sim 97c1df55a0 Report Codex thread confirmations so an unlisted model keeps its name
Withholding unlisted ids regressed Codex. On main the reader fabricated a row for
an unlisted `current.model`, so the pill named it; dropping that fabrication left
the name to provenance, and Codex reported none — `readCodexStructuredSessionOptions`
emitted no `confirmed` ids, so `result.current.confirmed ?? []` recorded even a
model the thread was genuinely running as `dispatched`. Probed against origin/main:
a resumed thread on `gpt-unlisted` rendered `gpt-unlisted` there and `Model` here.
That is the bug this branch fixes for Claude, recreated for Codex.

`input.current` cannot answer it alone: it merges two sources. `session.options`
holds a pick restored from a previous session or a write of ours the thread has
not echoed, while `session.reportedOptions` has one writer — the opened or resumed
thread — and is the agent's own state. Only `readLiveCodexSessionOptions` knows
which answered, so it now says, and the reader passes it on. `confirmed` is an
existing optional wire field whose absence already means "unconfirmed", so a host
that predates this sends nothing and old clients read exactly what they read now.

A model the reader substitutes because nothing was current is never confirmed:
that id is our choice, not a report.
2026-09-10 01:24:09 -07:00

242 lines
7.8 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import type { CodexAppServerConnection } from './codex-app-server-connection'
import { CodexAcquisitionWindow } from './codex-structured-acquisition-window'
import {
applyCodexStructuredSessionOption,
readCodexStructuredSessionOptions,
readLiveCodexSessionOptions,
reportedCodexThreadOptions,
restoredCodexSessionOptions
} from './codex-structured-session-options'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import type { CodexSession } from './codex-structured-session-state'
function optionSession(request: CodexAppServerConnection['request']): CodexSession {
return {
connection: {
pid: 1,
closed: false,
request,
notify: () => {},
respond: () => {},
respondWithError: () => {},
close: async () => true
},
backgroundTasks: new CodexBackgroundTaskTracker('thread-1'),
ended: false,
requestedClose: false,
fence: 1,
acquisitionGeneration: 'generation-1',
threadId: 'thread-1',
historyPath: null,
prompts: new CodexAcquisitionWindow().prompts,
options: new Map(),
reportedOptions: { model: 'gpt-live', effort: 'high' },
turnIdWaiters: [],
translator: null
}
}
describe('structured Codex session options', () => {
it('filters restored records to recognized turn options', () => {
expect(
Object.fromEntries(
restoredCodexSessionOptions({
model: 'gpt-live',
effort: 'high',
threadId: 'thread-injected',
input: 'input-injected'
})
)
).toEqual({ model: 'gpt-live', effort: 'high' })
})
it('hydrates paged provider models and their supported efforts', async () => {
const request = vi.fn(async (_method: string, params?: Record<string, unknown>) =>
params?.cursor
? {
data: [
{
model: 'gpt-second',
displayName: 'GPT Second',
description: 'Fast',
hidden: false,
supportedReasoningEfforts: [
{ reasoningEffort: 'low', description: 'Quick reasoning' }
],
defaultReasoningEffort: 'low',
isDefault: false
}
],
nextCursor: null
}
: {
data: [
{
model: 'gpt-live',
displayName: 'GPT Live',
hidden: false,
supportedReasoningEfforts: [
{ reasoningEffort: 'medium', description: 'Balanced' },
{ reasoningEffort: 'high', description: 'Deep reasoning' }
],
defaultReasoningEffort: 'medium',
isDefault: true
}
],
nextCursor: 'page-2'
}
)
await expect(
readCodexStructuredSessionOptions({
connection: { request } as never,
current: { model: 'gpt-live', effort: 'medium' }
})
).resolves.toEqual({
models: [
{
id: 'gpt-live',
label: 'GPT Live',
isDefault: true,
defaultEffort: 'medium',
efforts: [
{ value: 'medium', label: 'Medium', description: 'Balanced' },
{ value: 'high', label: 'High', description: 'Deep reasoning' }
]
},
{
id: 'gpt-second',
label: 'GPT Second',
description: 'Fast',
isDefault: false,
defaultEffort: 'low',
efforts: [{ value: 'low', label: 'Low', description: 'Quick reasoning' }]
}
],
current: { model: 'gpt-live', effort: 'medium' }
})
expect(request).toHaveBeenNthCalledWith(
2,
'model/list',
{ limit: 100, includeHidden: false, cursor: 'page-2' },
{ timeoutMs: undefined }
)
})
it('reports an unlisted current model without offering it as a choice', async () => {
// Was: a fabricated `{ id, label: id, efforts: [] }` row, which offered a raw launch
// id in the picker as though the account were entitled to it.
const request = vi.fn(async () => ({
data: [{ model: 'gpt-live', displayName: 'GPT Live', isDefault: true }],
nextCursor: null
}))
await expect(
readCodexStructuredSessionOptions({
connection: { request } as never,
current: { model: 'gpt-unlisted' }
})
).resolves.toEqual({
models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }],
current: { model: 'gpt-unlisted' }
})
})
it('confirms a model the thread reported, but not a pick we restored', async () => {
// The display rule names an unlisted model only when the agent reported it, so the
// reader has to say which of the two answered. `session.options` is a restored pick
// or an unechoed write of ours; `reportedOptions` is the opened thread's own state.
const request = vi.fn(async () => ({
data: [{ model: 'gpt-live', displayName: 'GPT Live', isDefault: true }],
nextCursor: null
}))
const session = (
options: Map<string, string>,
reported: Record<string, string>
): CodexSession =>
({ connection: { request }, options, reportedOptions: reported }) as unknown as CodexSession
await expect(
readLiveCodexSessionOptions(session(new Map(), { model: 'gpt-unlisted' }), undefined)
).resolves.toMatchObject({ current: { model: 'gpt-unlisted', confirmed: ['model'] } })
const restored = await readLiveCodexSessionOptions(
session(new Map([['model', 'gpt-stale']]), {}),
undefined
)
expect(restored.current).toEqual({ model: 'gpt-stale' })
expect(restored.current.confirmed).toBeUndefined()
})
it('does not confirm a model it substituted rather than read', async () => {
// With nothing current the reader picks the default; that is our choice, not a report.
const request = vi.fn(async () => ({
data: [{ model: 'gpt-live', displayName: 'GPT Live', isDefault: true }],
nextCursor: null
}))
await expect(
readCodexStructuredSessionOptions({
connection: { request } as never,
current: {},
confirmed: ['model']
})
).resolves.toEqual({
models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }],
current: { model: 'gpt-live' }
})
})
it('hydrates current values from thread start or resume', () => {
expect(
reportedCodexThreadOptions({
threadId: 'thread-1',
historyPath: null,
model: 'gpt-live',
effort: 'high'
})
).toEqual({ model: 'gpt-live', effort: 'high' })
})
it('reconciles an incompatible effort when only the model changes', async () => {
const session = optionSession(
vi.fn(async () => ({
data: [
{
model: 'gpt-live',
supportedReasoningEfforts: [{ reasoningEffort: 'high' }],
defaultReasoningEffort: 'high'
},
{
model: 'gpt-fast',
supportedReasoningEfforts: [{ reasoningEffort: 'low' }],
defaultReasoningEffort: 'low'
}
],
nextCursor: null
}))
)
await expect(
applyCodexStructuredSessionOption(session, 'model', 'gpt-fast', undefined)
).resolves.toEqual({ model: 'gpt-fast', effort: 'low' })
})
it('rejects values absent from the provider catalog', async () => {
const session = optionSession(
vi.fn(async () => ({
data: [{ model: 'gpt-live', supportedReasoningEfforts: [] }],
nextCursor: null
}))
)
await expect(
applyCodexStructuredSessionOption(session, 'model', 'not-entitled', undefined)
).rejects.toThrow('does not offer model not-entitled')
await expect(
applyCodexStructuredSessionOption(session, 'effort', 'high', undefined)
).rejects.toThrow('does not support high')
})
})