fix(chat): retain unlisted reported Codex models (#32068)

This commit is contained in:
Neil
2026-10-22 20:30:58 -07:00
committed by GitHub
parent 0dc7b88358
commit 722bb2495a
6 changed files with 114 additions and 4 deletions
@@ -157,6 +157,31 @@ describe('useMobileNativeChatSessionOptions', () => {
expect(onAgentPicker).toHaveBeenCalledOnce()
})
it.each([null, 'gpt-5.6-terra'])(
'tracks an unlisted Codex report after %s without inventing effort choices',
async (initialModel) => {
mount({ agent: 'codex', reportedModel: initialModel })
update({ reportedModel: 'gpt-6.1-sol' })
expect(api!.snapshot).toHaveLength(1)
expect(api!.snapshot[0]).toMatchObject({
valueSource: 'reported',
action: { type: 'agent-picker' },
kind: {
currentValue: 'gpt-6.1-sol',
choices: expect.arrayContaining([{ value: 'gpt-6.1-sol', label: 'gpt-6.1-sol' }])
}
})
const snapshot = api!.snapshot
update({ reportedModel: null })
update({ reportedModel: 'gpt-6.1-sol' })
expect(api!.snapshot).toBe(snapshot)
await act(async () => {
await api!.invokeAction('model')
})
expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' })
}
)
it('types the Codex effort picker command', async () => {
mount({ agent: 'codex', reportedModel: 'gpt-5.5' })
await act(async () => {
@@ -110,7 +110,11 @@ export function useMobileNativeChatSessionOptions(args: {
}
// OMP reports exact selectors, including models absent from cached discovery.
const matched =
agent === 'omp' ? reportedModel.trim() : matchNativeChatCatalogModelId(catalog, reportedModel)
agent === 'omp'
? reportedModel.trim()
: matchNativeChatCatalogModelId(catalog, reportedModel, {
allowUnlisted: agent === 'codex'
})
if (!matched) {
return
}
@@ -63,6 +63,52 @@ describe('useNativeChatSessionOptions model reporting', () => {
Object.defineProperty(window, 'api', { configurable: true, value: undefined })
})
it.each([null, 'gpt-5.6-terra'])(
'tracks an unlisted Codex report after %s without inventing effort choices',
async (initialModel) => {
discoverModels.mockResolvedValue(null)
const paneKey = `codex-unlisted-${initialModel}:leaf`
const dispatchCommand = vi.fn()
storeState.agentStatusByPaneKey[paneKey] = initialModel ? { model: initialModel } : {}
const { result, rerender } = renderHook(() =>
useNativeChatSessionOptions({
agent: 'codex',
terminalTabId: paneKey,
targetPtyId: paneKey,
dispatchCommand,
paneKey
})
)
await waitFor(() =>
expect(modelDescriptor(result.current.snapshot).currentValue).toBe(
initialModel ?? undefined
)
)
storeState.agentStatusByPaneKey[paneKey] = { model: 'gpt-6.1-sol' }
rerender()
await waitFor(() =>
expect(modelDescriptor(result.current.snapshot).currentValue).toBe('gpt-6.1-sol')
)
expect(result.current.snapshot).toHaveLength(1)
expect(result.current.snapshot[0]).toMatchObject({
valueSource: 'reported',
action: { type: 'agent-picker' }
})
expect(modelDescriptor(result.current.snapshot).choices).toContainEqual({
value: 'gpt-6.1-sol',
label: 'gpt-6.1-sol'
})
const snapshot = result.current.snapshot
storeState.agentStatusByPaneKey[paneKey] = {}
rerender()
storeState.agentStatusByPaneKey[paneKey] = { model: 'gpt-6.1-sol' }
rerender()
expect(result.current.snapshot).toBe(snapshot)
await result.current.surface?.invokeAction('model')
expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' })
}
)
it('names an OMP session by the model its hook reports, before and after discovery', async () => {
// Why: OMP seeds no models and has no terminal frame to read; the hook's
// `provider/id` stamp is the only way the pill can name the running model.
@@ -216,7 +216,9 @@ export function useNativeChatSessionOptions(args: {
const matched =
agent === 'omp'
? reportedModel.trim()
: matchNativeChatCatalogModelId({ ...catalog, models }, reportedModel)
: matchNativeChatCatalogModelId({ ...catalog, models }, reportedModel, {
allowUnlisted: agent === 'codex'
})
if (!matched || appliedReportedModelRef.current === matched) {
return
}
@@ -48,6 +48,35 @@ describe('applyNativeChatReportedSessionOptions', () => {
})
describe('matchNativeChatCatalogModelId', () => {
it.each(['gpt-6.1-sol', 'gpt-5.5-custom', 'custom/Model-v2'])(
'keeps the exact unlisted selector %s only when requested',
(reported) => {
expect(matchNativeChatCatalogModelId(CODEX_SESSION_OPTION_CATALOG, reported)).not.toBe(
reported
)
expect(
matchNativeChatCatalogModelId(CODEX_SESSION_OPTION_CATALOG, reported, {
allowUnlisted: true
})
).toBe(reported)
}
)
it('retains known labels and rejects blank or free text as unlisted selectors', () => {
expect(
matchNativeChatCatalogModelId(CODEX_SESSION_OPTION_CATALOG, ' GPT-5.5 ', {
allowUnlisted: true
})
).toBe('gpt-5.5')
for (const reported of ['', ' ', 'unknown model', 'unknown\nmodel']) {
expect(
matchNativeChatCatalogModelId(CODEX_SESSION_OPTION_CATALOG, reported, {
allowUnlisted: true
})
).toBeNull()
}
})
it('prefers the longest contained id and keeps catalog order for ties', () => {
const catalog = {
...CLAUDE_SESSION_OPTION_CATALOG,
@@ -153,10 +153,11 @@ export function applyNativeChatReportedSessionOptions(
/** Resolves a hook-reported model string to a catalog id: exact id, then label,
* then the longest id the report contains. A catalog that seeds no models keeps
* the report itself. Null when nothing matches. */
* the report itself. Callers may retain unlisted selectors without fuzzy id matching. */
export function matchNativeChatCatalogModelId(
catalog: AgentSessionOptionCatalog,
reported: string
reported: string,
options?: { allowUnlisted?: boolean }
): string | null {
const normalized = reported.trim().toLowerCase()
if (!normalized) {
@@ -175,6 +176,9 @@ export function matchNativeChatCatalogModelId(
if (byLabel) {
return byLabel.id
}
if (options?.allowUnlisted && !/\s/.test(normalized)) {
return reported.trim()
}
let containingId: string | null = null
for (const model of catalog.models) {
if (