mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
fix: restore session model selections and normalize runtime effort
This commit is contained in:
@@ -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'))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 的真实权限,避免本地影子值漂移。
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user