mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
29586ce384
commit
1f9af7e578
@ -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)
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
)
|
||||
)
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -60,6 +60,7 @@ function resolveRuntimeRefreshSelection(
|
||||
modelId: modelIds.has(currentSelection.modelId)
|
||||
? currentSelection.modelId
|
||||
: provider.models.main,
|
||||
...(currentSelection.effortLevel ? { effortLevel: currentSelection.effortLevel } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import type { EffortLevel } from './settings'
|
||||
|
||||
export type RuntimeSelection = {
|
||||
providerId: string | null
|
||||
modelId: string
|
||||
effortLevel?: EffortLevel
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'])
|
||||
})
|
||||
|
||||
|
||||
@ -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'])
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@ -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' }
|
||||
|
||||
|
||||
@ -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)')
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user