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 ab141d71744b..53c419937ffc 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -13,29 +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 { $defaultReasoningEffort, - DEFAULT_REASONING_EFFORT, 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. @@ -118,7 +113,7 @@ export function ModelEditSubmenu({ const touchesPrimary = view.kind === 'primary' const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT - const effortValue = normalizeEffort(effort, defaultEffort) + const effortValue = resolveReasoningEffort(effort, defaultEffort) const thinkingOn = isThinkingEnabled(effort, defaultEffort) // Editing always records the model's global preset (keyed by provider::model, @@ -247,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]} ))} @@ -265,19 +260,3 @@ export function ModelEditSubmenu({ ) } - -function isThinkingEnabled(effort: string, fallback: string): boolean { - // Empty = the profile default = on; only an explicit "none" is off. - return normalize(effort || fallback) !== 'none' -} - -function normalizeEffort(effort: string, fallback: string): string { - const value = normalize(effort || fallback) - - // Thinking off → no effort selected in the radio group. - if (value === 'none') { - return '' - } - - return EFFORT_OPTIONS.some(option => option.value === value) ? value : DEFAULT_REASONING_EFFORT -} diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index 6affa652c84e..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,7 +35,7 @@ import { setModelVisibilityOpen } from '@/store/model-visibility' import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse' -import { $defaultReasoningEffort, DEFAULT_REASONING_EFFORT } from '@/store/session' +import { $defaultReasoningEffort } from '@/store/session' import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu' diff --git a/apps/desktop/src/lib/model-status-label.test.ts b/apps/desktop/src/lib/model-status-label.test.ts index c0a1f867c22e..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', () => { diff --git a/apps/desktop/src/lib/model-status-label.ts b/apps/desktop/src/lib/model-status-label.ts index 1c6a7850dc12..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 @@ -122,7 +101,7 @@ export function formatModelStatusLabel( // 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 || 'medium')) + 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 f5a1a3aad4a4..31ea5ff832a2 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -454,14 +454,10 @@ export const setCurrentReasoningEffort = (next: Updater) => { persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null) } -// Hermes' built-in effort when neither the surface nor the profile config -// specifies one (see VALID_REASONING_EFFORTS / parse_reasoning_effort). -export const DEFAULT_REASONING_EFFORT = 'medium' - // 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. Empty until config loads, and -// re-seeded on every profile switch by useHermesConfig. +// 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)