fix(native-chat): route structured Codex options directly

This commit is contained in:
Brennan Benson
2026-08-12 12:43:24 -07:00
parent 7b3144c037
commit 1cf45caa24
5 changed files with 187 additions and 21 deletions
@@ -77,18 +77,22 @@ describe('useMobileStructuredSessionOptions', () => {
controller = null
})
it('uses only provider models and hydrates current model and effort', () => {
it('unions seeded and provider models and hydrates current model and effort', () => {
expect(sendRequest).toHaveBeenCalledWith('agentSession.options', { sessionId: 'mobile_1' })
const model = controller!.snapshot.find((entry) => entry.id === 'model')
expect(model?.kind).toEqual({
type: 'select',
currentValue: 'account-only',
choices: [
{ value: 'account-only', label: 'Account Only' },
{ value: 'gpt-5.6-sol', label: 'GPT-5.6 Sol Live' },
{ value: 'gpt-5.6-terra', label: 'GPT-5.6 Terra Live' }
{ value: 'gpt-5.6-terra', label: 'GPT-5.6 Terra Live' },
{ value: 'gpt-5.6-luna', label: 'GPT-5.6 Luna' },
{ value: 'gpt-5.5', label: 'GPT-5.5' },
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
{ value: 'account-only', label: 'Account Only' }
]
})
expect(model).not.toHaveProperty('action')
expect(controller!.snapshot.find((entry) => entry.id === 'effort')).toMatchObject({
kind: { currentValue: 'medium' },
valueSource: 'reported'
@@ -96,6 +96,24 @@ function fakeCodex(): CodexScript {
if (method === 'turn/start') {
return { turn: { id: TURN } }
}
if (method === 'model/list') {
return {
data: [
{
model: 'gpt-live',
displayName: 'GPT Live',
hidden: false,
supportedReasoningEfforts: [
{ reasoningEffort: 'medium', description: 'Balanced' },
{ reasoningEffort: 'high', description: 'Deep reasoning' }
],
defaultReasoningEffort: 'medium',
isDefault: true
}
],
nextCursor: null
}
}
return {}
},
notify: () => {},
@@ -337,6 +355,31 @@ describe('a structured codex session over agentSession.*', () => {
const stream = await subscribe('sub-1')
expect(stream[0]).toMatchObject({ type: 'snapshot', sessionId: SESSION })
// ── options ─────────────────────────────────────────────────────────────
const options = await call('agentSession.options', { sessionId: SESSION })
expect(options).toMatchObject({
ok: true,
result: {
models: [{ id: 'gpt-live', defaultEffort: 'medium' }],
current: { model: 'gpt-live' }
}
})
await ok('agentSession.setOption', {
envelope: envelope('agentSession.setOption', { key: 'model', value: 'gpt-live' }, fence),
key: 'model',
value: 'gpt-live'
})
await ok('agentSession.setOption', {
envelope: envelope('agentSession.setOption', { key: 'effort', value: 'high' }, fence),
key: 'effort',
value: 'high'
})
expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({
ok: true,
result: { current: { model: 'gpt-live', effort: 'high' } }
})
expect(itemsOf(stream)).toEqual([])
// ── send ────────────────────────────────────────────────────────────────
const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'list files' }] }
const sent = await ok<{
@@ -354,7 +397,12 @@ describe('a structured codex session over agentSession.*', () => {
})
expect(codex.live().calls.at(-1)).toMatchObject({
method: 'turn/start',
params: { threadId: THREAD, clientUserMessageId: sent.clientMessageId }
params: {
threadId: THREAD,
clientUserMessageId: sent.clientMessageId,
model: 'gpt-live',
effort: 'high'
}
})
// ── stream ──────────────────────────────────────────────────────────────
@@ -15,6 +15,7 @@ import {
} from './native-chat-session-option-state'
export type NativeChatSessionOptionMode = 'draft' | 'live'
export type NativeChatLiveOptionTransport = 'catalog' | 'agent-session'
function choiceWithCurrent(
choices: readonly SessionOptionSelectChoice[],
@@ -30,6 +31,7 @@ function choiceWithCurrent(
function settableState(args: {
mode: NativeChatSessionOptionMode
liveTransport: NativeChatLiveOptionTransport
apply: { launchArgs?: unknown; composedIntoModel?: true; midSession?: CatalogMidSessionApply }
composedModelApply?: { midSession?: CatalogMidSessionApply }
}): Pick<SessionOptionDescriptor, 'settable' | 'disabledReason'> {
@@ -38,6 +40,9 @@ function settableState(args: {
? { settable: true }
: { settable: false, disabledReason: 'available-after-session-start' }
}
if (args.liveTransport === 'agent-session') {
return { settable: true }
}
if (args.apply.composedIntoModel && args.composedModelApply?.midSession?.kind === 'command') {
return { settable: true }
}
@@ -50,9 +55,10 @@ function settableState(args: {
function actionForApply(
apply: { midSession?: CatalogMidSessionApply },
tracked: TrackedNativeChatSessionOption | undefined,
mode: NativeChatSessionOptionMode
mode: NativeChatSessionOptionMode,
liveTransport: NativeChatLiveOptionTransport
): SessionOptionDescriptor['action'] {
if (mode !== 'live') {
if (mode !== 'live' || liveTransport === 'agent-session') {
return undefined
}
if (apply.midSession?.kind === 'agent-picker') {
@@ -67,12 +73,13 @@ function optionDescriptor(args: {
option: CatalogOption
tracked: TrackedNativeChatSessionOption | undefined
mode: NativeChatSessionOptionMode
liveTransport: NativeChatLiveOptionTransport
modelIsCliDefault: boolean
composedModelApply: AgentSessionOptionCatalog['modelApply']
}): SessionOptionDescriptor | null {
const { option, tracked, mode, modelIsCliDefault, composedModelApply } = args
const action = actionForApply(option.apply, tracked, mode)
const settable = settableState({ mode, apply: option.apply, composedModelApply })
const { option, tracked, mode, liveTransport, modelIsCliDefault, composedModelApply } = args
const action = actionForApply(option.apply, tracked, mode, liveTransport)
const settable = settableState({ mode, liveTransport, apply: option.apply, composedModelApply })
// Why: the launch only emits `values[id] ?? defaultValue` alongside a model flag, so
// a draft names this option's value exactly when a model was picked. Under the CLI's
// own default no flag is sent at all, and the CLI's unstated choice is not ours to name.
@@ -195,8 +202,9 @@ export function buildNativeChatSessionOptionSnapshot(args: {
record: NativeChatSessionOptionRecord
mode: NativeChatSessionOptionMode
modelLabel: string
liveTransport?: NativeChatLiveOptionTransport
}): SessionOptionDescriptor[] {
const { catalog, models, record, mode, modelLabel } = args
const { catalog, models, record, mode, modelLabel, liveTransport = 'catalog' } = args
if (models.length === 0) {
return []
}
@@ -212,7 +220,7 @@ export function buildNativeChatSessionOptionSnapshot(args: {
const trackedModelId = typeof modelTracked?.value === 'string' ? modelTracked.value : null
const defaultModelId = cliDefaultModelId(catalog, models, trackedModelId)
const effectiveModelId = trackedModelId ?? defaultModelId
const modelAction = actionForApply(catalog.modelApply, modelTracked, mode)
const modelAction = actionForApply(catalog.modelApply, modelTracked, mode, liveTransport)
const snapshot: SessionOptionDescriptor[] = [
{
id: 'model',
@@ -224,7 +232,7 @@ export function buildNativeChatSessionOptionSnapshot(args: {
choices: modelChoices
},
valueSource: modelTracked?.source ?? (defaultModelId ? 'default' : 'unknown'),
...settableState({ mode, apply: catalog.modelApply }),
...settableState({ mode, liveTransport, apply: catalog.modelApply }),
...(modelAction ? { action: modelAction } : {})
}
]
@@ -238,6 +246,7 @@ export function buildNativeChatSessionOptionSnapshot(args: {
option,
tracked: trackedValues[option.id],
mode,
liveTransport,
modelIsCliDefault: effectiveModelId === defaultModelId,
composedModelApply: catalog.modelApply
})
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import { CODEX_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-claude-codex'
import { buildNativeChatSessionOptionSnapshot } from './native-chat-session-option-snapshot'
import { createNativeChatSessionOptionRecord } from './native-chat-session-option-state'
import {
applyStructuredAgentSessionOptions,
createStructuredAgentSessionOptionState,
structuredAgentSessionOptionSnapshot
} from './structured-agent-session-options'
describe('structured agent session options', () => {
it('projects native Codex selects while bridge Codex keeps its agent picker', () => {
const state = applyStructuredAgentSessionOptions(
createStructuredAgentSessionOptionState('codex'),
CODEX_SESSION_OPTION_CATALOG,
{
models: [
{
id: 'account-model',
label: 'Account Model',
isDefault: true,
defaultEffort: 'medium',
efforts: [
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' }
]
}
],
current: { model: 'account-model', effort: 'medium' }
}
)
const structured = structuredAgentSessionOptionSnapshot(state)
expect(structured.map((descriptor) => descriptor.id)).toEqual(['model', 'effort'])
expect(structured[0]).toMatchObject({
settable: true,
kind: { type: 'select', currentValue: 'account-model' }
})
expect(structured[0]).not.toHaveProperty('action')
expect(structured[1]).toMatchObject({
settable: true,
kind: { type: 'select', currentValue: 'medium' }
})
const bridgeRecord = createNativeChatSessionOptionRecord('codex')
bridgeRecord.model = { value: 'gpt-5.6-sol', source: 'reported' }
const bridge = buildNativeChatSessionOptionSnapshot({
catalog: CODEX_SESSION_OPTION_CATALOG,
models: CODEX_SESSION_OPTION_CATALOG.models,
record: bridgeRecord,
mode: 'live',
modelLabel: 'Model'
})
expect(bridge[0]).toMatchObject({ action: { type: 'agent-picker' } })
expect(bridge.find((descriptor) => descriptor.id === 'effort')).toMatchObject({
action: { type: 'agent-picker' }
})
})
it('unions catalog seeds with app-server models and retains the current unknown id', () => {
const state = applyStructuredAgentSessionOptions(
createStructuredAgentSessionOptionState('codex'),
CODEX_SESSION_OPTION_CATALOG,
{
models: [
{
id: 'account-model',
label: 'Account Model',
isDefault: false,
efforts: []
}
],
current: { model: 'persisted-unknown' }
}
)
const model = structuredAgentSessionOptionSnapshot(state)[0]
expect(
model.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : []
).toEqual([
...CODEX_SESSION_OPTION_CATALOG.models.map((entry) => entry.id),
'account-model',
'persisted-unknown'
])
expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('persisted-unknown')
})
})
+28 -9
View File
@@ -33,20 +33,38 @@ function effortOption(model: AgentSessionOptionsResult['models'][number]): Catal
}
}
function discoveredModel(model: AgentSessionOptionsResult['models'][number]): CatalogModel {
const effort = effortOption(model)
return {
id: model.id,
label: model.label,
...(model.description ? { description: model.description } : {}),
...(model.isDefault ? { isDefault: true } : {}),
options: effort ? [effort] : []
}
}
export function structuredAgentSessionOptionCatalog(
seed: AgentSessionOptionCatalog,
result: AgentSessionOptionsResult
): AgentSessionOptionCatalog {
const models: CatalogModel[] = result.models.map((model) => {
const effort = effortOption(model)
return {
id: model.id,
label: model.label,
...(model.description ? { description: model.description } : {}),
...(model.isDefault ? { isDefault: true } : {}),
options: effort ? [effort] : []
const discovered = new Map(result.models.map((model) => [model.id, discoveredModel(model)]))
const models: CatalogModel[] = seed.models.map((seeded) => {
const live = discovered.get(seeded.id)
if (!live) {
return seeded
}
discovered.delete(seeded.id)
return live
})
models.push(...discovered.values())
if (!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 }
}
@@ -85,7 +103,8 @@ export function structuredAgentSessionOptionSnapshot(
models: state.catalog.models,
record: state.record,
mode: 'live',
modelLabel: 'Model'
modelLabel: 'Model',
liveTransport: 'agent-session'
})
}