diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx index b5a6e976805b..2a26db4094c9 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -11,7 +11,7 @@ import { useI18n } from '@/i18n' import { ChevronDown } from '@/lib/icons' import { formatModelStatusLabel } from '@/lib/model-status-label' import { cn } from '@/lib/utils' -import { $currentModelSource, setModelPickerOpen } from '@/store/session' +import { $currentModelSource, $defaultReasoningEffort, setModelPickerOpen } from '@/store/session' import type { ChatBarState } from './types' @@ -48,6 +48,7 @@ export function ModelPill({ const fastMode = useStore(view.$fast) const reasoningEffort = useStore(view.$reasoningEffort) const modelSource = useStore($currentModelSource) + const defaultEffort = useStore($defaultReasoningEffort) const runtimeId = useStore(view.$runtimeId) const [open, setOpen] = useState(false) @@ -68,7 +69,9 @@ export function ModelPill({ ) : ( <> {currentModel.trim() ? ( - {formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })} + + {formatModelStatusLabel(currentModel, { defaultEffort, fastMode, reasoningEffort })} + ) : ( )} diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts index 23e0f5a74c81..a5ec2060bf52 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts @@ -8,11 +8,13 @@ import { $currentCwd, $currentFastMode, $currentReasoningEffort, + $defaultReasoningEffort, markComposerSelectionManual, setCurrentCwd, setCurrentFastMode, setCurrentModelSource, - setCurrentReasoningEffort + setCurrentReasoningEffort, + setDefaultReasoningEffort } from '@/store/session' import { useHermesConfig } from './use-hermes-config' @@ -44,9 +46,31 @@ describe('useHermesConfig refreshHermesConfig', () => { setCurrentFastMode(false) setCurrentModelSource('') setCurrentReasoningEffort('') + setDefaultReasoningEffort('') persistString(WORKSPACE_CWD_KEY, null) }) + // Regression: the composer keeps a manual model pick sticky, which skips the + // composer reseed. The profile default must still be published, because the + // model picker resolves "the default effort" from it when applying a model's + // preset — otherwise selecting a model silently downgrades a configured + // `agent.reasoning_effort: high` to Hermes' built-in medium. + it('publishes the profile default effort even when a manual pick blocks the composer reseed', async () => { + setCurrentModelSource('manual') + setCurrentReasoningEffort('low') + + mockConfig({ agent: { reasoning_effort: 'high' } }) + const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } })) + + await act(async () => { + await result.current.refreshHermesConfig() + }) + + expect($defaultReasoningEffort.get()).toBe('high') + // The manual pick itself is still respected. + expect($currentReasoningEffort.get()).toBe('low') + }) + it('does not let terminal.cwd replace an inactive selected workspace', async () => { setCurrentCwd('/Users/example/repo/.worktrees/feature') diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.ts index 5d78fb1c6c51..06f5a1393671 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts @@ -11,6 +11,7 @@ import { setCurrentPersonality, setCurrentReasoningEffort, setCurrentServiceTier, + setDefaultReasoningEffort, setIntroPersonality } from '@/store/session' import { applyAutoSpeakFromConfig } from '@/store/voice-prefs' @@ -83,6 +84,12 @@ export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) { const reasoning = normalizeConfigEffort(config.agent?.reasoning_effort) const tier = (config.agent?.service_tier ?? '').trim() + // Publish the profile default regardless of whether the composer is + // reseeded below: picker rows and preset application resolve "the + // default" from here, so a manual model pick must not leave them + // rendering/applying Hermes' built-in medium over the user's config. + setDefaultReasoningEffort(reasoning) + const shouldSeedComposer = !activeSessionIdRef.current && getComposerSelectionGeneration() === selectionGeneration && diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 2ecdfb1e7e44..f041b2992470 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -11,6 +11,7 @@ import { Sun, Wrench } from '@/lib/icons' +import { REASONING_EFFORTS } from '@/lib/reasoning-effort' import type { ThemeMode } from '@/themes/context' import { defineFieldCopy } from './field-copy' @@ -246,7 +247,8 @@ export const ENUM_OPTIONS: Record = { 'approvals.mode': ['manual', 'smart', 'off'], 'code_execution.mode': ['project', 'strict'], 'context.engine': ['compressor', 'default', 'custom'], - 'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + // '' = inherit the agent's own effort; the rest is the shared scale. + 'delegation.reasoning_effort': ['', ...REASONING_EFFORTS], // NOTE: memory.provider is intentionally NOT listed here. Its options are // discovery-driven and served by the backend config schema (merged // per-request in web_server._schema_with_dynamic_provider_options), so diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 9cc9838b9d94..3b1b8997ab06 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -25,6 +25,7 @@ import type { } from '@/hermes' import { useI18n } from '@/i18n' import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons' +import { DEFAULT_REASONING_EFFORT, REASONING_EFFORT_VALUES } from '@/lib/reasoning-effort' import { cn } from '@/lib/utils' import { notifyError } from '@/store/notifications' import { startManualLocalEndpoint, startManualOnboarding, startManualProviderOAuth } from '@/store/onboarding' @@ -81,10 +82,6 @@ export function ModelSettingsSkeleton() { ) } -// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off. -// Empty config = Hermes default (medium), shown as Medium. -const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const - // agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is // normal (mirrors tui_gateway _load_service_tier). const isFastTier = (tier: unknown): boolean => @@ -94,9 +91,6 @@ const isFastTier = (tier: unknown): boolean => .toLowerCase() ) -// Reuse the composer's effort labels. -const effortLabelKey = (v: string) => v as 'high' | 'low' | 'max' | 'medium' | 'minimal' | 'ultra' | 'xhigh' - // A provider row is "ready" to pick a model from when it reports models. The // backend now surfaces the full `hermes model` universe (every canonical // provider), so unconfigured providers come back with `authenticated:false` @@ -515,7 +509,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { .trim() .toLowerCase() - const effortValue = rawEffort === 'false' || rawEffort === 'disabled' ? 'none' : rawEffort || 'medium' + const effortValue = rawEffort === 'false' || rawEffort === 'disabled' ? 'none' : rawEffort || DEFAULT_REASONING_EFFORT const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier')) @@ -822,9 +816,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { - {EFFORT_VALUES.map(value => ( + {REASONING_EFFORT_VALUES.map(value => ( - {value === 'none' ? m.reasoningOff : t.shell.modelOptions[effortLabelKey(value)]} + {value === 'none' ? m.reasoningOff : t.shell.modelOptions[value]} ))} diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index 742ed304b65b..53c419937ffc 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -13,23 +13,24 @@ import { } from '@/components/ui/dropdown-menu' import { Switch } from '@/components/ui/switch' import { useI18n } from '@/i18n' -import { normalize } from '@/lib/text' +import { + DEFAULT_REASONING_EFFORT, + isThinkingEnabled, + REASONING_EFFORTS, + resolveReasoningEffort +} from '@/lib/reasoning-effort' import { setModelPreset } from '@/store/model-presets' import { notifyError } from '@/store/notifications' -import { markComposerSelectionManual, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session' +import { + $defaultReasoningEffort, + markComposerSelectionManual, + setCurrentFastMode, + setCurrentReasoningEffort +} from '@/store/session' import { sessionTileDelegate } from '@/store/session-states' -// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned +// Hermes' real reasoning levels live in lib/reasoning-effort; `none` is owned // by the Thinking toggle, not the radio. -const EFFORT_OPTIONS = [ - { value: 'minimal', labelKey: 'minimal' }, - { value: 'low', labelKey: 'low' }, - { value: 'medium', labelKey: 'medium' }, - { value: 'high', labelKey: 'high' }, - { value: 'xhigh', labelKey: 'xhigh' }, - { value: 'max', labelKey: 'max' }, - { value: 'ultra', labelKey: 'ultra' } -] as const /** How "fast" is achieved for a given model — two different mechanisms: * - `param`: the Anthropic/OpenAI `speed=fast` request parameter. @@ -111,8 +112,9 @@ export function ModelEditSubmenu({ const activeSessionId = useStore(view.$runtimeId) const touchesPrimary = view.kind === 'primary' - const effortValue = normalizeEffort(effort) - const thinkingOn = isThinkingEnabled(effort) + const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT + const effortValue = resolveReasoningEffort(effort, defaultEffort) + const thinkingOn = isThinkingEnabled(effort, defaultEffort) // Editing always records the model's global preset (keyed by provider::model, // not per-surface — a tile edit re-applies to that model everywhere); the @@ -224,7 +226,7 @@ export function ModelEditSubmenu({ void patchReasoning(checked ? effortValue || 'medium' : 'none')} + onCheckedChange={checked => void patchReasoning(checked ? effortValue || defaultEffort : 'none')} size="xs" /> @@ -240,14 +242,14 @@ export function ModelEditSubmenu({ {copy.effort} void patchReasoning(value)} value={effortValue}> - {EFFORT_OPTIONS.map(option => ( + {REASONING_EFFORTS.map(value => ( event.preventDefault()} - value={option.value} + value={value} > - {copy[option.labelKey]} + {copy[value]} ))} @@ -258,19 +260,3 @@ export function ModelEditSubmenu({ ) } - -function isThinkingEnabled(effort: string): boolean { - // Empty = Hermes default (medium) = on; only an explicit "none" is off. - return normalize(effort || 'medium') !== 'none' -} - -function normalizeEffort(effort: string): string { - const value = normalize(effort || 'medium') - - // Thinking off → no effort selected in the radio group. - if (value === 'none') { - return '' - } - - return EFFORT_OPTIONS.some(option => option.value === value) ? value : 'medium' -} diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index 9c3c60e5cdaf..c7fa0246cf72 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -20,12 +20,8 @@ import type { HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' import { ChevronDown, ChevronRight } from '@/lib/icons' import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' -import { - currentPickerSelection, - displayModelName, - modelDisplayParts, - reasoningEffortLabel -} from '@/lib/model-status-label' +import { currentPickerSelection, displayModelName, modelDisplayParts } from '@/lib/model-status-label' +import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort' import { normalize } from '@/lib/text' import { cn } from '@/lib/utils' import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets' @@ -39,6 +35,7 @@ import { setModelVisibilityOpen } from '@/store/model-visibility' import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse' +import { $defaultReasoningEffort } from '@/store/session' import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu' @@ -84,6 +81,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re const currentProvider = useStore(view.$provider) const currentReasoningEffort = useStore(view.$reasoningEffort) const modelPresets = useStore($modelPresets) + const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT const visibleModels = useStore($visibleModels) const collapsedProviders = useStore($collapsedProviders) @@ -181,7 +179,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re await applyModelPreset( { - effort: (caps?.reasoning ?? true) ? (preset.effort ?? 'medium') : undefined, + effort: (caps?.reasoning ?? true) ? (preset.effort ?? defaultEffort) : undefined, fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined }, { @@ -300,7 +298,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re const meta = [ fastControl.kind !== 'none' && fastControl.on ? copy.fast : null, - (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort) || copy.medium : null + (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort || defaultEffort) : null ] .filter(Boolean) .join(' ') diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 2d61c399998f..01f018d65936 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -1986,8 +1986,7 @@ export const ar = defineLocale({ noModels: 'لا توجد نماذج', editModels: 'تحرير النماذج', refreshModels: 'تحديث النماذج', - fast: 'سريع', - medium: 'متوسط' + fast: 'سريع' }, modelOptions: { noOptions: 'لا توجد خيارات لهذا النموذج', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 12d15e9657c4..25a6fa82fb61 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2306,8 +2306,7 @@ export const en: Translations = { noModels: 'No models found', editModels: 'Edit Models…', refreshModels: 'Refresh Models', - fast: 'Fast', - medium: 'Med' + fast: 'Fast' }, modelOptions: { noOptions: 'No options for this model', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 90d65ad5901a..75cfe0feaf8a 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2178,8 +2178,7 @@ export const ja = defineLocale({ noModels: 'モデルが見つかりません', editModels: 'モデルを編集…', refreshModels: 'モデルを更新', - fast: '高速', - medium: '中' + fast: '高速' }, modelOptions: { noOptions: 'このモデルにはオプションがありません', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 41417a4cf338..928493ec05a4 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1915,7 +1915,6 @@ export interface Translations { editModels: string refreshModels: string fast: string - medium: string } modelOptions: { noOptions: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 2772852eee51..90240c358a0e 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2105,8 +2105,7 @@ export const zhHant = defineLocale({ noModels: '找不到模型', editModels: '編輯模型…', refreshModels: '重新整理模型', - fast: '快速', - medium: '中' + fast: '快速' }, modelOptions: { noOptions: '此模型沒有可用選項', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 68cbbb77ad4a..f9f4c92a7748 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2483,8 +2483,7 @@ export const zh: Translations = { noModels: '未找到模型', editModels: '编辑模型…', refreshModels: '刷新模型', - fast: '快速', - medium: '中' + fast: '快速' }, modelOptions: { noOptions: '此模型没有可用选项', diff --git a/apps/desktop/src/lib/model-status-label.test.ts b/apps/desktop/src/lib/model-status-label.test.ts index cc499c86d565..f21c1f5f1d34 100644 --- a/apps/desktop/src/lib/model-status-label.test.ts +++ b/apps/desktop/src/lib/model-status-label.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from 'vitest' -import { - currentPickerSelection, - displayModelName, - formatModelStatusLabel, - reasoningEffortLabel -} from './model-status-label' +import { currentPickerSelection, displayModelName, formatModelStatusLabel } from './model-status-label' +import { reasoningEffortLabel } from './reasoning-effort' describe('model-status-label', () => { it('formats display names consistently', () => { @@ -34,9 +30,16 @@ describe('model-status-label', () => { ) }) - it('always surfaces the effort (default medium) so the level is visible', () => { + it('falls back to the profile default effort, then to medium', () => { expect(formatModelStatusLabel('openai/gpt-5.5', { reasoningEffort: 'medium' })).toBe('GPT-5.5 · Med') expect(formatModelStatusLabel('openai/gpt-5.5')).toBe('GPT-5.5 · Med') + // No session-level effort → the configured profile default is advertised, + // not Hermes' built-in medium. + expect(formatModelStatusLabel('openai/gpt-5.5', { defaultEffort: 'high' })).toBe('GPT-5.5 · High') + // An explicit session effort still wins over the profile default. + expect(formatModelStatusLabel('openai/gpt-5.5', { defaultEffort: 'high', reasoningEffort: 'low' })).toBe( + 'GPT-5.5 · Low' + ) }) it('returns just the placeholder name when there is no model', () => { diff --git a/apps/desktop/src/lib/model-status-label.ts b/apps/desktop/src/lib/model-status-label.ts index a1b28b8a4c58..0a1f14818504 100644 --- a/apps/desktop/src/lib/model-status-label.ts +++ b/apps/desktop/src/lib/model-status-label.ts @@ -1,25 +1,4 @@ -import { normalize } from '@/lib/text' - -const REASONING_LABELS: Record = { - none: 'Off', - minimal: 'Min', - low: 'Low', - medium: 'Med', - high: 'High', - xhigh: 'XHigh', - max: 'Max', - ultra: 'Ultra' -} - -export function reasoningEffortLabel(effort: string): string { - const key = normalize(effort) - - if (!key) { - return '' - } - - return REASONING_LABELS[key] ?? effort -} +import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort' /** Which model/provider a picker should mark "current". With a live session the * gateway's `model.options` is authoritative; pre-session there is no server @@ -99,10 +78,12 @@ export function displayModelName(model: string): string { return modelDisplayParts(model).name } -/** Status bar trigger label — model name plus the live session state (effort/fast). */ +/** Status bar trigger label — model name plus the live session state (effort/fast). + * `defaultEffort` is the profile's configured level, used when the surface has + * no explicit effort so the label never advertises a default the agent won't use. */ export function formatModelStatusLabel( model: string, - options?: { fastMode?: boolean; reasoningEffort?: string } + options?: { defaultEffort?: string; fastMode?: boolean; reasoningEffort?: string } ): string { const name = displayModelName(model) @@ -118,9 +99,9 @@ export function formatModelStatusLabel( parts.push('Fast') } - // Always surface the effort (empty = Hermes default of medium) so the - // current reasoning level is visible at a glance, not just when non-default. - parts.push(reasoningEffortLabel(options?.reasoningEffort ?? '') || 'Med') + // Always surface the effort so the current reasoning level is visible at a + // glance, not just when non-default. + parts.push(reasoningEffortLabel(options?.reasoningEffort || options?.defaultEffort || DEFAULT_REASONING_EFFORT)) return `${name} · ${parts.join(' ')}` } diff --git a/apps/desktop/src/lib/reasoning-effort.test.ts b/apps/desktop/src/lib/reasoning-effort.test.ts new file mode 100644 index 000000000000..bc61b91fb9b3 --- /dev/null +++ b/apps/desktop/src/lib/reasoning-effort.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { + DEFAULT_REASONING_EFFORT, + isReasoningEffort, + isThinkingEnabled, + REASONING_EFFORT_VALUES, + REASONING_EFFORTS, + reasoningEffortLabel, + resolveReasoningEffort +} from './reasoning-effort' + +describe('reasoning-effort', () => { + it('keeps the scale ascending and `none` off it', () => { + expect(REASONING_EFFORTS).not.toContain('none') + expect(REASONING_EFFORT_VALUES[0]).toBe('none') + expect(REASONING_EFFORT_VALUES).toHaveLength(REASONING_EFFORTS.length + 1) + }) + + it('labels every level it claims to support', () => { + for (const effort of REASONING_EFFORT_VALUES) { + expect(reasoningEffortLabel(effort)).not.toBe('') + } + + expect(reasoningEffortLabel('')).toBe('') + // Unknown values pass through rather than silently reading as a real level. + expect(reasoningEffortLabel('bogus')).toBe('bogus') + }) + + it('recognizes only real scale levels', () => { + expect(isReasoningEffort(DEFAULT_REASONING_EFFORT)).toBe(true) + expect(isReasoningEffort('HIGH')).toBe(true) + expect(isReasoningEffort('none')).toBe(false) + expect(isReasoningEffort('bogus')).toBe(false) + }) + + it('treats empty as inherit and only `none` as off', () => { + expect(isThinkingEnabled('none')).toBe(false) + expect(isThinkingEnabled('high')).toBe(true) + // Empty inherits the fallback, so an off fallback reads as off. + expect(isThinkingEnabled('', 'none')).toBe(false) + expect(isThinkingEnabled('', 'high')).toBe(true) + }) + + it('resolves a scale value: inherit, off, or clamp', () => { + expect(resolveReasoningEffort('high')).toBe('high') + // Empty inherits the profile default rather than snapping to medium. + expect(resolveReasoningEffort('', 'ultra')).toBe('ultra') + // Off selects nothing on the scale. + expect(resolveReasoningEffort('none')).toBe('') + expect(resolveReasoningEffort('bogus')).toBe(DEFAULT_REASONING_EFFORT) + }) +}) diff --git a/apps/desktop/src/lib/reasoning-effort.ts b/apps/desktop/src/lib/reasoning-effort.ts new file mode 100644 index 000000000000..5d3e17afd050 --- /dev/null +++ b/apps/desktop/src/lib/reasoning-effort.ts @@ -0,0 +1,54 @@ +import { normalize } from '@/lib/text' + +/** Hermes' reasoning levels, in ascending order — mirrors the backend's + * VALID_REASONING_EFFORTS (hermes_constants.py). `none` is not a level: it's + * thinking disabled, owned by the Thinking toggle rather than the scale. */ +export const REASONING_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const + +export type ReasoningEffort = (typeof REASONING_EFFORTS)[number] + +/** The scale plus the off state — the full set a config value may hold. */ +export const REASONING_EFFORT_VALUES = ['none', ...REASONING_EFFORTS] as const + +/** Hermes' built-in level when neither the surface nor the profile config + * specifies one (mirrors the backend's own fallback). */ +export const DEFAULT_REASONING_EFFORT: ReasoningEffort = 'medium' + +/** Compact labels for chrome where space is tight (pill, picker rows). Menus + * and settings use the translated `shell.modelOptions` strings instead. */ +const SHORT_LABELS: Record = { + none: 'Off', + minimal: 'Min', + low: 'Low', + medium: 'Med', + high: 'High', + xhigh: 'XHigh', + max: 'Max', + ultra: 'Ultra' +} + +export function reasoningEffortLabel(effort: string): string { + const key = normalize(effort) + + return key ? (SHORT_LABELS[key] ?? effort) : '' +} + +export const isReasoningEffort = (value: string): value is ReasoningEffort => + REASONING_EFFORTS.includes(normalize(value) as ReasoningEffort) + +/** Thinking is on unless a level explicitly says otherwise; an empty value + * means "inherit", so it resolves through `fallback` first. */ +export const isThinkingEnabled = (effort: string, fallback: string = DEFAULT_REASONING_EFFORT): boolean => + normalize(effort || fallback) !== 'none' + +/** The level a scale control should show. Empty inherits `fallback`; `none` + * (thinking off) selects nothing; anything unrecognized clamps to the default. */ +export function resolveReasoningEffort(effort: string, fallback: string = DEFAULT_REASONING_EFFORT): string { + const value = normalize(effort || fallback) + + if (value === 'none') { + return '' + } + + return isReasoningEffort(value) ? value : DEFAULT_REASONING_EFFORT +} diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 0044a7b34a6f..31ea5ff832a2 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -454,6 +454,14 @@ export const setCurrentReasoningEffort = (next: Updater) => { persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null) } +// The profile's `agent.reasoning_effort`, mirrored from config so surfaces that +// need to render or apply "the default" resolve the user's configured level +// instead of assuming DEFAULT_REASONING_EFFORT (lib/reasoning-effort). Empty +// until config loads, and re-seeded on every profile switch by useHermesConfig. +export const $defaultReasoningEffort = atom('') + +export const setDefaultReasoningEffort = (next: string) => updateAtom($defaultReasoningEffort, next) + export const setCurrentServiceTier = (next: Updater) => updateAtom($currentServiceTier, next) export const setCurrentFastMode = (next: Updater) => {