mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user