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) => {