diff --git a/mobile/src/session/use-mobile-native-chat-session-options.test.ts b/mobile/src/session/use-mobile-native-chat-session-options.test.ts index 720085cabe3..f2cde77d598 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.test.ts @@ -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 () => { diff --git a/mobile/src/session/use-mobile-native-chat-session-options.ts b/mobile/src/session/use-mobile-native-chat-session-options.ts index d57c4c06976..4fcaf7166f8 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.ts @@ -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 } 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 88d74631949..640d256e8b1 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 @@ -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. 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 6e77b06e445..0e7b5eff682 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 @@ -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 } diff --git a/src/shared/native-chat-session-option-state.test.ts b/src/shared/native-chat-session-option-state.test.ts index 7fa100abe23..31746ba7889 100644 --- a/src/shared/native-chat-session-option-state.test.ts +++ b/src/shared/native-chat-session-option-state.test.ts @@ -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, diff --git a/src/shared/native-chat-session-option-state.ts b/src/shared/native-chat-session-option-state.ts index 12f49ea1ec0..19aec983344 100644 --- a/src/shared/native-chat-session-option-state.ts +++ b/src/shared/native-chat-session-option-state.ts @@ -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 (