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()