From 1cf45caa24e2c0f2bdf7c00f0ed0d65f991563d8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:43:24 -0700 Subject: [PATCH] fix(native-chat): route structured Codex options directly --- ...-mobile-structured-session-options.test.ts | 10 ++- ...ructured-agent-session-integration.test.ts | 50 ++++++++++- .../native-chat-session-option-snapshot.ts | 25 ++++-- .../structured-agent-session-options.test.ts | 86 +++++++++++++++++++ .../structured-agent-session-options.ts | 37 ++++++-- 5 files changed, 187 insertions(+), 21 deletions(-) create mode 100644 src/shared/structured-agent-session-options.test.ts diff --git a/mobile/src/session/use-mobile-structured-session-options.test.ts b/mobile/src/session/use-mobile-structured-session-options.test.ts index d941344d589..9746a783264 100644 --- a/mobile/src/session/use-mobile-structured-session-options.test.ts +++ b/mobile/src/session/use-mobile-structured-session-options.test.ts @@ -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' diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 0007cc0c333..f0c233f2fe6 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -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 ────────────────────────────────────────────────────────────── diff --git a/src/shared/native-chat-session-option-snapshot.ts b/src/shared/native-chat-session-option-snapshot.ts index 21139d765f1..76556211ab0 100644 --- a/src/shared/native-chat-session-option-snapshot.ts +++ b/src/shared/native-chat-session-option-snapshot.ts @@ -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 { @@ -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 }) diff --git a/src/shared/structured-agent-session-options.test.ts b/src/shared/structured-agent-session-options.test.ts new file mode 100644 index 00000000000..c3a11a4a18e --- /dev/null +++ b/src/shared/structured-agent-session-options.test.ts @@ -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') + }) +}) diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index f4a1606f7b8..e8e1d91e886 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -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' }) }