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', () => {
|
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', () => {
|
it('keeps a long model label shrinkable in a fluid desktop toolbar', () => {
|
||||||
useSettingsStore.setState({ locale: 'en', availableModels: MODELS, currentModel: MODELS[0] })
|
useSettingsStore.setState({ locale: 'en', availableModels: MODELS, currentModel: MODELS[0] })
|
||||||
render(<ModelSelector value="alpha" onChange={vi.fn()} fluid />)
|
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({
|
useSettingsStore.setState({
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
availableModels: [
|
availableModels: [
|
||||||
@@ -635,17 +671,17 @@ describe('ModelSelector', () => {
|
|||||||
|
|
||||||
render(<ModelSelector runtimeKey="blank-session" />)
|
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 () => {
|
await act(async () => {
|
||||||
fireEvent.click(trigger)
|
fireEvent.click(trigger)
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
const flashOption = screen
|
const configuredOption = screen
|
||||||
.getAllByRole('button', { name: /deepseek-v4-flash/i })
|
.getAllByRole('button', { name: /deepseek-v4-pro/i })
|
||||||
.find((button) => button.textContent?.includes('Main Model'))
|
.find((button) => button.textContent?.includes('Sonnet Model'))
|
||||||
expect(flashOption).toBeDefined()
|
expect(configuredOption).toBeDefined()
|
||||||
expect(flashOption?.className).toContain('border-[var(--color-model-option-selected-border)]')
|
expect(configuredOption?.className).toContain('border-[var(--color-model-option-selected-border)]')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('closes the focus ring on both halves of the segmented control', () => {
|
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 { isDesktopRuntime } from '../../lib/desktopRuntime'
|
||||||
import {
|
import {
|
||||||
normalizeRuntimeSelection,
|
normalizeRuntimeSelection,
|
||||||
|
reconcileRuntimeSelection,
|
||||||
resolveDefaultRuntimeSelection,
|
resolveDefaultRuntimeSelection,
|
||||||
resolveProviderRuntimeModelId,
|
|
||||||
resolveProviderSlotModelId,
|
resolveProviderSlotModelId,
|
||||||
} from '../../lib/runtimeSelection'
|
} from '../../lib/runtimeSelection'
|
||||||
import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
|
import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
|
||||||
@@ -452,25 +452,29 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
|
|||||||
storeModel?.id,
|
storeModel?.id,
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
const requestedRuntimeProvider = providers.find(
|
const resolvedRuntimeSelection = requestedRuntimeSelection
|
||||||
(provider) => provider.id === requestedRuntimeSelection?.providerId,
|
? reconcileRuntimeSelection(requestedRuntimeSelection, {
|
||||||
)
|
providers, activeId, hasLoadedProviders: hasLoadedProviders && lockedProviderId === undefined,
|
||||||
const activeRuntimeSelection = requestedRuntimeSelection && providerChoices.some(
|
currentModelId: storeModel?.id,
|
||||||
(choice) => choice.providerId === requestedRuntimeSelection.providerId,
|
defaultEffortLevel: effortLevel,
|
||||||
)
|
})
|
||||||
? {
|
|
||||||
...requestedRuntimeSelection,
|
|
||||||
modelId: requestedRuntimeProvider
|
|
||||||
? resolveProviderRuntimeModelId(
|
|
||||||
requestedRuntimeProvider,
|
|
||||||
requestedRuntimeSelection.modelId,
|
|
||||||
)
|
|
||||||
: requestedRuntimeSelection.modelId,
|
|
||||||
}
|
|
||||||
: null
|
: 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
|
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
|
: null
|
||||||
|
|
||||||
const selectedRuntimeModel = activeRuntimeSelection
|
const selectedRuntimeModel = activeRuntimeSelection
|
||||||
@@ -491,7 +495,7 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
|
|||||||
(selectedRuntimeProvider.apiFormat ?? 'anthropic') === 'anthropic' &&
|
(selectedRuntimeProvider.apiFormat ?? 'anthropic') === 'anthropic' &&
|
||||||
!isOpenAIReasoningModel(selectedRuntimeModel?.id ?? '')
|
!isOpenAIReasoningModel(selectedRuntimeModel?.id ?? '')
|
||||||
|
|
||||||
const needsProviderConfiguration = isRuntimeScoped && providerChoices.length === 0
|
const needsProviderConfiguration = isRuntimeScoped && !activeRuntimeSelection && providerChoices.length === 0
|
||||||
const buttonModelLabel = isRuntimeScoped
|
const buttonModelLabel = isRuntimeScoped
|
||||||
? selectedRuntimeModel?.name
|
? selectedRuntimeModel?.name
|
||||||
?? (needsProviderConfiguration ? t('model.configureProvider') : t('model.selectModel'))
|
?? (needsProviderConfiguration ? t('model.configureProvider') : t('model.selectModel'))
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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'
|
import type { SavedProvider } from '../types/provider'
|
||||||
|
|
||||||
describe('normalizeRuntimeSelection', () => {
|
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([
|
it.each([
|
||||||
['Claude Official', null],
|
['Claude Official', null],
|
||||||
['ChatGPT Official', 'openai-official'],
|
['ChatGPT Official', 'openai-official'],
|
||||||
@@ -147,6 +153,37 @@ describe('provider 1M runtime selection', () => {
|
|||||||
model1mSupport: { main: true, fable: false, haiku: false, sonnet: true, opus: false },
|
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', () => {
|
it('materializes the active provider main slot by id and by legacy name', () => {
|
||||||
for (const activeId of [provider.id, null]) {
|
for (const activeId of [provider.id, null]) {
|
||||||
expect(resolveDefaultRuntimeSelection(activeId, provider.name, [provider], 'stale')).toEqual({
|
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', () => {
|
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, 'balanced-model')).toBe('balanced-model[1m]')
|
||||||
expect(resolveProviderRuntimeModelId(provider, 'large-model[1m]')).toBe('large-model')
|
expect(resolveProviderRuntimeModelId(provider, 'large-model[1m]')).toBe('large-model')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog'
|
import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog'
|
||||||
import {
|
import {
|
||||||
OPENAI_OFFICIAL_DEFAULT_MODEL_ID,
|
OPENAI_OFFICIAL_DEFAULT_MODEL_ID,
|
||||||
|
OPENAI_OFFICIAL_MODELS,
|
||||||
OPENAI_OFFICIAL_PROVIDER_ID,
|
OPENAI_OFFICIAL_PROVIDER_ID,
|
||||||
} from '../constants/openaiOfficialProvider'
|
} from '../constants/openaiOfficialProvider'
|
||||||
import type { SavedProvider } from '../types/provider'
|
import type { SavedProvider } from '../types/provider'
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
type ModelReasoningApiFormat,
|
type ModelReasoningApiFormat,
|
||||||
type ModelReasoningProviderKind,
|
type ModelReasoningProviderKind,
|
||||||
} from '../../../src/shared/modelReasoning'
|
} from '../../../src/shared/modelReasoning'
|
||||||
|
import { getBundledPresetReasoningProviderKind } from '../config/providerPresets'
|
||||||
|
|
||||||
const PROVIDER_MODEL_SLOTS = ['main', 'haiku', 'sonnet', 'opus', 'fable'] as const
|
const PROVIDER_MODEL_SLOTS = ['main', 'haiku', 'sonnet', 'opus', 'fable'] as const
|
||||||
|
|
||||||
@@ -61,10 +63,14 @@ export function resolveActiveProviderRuntimeSelection(
|
|||||||
if (!inferredProviderId) return null
|
if (!inferredProviderId) return null
|
||||||
|
|
||||||
const providerMainModelId = activeProvider ? resolveProviderSlotModelId(activeProvider, 'main') : undefined
|
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 {
|
return {
|
||||||
providerId: inferredProviderId,
|
providerId: inferredProviderId,
|
||||||
modelId: providerMainModelId || currentModelId || (
|
modelId: configuredModelId || providerMainModelId || currentModelId || (
|
||||||
inferredProviderId === OPENAI_OFFICIAL_PROVIDER_ID
|
inferredProviderId === OPENAI_OFFICIAL_PROVIDER_ID
|
||||||
? OPENAI_OFFICIAL_DEFAULT_MODEL_ID
|
? OPENAI_OFFICIAL_DEFAULT_MODEL_ID
|
||||||
: inferredProviderId === GROK_OFFICIAL_PROVIDER_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(
|
export function normalizeRuntimeSelection(
|
||||||
selection: RuntimeSelection,
|
selection: RuntimeSelection,
|
||||||
apiFormat?: ModelReasoningApiFormat,
|
apiFormat?: ModelReasoningApiFormat,
|
||||||
@@ -98,14 +140,14 @@ export function normalizeRuntimeSelection(
|
|||||||
): RuntimeSelection {
|
): RuntimeSelection {
|
||||||
if (
|
if (
|
||||||
selection.effortLevel === undefined ||
|
selection.effortLevel === undefined ||
|
||||||
selection.providerId === null ||
|
selection.providerId === null
|
||||||
selection.providerId === OPENAI_OFFICIAL_PROVIDER_ID
|
|
||||||
) {
|
) {
|
||||||
return selection
|
return selection
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selection.providerId === GROK_OFFICIAL_PROVIDER_ID) {
|
if (selection.providerId === GROK_OFFICIAL_PROVIDER_ID || selection.providerId === OPENAI_OFFICIAL_PROVIDER_ID) {
|
||||||
const model = GROK_OFFICIAL_MODELS.find((entry) => entry.id === selection.modelId)
|
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
|
// 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
|
// the bundled desktop list. Keep their effort untouched and let the server
|
||||||
// validate it against the live catalog instead of silently dropping it.
|
// 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)
|
const effortLevel = model.supportedReasoningEfforts?.includes(selection.effortLevel)
|
||||||
? selection.effortLevel
|
? selection.effortLevel
|
||||||
: model.defaultReasoningEffort ?? model.supportedReasoningEfforts?.[0]
|
: model.defaultReasoningEffort ?? model.supportedReasoningEfforts?.[0]
|
||||||
|
if (effortLevel === selection.effortLevel) return selection
|
||||||
const { effortLevel: _unsupportedEffort, ...runtime } = selection
|
const { effortLevel: _unsupportedEffort, ...runtime } = selection
|
||||||
return effortLevel ? { ...runtime, effortLevel } : runtime
|
return effortLevel ? { ...runtime, effortLevel } : runtime
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const {
|
|||||||
sendSubagentMessageMock: vi.fn(async () => ({ ok: true })),
|
sendSubagentMessageMock: vi.fn(async () => ({ ok: true })),
|
||||||
tabStoreSnapshot: { tabs: [] as Tab[], activeTabId: null as string | null },
|
tabStoreSnapshot: { tabs: [] as Tab[], activeTabId: null as string | null },
|
||||||
tabStoreListeners: new Set<(state: any, previous: any) => void>(),
|
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 }) } }))
|
vi.mock('./teamPlanStore', () => ({ useTeamPlanStore: { getState: () => ({ refresh: refreshTeamPlanMock }) } }))
|
||||||
@@ -532,6 +532,8 @@ describe('chatStore history mapping', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
providerStoreSnapshot.providers = []
|
providerStoreSnapshot.providers = []
|
||||||
providerStoreSnapshot.activeId = null
|
providerStoreSnapshot.activeId = null
|
||||||
|
providerStoreSnapshot.hasLoadedProviders = false
|
||||||
|
useSettingsStore.setState({ currentModel: null, activeProviderName: null, effortLevel: 'max' })
|
||||||
sendMock.mockReset()
|
sendMock.mockReset()
|
||||||
getMemberBySessionIdMock.mockReset()
|
getMemberBySessionIdMock.mockReset()
|
||||||
getMemberBySessionIdMock.mockReturnValue(null)
|
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) => {
|
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'
|
const model = 'deepseek-v4.1-flash-expires-on-0910'
|
||||||
providerStoreSnapshot.providers = [{
|
providerStoreSnapshot.providers = [{
|
||||||
@@ -5368,11 +5395,25 @@ describe('chatStore history mapping', () => {
|
|||||||
}]
|
}]
|
||||||
useChatStore.getState().sendMessage(TEST_SESSION_ID, 'continue')
|
useChatStore.getState().sendMessage(TEST_SESSION_ID, 'continue')
|
||||||
expect(sendMock.mock.calls.slice(0, 2)).toEqual([
|
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 }],
|
[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', () => {
|
it('does not prewarm unknown desktop sessions when connecting', () => {
|
||||||
useChatStore.getState().connectToSession(TEST_SESSION_ID)
|
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')
|
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) => {
|
it.each(['RUNTIME_CONFIG_INVALID', 'CLI_RESTART_FAILED'])('allows fresh metadata to correct a rejected selection (%s)', (code) => {
|
||||||
const runtime = useSessionRuntimeStore.getState()
|
const runtime = useSessionRuntimeStore.getState()
|
||||||
runtime.setSelection(TEST_SESSION_ID, { providerId: null, modelId: 'rejected-model' })
|
runtime.setSelection(TEST_SESSION_ID, { providerId: null, modelId: 'rejected-model' })
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import { useCLITaskStore } from './cliTaskStore'
|
|||||||
import { useWorkflowStore } from './workflowStore'
|
import { useWorkflowStore } from './workflowStore'
|
||||||
import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
||||||
import { useProviderStore } from './providerStore'
|
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 { useTabStore } from './tabStore'
|
||||||
import { randomSpinnerVerb } from '../config/spinnerVerbs'
|
import { randomSpinnerVerb } from '../config/spinnerVerbs'
|
||||||
import { notifyDesktop } from '../lib/desktopNotifications'
|
import { notifyDesktop } from '../lib/desktopNotifications'
|
||||||
@@ -60,11 +62,15 @@ import type {
|
|||||||
|
|
||||||
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
|
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
|
||||||
|
|
||||||
function reconcileProviderRuntimeSelection(selection: RuntimeSelection): RuntimeSelection {
|
function reconcileProviderRuntimeSelection(sessionId: string, selection: RuntimeSelection): RuntimeSelection {
|
||||||
const provider = useProviderStore.getState().providers.find((entry) => entry.id === selection.providerId)
|
const providers = useProviderStore.getState()
|
||||||
if (!provider) return selection
|
const settings = useSettingsStore.getState()
|
||||||
const modelId = resolveProviderRuntimeModelId(provider, selection.modelId)
|
return reconcileRuntimeSelection(selection, {
|
||||||
return modelId === selection.modelId ? selection : { ...selection, modelId }
|
...providers,
|
||||||
|
hasLoadedProviders: providers.hasLoadedProviders && !isSideChatSession(sessionId),
|
||||||
|
currentModelId: settings.currentModel?.id,
|
||||||
|
defaultEffortLevel: settings.effortLevel,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
||||||
type CompactSummaryMessage = Extract<UIMessage, { type: 'compact_summary' }>
|
type CompactSummaryMessage = Extract<UIMessage, { type: 'compact_summary' }>
|
||||||
@@ -3408,14 +3414,18 @@ export const useChatStore = create<ChatStore>((setState, get) => {
|
|||||||
|
|
||||||
const selection = useSessionRuntimeStore.getState().selections[sessionId]
|
const selection = useSessionRuntimeStore.getState().selections[sessionId]
|
||||||
if (selection) {
|
if (selection) {
|
||||||
const reconciled = reconcileProviderRuntimeSelection(selection)
|
const reconciled = reconcileProviderRuntimeSelection(sessionId, selection)
|
||||||
if (reconciled !== selection) get().setSessionRuntime(sessionId, selection)
|
if (reconciled !== selection) get().setSessionRuntime(sessionId, selection)
|
||||||
} else {
|
} else {
|
||||||
const providers = useProviderStore.getState()
|
const providers = useProviderStore.getState()
|
||||||
const defaultSelection = resolveActiveProviderRuntimeSelection(
|
const settings = useSettingsStore.getState()
|
||||||
providers.activeId, null, providers.providers, undefined,
|
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)
|
useSessionRuntimeStore.getState().setSelection(sessionId, defaultSelection)
|
||||||
get().setSessionRuntime(sessionId, defaultSelection)
|
get().setSessionRuntime(sessionId, defaultSelection)
|
||||||
}
|
}
|
||||||
@@ -3484,7 +3494,7 @@ export const useChatStore = create<ChatStore>((setState, get) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
setSessionRuntime: (sessionId, selection) => {
|
setSessionRuntime: (sessionId, selection) => {
|
||||||
const reconciled = reconcileProviderRuntimeSelection(selection)
|
const reconciled = reconcileProviderRuntimeSelection(sessionId, selection)
|
||||||
if (reconciled !== selection) {
|
if (reconciled !== selection) {
|
||||||
useSessionRuntimeStore.getState().setSelection(sessionId, reconciled)
|
useSessionRuntimeStore.getState().setSelection(sessionId, reconciled)
|
||||||
}
|
}
|
||||||
@@ -4865,14 +4875,22 @@ export const useChatStore = create<ChatStore>((setState, get) => {
|
|||||||
|
|
||||||
case 'runtime_config_applied': {
|
case 'runtime_config_applied': {
|
||||||
const selected = useSessionRuntimeStore.getState().selections[sessionId]
|
const selected = useSessionRuntimeStore.getState().selections[sessionId]
|
||||||
const matchesCurrentSelection = Boolean(selected) &&
|
const matchesSelection = (runtime: { providerId: string | null; modelId: string; effortLevel?: string }) =>
|
||||||
(selected?.providerId ?? null) === msg.providerId &&
|
Boolean(selected) && selected?.providerId === runtime.providerId &&
|
||||||
selected?.modelId === msg.modelId &&
|
selected?.modelId === runtime.modelId && selected?.effortLevel === runtime.effortLevel
|
||||||
selected?.effortLevel === msg.effortLevel
|
const matchesCurrentSelection = matchesSelection(msg)
|
||||||
if (matchesCurrentSelection) {
|
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)
|
useSessionRuntimeStore.getState().settleSelection(sessionId)
|
||||||
update((session) => ({
|
update((session) => ({
|
||||||
runtimeConfigReadyCount: (session.runtimeConfigReadyCount ?? 0) + 1,
|
runtimeConfigReadyCount: (session.runtimeConfigReadyCount ?? 0) + 1,
|
||||||
|
messages: session.messages.filter((message) => message.type !== 'error' || message.code !== 'RUNTIME_CONFIG_INVALID'),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export type ServerMessage =
|
|||||||
providerId: string | null
|
providerId: string | null
|
||||||
modelId: string
|
modelId: string
|
||||||
effortLevel?: string
|
effortLevel?: string
|
||||||
|
requestedConfig?: { providerId: string | null; modelId: string; effortLevel?: string }
|
||||||
}
|
}
|
||||||
// CLI 回传的权限模式变化(如 ExitPlanMode 退出 plan 后恢复、Shift+Tab)。
|
// CLI 回传的权限模式变化(如 ExitPlanMode 退出 plan 后恢复、Shift+Tab)。
|
||||||
// 桌面端据此把选择器校正回 CLI 的真实权限,避免本地影子值漂移。
|
// 桌面端据此把选择器校正回 CLI 的真实权限,避免本地影子值漂移。
|
||||||
|
|||||||
@@ -5687,14 +5687,23 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
}
|
}
|
||||||
}, 20_000)
|
}, 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 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({
|
const activeProvider = await providerService.addProvider({
|
||||||
presetId: 'custom',
|
presetId: 'custom',
|
||||||
name: 'Current Valid Provider',
|
name: 'Current Valid Provider',
|
||||||
apiKey: 'key-current-valid',
|
apiKey: 'key-current-valid',
|
||||||
baseUrl: 'http://127.0.0.1:1/anthropic',
|
baseUrl: 'http://127.0.0.1:1/anthropic',
|
||||||
apiFormat: 'anthropic',
|
apiFormat: 'anthropic',
|
||||||
|
...(savedEffort ? { model1mSupport: { main: true, haiku: false, sonnet: false, opus: false } } : {}),
|
||||||
models: {
|
models: {
|
||||||
main: 'current-main',
|
main: 'current-main',
|
||||||
haiku: 'current-haiku',
|
haiku: 'current-haiku',
|
||||||
@@ -5713,6 +5722,12 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
const { sessionId } = await createRes.json() as { sessionId: string }
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
||||||
|
|
||||||
const staleProviderId = crypto.randomUUID()
|
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 originalStartSession = conversationService.startSession.bind(conversationService)
|
||||||
const startCalls: Array<{
|
const startCalls: Array<{
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@@ -5743,11 +5758,15 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
messages.push(msg)
|
messages.push(msg)
|
||||||
|
|
||||||
if (msg.type === 'connected') {
|
if (msg.type === 'connected') {
|
||||||
ws.send(JSON.stringify({
|
if (replaySelection) {
|
||||||
type: 'set_runtime_config',
|
// A reconnect can replay the same saved selection more than once.
|
||||||
providerId: staleProviderId,
|
for (let replay = 0; replay < 2; replay++) ws.send(JSON.stringify({
|
||||||
modelId: 'stale-model',
|
type: 'set_runtime_config',
|
||||||
}))
|
providerId: staleProviderId,
|
||||||
|
modelId: 'stale-model',
|
||||||
|
...(savedEffort ? { effortLevel: savedEffort } : {}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
ws.send(JSON.stringify({ type: 'user_message', content: 'resume old session' }))
|
ws.send(JSON.stringify({ type: 'user_message', content: 'resume old session' }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -5777,15 +5796,82 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
sessionId,
|
sessionId,
|
||||||
options: {
|
options: {
|
||||||
providerId: activeProvider.id,
|
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)
|
expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true)
|
||||||
} finally {
|
} finally {
|
||||||
ws.close()
|
ws.close()
|
||||||
conversationService.startSession = originalStartSession
|
conversationService.startSession = originalStartSession
|
||||||
conversationService.stopSession(sessionId)
|
conversationService.stopSession(sessionId)
|
||||||
await providerService.activateOfficial()
|
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)
|
}, 20_000)
|
||||||
|
|
||||||
@@ -6054,7 +6140,7 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
}
|
}
|
||||||
}, 20_000)
|
}, 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()}`
|
const sessionId = `chat-openai-invalid-effort-${crypto.randomUUID()}`
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
||||||
@@ -6070,7 +6156,7 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
type: 'set_runtime_config',
|
type: 'set_runtime_config',
|
||||||
providerId: 'openai-official',
|
providerId: 'openai-official',
|
||||||
modelId: 'gpt-5.5',
|
modelId: 'gpt-5.5',
|
||||||
effortLevel: 'max',
|
effortLevel,
|
||||||
}))
|
}))
|
||||||
} else if (message.type === 'error') {
|
} else if (message.type === 'error') {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
@@ -6083,6 +6169,74 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
})
|
})
|
||||||
}, 10_000)
|
}, 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 () => {
|
it('should reject unsupported GLM 5.3 standard API effort aliases', async () => {
|
||||||
const providerService = new ProviderService()
|
const providerService = new ProviderService()
|
||||||
const provider = await providerService.addProvider({
|
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')
|
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)
|
expect(main.events.some(event => event.type === 'permission_request')).toBe(false)
|
||||||
// A runtime change that needs restart must fail before stopping the live child.
|
// 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' }))
|
const appliedRuntimeCount = child.events.filter(event => event.type === 'runtime_config_applied').length
|
||||||
await eventually(() => child.events.some(event => event.code === 'SIDE_CHAT_RUNTIME_RESTART_UNAVAILABLE'), 'safe runtime rejection')
|
for (const providerId of ['different-provider', 'openai-official']) {
|
||||||
expect(conversationService.hasSession(side.sessionId)).toBe(true)
|
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}/messages`)).messages).toEqual([])
|
||||||
expect(await api(`/api/sessions/${side.sessionId}/turn-checkpoints`)).toEqual({ checkpoints: [] })
|
expect(await api(`/api/sessions/${side.sessionId}/turn-checkpoints`)).toEqual({ checkpoints: [] })
|
||||||
expect(await sessionService.getSessionMessagesWithEvidence(side.sessionId)).toEqual({ messages: [], transcriptEvidenceComplete: false })
|
expect(await sessionService.getSessionMessagesWithEvidence(side.sessionId)).toEqual({ messages: [], transcriptEvidenceComplete: false })
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const CONTAINERS: Record<string, Classification> = {
|
|||||||
prewarmIdleTimers: { kind: 'cleared' },
|
prewarmIdleTimers: { kind: 'cleared' },
|
||||||
prewarmPendingSessions: { kind: 'cleared' },
|
prewarmPendingSessions: { kind: 'cleared' },
|
||||||
prewarmedSessions: { kind: 'cleared' },
|
prewarmedSessions: { kind: 'cleared' },
|
||||||
|
rejectedRuntimeConfigs: { kind: 'cleared' },
|
||||||
runtimeExitStoppedSessions: { kind: 'cleared' },
|
runtimeExitStoppedSessions: { kind: 'cleared' },
|
||||||
runtimeOverrides: { kind: 'cleared' },
|
runtimeOverrides: { kind: 'cleared' },
|
||||||
runtimeTransitionPromises: { kind: 'cleared' },
|
runtimeTransitionPromises: { kind: 'cleared' },
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
__resolveRuntimeRestartWorkDirForTests,
|
__resolveRuntimeRestartWorkDirForTests,
|
||||||
closeSessionConnection,
|
closeSessionConnection,
|
||||||
getActiveSessionIds,
|
getActiveSessionIds,
|
||||||
|
getRuntimeSettings,
|
||||||
handleWebSocket,
|
handleWebSocket,
|
||||||
stopSessionTurn,
|
stopSessionTurn,
|
||||||
__registerPendingSessionStartupForTests,
|
__registerPendingSessionStartupForTests,
|
||||||
@@ -27,6 +28,8 @@ import { sessionService } from '../services/sessionService.js'
|
|||||||
import { observeSessionTurns, type SessionTurnEvent } from '../services/sessionTurnEvents.js'
|
import { observeSessionTurns, type SessionTurnEvent } from '../services/sessionTurnEvents.js'
|
||||||
import * as titleService from '../services/titleService.js'
|
import * as titleService from '../services/titleService.js'
|
||||||
import { SettingsService } from '../services/settingsService.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 { activeBackgroundTaskIds } from '../ws/agentTaskState.js'
|
||||||
import * as teleportApi from '../../utils/teleport/api.js'
|
import * as teleportApi from '../../utils/teleport/api.js'
|
||||||
import { resetSettingsCache, setSessionSettingsCache } from '../../utils/settings/settingsCache.js'
|
import { resetSettingsCache, setSessionSettingsCache } from '../../utils/settings/settingsCache.js'
|
||||||
@@ -66,6 +69,28 @@ function makeSdkSocket(sessionId: string, sdkToken: string) {
|
|||||||
} as unknown as ServerWebSocket<WebSocketData>
|
} 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> {
|
async function flushMicrotasks(count = 12): Promise<void> {
|
||||||
for (let index = 0; index < count; index++) await Promise.resolve()
|
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 sessionId = `plan-same-provider-${crypto.randomUUID()}`
|
||||||
const ws = makeClientSocket(sessionId)
|
const ws = makeClientSocket(sessionId)
|
||||||
|
|
||||||
@@ -4166,7 +4192,7 @@ describe('WebSocket handler session isolation', () => {
|
|||||||
spyOn(conversationService, 'getSessionWorkDir').mockReturnValue('/tmp')
|
spyOn(conversationService, 'getSessionWorkDir').mockReturnValue('/tmp')
|
||||||
spyOn(conversationService, 'setModel').mockResolvedValue(true)
|
spyOn(conversationService, 'setModel').mockResolvedValue(true)
|
||||||
spyOn(conversationService, 'respondToPermission').mockReturnValue(true)
|
spyOn(conversationService, 'respondToPermission').mockReturnValue(true)
|
||||||
spyOn(sessionService, 'getSessionLaunchInfo').mockResolvedValue({
|
const launchInfo = {
|
||||||
filePath: '/tmp/plan-session.jsonl',
|
filePath: '/tmp/plan-session.jsonl',
|
||||||
projectDir: '/tmp',
|
projectDir: '/tmp',
|
||||||
workDir: '/tmp',
|
workDir: '/tmp',
|
||||||
@@ -4174,8 +4200,12 @@ describe('WebSocket handler session isolation', () => {
|
|||||||
customTitle: null,
|
customTitle: null,
|
||||||
runtimeProviderId: 'deepseek',
|
runtimeProviderId: 'deepseek',
|
||||||
runtimeModelId: 'deepseek-v4-pro',
|
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 startSession = spyOn(conversationService, 'startSession').mockResolvedValue()
|
||||||
const stopSession = spyOn(conversationService, 'stopSession').mockImplementation(() => {})
|
const stopSession = spyOn(conversationService, 'stopSession').mockImplementation(() => {})
|
||||||
|
|
||||||
@@ -4205,9 +4235,65 @@ describe('WebSocket handler session isolation', () => {
|
|||||||
workDir: '/tmp',
|
workDir: '/tmp',
|
||||||
runtimeProviderId: 'deepseek',
|
runtimeProviderId: 'deepseek',
|
||||||
runtimeModelId: 'deepseek-v4-flash',
|
runtimeModelId: 'deepseek-v4-flash',
|
||||||
|
effortLevel: 'high',
|
||||||
})
|
})
|
||||||
expect(startSession).not.toHaveBeenCalled()
|
expect(startSession).not.toHaveBeenCalled()
|
||||||
expect(stopSession).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 () => {
|
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 () => {
|
it('keeps the permission pending when the in-process model switch fails', async () => {
|
||||||
|
mockSavedDeepSeekProvider()
|
||||||
const sessionId = `plan-set-model-fail-${crypto.randomUUID()}`
|
const sessionId = `plan-set-model-fail-${crypto.randomUUID()}`
|
||||||
const ws = makeClientSocket(sessionId)
|
const ws = makeClientSocket(sessionId)
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,36 @@ async function sourceHash(path: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('session projector', () => {
|
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 () => {
|
it('persists an immediate title patch without waiting for transcript reprojection', async () => {
|
||||||
const root = await createTempDir('projector-title-patch')
|
const root = await createTempDir('projector-title-patch')
|
||||||
const databasePath = join(root, 'index.sqlite')
|
const databasePath = join(root, 'index.sqlite')
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ import type {
|
|||||||
// 5: protocol-lock metadata was projected into session summaries.
|
// 5: protocol-lock metadata was projected into session summaries.
|
||||||
// 6: protocol enforcement was removed; rebuild v5 summaries without protocol restrictions.
|
// 6: protocol enforcement was removed; rebuild v5 summaries without protocol restrictions.
|
||||||
// 7: independent desktop team workers remain addressable but leave sidebar listings.
|
// 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 = {
|
export type SessionSourceCandidate = {
|
||||||
path: string
|
path: string
|
||||||
|
|||||||
@@ -57,6 +57,24 @@ function assistant(timestamp: string, extra: Record<string, unknown> = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('reduceTranscript', () => {
|
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', () => {
|
it('projects the existing summary fields and title precedence from complete lines', () => {
|
||||||
const repository = {
|
const repository = {
|
||||||
requestedWorkDir: '/repo',
|
requestedWorkDir: '/repo',
|
||||||
|
|||||||
@@ -135,6 +135,25 @@ const VALID_SESSION_PERMISSION_MODES = new Set([
|
|||||||
'auto',
|
'auto',
|
||||||
])
|
])
|
||||||
const VALID_SESSION_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
|
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([
|
const ACTIVITY_TRANSCRIPT_MESSAGE_TYPES = new Set([
|
||||||
'user',
|
'user',
|
||||||
'assistant',
|
'assistant',
|
||||||
@@ -546,12 +565,7 @@ function applyEntry(state: ReducerState, entry: ReducerEntry): void {
|
|||||||
if (typeof record.runtimeModelId === 'string') {
|
if (typeof record.runtimeModelId === 'string') {
|
||||||
state.runtimeModelId = record.runtimeModelId
|
state.runtimeModelId = record.runtimeModelId
|
||||||
}
|
}
|
||||||
if (
|
state.effortLevel = resolveSessionEffortLevel(record, state.effortLevel)
|
||||||
typeof record.effortLevel === 'string' &&
|
|
||||||
VALID_SESSION_EFFORT_LEVELS.has(record.effortLevel)
|
|
||||||
) {
|
|
||||||
state.effortLevel = record.effortLevel
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof entry.cwd === 'string' && entry.cwd.trim()) {
|
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 {
|
import {
|
||||||
extractGoalCreationTitle,
|
extractGoalCreationTitle,
|
||||||
extractTranscriptUserTitle,
|
extractTranscriptUserTitle,
|
||||||
|
resolveSessionEffortLevel,
|
||||||
} from './localIndex/transcriptReducer.js'
|
} from './localIndex/transcriptReducer.js'
|
||||||
import type {
|
import type {
|
||||||
PersistedWorktreeSession,
|
PersistedWorktreeSession,
|
||||||
@@ -1013,11 +1014,7 @@ export class SessionService {
|
|||||||
if (metadata.runtimeModelId && launchInfo.runtimeModelId !== metadata.runtimeModelId) {
|
if (metadata.runtimeModelId && launchInfo.runtimeModelId !== metadata.runtimeModelId) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (
|
if (launchInfo.effortLevel !== resolveSessionEffortLevel(metadata, launchInfo.effortLevel)) {
|
||||||
metadata.effortLevel &&
|
|
||||||
VALID_SESSION_EFFORT_LEVELS.has(metadata.effortLevel) &&
|
|
||||||
launchInfo.effortLevel !== metadata.effortLevel
|
|
||||||
) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -1293,7 +1290,7 @@ export class SessionService {
|
|||||||
state.permissionMode = this.resolvePermissionModeFromEntries([entry]) ?? state.permissionMode
|
state.permissionMode = this.resolvePermissionModeFromEntries([entry]) ?? state.permissionMode
|
||||||
if (record.runtimeProviderId === null || typeof record.runtimeProviderId === 'string') state.runtimeProviderId = record.runtimeProviderId as string | null
|
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.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
|
state.repository = this.resolveRepositoryFromEntries([entry]) ?? state.repository
|
||||||
const worktree = this.resolveWorktreeSessionFromEntries([entry])
|
const worktree = this.resolveWorktreeSessionFromEntries([entry])
|
||||||
@@ -3148,12 +3145,7 @@ export class SessionService {
|
|||||||
if (typeof record.runtimeModelId === 'string') {
|
if (typeof record.runtimeModelId === 'string') {
|
||||||
runtimeModelId = record.runtimeModelId
|
runtimeModelId = record.runtimeModelId
|
||||||
}
|
}
|
||||||
if (
|
effortLevel = resolveSessionEffortLevel(record, effortLevel)
|
||||||
typeof record.effortLevel === 'string' &&
|
|
||||||
VALID_SESSION_EFFORT_LEVELS.has(record.effortLevel)
|
|
||||||
) {
|
|
||||||
effortLevel = record.effortLevel
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidateRepository = (entry as Record<string, unknown>)?.repository
|
const candidateRepository = (entry as Record<string, unknown>)?.repository
|
||||||
@@ -4811,7 +4803,11 @@ export class SessionService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (isSideChatId(sessionId)) {
|
if (isSideChatId(sessionId)) {
|
||||||
const side = getSideChat(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
|
return
|
||||||
}
|
}
|
||||||
const persist = this.shouldPersistSession()
|
const persist = this.shouldPersistSession()
|
||||||
@@ -4836,8 +4832,7 @@ export class SessionService {
|
|||||||
? { permissionMode: metadata.permissionMode } : {}),
|
? { permissionMode: metadata.permissionMode } : {}),
|
||||||
...(metadata.runtimeProviderId !== undefined ? { runtimeProviderId: metadata.runtimeProviderId } : {}),
|
...(metadata.runtimeProviderId !== undefined ? { runtimeProviderId: metadata.runtimeProviderId } : {}),
|
||||||
...(metadata.runtimeModelId ? { runtimeModelId: metadata.runtimeModelId } : {}),
|
...(metadata.runtimeModelId ? { runtimeModelId: metadata.runtimeModelId } : {}),
|
||||||
...(metadata.effortLevel && VALID_SESSION_EFFORT_LEVELS.has(metadata.effortLevel)
|
effortLevel: resolveSessionEffortLevel(metadata, previousInfo.effortLevel),
|
||||||
? { effortLevel: metadata.effortLevel } : {}),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!persist || !this.shouldPersistSession()) {
|
if (!persist || !this.shouldPersistSession()) {
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export type ServerMessage =
|
|||||||
| { type: 'status'; state: ChatState; verb?: string; attemptStart?: boolean }
|
| { type: 'status'; state: ChatState; verb?: string; attemptStart?: boolean }
|
||||||
| {
|
| {
|
||||||
type: typeof RUNTIME_CONFIG_APPLIED_EVENT
|
type: typeof RUNTIME_CONFIG_APPLIED_EVENT
|
||||||
|
requestedConfig?: { providerId: string | null; modelId: string; effortLevel?: string }
|
||||||
providerId: string | null
|
providerId: string | null
|
||||||
modelId: string
|
modelId: string
|
||||||
effortLevel?: string
|
effortLevel?: string
|
||||||
|
|||||||
+102
-23
@@ -198,6 +198,7 @@ type RuntimeOverride = {
|
|||||||
providerId: string | null
|
providerId: string | null
|
||||||
modelId: string
|
modelId: string
|
||||||
effort?: string
|
effort?: string
|
||||||
|
requestedConfig?: { providerId: string | null; modelId: string; effortLevel?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActiveUserTurnState = {
|
type ActiveUserTurnState = {
|
||||||
@@ -216,6 +217,9 @@ type ActiveUserTurnState = {
|
|||||||
|
|
||||||
|
|
||||||
const runtimeOverrides = new Map<string, RuntimeOverride>()
|
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 activeUserTurns = new Map<string, ActiveUserTurnState>()
|
||||||
const activeCliRuns = new Set<string>()
|
const activeCliRuns = new Set<string>()
|
||||||
const pendingInterruptedTurnResults = new Map<string, number>()
|
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(
|
async function handlePlanApprovalWithRuntimeOverride(
|
||||||
ws: SessionConnection,
|
ws: SessionConnection,
|
||||||
message: Extract<ClientMessage, { type: 'permission_response' }>,
|
message: Extract<ClientMessage, { type: 'permission_response' }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { sessionId } = ws.data
|
const { sessionId } = ws.data
|
||||||
|
if (rejectStartedSideChatProviderChange(ws, message.runtimeOverride!.providerId)) return
|
||||||
const normalized = await normalizeRuntimeOverrideInput(message.runtimeOverride!)
|
const normalized = await normalizeRuntimeOverrideInput(message.runtimeOverride!)
|
||||||
if (!normalized.ok) {
|
if (!normalized.ok) {
|
||||||
sendMessage(ws, {
|
sendMessage(ws, {
|
||||||
@@ -1637,6 +1659,7 @@ async function handlePlanApprovalWithRuntimeOverride(
|
|||||||
currentModelId === nextOverride.modelId &&
|
currentModelId === nextOverride.modelId &&
|
||||||
currentEffort === nextOverride.effort
|
currentEffort === nextOverride.effort
|
||||||
) {
|
) {
|
||||||
|
rejectedRuntimeConfigs.delete(sessionId)
|
||||||
finalizePermissionResponse(ws, message)
|
finalizePermissionResponse(ws, message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1659,12 +1682,16 @@ async function handlePlanApprovalWithRuntimeOverride(
|
|||||||
// reports it, and the permission stays pending (override untouched).
|
// reports it, and the permission stays pending (override untouched).
|
||||||
await enqueueRuntimeTransition(sessionId, async () => {
|
await enqueueRuntimeTransition(sessionId, async () => {
|
||||||
await conversationService.setModel(sessionId, nextOverride.modelId)
|
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(
|
runtimeOverrideVersions.set(
|
||||||
sessionId,
|
sessionId,
|
||||||
(runtimeOverrideVersions.get(sessionId) ?? 0) + 1,
|
(runtimeOverrideVersions.get(sessionId) ?? 0) + 1,
|
||||||
)
|
)
|
||||||
await persistSessionRuntimeConfig(sessionId, nextOverride)
|
await persistSessionRuntimeConfig(sessionId, appliedOverride)
|
||||||
broadcastAppliedRuntimeConfig(sessionId)
|
broadcastAppliedRuntimeConfig(sessionId)
|
||||||
})
|
})
|
||||||
finalizePermissionResponse(ws, message)
|
finalizePermissionResponse(ws, message)
|
||||||
@@ -1676,6 +1703,7 @@ async function handlePlanApprovalWithRuntimeOverride(
|
|||||||
// flow. The interrupted turn may bill one partial request to the planning
|
// flow. The interrupted turn may bill one partial request to the planning
|
||||||
// model — same as the manual flow.
|
// model — same as the manual flow.
|
||||||
await enqueueRuntimeTransition(sessionId, async () => {
|
await enqueueRuntimeTransition(sessionId, async () => {
|
||||||
|
rejectedRuntimeConfigs.delete(sessionId)
|
||||||
runtimeOverrides.set(sessionId, nextOverride)
|
runtimeOverrides.set(sessionId, nextOverride)
|
||||||
runtimeOverrideVersions.set(
|
runtimeOverrideVersions.set(
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -1842,11 +1870,37 @@ async function normalizeRuntimeOverrideInput(
|
|||||||
> {
|
> {
|
||||||
let modelId = typeof input.modelId === 'string' ? input.modelId.trim() : ''
|
let modelId = typeof input.modelId === 'string' ? input.modelId.trim() : ''
|
||||||
if (!modelId) return { ok: false, reason: 'model' }
|
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)) {
|
if (isGrokOfficialProviderId(input.providerId)) {
|
||||||
modelId = (await getGrokReasoningEfforts(modelId)).modelId
|
modelId = (await getGrokReasoningEfforts(modelId)).modelId
|
||||||
}
|
}
|
||||||
const requestedEffort =
|
|
||||||
typeof input.effortLevel === 'string' ? input.effortLevel.trim() : undefined
|
|
||||||
const effortResolution = requestedEffort === undefined
|
const effortResolution = requestedEffort === undefined
|
||||||
? { valid: true, effort: undefined }
|
? { valid: true, effort: undefined }
|
||||||
: await resolveRuntimeEffort(input.providerId, modelId, requestedEffort)
|
: await resolveRuntimeEffort(input.providerId, modelId, requestedEffort)
|
||||||
@@ -1866,30 +1920,20 @@ async function handleSetRuntimeConfig(
|
|||||||
message: Extract<ClientMessage, { type: 'set_runtime_config' }>
|
message: Extract<ClientMessage, { type: 'set_runtime_config' }>
|
||||||
) {
|
) {
|
||||||
const { sessionId } = ws.data
|
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.
|
// Register the transition before remote model-catalog or provider validation.
|
||||||
// A user message arriving in that async admission window must wait for the
|
// A user message arriving in that async admission window must wait for the
|
||||||
// selected runtime instead of entering the previous provider's CLI process.
|
// selected runtime instead of entering the previous provider's CLI process.
|
||||||
await enqueueRuntimeTransition(sessionId, async () => {
|
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)
|
const normalized = await normalizeRuntimeOverrideInput(message)
|
||||||
if (!normalized.ok) {
|
if (!normalized.ok) {
|
||||||
sendMessage(ws, {
|
const message = normalized.reason === 'model'
|
||||||
type: 'error',
|
? 'Runtime model selection is invalid.'
|
||||||
message:
|
: 'Runtime effort selection is invalid.'
|
||||||
normalized.reason === 'model'
|
rejectedRuntimeConfigs.set(sessionId, message)
|
||||||
? 'Runtime model selection is invalid.'
|
sendMessage(ws, { type: 'error', message, code: 'RUNTIME_CONFIG_INVALID' })
|
||||||
: 'Runtime effort selection is invalid.',
|
|
||||||
code: 'RUNTIME_CONFIG_INVALID',
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1904,11 +1948,13 @@ async function handleSetRuntimeConfig(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
await conversationService.setModel(sessionId, nextOverride.modelId)
|
await conversationService.setModel(sessionId, nextOverride.modelId)
|
||||||
|
rejectedRuntimeConfigs.delete(sessionId)
|
||||||
runtimeOverrides.set(sessionId, { ...nextOverride, ...(effort ? { effort } : {}) })
|
runtimeOverrides.set(sessionId, { ...nextOverride, ...(effort ? { effort } : {}) })
|
||||||
await persistSessionRuntimeConfig(sessionId, runtimeOverrides.get(sessionId)!)
|
await persistSessionRuntimeConfig(sessionId, runtimeOverrides.get(sessionId)!)
|
||||||
broadcastAppliedRuntimeConfig(sessionId)
|
broadcastAppliedRuntimeConfig(sessionId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
rejectedRuntimeConfigs.delete(sessionId)
|
||||||
const prevOverride = runtimeOverrides.get(sessionId)
|
const prevOverride = runtimeOverrides.get(sessionId)
|
||||||
if (
|
if (
|
||||||
prevOverride &&
|
prevOverride &&
|
||||||
@@ -1916,6 +1962,10 @@ async function handleSetRuntimeConfig(
|
|||||||
prevOverride.modelId === nextOverride.modelId &&
|
prevOverride.modelId === nextOverride.modelId &&
|
||||||
prevOverride.effort === nextOverride.effort
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2076,6 +2126,7 @@ function broadcastAppliedRuntimeConfig(sessionId: string): void {
|
|||||||
if (!runtime) return
|
if (!runtime) return
|
||||||
sendToSession(sessionId, {
|
sendToSession(sessionId, {
|
||||||
type: RUNTIME_CONFIG_APPLIED_EVENT,
|
type: RUNTIME_CONFIG_APPLIED_EVENT,
|
||||||
|
...(runtime.requestedConfig ? { requestedConfig: runtime.requestedConfig } : {}),
|
||||||
providerId: runtime.providerId,
|
providerId: runtime.providerId,
|
||||||
modelId: runtime.modelId,
|
modelId: runtime.modelId,
|
||||||
...(runtime.effort ? { effortLevel: runtime.effort } : {}),
|
...(runtime.effort ? { effortLevel: runtime.effort } : {}),
|
||||||
@@ -3111,6 +3162,7 @@ function cleanupSessionRuntimeState(
|
|||||||
sessionSlashCommands.delete(sessionId)
|
sessionSlashCommands.delete(sessionId)
|
||||||
sessionTitleState.delete(sessionId)
|
sessionTitleState.delete(sessionId)
|
||||||
runtimeOverrides.delete(sessionId)
|
runtimeOverrides.delete(sessionId)
|
||||||
|
rejectedRuntimeConfigs.delete(sessionId)
|
||||||
activeUserTurns.delete(sessionId)
|
activeUserTurns.delete(sessionId)
|
||||||
activeCliRuns.delete(sessionId)
|
activeCliRuns.delete(sessionId)
|
||||||
sessionStopRequested.delete(sessionId)
|
sessionStopRequested.delete(sessionId)
|
||||||
@@ -4739,6 +4791,7 @@ export async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSet
|
|||||||
const defaults = await getDefaultRuntimeSettings()
|
const defaults = await getDefaultRuntimeSettings()
|
||||||
return {
|
return {
|
||||||
...defaults,
|
...defaults,
|
||||||
|
model: await resolveDefaultRuntimeModel(defaults),
|
||||||
permissionMode: sessionPermissionMode ?? defaults.permissionMode,
|
permissionMode: sessionPermissionMode ?? defaults.permissionMode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4751,7 +4804,14 @@ export async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSet
|
|||||||
)
|
)
|
||||||
let effort = runtimeOverride.effort
|
let effort = runtimeOverride.effort
|
||||||
if (isOpenAIOfficialProviderId(runtimeOverride.providerId)) {
|
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)) {
|
} else if (isGrokOfficialProviderId(runtimeOverride.providerId)) {
|
||||||
const grokEffort = await getGrokReasoningEfforts(runtimeOverride.modelId)
|
const grokEffort = await getGrokReasoningEfforts(runtimeOverride.modelId)
|
||||||
runtimeOverride.modelId = grokEffort.modelId
|
runtimeOverride.modelId = grokEffort.modelId
|
||||||
@@ -4792,6 +4852,14 @@ async function getSessionPermissionMode(sessionId: string): Promise<string | und
|
|||||||
return launchInfo?.permissionMode
|
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> {
|
async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
|
||||||
// Check if a custom provider is active
|
// Check if a custom provider is active
|
||||||
const { providers, activeId } = await providerService.listProviders()
|
const { providers, activeId } = await providerService.listProviders()
|
||||||
@@ -4976,6 +5044,16 @@ async function waitForRuntimeTransitionBeforeUserTurn(
|
|||||||
: undefined
|
: 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 }
|
return { ok: true, waited }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5104,6 +5182,7 @@ export function __resetWebSocketHandlerStateForTests(): void {
|
|||||||
}
|
}
|
||||||
sessionTurnObservers.clear()
|
sessionTurnObservers.clear()
|
||||||
runtimeOverrides.clear()
|
runtimeOverrides.clear()
|
||||||
|
rejectedRuntimeConfigs.clear()
|
||||||
runtimeOverrideVersions.clear()
|
runtimeOverrideVersions.clear()
|
||||||
deferredRuntimeRestarts.clear()
|
deferredRuntimeRestarts.clear()
|
||||||
deferredPermissionModes.clear()
|
deferredPermissionModes.clear()
|
||||||
|
|||||||
Reference in New Issue
Block a user