From 1f9af7e578ca64cd9a4d2f304c7a340ea16379ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 30 May 2026 22:30:24 +0800 Subject: [PATCH] feat: scope desktop reasoning effort to sessions Desktop reasoning effort now follows the selected session runtime instead of the General settings surface. Session metadata records provider, model, and effort so reconnects and prewarm restarts preserve the per-session runtime, while the default effort is max for new sessions. Constraint: Existing transcript and localStorage runtime data must remain readable without a required migration. Rejected: Keep effort as a General setting | it made one session change leak into every other session. Confidence: high Scope-risk: moderate Directive: Do not reintroduce global desktop effort controls without proving multi-session isolation. Tested: bun run check:server Tested: bun run check:desktop Tested: bun run check:native Tested: bun run check:policy Tested: bun run check:coverage Tested: cd desktop && bun run test -- src/components/controls/ModelSelector.test.tsx src/stores/chatStore.test.ts Tested: bun test src/server/__tests__/settings.test.ts --test-name-pattern "effort" Tested: bun test src/server/__tests__/conversations.test.ts --test-name-pattern "runtime" Not-tested: Live provider smoke for domestic providers without local credentials. --- desktop/src/components/chat/ChatInput.tsx | 2 +- .../controls/ModelSelector.test.tsx | 74 +++++++++-- .../src/components/controls/ModelSelector.tsx | 24 +++- desktop/src/lib/persistenceMigrations.ts | 10 +- desktop/src/pages/EmptySession.tsx | 2 +- desktop/src/pages/Settings.tsx | 30 +---- desktop/src/stores/chatStore.test.ts | 5 + desktop/src/stores/providerStore.ts | 1 + desktop/src/stores/settingsStore.ts | 2 +- desktop/src/types/runtime.ts | 3 + src/server/__tests__/conversations.test.ts | 4 + .../__tests__/e2e/business-flow.test.ts | 4 +- src/server/__tests__/settings.test.ts | 4 +- src/server/api/models.ts | 2 +- src/server/services/sessionService.ts | 59 +++++++++ src/server/ws/events.ts | 2 +- src/server/ws/handler.ts | 124 ++++++++++++++---- src/utils/__tests__/thinking.test.ts | 14 ++ src/utils/effort.ts | 4 +- 19 files changed, 286 insertions(+), 84 deletions(-) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 2653419e..1a03397c 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -134,7 +134,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const currentModel = useSettingsStore((state) => state.currentModel) const chatSendBehavior = useSettingsStore((state) => state.chatSendBehavior) const runtimeSelectionKey = runtimeSelection - ? `${runtimeSelection.providerId ?? 'official'}:${runtimeSelection.modelId}` + ? `${runtimeSelection.providerId ?? 'official'}:${runtimeSelection.modelId}:${runtimeSelection.effortLevel ?? 'auto'}` : undefined const runtimeModelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null) diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index 78277703..632a0a9d 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -57,16 +57,14 @@ describe('ModelSelector', () => { expect(onChange).toHaveBeenCalledWith('beta') }) - it('routes uncontrolled model and effort changes through settings actions', async () => { + it('routes uncontrolled model changes through settings actions', async () => { const setModel = vi.fn(async () => {}) - const setEffort = vi.fn(async () => {}) useSettingsStore.setState({ locale: 'en', availableModels: MODELS, currentModel: MODELS[0], - effortLevel: 'medium', + effortLevel: 'max', setModel, - setEffort, }) render() @@ -74,10 +72,6 @@ describe('ModelSelector', () => { await clickByRole(/alpha/i) await clickByRole(/Beta/) expect(setModel).toHaveBeenCalledWith('beta') - - await clickByRole(/Alpha/) - await clickByRole(/^High$/) - expect(setEffort).toHaveBeenCalledWith('high') }) it('selects provider-scoped runtime models and mirrors session selections', async () => { @@ -122,13 +116,75 @@ describe('ModelSelector', () => { expect(useSessionRuntimeStore.getState().selections['session-1']).toEqual({ providerId: 'provider-a', modelId: 'provider-fast', + effortLevel: 'max', }) expect(setSessionRuntime).toHaveBeenCalledWith('session-1', { providerId: 'provider-a', modelId: 'provider-fast', + effortLevel: 'max', }) }) + it('keeps runtime effort scoped to the selected session', async () => { + const setSessionRuntime = vi.fn() + useSettingsStore.setState({ + locale: 'en', + availableModels: MODELS, + currentModel: MODELS[0], + activeProviderName: 'Provider A', + effortLevel: 'max', + }) + useProviderStore.setState({ + providers: [{ + id: 'provider-a', + presetId: 'custom', + name: 'Provider A', + apiKey: '***', + baseUrl: 'https://api.example.com', + apiFormat: 'anthropic', + models: { + main: 'provider-main', + haiku: 'provider-fast', + sonnet: 'provider-main', + opus: '', + }, + }], + activeId: 'provider-a', + hasLoadedProviders: true, + isLoading: true, + }) + useSessionRuntimeStore.getState().setSelection('session-2', { + providerId: 'provider-a', + modelId: 'provider-main', + effortLevel: 'max', + }) + useChatStore.setState({ + setSessionRuntime, + } as Partial>) + + render() + + await clickByRole(/alpha/i) + await clickByRole(/^High$/) + + expect(useSessionRuntimeStore.getState().selections['session-1']).toEqual({ + providerId: 'provider-a', + modelId: 'alpha', + effortLevel: 'high', + }) + expect(useSessionRuntimeStore.getState().selections['session-2']).toEqual({ + providerId: 'provider-a', + modelId: 'provider-main', + effortLevel: 'max', + }) + expect(setSessionRuntime).toHaveBeenCalledWith('session-1', { + providerId: 'provider-a', + modelId: 'alpha', + effortLevel: 'high', + }) + expect(useSettingsStore.getState().effortLevel).toBe('max') + }) + it('uses the ChatGPT Official catalog when that built-in provider is active', async () => { const openAIModels: ModelInfo[] = [ { @@ -176,10 +232,12 @@ describe('ModelSelector', () => { expect(useSessionRuntimeStore.getState().selections['session-openai']).toEqual({ providerId: OPENAI_OFFICIAL_PROVIDER_ID, modelId: 'gpt-5.5', + effortLevel: 'max', }) expect(setSessionRuntime).toHaveBeenCalledWith('session-openai', { providerId: OPENAI_OFFICIAL_PROVIDER_ID, modelId: 'gpt-5.5', + effortLevel: 'max', }) }) diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index 45e651d2..f4b86fdf 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -179,7 +179,6 @@ export function ModelSelector({ effortLevel, activeProviderName, setModel, - setEffort, } = useSettingsStore() const { providers, @@ -211,6 +210,7 @@ export function ModelSelector({ const isRuntimeScoped = !isControlled && (runtimeKey !== undefined || onRuntimeSelectionChange !== undefined) + const canEditRuntimeEffort = runtimeKey !== undefined useEffect(() => { if (!isRuntimeScoped || providersLoading || requestedProvidersRef.current) return @@ -351,6 +351,7 @@ export function ModelSelector({ const buttonProviderLabel = isRuntimeScoped ? selectedProviderChoice?.providerName ?? activeProviderName ?? t('settings.providers.officialName') : null + const selectedRuntimeEffort = activeRuntimeSelection?.effortLevel ?? effortLevel const handleRuntimeSelect = (selection: RuntimeSelection) => { onRuntimeSelectionChange?.(selection) @@ -363,6 +364,14 @@ export function ModelSelector({ setOpen(false) } + const handleRuntimeEffortSelect = (level: EffortLevel) => { + if (!activeRuntimeSelection) return + handleRuntimeSelect({ + ...activeRuntimeSelection, + effortLevel: level, + }) + } + const dropdownContent = ( <>
@@ -395,7 +404,11 @@ export function ModelSelector({ return (
- {!isControlled && !isRuntimeScoped && ( + {canEditRuntimeEffort && (
{t('model.effort')}
{EFFORT_OPTIONS.map((opt) => { - const isSelected = opt.value === effortLevel + const isSelected = opt.value === selectedRuntimeEffort return ( - ))} -
-

{t('settings.general.thinkingTitle')}

{t('settings.general.thinkingDescription')}

diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 6aba3d3e..688f3cb9 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -1619,6 +1619,7 @@ describe('chatStore history mapping', () => { useSessionRuntimeStore.getState().setSelection(TEST_SESSION_ID, { providerId: 'provider-1', modelId: 'kimi-k2.6', + effortLevel: 'high', }) useChatStore.getState().connectToSession(TEST_SESSION_ID) @@ -1627,6 +1628,7 @@ describe('chatStore history mapping', () => { type: 'set_runtime_config', providerId: 'provider-1', modelId: 'kimi-k2.6', + effortLevel: 'high', }) expect(sendMock.mock.calls.slice(0, 2)).toEqual([ [ @@ -1635,6 +1637,7 @@ describe('chatStore history mapping', () => { type: 'set_runtime_config', providerId: 'provider-1', modelId: 'kimi-k2.6', + effortLevel: 'high', }, ], [TEST_SESSION_ID, { type: 'prewarm_session' }], @@ -1693,12 +1696,14 @@ describe('chatStore history mapping', () => { useChatStore.getState().setSessionRuntime(TEST_SESSION_ID, { providerId: null, modelId: 'claude-opus-4-7', + effortLevel: 'max', }) expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, { type: 'set_runtime_config', providerId: null, modelId: 'claude-opus-4-7', + effortLevel: 'max', }) }) diff --git a/desktop/src/stores/providerStore.ts b/desktop/src/stores/providerStore.ts index 585ce6f9..e0f52833 100644 --- a/desktop/src/stores/providerStore.ts +++ b/desktop/src/stores/providerStore.ts @@ -60,6 +60,7 @@ function resolveRuntimeRefreshSelection( modelId: modelIds.has(currentSelection.modelId) ? currentSelection.modelId : provider.models.main, + ...(currentSelection.effortLevel ? { effortLevel: currentSelection.effortLevel } : {}), } } diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index e7edf80c..a18bf908 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -136,7 +136,7 @@ const DEFAULT_NETWORK_SETTINGS: NetworkSettings = { export const useSettingsStore = create((set, get) => ({ permissionMode: 'default', currentModel: null, - effortLevel: 'medium', + effortLevel: 'max', thinkingEnabled: true, availableModels: [], activeProviderName: null, diff --git a/desktop/src/types/runtime.ts b/desktop/src/types/runtime.ts index 1c0fcd00..c47f815e 100644 --- a/desktop/src/types/runtime.ts +++ b/desktop/src/types/runtime.ts @@ -1,4 +1,7 @@ +import type { EffortLevel } from './settings' + export type RuntimeSelection = { providerId: string | null modelId: string + effortLevel?: EffortLevel } diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 501ff292..3d55857a 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -2930,6 +2930,7 @@ describe('WebSocket Chat Integration', () => { type: 'set_runtime_config', providerId: providerA.id, modelId: 'model-a-sonnet', + effortLevel: 'medium', })) ws.send(JSON.stringify({ type: 'user_message', content: 'first turn' })) phase = 'turn1' @@ -2950,6 +2951,7 @@ describe('WebSocket Chat Integration', () => { type: 'set_runtime_config', providerId: providerB.id, modelId: 'model-b-opus', + effortLevel: 'max', })) return } @@ -2989,6 +2991,7 @@ describe('WebSocket Chat Integration', () => { options: { providerId: providerA.id, model: 'model-a-sonnet', + effort: 'medium', }, }) expect(startCalls[1]).toMatchObject({ @@ -2996,6 +2999,7 @@ describe('WebSocket Chat Integration', () => { options: { providerId: providerB.id, model: 'model-b-opus', + effort: 'max', }, }) } finally { diff --git a/src/server/__tests__/e2e/business-flow.test.ts b/src/server/__tests__/e2e/business-flow.test.ts index d894aee1..6f0cfa72 100644 --- a/src/server/__tests__/e2e/business-flow.test.ts +++ b/src/server/__tests__/e2e/business-flow.test.ts @@ -419,9 +419,9 @@ describe('Business Flow: Models & Effort', () => { expect(status).toBe(400) }) - it('should default effort to medium', async () => { + it('should default effort to max', async () => { const { data } = await api('GET', '/api/effort') - expect(data.level).toBe('medium') + expect(data.level).toBe('max') expect(data.available).toEqual(['low', 'medium', 'high', 'max']) }) diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index 6240658e..4d3aade9 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -691,7 +691,7 @@ describe('Models API', () => { expect(res.status).toBe(200) const body = await res.json() - expect(body.level).toBe('medium') + expect(body.level).toBe('max') expect(body.available).toEqual(['low', 'medium', 'high', 'max']) }) @@ -704,7 +704,7 @@ describe('Models API', () => { expect(res.status).toBe(200) const body = await res.json() - expect(body.level).toBe('medium') + expect(body.level).toBe('max') expect(body.available).toEqual(['low', 'medium', 'high', 'max']) }) diff --git a/src/server/api/models.ts b/src/server/api/models.ts index 570b6338..73755672 100644 --- a/src/server/api/models.ts +++ b/src/server/api/models.ts @@ -46,7 +46,7 @@ const DEFAULT_MODELS = [ const EFFORT_LEVELS = ['low', 'medium', 'high', 'max'] as const const DEFAULT_MODEL = 'claude-opus-4-7' -const DEFAULT_EFFORT = 'medium' +const DEFAULT_EFFORT = 'max' const settingsService = new SettingsService() const providerService = new ProviderService() diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 663045f3..eb611d9e 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -72,6 +72,9 @@ export type SessionLaunchInfo = { transcriptMessageCount: number customTitle: string | null permissionMode?: string + runtimeProviderId?: string | null + runtimeModelId?: string + effortLevel?: string } export type TrimSessionResult = { @@ -228,6 +231,9 @@ type SessionListSummary = { messageCount: number workDir: string | null permissionMode?: string + runtimeProviderId?: string | null + runtimeModelId?: string + effortLevel?: string repository?: PreparedSessionWorkspace['repository'] worktreeSession?: PersistedWorktreeSession | null } @@ -239,6 +245,7 @@ const VALID_SESSION_PERMISSION_MODES = new Set([ 'bypassPermissions', 'dontAsk', ]) +const VALID_SESSION_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']) type ContentBlock = Record @@ -348,6 +355,9 @@ export class SessionService { let latestWorkDir: string | null = null let latestCwd: string | null = null let permissionMode: string | undefined + let runtimeProviderId: string | null | undefined + let runtimeModelId: string | undefined + let effortLevel: string | undefined let repository: PreparedSessionWorkspace['repository'] | undefined let worktreeSession: PersistedWorktreeSession | null | undefined @@ -393,6 +403,21 @@ export class SessionService { ) { permissionMode = entry.permissionMode } + if ( + (entry as Record).runtimeProviderId === null || + typeof (entry as Record).runtimeProviderId === 'string' + ) { + runtimeProviderId = (entry as Record).runtimeProviderId as string | null + } + if (typeof (entry as Record).runtimeModelId === 'string') { + runtimeModelId = (entry as Record).runtimeModelId as string + } + if ( + typeof (entry as Record).effortLevel === 'string' && + VALID_SESSION_EFFORT_LEVELS.has((entry as Record).effortLevel as string) + ) { + effortLevel = (entry as Record).effortLevel as string + } } if (typeof entry.cwd === 'string' && entry.cwd.trim()) { @@ -454,6 +479,9 @@ export class SessionService { messageCount, workDir: latestWorkDir || latestCwd || this.desanitizePath(projectDir), ...(permissionMode ? { permissionMode } : {}), + ...(runtimeProviderId !== undefined ? { runtimeProviderId } : {}), + ...(runtimeModelId ? { runtimeModelId } : {}), + ...(effortLevel ? { effortLevel } : {}), ...(repository ? { repository } : {}), ...(worktreeSession !== undefined ? { worktreeSession } : {}), } @@ -1846,11 +1874,29 @@ export class SessionService { const worktreeSession = this.resolveWorktreeSessionFromEntries(entries) const permissionMode = this.resolvePermissionModeFromEntries(entries) let customTitle: string | null = null + let runtimeProviderId: string | null | undefined + let runtimeModelId: string | undefined + let effortLevel: string | undefined for (const entry of entries) { if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') { customTitle = entry.customTitle } + if (entry.type === 'session-meta') { + const record = entry as Record + if (record.runtimeProviderId === null || typeof record.runtimeProviderId === 'string') { + runtimeProviderId = record.runtimeProviderId as string | null + } + if (typeof record.runtimeModelId === 'string') { + runtimeModelId = record.runtimeModelId + } + if ( + typeof record.effortLevel === 'string' && + VALID_SESSION_EFFORT_LEVELS.has(record.effortLevel) + ) { + effortLevel = record.effortLevel + } + } } const transcriptMessageCount = this.countTranscriptMessages(entries) @@ -1863,6 +1909,9 @@ export class SessionService { transcriptMessageCount, customTitle, permissionMode, + ...(runtimeProviderId !== undefined ? { runtimeProviderId } : {}), + ...(runtimeModelId ? { runtimeModelId } : {}), + ...(effortLevel ? { effortLevel } : {}), } } @@ -1928,6 +1977,9 @@ export class SessionService { customTitle?: string | null repository?: PreparedSessionWorkspace['repository'] permissionMode?: string + runtimeProviderId?: string | null + runtimeModelId?: string + effortLevel?: string } ): Promise { const matches = await this.findSessionFiles(sessionId) @@ -1957,6 +2009,13 @@ export class SessionService { ...(metadata.permissionMode && VALID_SESSION_PERMISSION_MODES.has(metadata.permissionMode) ? { permissionMode: metadata.permissionMode } : {}), + ...(metadata.runtimeProviderId !== undefined + ? { runtimeProviderId: metadata.runtimeProviderId } + : {}), + ...(metadata.runtimeModelId ? { runtimeModelId: metadata.runtimeModelId } : {}), + ...(metadata.effortLevel && VALID_SESSION_EFFORT_LEVELS.has(metadata.effortLevel) + ? { effortLevel: metadata.effortLevel } + : {}), timestamp: new Date().toISOString(), }) diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts index ea9b6ea7..d729763d 100644 --- a/src/server/ws/events.ts +++ b/src/server/ws/events.ts @@ -24,7 +24,7 @@ export type ClientMessage = response: ComputerUsePermissionResponse } | { type: 'set_permission_mode'; mode: string } - | { type: 'set_runtime_config'; providerId: string | null; modelId: string } + | { type: 'set_runtime_config'; providerId: string | null; modelId: string; effortLevel?: string } | { type: 'stop_generation' } | { type: 'ping' } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 0f6a8400..37f1bc90 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -70,15 +70,19 @@ const sessionTitleState = new Map() const runtimeTransitionPromises = new Map>() const sessionStartupPromises = new Map>() +const runtimeOverrideVersions = new Map() +const sessionStartupRuntimeVersions = new Map() const lastResolvedStartupWorkDirs = new Map() const prewarmPendingSessions = new Set() const prewarmedSessions = new Set() const prewarmIdleTimers = new Map>() const DEFAULT_PREWARM_IDLE_TIMEOUT_MS = 5 * 60_000 +const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']) async function sendRepositoryStartupStatus( ws: ServerWebSocket, @@ -549,44 +553,73 @@ async function handleSetRuntimeConfig( }) return } + const effortLevel = + typeof message.effortLevel === 'string' ? message.effortLevel.trim() : undefined + if (effortLevel !== undefined && !VALID_EFFORT_LEVELS.has(effortLevel)) { + sendMessage(ws, { + type: 'error', + message: 'Runtime effort selection is invalid.', + code: 'RUNTIME_CONFIG_INVALID', + }) + return + } const nextOverride = { providerId: message.providerId ?? null, modelId, + ...(effortLevel ? { effort: effortLevel } : {}), } const prevOverride = runtimeOverrides.get(sessionId) - runtimeOverrides.set(sessionId, nextOverride) - if ( prevOverride && prevOverride.providerId === nextOverride.providerId && - prevOverride.modelId === nextOverride.modelId + prevOverride.modelId === nextOverride.modelId && + prevOverride.effort === nextOverride.effort ) { return } - if (!conversationService.hasSession(sessionId)) { - const pendingStartup = sessionStartupPromises.get(sessionId) - if (pendingStartup) { - await enqueueRuntimeTransition(sessionId, async () => { - await pendingStartup.catch(() => undefined) - const currentOverride = runtimeOverrides.get(sessionId) - if ( - currentOverride?.providerId !== nextOverride.providerId || - currentOverride.modelId !== nextOverride.modelId || - !conversationService.hasSession(sessionId) - ) { - return - } - await restartSessionWithRuntimeConfig(ws, sessionId) - }) - } + runtimeOverrides.set(sessionId, nextOverride) + runtimeOverrideVersions.set( + sessionId, + (runtimeOverrideVersions.get(sessionId) ?? 0) + 1, + ) + + if (conversationService.hasSession(sessionId)) { + await enqueueRuntimeTransition(sessionId, async () => { + await persistSessionRuntimeConfig(sessionId, nextOverride) + await restartSessionWithRuntimeConfig(ws, sessionId) + }) return } - await enqueueRuntimeTransition(sessionId, () => - restartSessionWithRuntimeConfig(ws, sessionId), - ) + const pendingStartup = sessionStartupPromises.get(sessionId) + if (pendingStartup) { + const startupRuntimeVersion = sessionStartupRuntimeVersions.get(sessionId) ?? 0 + const currentRuntimeVersion = runtimeOverrideVersions.get(sessionId) ?? 0 + if (startupRuntimeVersion >= currentRuntimeVersion) { + await persistSessionRuntimeConfig(sessionId, nextOverride) + return + } + + await enqueueRuntimeTransition(sessionId, async () => { + await persistSessionRuntimeConfig(sessionId, nextOverride) + await pendingStartup.catch(() => undefined) + const currentOverride = runtimeOverrides.get(sessionId) + if ( + currentOverride?.providerId !== nextOverride.providerId || + currentOverride.modelId !== nextOverride.modelId || + currentOverride.effort !== nextOverride.effort || + !conversationService.hasSession(sessionId) + ) { + return + } + await restartSessionWithRuntimeConfig(ws, sessionId) + }) + return + } + + await persistSessionRuntimeConfig(sessionId, nextOverride) } async function restartSessionWithPermissionMode( @@ -648,6 +681,24 @@ async function persistSessionPermissionMode( }) } +async function persistSessionRuntimeConfig( + sessionId: string, + runtime: { providerId: string | null; modelId: string; effort?: string }, +): Promise { + const workDir = + conversationService.getSessionWorkDir(sessionId) || + await sessionService.getSessionWorkDir(sessionId).catch(() => null) + + if (!workDir) return + + await sessionService.appendSessionMetadata(sessionId, { + workDir, + runtimeProviderId: runtime.providerId, + runtimeModelId: runtime.modelId, + ...(runtime.effort ? { effortLevel: runtime.effort } : {}), + }) +} + async function restartSessionWithRuntimeConfig( ws: ServerWebSocket, sessionId: string, @@ -981,6 +1032,9 @@ async function ensureCliSessionStarted( if (conversationService.hasSession(sessionId)) return + const startupRuntimeVersion = runtimeOverrideVersions.get(sessionId) ?? 0 + sessionStartupRuntimeVersions.set(sessionId, startupRuntimeVersion) + const startup = (async () => { const workDir = await resolveSessionWorkDir(sessionId) lastResolvedStartupWorkDirs.set(sessionId, workDir) @@ -999,6 +1053,7 @@ async function ensureCliSessionStarted( } finally { if (sessionStartupPromises.get(sessionId) === startup) { sessionStartupPromises.delete(sessionId) + sessionStartupRuntimeVersions.delete(sessionId) } } } @@ -1859,10 +1914,23 @@ function isKnownRuntimeProviderId( } async function getRuntimeSettings(sessionId?: string): Promise { + const launchInfo = sessionId + ? await sessionService.getSessionLaunchInfo(sessionId).catch(() => null) + : null const sessionPermissionMode = sessionId - ? await getSessionPermissionMode(sessionId) + ? launchInfo?.permissionMode ?? await getSessionPermissionMode(sessionId) + : undefined + const persistedRuntimeOverride = + launchInfo?.runtimeModelId + ? { + providerId: launchInfo.runtimeProviderId ?? null, + modelId: launchInfo.runtimeModelId, + ...(launchInfo.effortLevel ? { effort: launchInfo.effortLevel } : {}), + } + : undefined + const runtimeOverride = sessionId + ? runtimeOverrides.get(sessionId) ?? persistedRuntimeOverride : undefined - const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined if (runtimeOverride) { if (typeof runtimeOverride.providerId === 'string') { const { providers } = await providerService.listProviders() @@ -1881,16 +1949,12 @@ async function getRuntimeSettings(sessionId?: string): Promise } const userSettings = await settingsService.getUserSettings() - const effort = - typeof userSettings.effort === 'string' && userSettings.effort.trim() - ? userSettings.effort - : undefined const thinking = resolveDesktopThinkingMode(userSettings) return { permissionMode: sessionPermissionMode ?? await settingsService.getPermissionMode().catch(() => undefined), model: runtimeOverride.modelId, - effort, + effort: runtimeOverride.effort, thinking, providerId: runtimeOverride.providerId, } @@ -1900,6 +1964,7 @@ async function getRuntimeSettings(sessionId?: string): Promise return { ...defaults, permissionMode: sessionPermissionMode ?? defaults.permissionMode, + effort: launchInfo?.effortLevel ?? defaults.effort, } } @@ -1995,6 +2060,7 @@ async function buildSessionStartupDiagnosticMessage( if (runtimeOverride) { lines.push(`- runtimeOverride.providerId: ${runtimeOverride.providerId ?? '(official)'}`) lines.push(`- runtimeOverride.modelId: ${runtimeOverride.modelId}`) + lines.push(`- runtimeOverride.effort: ${runtimeOverride.effort ?? '(auto)'}`) } else { lines.push('- runtimeOverride: (none)') } diff --git a/src/utils/__tests__/thinking.test.ts b/src/utils/__tests__/thinking.test.ts index 1e398570..c8d2679d 100644 --- a/src/utils/__tests__/thinking.test.ts +++ b/src/utils/__tests__/thinking.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { get3PModelCapabilityOverride } from '../model/modelSupportOverrides.js' import { resolveSideQueryThinkingConfig } from '../sideQuery.js' +import { modelSupportsEffort, modelSupportsMaxEffort } from '../effort.js' import { modelSupportsAdaptiveThinking, modelSupportsThinking, @@ -87,6 +88,8 @@ describe('provider-aware thinking support', () => { expect(modelSupportsThinking('deepseek-v4-pro')).toBe(true) expect(modelSupportsAdaptiveThinking('deepseek-v4-pro')).toBe(true) + expect(modelSupportsEffort('deepseek-v4-pro')).toBe(true) + expect(modelSupportsMaxEffort('deepseek-v4-pro')).toBe(true) expect(shouldSendExplicitDisabledThinking()).toBe(false) }) @@ -98,6 +101,17 @@ describe('provider-aware thinking support', () => { expect(modelSupportsThinking('MiniMax-M2.7')).toBe(true) expect(modelSupportsAdaptiveThinking('MiniMax-M2.7')).toBe(false) + expect(modelSupportsEffort('MiniMax-M2.7')).toBe(false) + expect(modelSupportsMaxEffort('MiniMax-M2.7')).toBe(false) + }) + + test('third-party base URLs do not default unknown model names to effort support', () => { + process.env.ANTHROPIC_BASE_URL = 'https://api.moonshot.cn/anthropic' + delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES + clearCapabilityCache() + + expect(modelSupportsEffort('kimi-k2.6')).toBe(false) + expect(modelSupportsMaxEffort('kimi-k2.6')).toBe(false) }) test('side queries inherit explicit disabled thinking for opted-in providers', () => { diff --git a/src/utils/effort.ts b/src/utils/effort.ts index 2320e9c9..d38cfa5e 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -3,7 +3,7 @@ import { isUltrathinkEnabled } from './thinking.js' import { getInitialSettings } from './settings/settings.js' import { isProSubscriber, isMaxSubscriber, isTeamSubscriber } from './auth.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' -import { getAPIProvider } from './model/providers.js' +import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './model/providers.js' import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js' import { isEnvTruthy } from './envUtils.js' import type { EffortLevel } from 'src/entrypoints/sdk/runtimeTypes.js' @@ -45,7 +45,7 @@ export function modelSupportsEffort(model: string): boolean { // Default to true for unknown model strings on 1P. // Do not default to true for 3P as they have different formats for their // model strings (ex. anthropics/claude-code#30795) - return getAPIProvider() === 'firstParty' + return getAPIProvider() === 'firstParty' && isFirstPartyAnthropicBaseUrl() } // @[MODEL LAUNCH]: Add the new model to the allowlist if it supports 'max' effort.