diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index 8716c568..b9144c3a 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -63,6 +63,42 @@ beforeEach(() => { }) describe('ModelSelector', () => { + it('shows the configured default for an opened session whose saved provider was removed', () => { + useSettingsStore.setState({ locale: 'en', effortLevel: 'high' }) + useProviderStore.setState({ + activeId: 'replacement', hasLoadedProviders: true, isLoading: false, + providers: [{ + id: 'replacement', presetId: 'custom', name: 'Replacement', + apiFormat: 'anthropic', apiKey: 'fixture', baseUrl: 'http://127.0.0.1:1', + models: { main: 'current-model', haiku: '', sonnet: '', opus: '' }, + }], + }) + useSessionRuntimeStore.getState().setSelection('restored-session', { + providerId: 'deleted-provider', modelId: 'old-model', effortLevel: 'max', + }) + + render() + + expect(screen.getByRole('button', { name: 'current-model, Replacement' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Select model' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /High/ })).toBeInTheDocument() + }) + + it('keeps a restored official model visible before lazy OAuth status is fetched', () => { + useSettingsStore.setState({ locale: 'en' }) + useProviderStore.setState({ hasLoadedProviders: true, providers: [], activeId: null }) + useSessionRuntimeStore.getState().setSelection('official-restored', { + providerId: null, modelId: 'claude-opus-4-8', effortLevel: 'high', + }) + const fetchStatus = vi.fn(async () => {}) + useHahaOAuthStore.setState({ status: null, fetchStatus }) + + render() + + expect(screen.getByRole('button', { name: 'Opus 4.8, Claude Official' })).toBeInTheDocument() + expect(fetchStatus).not.toHaveBeenCalled() + }) + it('keeps a long model label shrinkable in a fluid desktop toolbar', () => { useSettingsStore.setState({ locale: 'en', availableModels: MODELS, currentModel: MODELS[0] }) render() @@ -603,7 +639,7 @@ describe('ModelSelector', () => { }) }) - it('defaults blank provider-scoped runtime selections to the active provider main model', async () => { + it('defaults blank runtime selections to the model selected in settings', async () => { useSettingsStore.setState({ locale: 'en', availableModels: [ @@ -635,17 +671,17 @@ describe('ModelSelector', () => { render() - const trigger = screen.getByRole('button', { name: /deepseek-v4-flash/i }) + const trigger = screen.getByRole('button', { name: /deepseek-v4-pro/i }) await act(async () => { fireEvent.click(trigger) await Promise.resolve() }) - const flashOption = screen - .getAllByRole('button', { name: /deepseek-v4-flash/i }) - .find((button) => button.textContent?.includes('Main Model')) - expect(flashOption).toBeDefined() - expect(flashOption?.className).toContain('border-[var(--color-model-option-selected-border)]') + const configuredOption = screen + .getAllByRole('button', { name: /deepseek-v4-pro/i }) + .find((button) => button.textContent?.includes('Sonnet Model')) + expect(configuredOption).toBeDefined() + expect(configuredOption?.className).toContain('border-[var(--color-model-option-selected-border)]') }) it('closes the focus ring on both halves of the segmented control', () => { diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index 82c7b81e..f539bff3 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -22,8 +22,8 @@ import { useMobileViewport } from '../../hooks/useMobileViewport' import { isDesktopRuntime } from '../../lib/desktopRuntime' import { normalizeRuntimeSelection, + reconcileRuntimeSelection, resolveDefaultRuntimeSelection, - resolveProviderRuntimeModelId, resolveProviderSlotModelId, } from '../../lib/runtimeSelection' import { useHahaOAuthStore } from '../../stores/hahaOAuthStore' @@ -452,25 +452,29 @@ export const ModelSelector = forwardRef(function Mod storeModel?.id, ) : null - const requestedRuntimeProvider = providers.find( - (provider) => provider.id === requestedRuntimeSelection?.providerId, - ) - const activeRuntimeSelection = requestedRuntimeSelection && providerChoices.some( - (choice) => choice.providerId === requestedRuntimeSelection.providerId, - ) - ? { - ...requestedRuntimeSelection, - modelId: requestedRuntimeProvider - ? resolveProviderRuntimeModelId( - requestedRuntimeProvider, - requestedRuntimeSelection.modelId, - ) - : requestedRuntimeSelection.modelId, - } + const resolvedRuntimeSelection = requestedRuntimeSelection + ? reconcileRuntimeSelection(requestedRuntimeSelection, { + providers, activeId, hasLoadedProviders: hasLoadedProviders && lockedProviderId === undefined, + currentModelId: storeModel?.id, + defaultEffortLevel: effortLevel, + }) : null + // OAuth catalogs are loaded lazily. Their absence must not erase an already + // selected model, even while another provider is the global default. + const activeRuntimeSelection = resolvedRuntimeSelection && ( + controlledRuntimeSelection || runtimeSelection || + providerChoices.some((choice) => choice.providerId === resolvedRuntimeSelection.providerId) + ) ? resolvedRuntimeSelection : null const selectedProviderChoice = activeRuntimeSelection - ? providerChoices.find((choice) => choice.providerId === activeRuntimeSelection.providerId) ?? null + ? providerChoices.find((choice) => choice.providerId === activeRuntimeSelection.providerId) + ?? (activeRuntimeSelection.providerId === null + ? officialChoices(null, mergeOfficialModels(activeId === null ? availableModels : []), activeId === null, t('settings.providers.officialName')) + : activeRuntimeSelection.providerId === OPENAI_OFFICIAL_PROVIDER_ID + ? officialChoices(OPENAI_OFFICIAL_PROVIDER_ID, activeId === OPENAI_OFFICIAL_PROVIDER_ID && availableModels.length ? availableModels : OPENAI_OFFICIAL_MODELS, activeId === OPENAI_OFFICIAL_PROVIDER_ID, t('settings.providers.openaiOfficialName')) + : activeRuntimeSelection.providerId === GROK_OFFICIAL_PROVIDER_ID + ? officialChoices(GROK_OFFICIAL_PROVIDER_ID, activeId === GROK_OFFICIAL_PROVIDER_ID && availableModels.length ? availableModels : GROK_OFFICIAL_MODELS, activeId === GROK_OFFICIAL_PROVIDER_ID, t('settings.providers.grokOfficialName')) + : null) : null const selectedRuntimeModel = activeRuntimeSelection @@ -491,7 +495,7 @@ export const ModelSelector = forwardRef(function Mod (selectedRuntimeProvider.apiFormat ?? 'anthropic') === 'anthropic' && !isOpenAIReasoningModel(selectedRuntimeModel?.id ?? '') - const needsProviderConfiguration = isRuntimeScoped && providerChoices.length === 0 + const needsProviderConfiguration = isRuntimeScoped && !activeRuntimeSelection && providerChoices.length === 0 const buttonModelLabel = isRuntimeScoped ? selectedRuntimeModel?.name ?? (needsProviderConfiguration ? t('model.configureProvider') : t('model.selectModel')) diff --git a/desktop/src/lib/runtimeSelection.test.ts b/desktop/src/lib/runtimeSelection.test.ts index 18ab4575..3cf384fe 100644 --- a/desktop/src/lib/runtimeSelection.test.ts +++ b/desktop/src/lib/runtimeSelection.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from 'vitest' -import { normalizeRuntimeSelection, resolveDefaultRuntimeSelection, resolveProviderRuntimeModelId, resolveProviderSlotModelId } from './runtimeSelection' +import { normalizeRuntimeSelection, reconcileRuntimeSelection, resolveDefaultRuntimeSelection, resolveProviderRuntimeModelId, resolveProviderSlotModelId } from './runtimeSelection' import type { SavedProvider } from '../types/provider' describe('normalizeRuntimeSelection', () => { + it('normalizes an old ChatGPT effort against its model without downgrading supported choices', () => { + expect(normalizeRuntimeSelection({ providerId: 'openai-official', modelId: 'gpt-5.5', effortLevel: 'max' })) + .toEqual({ providerId: 'openai-official', modelId: 'gpt-5.5', effortLevel: 'medium' }) + const futureModel = { providerId: 'openai-official', modelId: 'future-catalog-model', effortLevel: 'max' as const } + expect(normalizeRuntimeSelection(futureModel)).toBe(futureModel) + }) it.each([ ['Claude Official', null], ['ChatGPT Official', 'openai-official'], @@ -147,6 +153,37 @@ describe('provider 1M runtime selection', () => { model1mSupport: { main: true, fable: false, haiku: false, sonnet: true, opus: false }, } + it('waits for provider hydration before recovering a removed provider and preserves valid session choices', () => { + const selection = { providerId: 'deleted-provider', modelId: 'old-model', effortLevel: 'max' as const } + const context = { activeId: provider.id, providers: [provider], hasLoadedProviders: false } + expect(reconcileRuntimeSelection(selection, context)).toBe(selection) + expect(reconcileRuntimeSelection(selection, { ...context, hasLoadedProviders: true })).toEqual({ + providerId: provider.id, modelId: 'main-model[1m]', + }) + const explicit = { providerId: provider.id, modelId: 'balanced-model[1m]', effortLevel: 'high' as const } + expect(reconcileRuntimeSelection(explicit, { ...context, hasLoadedProviders: true })).toBe(explicit) + const official = { providerId: null, modelId: 'claude-opus-4-8', effortLevel: 'high' as const } + expect(reconcileRuntimeSelection(official, { ...context, hasLoadedProviders: true })).toBe(official) + }) + + it('reconciles effort after the provider protocol and preset become available', () => { + const selection = { providerId: provider.id, modelId: 'glm-5.3', effortLevel: 'medium' as const } + const context = { + activeId: provider.id, hasLoadedProviders: true, + providers: [{ ...provider, presetId: 'zhipuglm', apiFormat: 'anthropic' as const }], + } + expect(reconcileRuntimeSelection(selection, context)).toEqual({ ...selection, effortLevel: 'max' }) + }) + + it('uses the current default effort rather than a removed provider effort or the new model default', () => { + const context = { + activeId: provider.id, hasLoadedProviders: true, defaultEffortLevel: 'high' as const, + providers: [{ ...provider, presetId: 'zhipuglm', models: { main: 'glm-5.3', haiku: '', sonnet: '', opus: '' } }], + } + expect(reconcileRuntimeSelection({ providerId: 'removed', modelId: 'old', effortLevel: 'medium' }, context)) + .toEqual({ providerId: provider.id, modelId: 'glm-5.3[1m]', effortLevel: 'high' }) + }) + it('materializes the active provider main slot by id and by legacy name', () => { for (const activeId of [provider.id, null]) { expect(resolveDefaultRuntimeSelection(activeId, provider.name, [provider], 'stale')).toEqual({ @@ -155,6 +192,13 @@ describe('provider 1M runtime selection', () => { } }) + it('restores the model selected in settings even when it is not the provider main slot', () => { + const context = { providers: [provider], activeId: provider.id, hasLoadedProviders: true, currentModelId: 'balanced-model' } + const expected = { providerId: provider.id, modelId: 'balanced-model[1m]' } + expect(resolveDefaultRuntimeSelection(provider.id, provider.name, [provider], 'balanced-model')).toEqual(expected) + expect(reconcileRuntimeSelection({ providerId: 'removed', modelId: 'old-model' }, context)).toEqual(expected) + }) + it('reconciles restored raw and marked IDs without losing a non-main model or effort', () => { expect(resolveProviderRuntimeModelId(provider, 'balanced-model')).toBe('balanced-model[1m]') expect(resolveProviderRuntimeModelId(provider, 'large-model[1m]')).toBe('large-model') diff --git a/desktop/src/lib/runtimeSelection.ts b/desktop/src/lib/runtimeSelection.ts index df65975f..688ab590 100644 --- a/desktop/src/lib/runtimeSelection.ts +++ b/desktop/src/lib/runtimeSelection.ts @@ -1,6 +1,7 @@ import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog' import { OPENAI_OFFICIAL_DEFAULT_MODEL_ID, + OPENAI_OFFICIAL_MODELS, OPENAI_OFFICIAL_PROVIDER_ID, } from '../constants/openaiOfficialProvider' import type { SavedProvider } from '../types/provider' @@ -17,6 +18,7 @@ import { type ModelReasoningApiFormat, type ModelReasoningProviderKind, } from '../../../src/shared/modelReasoning' +import { getBundledPresetReasoningProviderKind } from '../config/providerPresets' const PROVIDER_MODEL_SLOTS = ['main', 'haiku', 'sonnet', 'opus', 'fable'] as const @@ -61,10 +63,14 @@ export function resolveActiveProviderRuntimeSelection( if (!inferredProviderId) return null const providerMainModelId = activeProvider ? resolveProviderSlotModelId(activeProvider, 'main') : undefined + const configuredModelId = activeProvider && currentModelId && PROVIDER_MODEL_SLOTS.some( + (slot) => activeProvider.models[slot]?.trim() && + baseProviderModelId(activeProvider.models[slot]!) === baseProviderModelId(currentModelId), + ) ? resolveProviderRuntimeModelId(activeProvider, currentModelId) : undefined return { providerId: inferredProviderId, - modelId: providerMainModelId || currentModelId || ( + modelId: configuredModelId || providerMainModelId || currentModelId || ( inferredProviderId === OPENAI_OFFICIAL_PROVIDER_ID ? OPENAI_OFFICIAL_DEFAULT_MODEL_ID : inferredProviderId === GROK_OFFICIAL_PROVIDER_ID @@ -91,6 +97,42 @@ export function resolveDefaultRuntimeSelection( } } +/** Resolve restored choices only after the saved-provider list is authoritative. */ +export function reconcileRuntimeSelection( + selection: RuntimeSelection, + context: { + providers: SavedProvider[] + hasLoadedProviders: boolean + activeId: string | null + currentModelId?: string + defaultEffortLevel?: RuntimeSelection['effortLevel'] + }, +): RuntimeSelection { + const provider = context.providers.find((entry) => entry.id === selection.providerId) + const isOfficial = selection.providerId === null || + selection.providerId === OPENAI_OFFICIAL_PROVIDER_ID || + selection.providerId === GROK_OFFICIAL_PROVIDER_ID + if (!provider && !isOfficial && context.hasLoadedProviders) { + // Deleted/recreated providers must not carry their old model or effort into + // the default provider. Use the same choice for rendering and transport. + const fallback = resolveDefaultRuntimeSelection( + context.activeId, null, context.providers, context.currentModelId, + ) + const fallbackProvider = context.providers.find((entry) => entry.id === fallback.providerId) + return normalizeRuntimeSelection( + { ...fallback, ...(context.defaultEffortLevel ? { effortLevel: context.defaultEffortLevel } : {}) }, + fallbackProvider?.apiFormat, + fallbackProvider ? getBundledPresetReasoningProviderKind(fallbackProvider.presetId) : undefined, + ) + } + const modelId = provider ? resolveProviderRuntimeModelId(provider, selection.modelId) : selection.modelId + return normalizeRuntimeSelection( + modelId === selection.modelId ? selection : { ...selection, modelId }, + provider?.apiFormat, + provider ? getBundledPresetReasoningProviderKind(provider.presetId) : undefined, + ) +} + export function normalizeRuntimeSelection( selection: RuntimeSelection, apiFormat?: ModelReasoningApiFormat, @@ -98,14 +140,14 @@ export function normalizeRuntimeSelection( ): RuntimeSelection { if ( selection.effortLevel === undefined || - selection.providerId === null || - selection.providerId === OPENAI_OFFICIAL_PROVIDER_ID + selection.providerId === null ) { return selection } - if (selection.providerId === GROK_OFFICIAL_PROVIDER_ID) { - const model = GROK_OFFICIAL_MODELS.find((entry) => entry.id === selection.modelId) + if (selection.providerId === GROK_OFFICIAL_PROVIDER_ID || selection.providerId === OPENAI_OFFICIAL_PROVIDER_ID) { + const models = selection.providerId === GROK_OFFICIAL_PROVIDER_ID ? GROK_OFFICIAL_MODELS : OPENAI_OFFICIAL_MODELS + const model = models.find((entry) => entry.id === selection.modelId) // Models only known from the live catalog (e.g. grok-4.6) are absent from // the bundled desktop list. Keep their effort untouched and let the server // validate it against the live catalog instead of silently dropping it. @@ -113,6 +155,7 @@ export function normalizeRuntimeSelection( const effortLevel = model.supportedReasoningEfforts?.includes(selection.effortLevel) ? selection.effortLevel : model.defaultReasoningEffort ?? model.supportedReasoningEfforts?.[0] + if (effortLevel === selection.effortLevel) return selection const { effortLevel: _unsupportedEffort, ...runtime } = selection return effortLevel ? { ...runtime, effortLevel } : runtime } diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 1c115de5..0e92cd81 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -83,7 +83,7 @@ const { sendSubagentMessageMock: vi.fn(async () => ({ ok: true })), tabStoreSnapshot: { tabs: [] as Tab[], activeTabId: null as string | null }, tabStoreListeners: new Set<(state: any, previous: any) => void>(), - providerStoreSnapshot: { providers: [] as SavedProvider[], activeId: null as string | null }, + providerStoreSnapshot: { providers: [] as SavedProvider[], activeId: null as string | null, hasLoadedProviders: false }, })) vi.mock('./teamPlanStore', () => ({ useTeamPlanStore: { getState: () => ({ refresh: refreshTeamPlanMock }) } })) @@ -532,6 +532,8 @@ describe('chatStore history mapping', () => { beforeEach(() => { providerStoreSnapshot.providers = [] providerStoreSnapshot.activeId = null + providerStoreSnapshot.hasLoadedProviders = false + useSettingsStore.setState({ currentModel: null, activeProviderName: null, effortLevel: 'max' }) sendMock.mockReset() getMemberBySessionIdMock.mockReset() getMemberBySessionIdMock.mockReturnValue(null) @@ -5333,6 +5335,31 @@ describe('chatStore history mapping', () => { ]) }) + it.each(['reconnect', 'send'] as const)('recovers a removed provider before %s without replaying its stale model or effort', (action) => { + useSettingsStore.setState({ effortLevel: 'high' }) + providerStoreSnapshot.hasLoadedProviders = true + providerStoreSnapshot.activeId = 'replacement' + providerStoreSnapshot.providers = [{ + id: 'replacement', presetId: 'custom', name: 'Replacement', apiKey: 'fixture', + baseUrl: 'http://127.0.0.1:1', apiFormat: 'anthropic', + models: { main: 'current-model', haiku: '', sonnet: '', opus: '' }, + }] + useSessionRuntimeStore.getState().setSelection(TEST_SESSION_ID, { + providerId: 'deleted-provider', modelId: 'old-model', effortLevel: 'max', + }) + if (action === 'reconnect') { + useChatStore.getState().connectToSession(TEST_SESSION_ID, { prewarm: false, minimalBootstrap: true }) + } else { + useChatStore.getState().sendMessage(TEST_SESSION_ID, 'continue') + } + const expected = { providerId: 'replacement', modelId: 'current-model', effortLevel: 'high' } + expect(sendMock.mock.calls[0]).toEqual([TEST_SESSION_ID, { type: 'set_runtime_config', ...expected }]) + expect(useSessionRuntimeStore.getState().selections[TEST_SESSION_ID]).toEqual(expected) + if (action === 'send') { + expect(sendMock.mock.calls[1]).toEqual([TEST_SESSION_ID, { type: 'user_message', content: 'continue', attachments: undefined }]) + } + }) + it.each([true, false])('reconciles restored raw runtime models before reconnect and the next turn (1m=%s)', (enabled) => { const model = 'deepseek-v4.1-flash-expires-on-0910' providerStoreSnapshot.providers = [{ @@ -5368,11 +5395,25 @@ describe('chatStore history mapping', () => { }] useChatStore.getState().sendMessage(TEST_SESSION_ID, 'continue') expect(sendMock.mock.calls.slice(0, 2)).toEqual([ - [TEST_SESSION_ID, { type: 'set_runtime_config', providerId: 'provider-1', modelId: 'deepseek-v4.1[1m]' }], + [TEST_SESSION_ID, { type: 'set_runtime_config', providerId: 'provider-1', modelId: 'deepseek-v4.1[1m]', effortLevel: 'max' }], [TEST_SESSION_ID, { type: 'user_message', content: 'continue', attachments: undefined }], ]) }) + it('sends an implicit old session with the non-main model and effort shown from settings', () => { + providerStoreSnapshot.activeId = 'provider-1' + providerStoreSnapshot.providers = [{ + id: 'provider-1', presetId: 'zhipuglm', name: 'GLM', apiKey: 'fixture', + baseUrl: 'http://127.0.0.1:1', apiFormat: 'anthropic', + models: { main: 'glm-5.2', haiku: '', sonnet: 'glm-5.3', opus: '' }, + }] + useSettingsStore.setState({ currentModel: { id: 'glm-5.3', name: 'GLM', context: '', description: '' }, effortLevel: 'high' }) + useChatStore.getState().sendMessage(TEST_SESSION_ID, 'continue') + expect(sendMock.mock.calls[0]).toEqual([TEST_SESSION_ID, { + type: 'set_runtime_config', providerId: 'provider-1', modelId: 'glm-5.3', effortLevel: 'high', + }]) + }) + it('does not prewarm unknown desktop sessions when connecting', () => { useChatStore.getState().connectToSession(TEST_SESSION_ID) @@ -9213,6 +9254,32 @@ describe('chatStore history mapping', () => { expect(useSessionRuntimeStore.getState().selections[TEST_SESSION_ID]?.modelId).toBe('model-a') }) + it('accepts a corrected runtime acknowledgement only for the matching restored choice', () => { + useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ runtimeConfigReadyCount: 0 }) } }) + const requestedConfig = { providerId: 'deleted-provider', modelId: 'old-model', effortLevel: 'max' as const } + const applied = { providerId: 'replacement', modelId: 'current-model', effortLevel: 'high' as const } + useSessionRuntimeStore.getState().setSelection(TEST_SESSION_ID, requestedConfig) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'error', code: 'RUNTIME_CONFIG_INVALID', message: 'Runtime effort selection is invalid.', + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'runtime_config_applied', ...applied, requestedConfig, + }) + expect(useSessionRuntimeStore.getState().selections[TEST_SESSION_ID]).toEqual(applied) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.runtimeConfigReadyCount).toBe(1) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'RUNTIME_CONFIG_INVALID' }), + ])) + + const newChoice = { providerId: 'another-provider', modelId: 'my-selection' } + useSessionRuntimeStore.getState().setSelection(TEST_SESSION_ID, newChoice) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'runtime_config_applied', ...applied, requestedConfig, + }) + expect(useSessionRuntimeStore.getState().selections[TEST_SESSION_ID]).toEqual(newChoice) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.runtimeConfigReadyCount).toBe(1) + }) + it.each(['RUNTIME_CONFIG_INVALID', 'CLI_RESTART_FAILED'])('allows fresh metadata to correct a rejected selection (%s)', (code) => { const runtime = useSessionRuntimeStore.getState() runtime.setSelection(TEST_SESSION_ID, { providerId: null, modelId: 'rejected-model' }) diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 583d2a1c..64cfa067 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -15,7 +15,9 @@ import { useCLITaskStore } from './cliTaskStore' import { useWorkflowStore } from './workflowStore' import { useSessionRuntimeStore } from './sessionRuntimeStore' import { useProviderStore } from './providerStore' -import { resolveActiveProviderRuntimeSelection, resolveProviderRuntimeModelId } from '../lib/runtimeSelection' +import { reconcileRuntimeSelection, resolveActiveProviderRuntimeSelection } from '../lib/runtimeSelection' +import { useSettingsStore } from './settingsStore' +import { isModelReasoningEffort } from '../../../src/shared/modelReasoning' import { useTabStore } from './tabStore' import { randomSpinnerVerb } from '../config/spinnerVerbs' import { notifyDesktop } from '../lib/desktopNotifications' @@ -60,11 +62,15 @@ import type { type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' -function reconcileProviderRuntimeSelection(selection: RuntimeSelection): RuntimeSelection { - const provider = useProviderStore.getState().providers.find((entry) => entry.id === selection.providerId) - if (!provider) return selection - const modelId = resolveProviderRuntimeModelId(provider, selection.modelId) - return modelId === selection.modelId ? selection : { ...selection, modelId } +function reconcileProviderRuntimeSelection(sessionId: string, selection: RuntimeSelection): RuntimeSelection { + const providers = useProviderStore.getState() + const settings = useSettingsStore.getState() + return reconcileRuntimeSelection(selection, { + ...providers, + hasLoadedProviders: providers.hasLoadedProviders && !isSideChatSession(sessionId), + currentModelId: settings.currentModel?.id, + defaultEffortLevel: settings.effortLevel, + }) } type ToolCall = Extract type CompactSummaryMessage = Extract @@ -3408,14 +3414,18 @@ export const useChatStore = create((setState, get) => { const selection = useSessionRuntimeStore.getState().selections[sessionId] if (selection) { - const reconciled = reconcileProviderRuntimeSelection(selection) + const reconciled = reconcileProviderRuntimeSelection(sessionId, selection) if (reconciled !== selection) get().setSessionRuntime(sessionId, selection) } else { const providers = useProviderStore.getState() - const defaultSelection = resolveActiveProviderRuntimeSelection( - providers.activeId, null, providers.providers, undefined, + const settings = useSettingsStore.getState() + const configuredDefault = resolveActiveProviderRuntimeSelection( + providers.activeId, settings.activeProviderName, providers.providers, settings.currentModel?.id, ) - if (defaultSelection) { + if (configuredDefault) { + const defaultSelection = reconcileProviderRuntimeSelection(sessionId, { + ...configuredDefault, effortLevel: settings.effortLevel, + }) useSessionRuntimeStore.getState().setSelection(sessionId, defaultSelection) get().setSessionRuntime(sessionId, defaultSelection) } @@ -3484,7 +3494,7 @@ export const useChatStore = create((setState, get) => { }, setSessionRuntime: (sessionId, selection) => { - const reconciled = reconcileProviderRuntimeSelection(selection) + const reconciled = reconcileProviderRuntimeSelection(sessionId, selection) if (reconciled !== selection) { useSessionRuntimeStore.getState().setSelection(sessionId, reconciled) } @@ -4865,14 +4875,22 @@ export const useChatStore = create((setState, get) => { case 'runtime_config_applied': { const selected = useSessionRuntimeStore.getState().selections[sessionId] - const matchesCurrentSelection = Boolean(selected) && - (selected?.providerId ?? null) === msg.providerId && - selected?.modelId === msg.modelId && - selected?.effortLevel === msg.effortLevel - if (matchesCurrentSelection) { + const matchesSelection = (runtime: { providerId: string | null; modelId: string; effortLevel?: string }) => + Boolean(selected) && selected?.providerId === runtime.providerId && + selected?.modelId === runtime.modelId && selected?.effortLevel === runtime.effortLevel + const matchesCurrentSelection = matchesSelection(msg) + const correctsCurrentSelection = msg.requestedConfig && matchesSelection(msg.requestedConfig) + if (matchesCurrentSelection || correctsCurrentSelection) { + if (correctsCurrentSelection && !matchesCurrentSelection) { + useSessionRuntimeStore.getState().setSelection(sessionId, { + providerId: msg.providerId, modelId: msg.modelId, + ...(msg.effortLevel && isModelReasoningEffort(msg.effortLevel) ? { effortLevel: msg.effortLevel } : {}), + }) + } useSessionRuntimeStore.getState().settleSelection(sessionId) update((session) => ({ runtimeConfigReadyCount: (session.runtimeConfigReadyCount ?? 0) + 1, + messages: session.messages.filter((message) => message.type !== 'error' || message.code !== 'RUNTIME_CONFIG_INVALID'), })) } break diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index 9f46fd3a..5e8a8b4d 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -141,6 +141,7 @@ export type ServerMessage = providerId: string | null modelId: string effortLevel?: string + requestedConfig?: { providerId: string | null; modelId: string; effortLevel?: string } } // CLI 回传的权限模式变化(如 ExitPlanMode 退出 plan 后恢复、Shift+Tab)。 // 桌面端据此把选择器校正回 CLI 的真实权限,避免本地影子值漂移。 diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index c96a569a..0b5099b7 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -5687,14 +5687,23 @@ describe('WebSocket Chat Integration', () => { } }, 20_000) - it('should ignore stale persisted runtime provider ids when resuming old sessions', async () => { + it.each([[undefined, true], ['high', true], [undefined, false], ['high', false]] as const)( + 'should recover a deleted runtime provider with saved effort %s (replayed %s) and acknowledge the effective runtime', + async (savedEffort, replaySelection) => { const providerService = new ProviderService() + const expectedModelId = savedEffort ? 'current-main[1m]' : 'current-main' + const settingsService = new SettingsService() + const previousSettings = await settingsService.getUserSettings() + const previousManagedSettings = await providerService.getManagedSettings() + await settingsService.updateUserSettings({ effort: 'medium' }) + await providerService.updateManagedSettings({ model: undefined, modelContext: undefined }) const activeProvider = await providerService.addProvider({ presetId: 'custom', name: 'Current Valid Provider', apiKey: 'key-current-valid', baseUrl: 'http://127.0.0.1:1/anthropic', apiFormat: 'anthropic', + ...(savedEffort ? { model1mSupport: { main: true, haiku: false, sonnet: false, opus: false } } : {}), models: { main: 'current-main', haiku: 'current-haiku', @@ -5713,6 +5722,12 @@ describe('WebSocket Chat Integration', () => { const { sessionId } = await createRes.json() as { sessionId: string } const staleProviderId = crypto.randomUUID() + await sessionService.appendSessionMetadata(sessionId, { + workDir: process.cwd(), + runtimeProviderId: staleProviderId, + runtimeModelId: 'stale-model', + ...(savedEffort ? { effortLevel: savedEffort } : {}), + }) const originalStartSession = conversationService.startSession.bind(conversationService) const startCalls: Array<{ sessionId: string @@ -5743,11 +5758,15 @@ describe('WebSocket Chat Integration', () => { messages.push(msg) if (msg.type === 'connected') { - ws.send(JSON.stringify({ - type: 'set_runtime_config', - providerId: staleProviderId, - modelId: 'stale-model', - })) + if (replaySelection) { + // A reconnect can replay the same saved selection more than once. + for (let replay = 0; replay < 2; replay++) ws.send(JSON.stringify({ + type: 'set_runtime_config', + providerId: staleProviderId, + modelId: 'stale-model', + ...(savedEffort ? { effortLevel: savedEffort } : {}), + })) + } ws.send(JSON.stringify({ type: 'user_message', content: 'resume old session' })) return } @@ -5777,15 +5796,82 @@ describe('WebSocket Chat Integration', () => { sessionId, options: { providerId: activeProvider.id, + model: expectedModelId, + effort: 'medium', }, }) - expect(startCalls[0]?.options?.model).not.toBe('stale-model') + if (replaySelection) expect(messages.filter((message) => message.type === 'runtime_config_applied')).toHaveLength(2) + if (replaySelection) expect(messages.find((message) => message.type === 'runtime_config_applied')).toMatchObject({ + requestedConfig: { + providerId: staleProviderId, + modelId: 'stale-model', + ...(savedEffort ? { effortLevel: savedEffort } : {}), + }, + providerId: activeProvider.id, + modelId: expectedModelId, + effortLevel: 'medium', + }) + const launchInfo = await sessionService.getSessionLaunchInfo(sessionId) + expect(launchInfo).toMatchObject({ + runtimeProviderId: activeProvider.id, + runtimeModelId: expectedModelId, + effortLevel: 'medium', + }) expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true) } finally { ws.close() conversationService.startSession = originalStartSession conversationService.stopSession(sessionId) await providerService.activateOfficial() + await providerService.deleteProvider(activeProvider.id) + await fs.writeFile(path.join(tmpDir, 'settings.json'), JSON.stringify(previousSettings), 'utf-8') + await providerService.updateManagedSettings({ model: undefined, modelContext: undefined, ...previousManagedSettings }) + } + }, 20_000) + + it('should normalize saved ChatGPT effort when the restored model no longer supports it', async () => { + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir: process.cwd() }), + }) + expect(createRes.status).toBe(201) + const { sessionId } = await createRes.json() as { sessionId: string } + await sessionService.appendSessionMetadata(sessionId, { + workDir: process.cwd(), + runtimeProviderId: 'openai-official', + runtimeModelId: 'gpt-5.5', + effortLevel: 'max', + }) + const originalStartSession = conversationService.startSession.bind(conversationService) + const startCalls: Array<{ options?: { model?: string; effort?: string; providerId?: string | null } }> = [] + conversationService.startSession = (async function patchedStartSession( + sid: string, + workDir: string, + sdkUrl: string, + options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null }, + ) { + startCalls.push({ options }) + return originalStartSession(sid, workDir, sdkUrl, options) + }) as typeof conversationService.startSession + + try { + const messages = await runTurn(sessionId, 'resume the saved ChatGPT runtime') + expect(messages.some((message) => message.type === 'message_complete')).toBe(true) + expect(startCalls).toHaveLength(1) + expect(startCalls[0]?.options).toMatchObject({ + providerId: 'openai-official', + model: 'gpt-5.5', + effort: 'medium', + }) + expect(await sessionService.getSessionLaunchInfo(sessionId)).toMatchObject({ + runtimeProviderId: 'openai-official', + runtimeModelId: 'gpt-5.5', + effortLevel: 'medium', + }) + } finally { + conversationService.startSession = originalStartSession + conversationService.stopSession(sessionId) } }, 20_000) @@ -6054,7 +6140,7 @@ describe('WebSocket Chat Integration', () => { } }, 20_000) - it('should reject a reasoning effort that the selected ChatGPT model does not support', async () => { + it.each(['max', 'not-an-effort'])('should reject an explicitly requested ChatGPT effort %s that the selected model does not support', async (effortLevel) => { const sessionId = `chat-openai-invalid-effort-${crypto.randomUUID()}` await new Promise((resolve, reject) => { const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) @@ -6070,7 +6156,7 @@ describe('WebSocket Chat Integration', () => { type: 'set_runtime_config', providerId: 'openai-official', modelId: 'gpt-5.5', - effortLevel: 'max', + effortLevel, })) } else if (message.type === 'error') { clearTimeout(timeout) @@ -6083,6 +6169,74 @@ describe('WebSocket Chat Integration', () => { }) }, 10_000) + it.each([ + { providerId: null, modelId: '', effortLevel: 'high' }, + { providerId: null, modelId: 'mock-next', effortLevel: 'invalid' }, + { providerId: 'openai-official', modelId: 'gpt-5.5', effortLevel: 'max' }, + ])('does not send queued or subsequent user messages to the old runtime after rejected selection %j', async (invalidSelection) => { + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir: process.cwd() }), + }) + const { sessionId } = await createRes.json() as { sessionId: string } + let ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + const messages: any[] = [] + ws.onmessage = event => messages.push(JSON.parse(String(event.data))) + const originalSendMessage = conversationService.sendMessage.bind(conversationService) + const sentContents: string[] = [] + conversationService.sendMessage = (function (sid, content, attachments, options) { + if (sid === sessionId) sentContents.push(content) + return originalSendMessage(sid, content, attachments, options) + }) as typeof conversationService.sendMessage + const hasTurnOutcome = (since: number) => messages.slice(since).some(message => + message.type === 'message_complete' || message.code === 'USER_TURN_FAILED') + + try { + await waitUntil(() => messages.some(message => message.type === 'connected'), 'runtime rejection connection') + ws.send(JSON.stringify({ type: 'set_runtime_config', providerId: null, modelId: 'mock-old', effortLevel: 'high' })) + await waitUntil(() => messages.some(message => message.type === 'runtime_config_applied'), 'original runtime accepted') + ws.send(JSON.stringify({ type: 'user_message', content: 'warm original runtime' })) + await waitUntil(() => messages.some(message => message.type === 'message_complete'), 'original runtime completed') + + for (const [index, content] of ['queued after invalid selection', 'retry while selection remains invalid'].entries()) { + const since = messages.length + if (index === 0) ws.send(JSON.stringify({ type: 'set_runtime_config', ...invalidSelection })) + ws.send(JSON.stringify({ type: 'user_message', content })) + await waitUntil(() => hasTurnOutcome(since), 'rejected runtime user turn settled') + expect(sentContents).toEqual(['warm original runtime']) + expect(messages.slice(since)).toContainEqual(expect.objectContaining({ type: 'error', code: 'USER_TURN_FAILED' })) + expect(messages.slice(since)).toContainEqual({ type: 'status', state: 'idle' }) + expect(conversationService.hasSession(sessionId)).toBe(true) + } + + ws.send(JSON.stringify({ type: 'set_runtime_config', providerId: null, modelId: 'mock-new', effortLevel: 'medium' })) + await waitUntil(() => messages.some(message => message.type === 'runtime_config_applied' && message.modelId === 'mock-new'), 'corrected runtime applied') + const since = messages.length + ws.send(JSON.stringify({ type: 'user_message', content: 'send with corrected runtime' })) + await waitUntil(() => hasTurnOutcome(since), 'corrected runtime user turn settled') + expect(messages.slice(since).some(message => message.type === 'message_complete')).toBe(true) + expect(sentContents).toEqual(['warm original runtime', 'send with corrected runtime']) + expect(await sessionService.getSessionLaunchInfo(sessionId)).toMatchObject({ runtimeModelId: 'mock-new', effortLevel: 'medium' }) + const rejectedSince = messages.length + ws.send(JSON.stringify({ type: 'set_runtime_config', ...invalidSelection })) + await waitUntil(() => messages.slice(rejectedSince).some(message => message.code === 'RUNTIME_CONFIG_INVALID'), 'another rejected selection') + const { closeSessionConnection } = await import('../ws/handler.js') + closeSessionConnection(sessionId) + const reconnectSince = messages.length + ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + ws.onmessage = event => messages.push(JSON.parse(String(event.data))) + await waitUntil(() => messages.slice(reconnectSince).some(message => message.type === 'connected'), 'connection after cleanup') + ws.send(JSON.stringify({ type: 'user_message', content: 'send after session cleanup' })) + await waitUntil(() => hasTurnOutcome(reconnectSince), 'turn after cleanup settled') + expect(messages.slice(reconnectSince).some(message => message.type === 'message_complete')).toBe(true) + expect(sentContents).toEqual(['warm original runtime', 'send with corrected runtime', 'send after session cleanup']) + } finally { + ws.close() + conversationService.sendMessage = originalSendMessage + conversationService.stopSession(sessionId) + } + }, 20_000) + it('should reject unsupported GLM 5.3 standard API effort aliases', async () => { const providerService = new ProviderService() const provider = await providerService.addProvider({ diff --git a/src/server/__tests__/e2e/side-chat.test.ts b/src/server/__tests__/e2e/side-chat.test.ts index 72123bf3..db957743 100644 --- a/src/server/__tests__/e2e/side-chat.test.ts +++ b/src/server/__tests__/e2e/side-chat.test.ts @@ -80,9 +80,15 @@ test('temporary side chat forks a fixed boundary, supports independent multi-tur await eventually(() => child.events.filter(event => event.type === 'message_complete').length >= 3, 'side tool completed') expect(main.events.some(event => event.type === 'permission_request')).toBe(false) // A runtime change that needs restart must fail before stopping the live child. - child.socket.send(JSON.stringify({ type: 'set_runtime_config', providerId: 'different-provider', modelId: 'other' })) - await eventually(() => child.events.some(event => event.code === 'SIDE_CHAT_RUNTIME_RESTART_UNAVAILABLE'), 'safe runtime rejection') - expect(conversationService.hasSession(side.sessionId)).toBe(true) + const appliedRuntimeCount = child.events.filter(event => event.type === 'runtime_config_applied').length + for (const providerId of ['different-provider', 'openai-official']) { + const rejectionCount = child.events.filter(event => event.code === 'SIDE_CHAT_RUNTIME_RESTART_UNAVAILABLE').length + child.socket.send(JSON.stringify({ type: 'set_runtime_config', providerId, modelId: 'other' })) + await eventually(() => child.events.filter(event => event.code === 'SIDE_CHAT_RUNTIME_RESTART_UNAVAILABLE').length > rejectionCount, 'safe runtime rejection') + expect(conversationService.hasSession(side.sessionId)).toBe(true) + expect(child.events.filter(event => event.type === 'runtime_config_applied')).toHaveLength(appliedRuntimeCount) + expect((await sessionService.getSessionLaunchInfo(side.sessionId))?.runtimeModelId).toBe('mock-next') + } expect((await api(`/api/sessions/${side.sessionId}/messages`)).messages).toEqual([]) expect(await api(`/api/sessions/${side.sessionId}/turn-checkpoints`)).toEqual({ checkpoints: [] }) expect(await sessionService.getSessionMessagesWithEvidence(side.sessionId)).toEqual({ messages: [], transcriptEvidenceComplete: false }) diff --git a/src/server/__tests__/sessionStateCleanup.test.ts b/src/server/__tests__/sessionStateCleanup.test.ts index 979ad486..07efd0ed 100644 --- a/src/server/__tests__/sessionStateCleanup.test.ts +++ b/src/server/__tests__/sessionStateCleanup.test.ts @@ -55,6 +55,7 @@ const CONTAINERS: Record = { prewarmIdleTimers: { kind: 'cleared' }, prewarmPendingSessions: { kind: 'cleared' }, prewarmedSessions: { kind: 'cleared' }, + rejectedRuntimeConfigs: { kind: 'cleared' }, runtimeExitStoppedSessions: { kind: 'cleared' }, runtimeOverrides: { kind: 'cleared' }, runtimeTransitionPromises: { kind: 'cleared' }, diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index 54d48e7e..bd2001d1 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -11,6 +11,7 @@ import { __resolveRuntimeRestartWorkDirForTests, closeSessionConnection, getActiveSessionIds, + getRuntimeSettings, handleWebSocket, stopSessionTurn, __registerPendingSessionStartupForTests, @@ -27,6 +28,8 @@ import { sessionService } from '../services/sessionService.js' import { observeSessionTurns, type SessionTurnEvent } from '../services/sessionTurnEvents.js' import * as titleService from '../services/titleService.js' import { SettingsService } from '../services/settingsService.js' +import { ProviderService } from '../services/providerService.js' +import * as sideChatRegistry from '../services/sideChatRegistry.js' import { activeBackgroundTaskIds } from '../ws/agentTaskState.js' import * as teleportApi from '../../utils/teleport/api.js' import { resetSettingsCache, setSessionSettingsCache } from '../../utils/settings/settingsCache.js' @@ -66,6 +69,28 @@ function makeSdkSocket(sessionId: string, sdkToken: string) { } as unknown as ServerWebSocket } +function mockSavedDeepSeekProvider(): void { + spyOn(ProviderService.prototype, 'listProviders').mockResolvedValue({ + activeId: 'deepseek', + providerOrder: ['deepseek'], + providers: [{ + id: 'deepseek', + presetId: 'deepseek', + name: 'Test DeepSeek provider', + apiKey: 'test-deepseek-key', + baseUrl: 'http://127.0.0.1:1/anthropic', + apiFormat: 'anthropic', + runtimeKind: 'anthropic_compatible', + models: { + main: 'deepseek-v4-pro', + haiku: 'deepseek-v4-flash', + sonnet: 'deepseek-v4-pro', + opus: 'deepseek-v4-pro', + }, + }], + }) +} + async function flushMicrotasks(count = 12): Promise { for (let index = 0; index < count; index++) await Promise.resolve() } @@ -4157,7 +4182,8 @@ describe('WebSocket handler session isolation', () => { } }) - it('approves ExitPlanMode with an in-process model switch for the same provider', async () => { + it('preserves effort through a same-provider model-only plan approval and session restoration', async () => { + mockSavedDeepSeekProvider() const sessionId = `plan-same-provider-${crypto.randomUUID()}` const ws = makeClientSocket(sessionId) @@ -4166,7 +4192,7 @@ describe('WebSocket handler session isolation', () => { spyOn(conversationService, 'getSessionWorkDir').mockReturnValue('/tmp') spyOn(conversationService, 'setModel').mockResolvedValue(true) spyOn(conversationService, 'respondToPermission').mockReturnValue(true) - spyOn(sessionService, 'getSessionLaunchInfo').mockResolvedValue({ + const launchInfo = { filePath: '/tmp/plan-session.jsonl', projectDir: '/tmp', workDir: '/tmp', @@ -4174,8 +4200,12 @@ describe('WebSocket handler session isolation', () => { customTitle: null, runtimeProviderId: 'deepseek', runtimeModelId: 'deepseek-v4-pro', + effortLevel: 'high', + } + spyOn(sessionService, 'getSessionLaunchInfo').mockImplementation(async () => launchInfo) + spyOn(sessionService, 'appendSessionMetadata').mockImplementation(async (_id, metadata) => { + Object.assign(launchInfo, metadata) }) - spyOn(sessionService, 'appendSessionMetadata').mockResolvedValue(undefined) const startSession = spyOn(conversationService, 'startSession').mockResolvedValue() const stopSession = spyOn(conversationService, 'stopSession').mockImplementation(() => {}) @@ -4205,9 +4235,65 @@ describe('WebSocket handler session isolation', () => { workDir: '/tmp', runtimeProviderId: 'deepseek', runtimeModelId: 'deepseek-v4-flash', + effortLevel: 'high', }) expect(startSession).not.toHaveBeenCalled() expect(stopSession).not.toHaveBeenCalled() + expect(ws.sent.map(payload => JSON.parse(payload))).toContainEqual({ + type: 'runtime_config_applied', + providerId: 'deepseek', + modelId: 'deepseek-v4-flash', + effortLevel: 'high', + requestedConfig: { providerId: 'deepseek', modelId: 'deepseek-v4-flash' }, + }) + expect(await getRuntimeSettings(sessionId)).toMatchObject({ model: 'deepseek-v4-flash', effort: 'high' }) + closeSessionConnection(sessionId) + expect(await getRuntimeSettings(sessionId)).toMatchObject({ model: 'deepseek-v4-flash', effort: 'high' }) + }) + + it('rejects a deleted provider in side-chat plan approval before default fallback', async () => { + const sessionId = `side-plan-provider-${crypto.randomUUID()}` + const ws = makeClientSocket(sessionId) + spyOn(sideChatRegistry, 'getSideChat').mockReturnValue({ + sessionId, + parentSessionId: 'parent-plan', + cliSessionId: 'side-plan-cli', + resumePath: '/tmp/side-plan.jsonl', + resumeAt: 'plan-boundary', + createdAt: new Date().toISOString(), + started: true, + closed: false, + launchInfo: { + filePath: '/tmp/side-plan.jsonl', + projectDir: '/tmp', + workDir: '/tmp', + transcriptMessageCount: 0, + customTitle: null, + runtimeProviderId: null, + runtimeModelId: 'claude-sonnet-5', + }, + }) + spyOn(conversationService, 'getPendingPermissionToolName').mockReturnValue('ExitPlanMode') + const setModel = spyOn(conversationService, 'setModel').mockResolvedValue(true) + const approve = spyOn(conversationService, 'respondToPermission').mockReturnValue(true) + const listProviders = spyOn(ProviderService.prototype, 'listProviders').mockResolvedValue({ + providers: [], activeId: null, providerOrder: [], + }) + + handleWebSocket.message(ws, JSON.stringify({ + type: 'permission_response', + requestId: 'side-plan-approval', + allowed: true, + runtimeOverride: { providerId: 'deleted-provider', modelId: 'old-model' }, + })) + await flushMicrotasks() + + expect(ws.sent.map(payload => JSON.parse(payload))).toContainEqual(expect.objectContaining({ + type: 'error', code: 'SIDE_CHAT_RUNTIME_RESTART_UNAVAILABLE', + })) + expect(listProviders).not.toHaveBeenCalled() + expect(setModel).not.toHaveBeenCalled() + expect(approve).not.toHaveBeenCalled() }) it('approves ExitPlanMode with a cross-provider switch via restart and auto-continue', async () => { @@ -4309,6 +4395,7 @@ describe('WebSocket handler session isolation', () => { }) it('keeps the permission pending when the in-process model switch fails', async () => { + mockSavedDeepSeekProvider() const sessionId = `plan-set-model-fail-${crypto.randomUUID()}` const ws = makeClientSocket(sessionId) diff --git a/src/server/services/localIndex/sessionProjector.test.ts b/src/server/services/localIndex/sessionProjector.test.ts index 99a6f55e..97fbfbc1 100644 --- a/src/server/services/localIndex/sessionProjector.test.ts +++ b/src/server/services/localIndex/sessionProjector.test.ts @@ -88,6 +88,36 @@ async function sourceHash(path: string): Promise { } describe('session projector', () => { + it('rebuilds a v7 cached runtime selection to clear effort omitted by its replacement', async () => { + const root = await createTempDir('projector-runtime-effort') + const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') }) + const index = createSessionIndex(database) + const candidate = await createCandidate({ + root, projectPath: '-repo', sessionId: 'runtime-replacement', + content: [ + { type: 'session-meta', runtimeProviderId: 'old-provider', runtimeModelId: 'old-model', effortLevel: 'high' }, + user('Existing conversation', '2026-01-01T00:00:00Z'), + { type: 'session-meta', runtimeProviderId: 'new-provider', runtimeModelId: 'new-model' }, + ].map(line).join(''), + }) + try { + const original = await readFile(candidate.path, 'utf8') + await createSessionProjector({ database, index, scope: root, parserVersion: 7 }).projectSource(candidate) + // Reproduce the cached summary written by the v7 reducer. + database.write(operation => operation.run( + 'UPDATE sessions SET effort_level = ? WHERE session_id = ?', 'high', candidate.sessionId, + )) + expect(index.listSessions().sessions[0]?.effortLevel).toBe('high') + const restarted = createSessionProjector({ database, index, scope: root }) + expect(await restarted.projectSource(candidate)).toMatchObject({ kind: 'indexed', action: 'rebuild' }) + expect(index.listSessions().sessions[0]).toMatchObject({ runtimeProviderId: 'new-provider', runtimeModelId: 'new-model' }) + expect(index.listSessions().sessions[0]?.effortLevel).toBeUndefined() + expect(await readFile(candidate.path, 'utf8')).toBe(original) + } finally { + database.close() + } + }) + it('persists an immediate title patch without waiting for transcript reprojection', async () => { const root = await createTempDir('projector-title-patch') const databasePath = join(root, 'index.sqlite') diff --git a/src/server/services/localIndex/sessionProjector.ts b/src/server/services/localIndex/sessionProjector.ts index 76cb4a82..c9848816 100644 --- a/src/server/services/localIndex/sessionProjector.ts +++ b/src/server/services/localIndex/sessionProjector.ts @@ -37,7 +37,8 @@ import type { // 5: protocol-lock metadata was projected into session summaries. // 6: protocol enforcement was removed; rebuild v5 summaries without protocol restrictions. // 7: independent desktop team workers remain addressable but leave sidebar listings. -export const SESSION_SUMMARY_PARSER_VERSION = 7 +// 8: complete runtime selections clear the previous effort when no override is saved. +export const SESSION_SUMMARY_PARSER_VERSION = 8 export type SessionSourceCandidate = { path: string diff --git a/src/server/services/localIndex/transcriptReducer.test.ts b/src/server/services/localIndex/transcriptReducer.test.ts index 57eabf5a..dd7c2b64 100644 --- a/src/server/services/localIndex/transcriptReducer.test.ts +++ b/src/server/services/localIndex/transcriptReducer.test.ts @@ -57,6 +57,24 @@ function assistant(timestamp: string, extra: Record = {}) { } describe('reduceTranscript', () => { + it('clears inherited effort on full runtime replacements in complete and incremental projections', () => { + const oldSelection = { type: 'session-meta', runtimeProviderId: 'old-provider', runtimeModelId: 'old-model', effortLevel: 'high' } + const replacement = { type: 'session-meta', runtimeProviderId: 'new-provider', runtimeModelId: 'new-model' } + const first = reduceTranscript(completeChunks([oldSelection]), initialProjection()) + expect(first.summary.effortLevel).toBe('high') + const incremental = reduceTranscript(completeChunks([replacement], first.indexedBytes), first) + const complete = reduceTranscript(completeChunks([oldSelection, replacement]), initialProjection()) + for (const projection of [incremental, complete]) { + expect(projection.summary).toMatchObject({ runtimeProviderId: 'new-provider', runtimeModelId: 'new-model' }) + expect(projection.summary.effortLevel).toBeUndefined() + } + const partial = reduceTranscript(completeChunks([ + { type: 'session-meta', permissionMode: 'plan' }, + { type: 'session-meta', runtimeModelId: 'historical-model' }, + ], first.indexedBytes), first) + expect(partial.summary.effortLevel).toBe('high') + }) + it('projects the existing summary fields and title precedence from complete lines', () => { const repository = { requestedWorkDir: '/repo', diff --git a/src/server/services/localIndex/transcriptReducer.ts b/src/server/services/localIndex/transcriptReducer.ts index 8c4ca00c..b013ce0d 100644 --- a/src/server/services/localIndex/transcriptReducer.ts +++ b/src/server/services/localIndex/transcriptReducer.ts @@ -135,6 +135,25 @@ const VALID_SESSION_PERMISSION_MODES = new Set([ 'auto', ]) const VALID_SESSION_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']) + +export function resolveSessionEffortLevel( + record: Record, + previous: string | undefined, +): string | undefined { + if (typeof record.effortLevel === 'string' && VALID_SESSION_EFFORT_LEVELS.has(record.effortLevel)) { + return record.effortLevel + } + // A complete runtime selection replaces the previous selection, including + // its effort override. Historical partial metadata remains a patch. + if ( + (record.runtimeProviderId === null || typeof record.runtimeProviderId === 'string') && + typeof record.runtimeModelId === 'string' && record.runtimeModelId.length > 0 + ) { + return undefined + } + return previous +} + const ACTIVITY_TRANSCRIPT_MESSAGE_TYPES = new Set([ 'user', 'assistant', @@ -546,12 +565,7 @@ function applyEntry(state: ReducerState, entry: ReducerEntry): void { if (typeof record.runtimeModelId === 'string') { state.runtimeModelId = record.runtimeModelId } - if ( - typeof record.effortLevel === 'string' && - VALID_SESSION_EFFORT_LEVELS.has(record.effortLevel) - ) { - state.effortLevel = record.effortLevel - } + state.effortLevel = resolveSessionEffortLevel(record, state.effortLevel) } if (typeof entry.cwd === 'string' && entry.cwd.trim()) { diff --git a/src/server/services/sessionService.runtimeSelection.test.ts b/src/server/services/sessionService.runtimeSelection.test.ts new file mode 100644 index 00000000..1c097683 --- /dev/null +++ b/src/server/services/sessionService.runtimeSelection.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { SessionService } from './sessionService.js' +import { registerSideChat, closeSideChatsForParent } from './sideChatRegistry.js' +import { resetSettingsCache } from '../../utils/settings/settingsCache.js' +import { sanitizePath } from '../../utils/sessionStoragePortable.js' + +const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' +let directory: string +let service: SessionService +let previousConfig: string | undefined +let previousHome: string | undefined + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'session-runtime-selection-')) + previousConfig = process.env.CLAUDE_CONFIG_DIR + previousHome = process.env.HOME + process.env.CLAUDE_CONFIG_DIR = directory + process.env.HOME = directory + resetSettingsCache() + service = new SessionService() +}) + +afterEach(async () => { + closeSideChatsForParent(sessionId) + if (previousConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previousConfig + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + resetSettingsCache() + await rm(directory, { recursive: true, force: true }) +}) + +async function seedLegacySelection() { + const projectDir = sanitizePath(directory) + const filePath = join(directory, 'projects', projectDir, `${sessionId}.jsonl`) + await mkdir(join(directory, 'projects', projectDir), { recursive: true }) + await writeFile(filePath, [ + { type: 'session-meta', workDir: directory, runtimeProviderId: 'old-provider', runtimeModelId: 'old-model', effortLevel: 'high' }, + { type: 'user', uuid: 'old-message', message: { role: 'user', content: 'Existing conversation' } }, + ].map(entry => JSON.stringify(entry)).join('\n') + '\n') + return filePath +} + +async function expectSelection(reader: SessionService, provider: string | null, model: string, effort: string | undefined) { + const launchInfo = await reader.getSessionLaunchInfo(sessionId) + const summary = await reader.getSessionSummary(sessionId) + const listed = (await reader.listSessions()).sessions.find(session => session.id === sessionId) + const inspection = await reader.getInspectionTranscriptSnapshot(sessionId) + for (const result of [launchInfo, summary, listed, inspection?.launchInfo]) { + expect(result).toMatchObject({ runtimeProviderId: provider, runtimeModelId: model }) + expect(result?.effortLevel).toBe(effort) + } +} + +test('a complete runtime replacement clears old effort from warm readers and after restart', async () => { + const filePath = await seedLegacySelection() + await expectSelection(service, 'old-provider', 'old-model', 'high') + await service.appendSessionMetadata(sessionId, { + workDir: directory, runtimeProviderId: 'replacement-provider', runtimeModelId: 'replacement-model', + }) + await expectSelection(service, 'replacement-provider', 'replacement-model', undefined) + await expectSelection(new SessionService(), 'replacement-provider', 'replacement-model', undefined) + const latest = JSON.parse((await readFile(filePath, 'utf8')).trim().split('\n').at(-1)!) + expect(latest).not.toHaveProperty('effortLevel') +}) + +test('resetting effort on an unchanged provider and model is persisted exactly once', async () => { + const filePath = await seedLegacySelection() + const before = await readFile(filePath, 'utf8') + const metadata = { workDir: directory, runtimeProviderId: 'old-provider', runtimeModelId: 'old-model' } + await service.appendSessionMetadata(sessionId, metadata) + const reset = await readFile(filePath, 'utf8') + expect(reset).not.toBe(before) + await expectSelection(new SessionService(), 'old-provider', 'old-model', undefined) + await service.appendSessionMetadata(sessionId, metadata) + expect(await readFile(filePath, 'utf8')).toBe(reset) +}) + +test('ordinary metadata and historical partial runtime updates preserve effort', async () => { + await seedLegacySelection() + await service.appendSessionMetadata(sessionId, { workDir: directory, permissionMode: 'plan' }) + await service.appendSessionMetadata(sessionId, { workDir: directory, runtimeModelId: 'legacy-model' }) + await expectSelection(new SessionService(), 'old-provider', 'legacy-model', 'high') +}) + +test('a complete built-in runtime selection also clears old effort', async () => { + await seedLegacySelection() + await service.appendSessionMetadata(sessionId, { workDir: directory, runtimeProviderId: null, runtimeModelId: 'default-model' }) + await expectSelection(new SessionService(), null, 'default-model', undefined) +}) + +test('memory-only runtime changes clear inherited effort without writing a transcript', async () => { + await writeFile(join(directory, 'settings.json'), JSON.stringify({ cleanupPeriodDays: 0 })) + resetSettingsCache() + const created = await service.createSession(directory) + await service.appendSessionMetadata(created.sessionId, { + workDir: directory, runtimeProviderId: 'old-provider', runtimeModelId: 'old-model', effortLevel: 'high', + }) + await service.appendSessionMetadata(created.sessionId, { + workDir: directory, runtimeProviderId: 'replacement-provider', runtimeModelId: 'replacement-model', + }) + const launchInfo = await service.getSessionLaunchInfo(created.sessionId) + expect(launchInfo?.runtimeModelId).toBe('replacement-model') + expect(launchInfo?.effortLevel).toBeUndefined() + await expect(readFile(launchInfo!.filePath)).rejects.toThrow() +}) + +test('a memory-only runtime replacement overrides old persisted effort without changing history', async () => { + const filePath = await seedLegacySelection() + const original = await readFile(filePath, 'utf8') + await writeFile(join(directory, 'settings.json'), JSON.stringify({ cleanupPeriodDays: 0 })) + resetSettingsCache() + await service.appendSessionMetadata(sessionId, { + workDir: directory, runtimeProviderId: 'replacement-provider', runtimeModelId: 'replacement-model', + }) + const launchInfo = await service.getSessionLaunchInfo(sessionId) + expect(launchInfo).toMatchObject({ runtimeProviderId: 'replacement-provider', runtimeModelId: 'replacement-model' }) + expect(launchInfo?.effortLevel).toBeUndefined() + expect(await readFile(filePath, 'utf8')).toBe(original) +}) + +test('a side conversation clears inherited effort when its runtime selection changes', async () => { + await seedLegacySelection() + const launchInfo = (await service.getSessionLaunchInfo(sessionId))! + const sideId = `side-${sessionId}` + registerSideChat({ + sessionId: sideId, parentSessionId: sessionId, cliSessionId: sessionId, + resumePath: launchInfo.filePath, resumeAt: 'old-message', launchInfo, + createdAt: new Date().toISOString(), started: false, closed: false, + }) + await service.appendSessionMetadata(sideId, { + workDir: directory, runtimeProviderId: 'replacement-provider', runtimeModelId: 'replacement-model', + }) + expect((await service.getSessionLaunchInfo(sideId))?.effortLevel).toBeUndefined() +}) diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 10a22166..8631b66e 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -58,6 +58,7 @@ import { getSettings_DEPRECATED } from '../../utils/settings/settings.js' import { extractGoalCreationTitle, extractTranscriptUserTitle, + resolveSessionEffortLevel, } from './localIndex/transcriptReducer.js' import type { PersistedWorktreeSession, @@ -1013,11 +1014,7 @@ export class SessionService { if (metadata.runtimeModelId && launchInfo.runtimeModelId !== metadata.runtimeModelId) { return false } - if ( - metadata.effortLevel && - VALID_SESSION_EFFORT_LEVELS.has(metadata.effortLevel) && - launchInfo.effortLevel !== metadata.effortLevel - ) { + if (launchInfo.effortLevel !== resolveSessionEffortLevel(metadata, launchInfo.effortLevel)) { return false } return true @@ -1293,7 +1290,7 @@ export class SessionService { state.permissionMode = this.resolvePermissionModeFromEntries([entry]) ?? state.permissionMode if (record.runtimeProviderId === null || typeof record.runtimeProviderId === 'string') state.runtimeProviderId = record.runtimeProviderId as string | null if (typeof record.runtimeModelId === 'string') state.runtimeModelId = record.runtimeModelId - if (typeof record.effortLevel === 'string' && VALID_SESSION_EFFORT_LEVELS.has(record.effortLevel)) state.effortLevel = record.effortLevel + state.effortLevel = resolveSessionEffortLevel(record, state.effortLevel) } state.repository = this.resolveRepositoryFromEntries([entry]) ?? state.repository const worktree = this.resolveWorktreeSessionFromEntries([entry]) @@ -3148,12 +3145,7 @@ export class SessionService { if (typeof record.runtimeModelId === 'string') { runtimeModelId = record.runtimeModelId } - if ( - typeof record.effortLevel === 'string' && - VALID_SESSION_EFFORT_LEVELS.has(record.effortLevel) - ) { - effortLevel = record.effortLevel - } + effortLevel = resolveSessionEffortLevel(record, effortLevel) } const candidateRepository = (entry as Record)?.repository @@ -4811,7 +4803,11 @@ export class SessionService { ): Promise { if (isSideChatId(sessionId)) { const side = getSideChat(sessionId) - if (side && !side.closed) Object.assign(side.launchInfo, metadata) + if (side && !side.closed) { + Object.assign(side.launchInfo, metadata, { + effortLevel: resolveSessionEffortLevel(metadata, side.launchInfo.effortLevel), + }) + } return } const persist = this.shouldPersistSession() @@ -4836,8 +4832,7 @@ export class SessionService { ? { permissionMode: metadata.permissionMode } : {}), ...(metadata.runtimeProviderId !== undefined ? { runtimeProviderId: metadata.runtimeProviderId } : {}), ...(metadata.runtimeModelId ? { runtimeModelId: metadata.runtimeModelId } : {}), - ...(metadata.effortLevel && VALID_SESSION_EFFORT_LEVELS.has(metadata.effortLevel) - ? { effortLevel: metadata.effortLevel } : {}), + effortLevel: resolveSessionEffortLevel(metadata, previousInfo.effortLevel), }) } if (!persist || !this.shouldPersistSession()) { diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts index 59b60c65..5f74462f 100644 --- a/src/server/ws/events.ts +++ b/src/server/ws/events.ts @@ -119,6 +119,7 @@ export type ServerMessage = | { type: 'status'; state: ChatState; verb?: string; attemptStart?: boolean } | { type: typeof RUNTIME_CONFIG_APPLIED_EVENT + requestedConfig?: { providerId: string | null; modelId: string; effortLevel?: string } providerId: string | null modelId: string effortLevel?: string diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index c06d4fa1..7fae854c 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -198,6 +198,7 @@ type RuntimeOverride = { providerId: string | null modelId: string effort?: string + requestedConfig?: { providerId: string | null; modelId: string; effortLevel?: string } } type ActiveUserTurnState = { @@ -216,6 +217,9 @@ type ActiveUserTurnState = { const runtimeOverrides = new Map() +// A rejected optimistic choice must not silently send later prompts to the +// previous process. Keep the rejection until a valid selection or cleanup. +const rejectedRuntimeConfigs = new Map() const activeUserTurns = new Map() const activeCliRuns = new Set() const pendingInterruptedTurnResults = new Map() @@ -1607,11 +1611,29 @@ function waitForTurnResultOrTimeout(sessionId: string, timeoutMs: number): Promi }) } +function rejectStartedSideChatProviderChange( + ws: SessionConnection, + requestedProviderId: string | null, +): boolean { + const { sessionId } = ws.data + const side = getSideChat(sessionId) + if (!side?.started) return false + const providerId = runtimeOverrides.get(sessionId)?.providerId ?? side.launchInfo.runtimeProviderId ?? null + if (providerId === requestedProviderId) return false + sendMessage(ws, { + type: 'error', + code: 'SIDE_CHAT_RUNTIME_RESTART_UNAVAILABLE', + message: 'Open a new side chat to change provider or reasoning effort.', + }) + return true +} + async function handlePlanApprovalWithRuntimeOverride( ws: SessionConnection, message: Extract, ): Promise { const { sessionId } = ws.data + if (rejectStartedSideChatProviderChange(ws, message.runtimeOverride!.providerId)) return const normalized = await normalizeRuntimeOverrideInput(message.runtimeOverride!) if (!normalized.ok) { sendMessage(ws, { @@ -1637,6 +1659,7 @@ async function handlePlanApprovalWithRuntimeOverride( currentModelId === nextOverride.modelId && currentEffort === nextOverride.effort ) { + rejectedRuntimeConfigs.delete(sessionId) finalizePermissionResponse(ws, message) return } @@ -1659,12 +1682,16 @@ async function handlePlanApprovalWithRuntimeOverride( // reports it, and the permission stays pending (override untouched). await enqueueRuntimeTransition(sessionId, async () => { await conversationService.setModel(sessionId, nextOverride.modelId) - runtimeOverrides.set(sessionId, nextOverride) + const appliedOverride = nextOverride.effort === undefined && currentEffort + ? { ...nextOverride, effort: currentEffort, requestedConfig: nextOverride.requestedConfig ?? message.runtimeOverride } + : nextOverride + rejectedRuntimeConfigs.delete(sessionId) + runtimeOverrides.set(sessionId, appliedOverride) runtimeOverrideVersions.set( sessionId, (runtimeOverrideVersions.get(sessionId) ?? 0) + 1, ) - await persistSessionRuntimeConfig(sessionId, nextOverride) + await persistSessionRuntimeConfig(sessionId, appliedOverride) broadcastAppliedRuntimeConfig(sessionId) }) finalizePermissionResponse(ws, message) @@ -1676,6 +1703,7 @@ async function handlePlanApprovalWithRuntimeOverride( // flow. The interrupted turn may bill one partial request to the planning // model — same as the manual flow. await enqueueRuntimeTransition(sessionId, async () => { + rejectedRuntimeConfigs.delete(sessionId) runtimeOverrides.set(sessionId, nextOverride) runtimeOverrideVersions.set( sessionId, @@ -1842,11 +1870,37 @@ async function normalizeRuntimeOverrideInput( > { let modelId = typeof input.modelId === 'string' ? input.modelId.trim() : '' if (!modelId) return { ok: false, reason: 'model' } + const requestedEffort = + typeof input.effortLevel === 'string' ? input.effortLevel.trim() : undefined + if (typeof input.providerId === 'string') { + const { providers } = await providerService.listProviders() + if (!isKnownRuntimeProviderId(input.providerId, providers)) { + // Reopened tabs can still hold a provider deleted in Settings. Resolve + // the complete replacement before validating effort or acknowledging it: + // an effort attached to the deleted provider cannot apply to the new one. + if (requestedEffort !== undefined && !isModelReasoningEffort(requestedEffort)) { + return { ok: false, reason: 'effort' } + } + const defaults = await getDefaultRuntimeSettings() + const defaultModel = await resolveDefaultRuntimeModel(defaults) + return { + ok: true, + override: { + providerId: defaults.providerId ?? null, + modelId: defaultModel, + ...(defaults.effort ? { effort: defaults.effort } : {}), + requestedConfig: { + providerId: input.providerId, + modelId, + ...(requestedEffort !== undefined ? { effortLevel: requestedEffort } : {}), + }, + }, + } + } + } if (isGrokOfficialProviderId(input.providerId)) { modelId = (await getGrokReasoningEfforts(modelId)).modelId } - const requestedEffort = - typeof input.effortLevel === 'string' ? input.effortLevel.trim() : undefined const effortResolution = requestedEffort === undefined ? { valid: true, effort: undefined } : await resolveRuntimeEffort(input.providerId, modelId, requestedEffort) @@ -1866,30 +1920,20 @@ async function handleSetRuntimeConfig( message: Extract ) { const { sessionId } = ws.data - const requestedModelId = typeof message.modelId === 'string' ? message.modelId.trim() : '' - if (!requestedModelId) { - sendMessage(ws, { - type: 'error', - message: 'Runtime model selection is invalid.', - code: 'RUNTIME_CONFIG_INVALID', - }) - return - } - // Register the transition before remote model-catalog or provider validation. // A user message arriving in that async admission window must wait for the // selected runtime instead of entering the previous provider's CLI process. await enqueueRuntimeTransition(sessionId, async () => { + // A missing provider may fall back to the active default for durable chats, + // but that must not disguise a forbidden provider change on a live side chat. + if (rejectStartedSideChatProviderChange(ws, message.providerId)) return const normalized = await normalizeRuntimeOverrideInput(message) if (!normalized.ok) { - sendMessage(ws, { - type: 'error', - message: - normalized.reason === 'model' - ? 'Runtime model selection is invalid.' - : 'Runtime effort selection is invalid.', - code: 'RUNTIME_CONFIG_INVALID', - }) + const message = normalized.reason === 'model' + ? 'Runtime model selection is invalid.' + : 'Runtime effort selection is invalid.' + rejectedRuntimeConfigs.set(sessionId, message) + sendMessage(ws, { type: 'error', message, code: 'RUNTIME_CONFIG_INVALID' }) return } @@ -1904,11 +1948,13 @@ async function handleSetRuntimeConfig( return } await conversationService.setModel(sessionId, nextOverride.modelId) + rejectedRuntimeConfigs.delete(sessionId) runtimeOverrides.set(sessionId, { ...nextOverride, ...(effort ? { effort } : {}) }) await persistSessionRuntimeConfig(sessionId, runtimeOverrides.get(sessionId)!) broadcastAppliedRuntimeConfig(sessionId) return } + rejectedRuntimeConfigs.delete(sessionId) const prevOverride = runtimeOverrides.get(sessionId) if ( prevOverride && @@ -1916,6 +1962,10 @@ async function handleSetRuntimeConfig( prevOverride.modelId === nextOverride.modelId && prevOverride.effort === nextOverride.effort ) { + // Replayed selections still need confirmation, including the original + // stale provider that this request resolved to the existing runtime. + runtimeOverrides.set(sessionId, nextOverride) + if (!deferredRuntimeRestarts.has(sessionId)) broadcastAppliedRuntimeConfig(sessionId) return } @@ -2076,6 +2126,7 @@ function broadcastAppliedRuntimeConfig(sessionId: string): void { if (!runtime) return sendToSession(sessionId, { type: RUNTIME_CONFIG_APPLIED_EVENT, + ...(runtime.requestedConfig ? { requestedConfig: runtime.requestedConfig } : {}), providerId: runtime.providerId, modelId: runtime.modelId, ...(runtime.effort ? { effortLevel: runtime.effort } : {}), @@ -3111,6 +3162,7 @@ function cleanupSessionRuntimeState( sessionSlashCommands.delete(sessionId) sessionTitleState.delete(sessionId) runtimeOverrides.delete(sessionId) + rejectedRuntimeConfigs.delete(sessionId) activeUserTurns.delete(sessionId) activeCliRuns.delete(sessionId) sessionStopRequested.delete(sessionId) @@ -4739,6 +4791,7 @@ export async function getRuntimeSettings(sessionId?: string): Promise { + if (runtime.model) return runtime.model + if (runtime.providerId) { + return (await providerService.getProviderRuntimeEnv(runtime.providerId)).ANTHROPIC_MODEL + } + return (await providerService.getOfficialProviderModels('claude-official')).main +} + async function getDefaultRuntimeSettings(): Promise { // Check if a custom provider is active const { providers, activeId } = await providerService.listProviders() @@ -4976,6 +5044,16 @@ async function waitForRuntimeTransitionBeforeUserTurn( : undefined } + const rejectedConfig = rejectedRuntimeConfigs.get(sessionId) + if (rejectedConfig) { + const message = `The message was not sent. ${rejectedConfig} Select a valid model and reasoning effort, then retry.` + sendMessage(ws, { type: 'error', message, code: 'USER_TURN_FAILED', retryable: true }) + sendMessage(ws, { type: 'status', state: 'idle' }) + failSessionChatActivity(sessionId) + emitSessionTurnEvent({ type: 'output', sessionId, message: { type: 'result', is_error: true, result: message } }) + return { ok: false, waited } + } + return { ok: true, waited } } @@ -5104,6 +5182,7 @@ export function __resetWebSocketHandlerStateForTests(): void { } sessionTurnObservers.clear() runtimeOverrides.clear() + rejectedRuntimeConfigs.clear() runtimeOverrideVersions.clear() deferredRuntimeRestarts.clear() deferredPermissionModes.clear()