fix(native-chat): show Codex's effective model in the session picker (STA-4317)

The composer latched the launch-seeded model, so it could keep showing
GPT-5.6 Terra after Codex had already switched to gpt-5.6-luna. Read the
TUI status line and live agent-status model the same way Claude already
does, and keep following later /model and fallback reports.
This commit is contained in:
Brennan Benson
2026-08-20 17:50:49 -07:00
parent 012e9f410c
commit fb85ab6ce5
6 changed files with 358 additions and 15 deletions
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { readCodexSessionOptionsFromTerminalScreen } from './codex-terminal-session-options'
const STATUS_LINE = 'gpt-5.6-luna max fast · ████████░░░░'
describe('readCodexSessionOptionsFromTerminalScreen', () => {
it('reads model and effort from the model-with-reasoning status line', () => {
expect(readCodexSessionOptionsFromTerminalScreen(STATUS_LINE)).toEqual({
model: 'gpt-5.6-luna',
effort: 'max'
})
})
it('ignores a ChatGPT fast service-tier suffix', () => {
expect(
readCodexSessionOptionsFromTerminalScreen('gpt-5.6-terra ultra fast · Context 12% used')
).toEqual({ model: 'gpt-5.6-terra', effort: 'ultra' })
})
it('matches a catalog label the same as its id', () => {
expect(readCodexSessionOptionsFromTerminalScreen('GPT-5.6 Luna max · /repo')).toEqual({
model: 'gpt-5.6-luna',
effort: 'max'
})
})
it('does not treat conversation text that names a model as the status line', () => {
expect(
readCodexSessionOptionsFromTerminalScreen(
'please switch off gpt-5.6-luna max and use terra instead\nready'
)
).toBeNull()
})
it('prefers the bottom status line over an earlier mention', () => {
const screen = [
'User asked for gpt-5.6-terra ultra',
'> Find and fix a bug in @filename',
'gpt-5.6-luna max fast · ████████░░░░'
].join('\n')
expect(readCodexSessionOptionsFromTerminalScreen(screen)).toEqual({
model: 'gpt-5.6-luna',
effort: 'max'
})
})
})
@@ -0,0 +1,101 @@
import {
getAgentSessionOptionCatalog,
type CatalogModel
} from '../../../../shared/agent-session-option-catalog'
import type { SessionOptionValue } from '../../../../shared/native-chat-session-options'
import { matchNativeChatCatalogModelId } from '../../../../shared/native-chat-session-option-state'
import { stripScrollbackAnsi } from './native-chat-scrape-fallback'
const CODEX_EFFORT_IDS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'])
function normalizedScreenLines(screen: string): string[] {
return stripScrollbackAnsi(screen)
.split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())
.filter((line) => line.length > 0)
}
function statusItemHead(line: string): string {
return (
line
.replace(/^[^A-Za-z0-9]+/, '')
.split(' · ')[0]
?.trim() ?? ''
)
}
function catalogForModels(
models: readonly CatalogModel[] | undefined
): NonNullable<ReturnType<typeof getAgentSessionOptionCatalog>> | null {
const catalog = getAgentSessionOptionCatalog('codex')
if (!catalog) {
return null
}
return models ? { ...catalog, models: [...models] } : catalog
}
function matchCatalogModelId(
catalog: NonNullable<ReturnType<typeof getAgentSessionOptionCatalog>>,
candidate: string
): string | null {
const seeded = getAgentSessionOptionCatalog('codex')
return (
matchNativeChatCatalogModelId(catalog, candidate) ??
(seeded ? matchNativeChatCatalogModelId(seeded, candidate) : null)
)
}
function matchLeadingCodexModel(
head: string,
catalog: NonNullable<ReturnType<typeof getAgentSessionOptionCatalog>>
): { modelId: string; rest: string } | null {
const tokens = head.split(' ').filter(Boolean)
const first = tokens[0]
if (!first) {
return null
}
const second = tokens[1]
// Labels are two tokens (`GPT-5.6 Luna`); the effort token must not be
// folded into a containment match of `{slug} max`.
if (second && !CODEX_EFFORT_IDS.has(second.toLowerCase())) {
const labeled = matchCatalogModelId(catalog, `${first} ${second}`)
if (labeled) {
return { modelId: labeled, rest: tokens.slice(2).join(' ') }
}
}
const modelId = matchCatalogModelId(catalog, first)
return modelId ? { modelId, rest: tokens.slice(1).join(' ') } : null
}
/**
* Codex prints `model-with-reasoning` as `{slug} {effort}[ {tier}]`, then joins
* later status items with ` · `. Search from the bottom so conversation text
* that happens to name a model cannot win over the live status line.
*/
export function readCodexSessionOptionsFromTerminalScreen(
screen: string | null | undefined,
models?: readonly CatalogModel[]
): Record<string, SessionOptionValue> | null {
if (!screen) {
return null
}
const catalog = catalogForModels(models)
if (!catalog) {
return null
}
const lines = normalizedScreenLines(screen)
for (let index = lines.length - 1; index >= 0; index -= 1) {
const head = statusItemHead(lines[index] ?? '')
const matched = matchLeadingCodexModel(head, catalog)
if (!matched) {
continue
}
const result: Record<string, SessionOptionValue> = { model: matched.modelId }
const effort = matched.rest.split(' ')[0]?.toLowerCase()
if (effort && CODEX_EFFORT_IDS.has(effort)) {
result.effort = effort
}
return result
}
return null
}
@@ -0,0 +1,38 @@
import {
getAgentSessionOptionCatalog,
type CatalogModel
} from '../../../../shared/agent-session-option-catalog'
import type { AgentType } from '../../../../shared/agent-status-types'
import type { SessionOptionValue } from '../../../../shared/native-chat-session-options'
import { matchNativeChatCatalogModelId } from '../../../../shared/native-chat-session-option-state'
import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options'
import { readCodexSessionOptionsFromTerminalScreen } from './codex-terminal-session-options'
export function readReportedSessionOptionsFromTerminalScreen(
agent: AgentType,
screen: string | null | undefined,
models?: readonly CatalogModel[]
): Record<string, SessionOptionValue> | null {
if (agent === 'claude') {
return readClaudeSessionOptionsFromTerminalScreen(screen, models)
}
if (agent === 'codex') {
return readCodexSessionOptionsFromTerminalScreen(screen, models)
}
return null
}
export function reportedNativeChatModelValues(
agent: AgentType,
reportedModel: string | null | undefined
): Record<string, SessionOptionValue> | null {
const trimmed = reportedModel?.trim()
if (!trimmed) {
return null
}
const catalog = getAgentSessionOptionCatalog(agent)
if (!catalog) {
return null
}
return { model: matchNativeChatCatalogModelId(catalog, trimmed) ?? trimmed }
}
@@ -19,12 +19,21 @@ vi.mock('../../store', () => ({
}))
import { useNativeChatSessionOptions } from './use-native-chat-session-options'
import {
clearNativeChatSessionOptionCacheForTests,
seedNativeChatAppliedSessionOptions
} from './native-chat-session-option-cache'
// A 1M-context Opus session: the frame names the resolved model while the
// host's discovered catalog names the alias.
const CLAUDE_SCREEN =
'Claude Code v2.1.220\r\nOpus 5 (1M context) with high effort · API Usage Billing\r\n~/repo'
// Codex `model-with-reasoning` + `task-progress` status line captured from the
// reporter's TUI (STA-4317): slug + effort + service tier, then a ` · ` item.
const CODEX_STATUS_LINE =
'> Find and fix a bug in @filename\r\ngpt-5.6-luna max fast · ████████░░░░'
const DISCOVERED: CatalogModel[] = [
{ id: 'opus[1m]', label: 'Opus (1M context)', options: [] },
{ id: 'haiku', label: 'Haiku', options: [] }
@@ -38,9 +47,15 @@ function modelDescriptor(snapshot: { id: string; kind: unknown }[]): {
return model?.kind as { currentValue?: string; choices: { value: string }[] }
}
function effortValue(snapshot: { id: string; kind: unknown }[]): string | undefined {
const effort = snapshot.find((descriptor) => descriptor.id === 'effort')
return (effort?.kind as { currentValue?: string } | undefined)?.currentValue
}
describe('useNativeChatSessionOptions model reporting', () => {
beforeEach(() => {
clearNativeChatModelEnrichmentForTests()
clearNativeChatSessionOptionCacheForTests()
discoverModels.mockReset()
Object.defineProperty(window, 'api', { configurable: true, value: undefined })
})
@@ -175,4 +190,84 @@ describe('useNativeChatSessionOptions model reporting', () => {
)
expect(modelDescriptor(result.current.snapshot).currentValue).toBeUndefined()
})
it('keeps Codex UI and TUI on the same model for a plain session', async () => {
const { result } = renderHook(() =>
useNativeChatSessionOptions({
agent: 'codex',
terminalTabId: 'tab-codex-plain',
targetPtyId: 'pty-codex-plain',
dispatchCommand: vi.fn(),
readTerminalScreen: () => CODEX_STATUS_LINE
})
)
await waitFor(() =>
expect(modelDescriptor(result.current.snapshot).currentValue).toBe('gpt-5.6-luna')
)
expect(effortValue(result.current.snapshot)).toBe('max')
})
it('follows a mid-session Codex model change reported by the agent hook', async () => {
seedNativeChatAppliedSessionOptions('pty-codex-mid', 'codex', { model: 'gpt-5.6-terra' })
const { result, rerender } = renderHook(
({ reportedModel }) =>
useNativeChatSessionOptions({
agent: 'codex',
terminalTabId: 'tab-codex-mid',
targetPtyId: 'pty-codex-mid',
dispatchCommand: vi.fn(),
reportedModel
}),
{ initialProps: { reportedModel: 'gpt-5.6-terra' as string | null } }
)
await waitFor(() =>
expect(modelDescriptor(result.current.snapshot).currentValue).toBe('gpt-5.6-terra')
)
rerender({ reportedModel: 'gpt-5.6-sol' })
await waitFor(() =>
expect(modelDescriptor(result.current.snapshot).currentValue).toBe('gpt-5.6-sol')
)
})
it('shows the Codex TUI’s effective model after a provider fallback, not the launched one', async () => {
seedNativeChatAppliedSessionOptions('pty-codex-fallback', 'codex', {
model: 'gpt-5.6-terra',
effort: 'ultra'
})
const { result } = renderHook(() =>
useNativeChatSessionOptions({
agent: 'codex',
terminalTabId: 'tab-codex-fallback',
targetPtyId: 'pty-codex-fallback',
dispatchCommand: vi.fn(),
readTerminalScreen: () => CODEX_STATUS_LINE
})
)
await waitFor(() =>
expect(modelDescriptor(result.current.snapshot).currentValue).toBe('gpt-5.6-luna')
)
expect(effortValue(result.current.snapshot)).toBe('max')
})
it('treats a Codex catalog label and id as the same model', async () => {
seedNativeChatAppliedSessionOptions('pty-codex-label', 'codex', { model: 'gpt-5.6-terra' })
const { result } = renderHook(() =>
useNativeChatSessionOptions({
agent: 'codex',
terminalTabId: 'tab-codex-label',
targetPtyId: 'pty-codex-label',
dispatchCommand: vi.fn(),
reportedModel: 'GPT-5.6 Luna'
})
)
await waitFor(() =>
expect(modelDescriptor(result.current.snapshot).currentValue).toBe('gpt-5.6-luna')
)
})
})
@@ -27,7 +27,11 @@ import {
discoverNativeChatCatalogModels,
resolveNativeChatModelDiscoveryContext
} from './native-chat-session-option-discovery'
import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options'
import { findTabAgentEntry } from './native-chat-tab-agent-entry'
import {
readReportedSessionOptionsFromTerminalScreen,
reportedNativeChatModelValues
} from './native-chat-terminal-session-options'
const EMPTY_SNAPSHOT: SessionOptionDescriptor[] = []
const subscribeEmpty = (): (() => void) => () => {}
@@ -90,15 +94,37 @@ export function useNativeChatSessionOptions(args: {
dispatchCommand: NativeChatSessionOptionDispatchCommand
onAgentPicker?: () => void
readTerminalScreen?: () => string | null
/** Live model from agent-status hooks; authority over a launch-seeded guess. */
reportedModel?: string | null
}): {
surface: NativeChatPtySessionOptionsSurface | null
snapshot: SessionOptionDescriptor[]
} {
const { agent, terminalTabId, targetPtyId, dispatchCommand, onAgentPicker, readTerminalScreen } =
args
const {
agent,
terminalTabId,
targetPtyId,
dispatchCommand,
onAgentPicker,
readTerminalScreen,
reportedModel: reportedModelOverride
} = args
const statusModel = useSyncExternalStore(
(onStoreChange) => {
const subscribe = useAppStore.subscribe
return typeof subscribe === 'function' ? subscribe(onStoreChange) : () => {}
},
() =>
findTabAgentEntry(useAppStore.getState().agentStatusByPaneKey ?? {}, terminalTabId)?.model ??
null,
() => null
)
const reportedModel = reportedModelOverride ?? statusModel ?? null
// The screen text that last parsed into reported values, so a later model
// discovery can re-resolve it against the host's real ids.
const reportedScreenRef = useRef<string | null>(null)
const tuiSeededRef = useRef(false)
const previousHookModelRef = useRef<string | null>(null)
const discoveryContext = useMemo(
() => resolveNativeChatModelDiscoveryContext(terminalTabId),
[terminalTabId]
@@ -107,19 +133,19 @@ export function useNativeChatSessionOptions(args: {
// Why: native chat currently attaches only after startup is already queued;
// exposing a draft picker here would claim it can still mutate that command.
if (!targetPtyId) {
tuiSeededRef.current = false
return null
}
const scopeKey = targetPtyId ?? terminalTabId
const discoveredModels = discoveryContext
? readNativeChatEnrichedModels(agent, discoveryContext.hostKey)
: null
const reportedValues =
agent === 'claude'
? readClaudeSessionOptionsFromTerminalScreen(
readTerminalScreen?.(),
discoveredModels ?? undefined
)
: null
const screenValues = readReportedSessionOptionsFromTerminalScreen(
agent,
readTerminalScreen?.(),
discoveredModels ?? undefined
)
tuiSeededRef.current = screenValues != null
return createNativeChatPtySessionOptions({
agent,
scopeKey,
@@ -129,7 +155,7 @@ export function useNativeChatSessionOptions(args: {
// — without it the whole picker would pop in late or never appear.
...(discoveryContext ? { initialModels: discoveredModels ?? undefined } : {}),
mode: targetPtyId ? 'live' : 'draft',
reportedValues,
reportedValues: screenValues,
dispatchCommand,
onAgentPicker,
persistSelection: ({ modelId, optionId, value, adoptModelAsLaunchDefault }) =>
@@ -155,7 +181,7 @@ export function useNativeChatSessionOptions(args: {
])
useEffect(() => {
if (!surface || agent !== 'claude') {
if (!surface || (agent !== 'claude' && agent !== 'codex')) {
return
}
let cancelled = false
@@ -178,7 +204,8 @@ export function useNativeChatSessionOptions(args: {
? readNativeChatEnrichedModels(agent, discoveryContext.hostKey)
: null
for (const screen of [authoritativeScreen, readTerminalScreen?.() ?? null]) {
const reportedValues = readClaudeSessionOptionsFromTerminalScreen(
const reportedValues = readReportedSessionOptionsFromTerminalScreen(
agent,
screen,
models ?? undefined
)
@@ -192,6 +219,7 @@ export function useNativeChatSessionOptions(args: {
return
}
reportedScreenRef.current = screen
tuiSeededRef.current = true
surface.reportSessionOptions(reportedValues)
return
}
@@ -202,6 +230,32 @@ export function useNativeChatSessionOptions(args: {
}
}, [agent, discoveryContext, readTerminalScreen, surface, targetPtyId])
// Hook reports follow later /model and provider-fallback changes. The first
// slug is skipped only when the TUI screen already named the model — otherwise
// a late SessionStart would be the only evidence of a fallback.
useEffect(() => {
previousHookModelRef.current = null
}, [surface])
useEffect(() => {
if (!surface) {
return
}
const values = reportedNativeChatModelValues(agent, reportedModel)
const matched = typeof values?.model === 'string' ? values.model : null
if (!matched || !values) {
return
}
const previous = previousHookModelRef.current
if (previous === matched) {
return
}
previousHookModelRef.current = matched
if (!previous && tuiSeededRef.current) {
return
}
surface.reportSessionOptions(values)
}, [agent, reportedModel, surface])
useEffect(() => {
if (!surface || !discoveryContext) {
return
@@ -211,9 +265,9 @@ export function useNativeChatSessionOptions(args: {
discoveryContext.hostKey,
(models) => {
surface.replaceModels(models)
const screen = agent === 'claude' ? reportedScreenRef.current : null
const screen = agent === 'claude' || agent === 'codex' ? reportedScreenRef.current : null
const reportedValues = screen
? readClaudeSessionOptionsFromTerminalScreen(screen, models)
? readReportedSessionOptionsFromTerminalScreen(agent, screen, models)
: null
if (reportedValues) {
surface.reportSessionOptions(reportedValues)
@@ -55,6 +55,15 @@ describe('matchNativeChatCatalogModelId', () => {
'sonnet'
)
expect(matchNativeChatCatalogModelId(CODEX_SESSION_OPTION_CATALOG, 'gpt-5.5')).toBe('gpt-5.5')
expect(matchNativeChatCatalogModelId(CODEX_SESSION_OPTION_CATALOG, 'gpt-5.6-terra')).toBe(
'gpt-5.6-terra'
)
expect(matchNativeChatCatalogModelId(CODEX_SESSION_OPTION_CATALOG, 'GPT-5.6 Terra')).toBe(
'gpt-5.6-terra'
)
expect(
matchNativeChatCatalogModelId(CODEX_SESSION_OPTION_CATALOG, 'gpt-5.6-luna max fast')
).toBe('gpt-5.6-luna')
})
it('returns null for unrecognized reports', () => {