From fb85ab6ce56e3c650b39bcddbc65e5fad01ccc28 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:50:49 -0700 Subject: [PATCH] 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. --- .../codex-terminal-session-options.test.ts | 46 ++++++++ .../codex-terminal-session-options.ts | 101 ++++++++++++++++++ .../native-chat-terminal-session-options.ts | 38 +++++++ .../use-native-chat-session-options.test.ts | 95 ++++++++++++++++ .../use-native-chat-session-options.ts | 84 ++++++++++++--- .../native-chat-session-option-state.test.ts | 9 ++ 6 files changed, 358 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/components/native-chat/codex-terminal-session-options.test.ts create mode 100644 src/renderer/src/components/native-chat/codex-terminal-session-options.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-terminal-session-options.ts diff --git a/src/renderer/src/components/native-chat/codex-terminal-session-options.test.ts b/src/renderer/src/components/native-chat/codex-terminal-session-options.test.ts new file mode 100644 index 00000000000..3e8bee4cf88 --- /dev/null +++ b/src/renderer/src/components/native-chat/codex-terminal-session-options.test.ts @@ -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' + }) + }) +}) diff --git a/src/renderer/src/components/native-chat/codex-terminal-session-options.ts b/src/renderer/src/components/native-chat/codex-terminal-session-options.ts new file mode 100644 index 00000000000..a20345f6804 --- /dev/null +++ b/src/renderer/src/components/native-chat/codex-terminal-session-options.ts @@ -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> | null { + const catalog = getAgentSessionOptionCatalog('codex') + if (!catalog) { + return null + } + return models ? { ...catalog, models: [...models] } : catalog +} + +function matchCatalogModelId( + catalog: NonNullable>, + candidate: string +): string | null { + const seeded = getAgentSessionOptionCatalog('codex') + return ( + matchNativeChatCatalogModelId(catalog, candidate) ?? + (seeded ? matchNativeChatCatalogModelId(seeded, candidate) : null) + ) +} + +function matchLeadingCodexModel( + head: string, + catalog: NonNullable> +): { 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 | 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 = { model: matched.modelId } + const effort = matched.rest.split(' ')[0]?.toLowerCase() + if (effort && CODEX_EFFORT_IDS.has(effort)) { + result.effort = effort + } + return result + } + return null +} diff --git a/src/renderer/src/components/native-chat/native-chat-terminal-session-options.ts b/src/renderer/src/components/native-chat/native-chat-terminal-session-options.ts new file mode 100644 index 00000000000..2cbba8e3d25 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-terminal-session-options.ts @@ -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 | 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 | null { + const trimmed = reportedModel?.trim() + if (!trimmed) { + return null + } + const catalog = getAgentSessionOptionCatalog(agent) + if (!catalog) { + return null + } + return { model: matchNativeChatCatalogModelId(catalog, trimmed) ?? trimmed } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts index a24d1b8f87f..7a4f1e2e41b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts @@ -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') + ) + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts index 54237b05628..bb695268072 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts @@ -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(null) + const tuiSeededRef = useRef(false) + const previousHookModelRef = useRef(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) diff --git a/src/shared/native-chat-session-option-state.test.ts b/src/shared/native-chat-session-option-state.test.ts index f2827207bf7..c1e50e568cc 100644 --- a/src/shared/native-chat-session-option-state.test.ts +++ b/src/shared/native-chat-session-option-state.test.ts @@ -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', () => {