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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-30 22:30:24 +08:00
parent 29586ce384
commit 1f9af7e578
19 changed files with 286 additions and 84 deletions

View File

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

View File

@ -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(<ModelSelector />)
@ -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<ReturnType<typeof useChatStore.getState>>)
render(<ModelSelector runtimeKey="session-1" />)
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',
})
})

View File

@ -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 = (
<>
<div className={`overflow-y-auto ${isMobileBrowser ? 'p-1' : 'p-3'}`} style={{ maxHeight: isMobileBrowser ? undefined : dropdownPosition?.maxHeight }}>
@ -395,7 +404,11 @@ export function ModelSelector({
return (
<button
key={`${choice.providerId ?? 'official'}:${model.id}`}
onClick={() => handleRuntimeSelect({ providerId: choice.providerId, modelId: model.id })}
onClick={() => handleRuntimeSelect({
providerId: choice.providerId,
modelId: model.id,
effortLevel: selectedRuntimeEffort,
})}
className={`
w-full rounded-lg border px-3 text-left transition-colors
${isMobileBrowser ? 'min-h-[56px] py-3' : 'py-2.5'}
@ -481,20 +494,19 @@ export function ModelSelector({
)}
</div>
{!isControlled && !isRuntimeScoped && (
{canEditRuntimeEffort && (
<div className="border-t border-[var(--color-border)] p-3">
<div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('model.effort')}
</div>
<div className="grid grid-cols-4 gap-1.5">
{EFFORT_OPTIONS.map((opt) => {
const isSelected = opt.value === effortLevel
const isSelected = opt.value === selectedRuntimeEffort
return (
<button
key={opt.value}
onClick={() => {
void setEffort(opt.value)
setOpen(false)
handleRuntimeEffortSelect(opt.value)
}}
className={`
rounded-lg py-2 text-center text-xs font-semibold transition-colors

View File

@ -19,6 +19,7 @@ const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
const SESSION_RUNTIME_STORAGE_KEY = 'cc-haha-session-runtime'
const THEME_STORAGE_KEY = 'cc-haha-theme'
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
const EFFORT_LEVELS = ['low', 'medium', 'high', 'max']
function readJson(storage: StorageLike, key: string): unknown {
const raw = storage.getItem(key)
@ -88,7 +89,14 @@ function migrateSessionRuntime(storage: StorageLike, report: DesktopMigrationRep
Object.entries(parsed).filter(([, selection]) => (
isRecord(selection) &&
typeof selection.modelId === 'string' &&
(selection.providerId === null || typeof selection.providerId === 'string')
(selection.providerId === null || typeof selection.providerId === 'string') &&
(
selection.effortLevel === undefined ||
(
typeof selection.effortLevel === 'string' &&
EFFORT_LEVELS.includes(selection.effortLevel)
)
)
)),
)

View File

@ -116,7 +116,7 @@ export function EmptySession() {
const lastPluginReloadSummary = usePluginStore((state) => state.lastReloadSummary)
const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY])
const draftRuntimeSelectionKey = draftRuntimeSelection
? `${draftRuntimeSelection.providerId ?? 'official'}:${draftRuntimeSelection.modelId}`
? `${draftRuntimeSelection.providerId ?? 'official'}:${draftRuntimeSelection.modelId}:${draftRuntimeSelection.effortLevel ?? 'auto'}`
: undefined
const draftModelLabel = draftRuntimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id
const isMobileComposer = useMobileViewport() && !isTauriRuntime()

View File

@ -9,7 +9,7 @@ import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import { Dropdown } from '../components/shared/Dropdown'
import type { EffortLevel, ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode, ChatSendBehavior } from '../types/settings'
import type { ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode, ChatSendBehavior } from '../types/settings'
import type { Locale } from '../i18n'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider'
import type { ProviderPreset } from '../types/providerPreset'
@ -1430,8 +1430,6 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
function GeneralSettings() {
const {
effortLevel,
setEffort,
thinkingEnabled,
setThinkingEnabled,
locale,
@ -1517,13 +1515,6 @@ function GeneralSettings() {
setPortableDirDraft(appMode.portableDir ?? appMode.defaultPortableDir ?? '')
}, [appMode.defaultPortableDir, appMode.portableDir])
const EFFORT_LABELS: Record<EffortLevel, string> = {
low: t('settings.general.effort.low'),
medium: t('settings.general.effort.medium'),
high: t('settings.general.effort.high'),
max: t('settings.general.effort.max'),
}
const LANGUAGES: Array<{ value: Locale; label: string }> = [
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文' },
@ -1963,25 +1954,6 @@ function GeneralSettings() {
}
/>
{/* Effort Level */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.effortTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.effortDescription')}</p>
<div className="flex gap-2">
{(['low', 'medium', 'high', 'max'] as EffortLevel[]).map((level) => (
<button
key={level}
onClick={() => setEffort(level)}
className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
effortLevel === level
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
{EFFORT_LABELS[level]}
</button>
))}
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.thinkingTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.thinkingDescription')}</p>

View File

@ -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',
})
})

View File

@ -60,6 +60,7 @@ function resolveRuntimeRefreshSelection(
modelId: modelIds.has(currentSelection.modelId)
? currentSelection.modelId
: provider.models.main,
...(currentSelection.effortLevel ? { effortLevel: currentSelection.effortLevel } : {}),
}
}

View File

@ -136,7 +136,7 @@ const DEFAULT_NETWORK_SETTINGS: NetworkSettings = {
export const useSettingsStore = create<SettingsStore>((set, get) => ({
permissionMode: 'default',
currentModel: null,
effortLevel: 'medium',
effortLevel: 'max',
thinkingEnabled: true,
availableModels: [],
activeProviderName: null,

View File

@ -1,4 +1,7 @@
import type { EffortLevel } from './settings'
export type RuntimeSelection = {
providerId: string | null
modelId: string
effortLevel?: EffortLevel
}

View File

@ -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 {

View File

@ -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'])
})

View File

@ -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'])
})

View File

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

View File

@ -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<string, unknown>
@ -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<string, unknown>).runtimeProviderId === null ||
typeof (entry as Record<string, unknown>).runtimeProviderId === 'string'
) {
runtimeProviderId = (entry as Record<string, unknown>).runtimeProviderId as string | null
}
if (typeof (entry as Record<string, unknown>).runtimeModelId === 'string') {
runtimeModelId = (entry as Record<string, unknown>).runtimeModelId as string
}
if (
typeof (entry as Record<string, unknown>).effortLevel === 'string' &&
VALID_SESSION_EFFORT_LEVELS.has((entry as Record<string, unknown>).effortLevel as string)
) {
effortLevel = (entry as Record<string, unknown>).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<string, unknown>
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<void> {
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(),
})

View File

@ -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' }

View File

@ -70,15 +70,19 @@ const sessionTitleState = new Map<string, {
const runtimeOverrides = new Map<string, {
providerId: string | null
modelId: string
effort?: string
}>()
const runtimeTransitionPromises = new Map<string, Promise<void>>()
const sessionStartupPromises = new Map<string, Promise<void>>()
const runtimeOverrideVersions = new Map<string, number>()
const sessionStartupRuntimeVersions = new Map<string, number>()
const lastResolvedStartupWorkDirs = new Map<string, string>()
const prewarmPendingSessions = new Set<string>()
const prewarmedSessions = new Set<string>()
const prewarmIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
const DEFAULT_PREWARM_IDLE_TIMEOUT_MS = 5 * 60_000
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max'])
async function sendRepositoryStartupStatus(
ws: ServerWebSocket<WebSocketData>,
@ -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<void> {
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<WebSocketData>,
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<RuntimeSettings> {
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<RuntimeSettings>
}
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<RuntimeSettings>
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)')
}

View File

@ -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', () => {

View File

@ -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.