fix: restore session model selections and normalize runtime effort

This commit is contained in:
程序员阿江(Relakkes)
2026-09-26 23:43:08 +08:00
parent 9ce414ae4c
commit 26d3a3d558
19 changed files with 846 additions and 109 deletions
@@ -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(<ModelSelector runtimeKey="restored-session" />)
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(<ModelSelector runtimeKey="official-restored" />)
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(<ModelSelector value="alpha" onChange={vi.fn()} fluid />)
@@ -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(<ModelSelector runtimeKey="blank-session" />)
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', () => {
@@ -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<ModelSelectorHandle, Props>(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<ModelSelectorHandle, Props>(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'))
+45 -1
View File
@@ -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')
+48 -5
View File
@@ -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
}
+69 -2
View File
@@ -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' })
+34 -16
View File
@@ -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<UIMessage, { type: 'tool_use' }>
type CompactSummaryMessage = Extract<UIMessage, { type: 'compact_summary' }>
@@ -3408,14 +3414,18 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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
+1
View File
@@ -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 的真实权限,避免本地影子值漂移。
+163 -9
View File
@@ -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<void>((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({
+9 -3
View File
@@ -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 })
@@ -55,6 +55,7 @@ const CONTAINERS: Record<string, Classification> = {
prewarmIdleTimers: { kind: 'cleared' },
prewarmPendingSessions: { kind: 'cleared' },
prewarmedSessions: { kind: 'cleared' },
rejectedRuntimeConfigs: { kind: 'cleared' },
runtimeExitStoppedSessions: { kind: 'cleared' },
runtimeOverrides: { kind: 'cleared' },
runtimeTransitionPromises: { kind: 'cleared' },
+90 -3
View File
@@ -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<WebSocketData>
}
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<void> {
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)
@@ -88,6 +88,36 @@ async function sourceHash(path: string): Promise<string> {
}
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')
@@ -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
@@ -57,6 +57,24 @@ function assistant(timestamp: string, extra: Record<string, unknown> = {}) {
}
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',
@@ -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<string, unknown>,
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()) {
@@ -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()
})
+10 -15
View File
@@ -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<string, unknown>)?.repository
@@ -4811,7 +4803,11 @@ export class SessionService {
): Promise<void> {
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()) {
+1
View File
@@ -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
+102 -23
View File
@@ -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<string, RuntimeOverride>()
// 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<string, string>()
const activeUserTurns = new Map<string, ActiveUserTurnState>()
const activeCliRuns = new Set<string>()
const pendingInterruptedTurnResults = new Map<string, number>()
@@ -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<ClientMessage, { type: 'permission_response' }>,
): Promise<void> {
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<ClientMessage, { type: 'set_runtime_config' }>
) {
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<RuntimeSet
const defaults = await getDefaultRuntimeSettings()
return {
...defaults,
model: await resolveDefaultRuntimeModel(defaults),
permissionMode: sessionPermissionMode ?? defaults.permissionMode,
}
}
@@ -4751,7 +4804,14 @@ export async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSet
)
let effort = runtimeOverride.effort
if (isOpenAIOfficialProviderId(runtimeOverride.providerId)) {
effort = effort ?? await getDefaultOpenAIReasoningEffort(runtimeOverride.modelId)
const catalog = await getDesktopOpenAICodexModelCatalog()
const model = getOpenAIModelCatalogEntry(runtimeOverride.modelId, catalog)
// Saved selections may outlive a model's supported effort catalog. The
// explicit WS selection path stays strict; restoration uses its default.
effort = effort && isOpenAIReasoningEffort(effort) &&
(!model || model.supportedReasoningEfforts.includes(effort))
? effort
: model?.defaultReasoningEffort ?? 'medium'
} else if (isGrokOfficialProviderId(runtimeOverride.providerId)) {
const grokEffort = await getGrokReasoningEfforts(runtimeOverride.modelId)
runtimeOverride.modelId = grokEffort.modelId
@@ -4792,6 +4852,14 @@ async function getSessionPermissionMode(sessionId: string): Promise<string | und
return launchInfo?.permissionMode
}
async function resolveDefaultRuntimeModel(runtime: RuntimeSettings): Promise<string> {
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<RuntimeSettings> {
// 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()