Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions apps/desktop/src/app/session/hooks/use-model-controls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ function Harness({

describe('useModelControls', () => {
beforeEach(() => {
window.localStorage.clear()
$activeSessionId.set(null)
setCurrentModel('')
setCurrentProvider('')
Expand All @@ -67,6 +68,7 @@ describe('useModelControls', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
window.localStorage.clear()
$activeSessionId.set(null)
setCurrentModel('')
setCurrentProvider('')
Expand Down Expand Up @@ -195,4 +197,103 @@ describe('useModelControls', () => {
await result.current.refreshCurrentModel(true)
expect($currentModel.get()).toBe('openai/gpt-5.5')
})

it('reseeds stale composer state when the profile default changes', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValueOnce({
model: 'openai/gpt-5.5',
provider: 'openai-codex'
})

const { result } = renderHook(() =>
useModelControls({
activeSessionId: null,
queryClient: new QueryClient(),
requestGateway: vi.fn()
})
)

await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('openai/gpt-5.5')
expect($currentProvider.get()).toBe('openai-codex')

vi.mocked(getGlobalModelInfo).mockResolvedValueOnce({
model: 'openrouter/fusion',
provider: 'openrouter'
})

await result.current.refreshCurrentModel()

expect($currentModel.get()).toBe('openrouter/fusion')
expect($currentProvider.get()).toBe('openrouter')
})

it('replaces the legacy desktop default slug when no baseline has been recorded', async () => {
setCurrentModel('gpt-5.5')
setCurrentProvider('openai-codex')
vi.mocked(getGlobalModelInfo).mockResolvedValue({
model: 'openrouter/fusion',
provider: 'openrouter'
})

const { result } = renderHook(() =>
useModelControls({
activeSessionId: null,
queryClient: new QueryClient(),
requestGateway: vi.fn()
})
)

await result.current.refreshCurrentModel()

expect($currentModel.get()).toBe('openrouter/fusion')
expect($currentProvider.get()).toBe('openrouter')
})

it('replaces the legacy desktop OpenAI-prefixed default when no baseline has been recorded', async () => {
setCurrentModel('openai/gpt-5.5')
setCurrentProvider('openai-codex')
vi.mocked(getGlobalModelInfo).mockResolvedValue({
model: 'openrouter/fusion',
provider: 'openrouter'
})

const { result } = renderHook(() =>
useModelControls({
activeSessionId: null,
queryClient: new QueryClient(),
requestGateway: vi.fn()
})
)

await result.current.refreshCurrentModel()

expect($currentModel.get()).toBe('openrouter/fusion')
expect($currentProvider.get()).toBe('openrouter')
})

it('does not clobber a same-profile user pick on routine refresh', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({
model: 'openrouter/fusion',
provider: 'openrouter'
})

const { result } = renderHook(() =>
useModelControls({
activeSessionId: null,
queryClient: new QueryClient(),
requestGateway: vi.fn()
})
)

await result.current.refreshCurrentModel()

await result.current.selectModel({
model: 'anthropic/claude-sonnet-4.6',
provider: 'openrouter'
})
await result.current.refreshCurrentModel()

expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6')
expect($currentProvider.get()).toBe('openrouter')
})
})
86 changes: 72 additions & 14 deletions apps/desktop/src/app/session/hooks/use-model-controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useCallback } from 'react'

import { getGlobalModelInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { persistString, storedString } from '@/lib/storage'
import { notifyError } from '@/store/notifications'
import {
$activeSessionId,
Expand All @@ -18,6 +19,50 @@ interface ModelSelection {
provider: string
}

const COMPOSER_DEFAULT_BASELINE_KEY = 'hermes.desktop.composer.default-baseline'

const LEGACY_DESKTOP_DEFAULTS: readonly ModelSelection[] = [
{ model: 'gpt-5.5', provider: 'openai-codex' },
{ model: 'openai/gpt-5.5', provider: 'openai-codex' }
]

function sameSelection(left: ModelSelection, right: ModelSelection): boolean {
return left.model === right.model && left.provider === right.provider
}

function normalizedSelection(selection: ModelSelection): ModelSelection {
return {
model: selection.model.trim(),
provider: selection.provider.trim()
}
}

function readComposerDefaultBaseline(): ModelSelection | null {
const raw = storedString(COMPOSER_DEFAULT_BASELINE_KEY)

if (!raw) {
return null
}

try {
const parsed = JSON.parse(raw) as Partial<ModelSelection>
const model = typeof parsed.model === 'string' ? parsed.model.trim() : ''
const provider = typeof parsed.provider === 'string' ? parsed.provider.trim() : ''

return model ? { model, provider } : null
} catch {
return null
}
}

function writeComposerDefaultBaseline(selection: ModelSelection): void {
persistString(COMPOSER_DEFAULT_BASELINE_KEY, JSON.stringify(normalizedSelection(selection)))
}

function isLegacyDesktopDefault(selection: ModelSelection): boolean {
return LEGACY_DESKTOP_DEFAULTS.some(defaultSelection => sameSelection(selection, defaultSelection))
}

interface ModelControlsOptions {
activeSessionId: string | null
queryClient: QueryClient
Expand All @@ -42,33 +87,46 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
)

// Seed the composer's model state from the profile default. `force` reseeds
// for a profile swap (the new profile has its own default); otherwise this
// only fills an EMPTY selection so a user's pick (plain UI state in
// $currentModel) survives the lifecycle refreshes that fire on boot / fresh
// draft / session events. A live session owns the footer, so skip entirely.
// for a profile swap (the new profile has its own default). Routine refreshes
// may also reseed when the composer still matches the last default we seeded
// and the profile default has changed; once a user picks a different model,
// that plain UI state survives boot / fresh draft / session-event refreshes.
// A live session owns the footer, so skip entirely.
const refreshCurrentModel = useCallback(async (force = false) => {
try {
if ($activeSessionId.get()) {
return
}

if (!force && $currentModel.get()) {
return
}

const result = await getGlobalModelInfo()
const nextDefault = normalizedSelection({
model: typeof result.model === 'string' ? result.model : '',
provider: typeof result.provider === 'string' ? result.provider : ''
})

if ($activeSessionId.get() || (!force && $currentModel.get())) {
if ($activeSessionId.get() || !nextDefault.model) {
return
}

if (typeof result.model === 'string') {
setCurrentModel(result.model)
const current = normalizedSelection({
model: $currentModel.get(),
provider: $currentProvider.get()
})
const baseline = readComposerDefaultBaseline()
const shouldReseed =
force ||
!current.model ||
sameSelection(current, nextDefault) ||
(baseline != null && sameSelection(current, baseline) && !sameSelection(baseline, nextDefault)) ||
(baseline == null && isLegacyDesktopDefault(current) && !sameSelection(current, nextDefault))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This treats an unmarked historic gpt-5.5 / openai-codex picker value as default-derived even if the user deliberately selected it. Model/provider alone cannot encode that provenance; persist an explicit manual/default source marker instead, then add a regression test that preserves a manual legacy selection after the profile default changes.


if (!shouldReseed) {
return
}

if (typeof result.provider === 'string') {
setCurrentProvider(result.provider)
}
setCurrentModel(nextDefault.model)
setCurrentProvider(nextDefault.provider)
writeComposerDefaultBaseline(nextDefault)
} catch {
// The delayed session.info event still updates this once the agent is ready.
}
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/model-status-label.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ describe('model-status-label', () => {
expect(displayModelName('openai/gpt-5.5-fast')).toBe('GPT-5.5')
expect(displayModelName('deepseek/deepseek-v4-pro-thinking')).toBe('Deepseek V4 Pro')
expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5')
expect(displayModelName('openrouter/fusion')).toBe('OpenRouter Fusion')
})

it('strips trailing date-pin snapshots from the display name', () => {
Expand All @@ -30,6 +31,7 @@ describe('model-status-label', () => {
it('always surfaces the effort (default medium) so the level is visible', () => {
expect(formatModelStatusLabel('openai/gpt-5.5', { reasoningEffort: 'medium' })).toBe('GPT-5.5 · Med')
expect(formatModelStatusLabel('openai/gpt-5.5')).toBe('GPT-5.5 · Med')
expect(formatModelStatusLabel('openrouter/fusion')).toBe('OpenRouter Fusion · Med')
})

it('returns just the placeholder name when there is no model', () => {
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/lib/model-status-label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ const VARIANT_TAGS: ReadonlyArray<readonly [RegExp, string]> = [
[/-latest$/i, 'Latest']
]

const MODEL_DISPLAY_NAMES: Record<string, string> = {
'openrouter/fusion': 'OpenRouter Fusion'
}

const titleCase = (text: string): string => text.replace(/\b\w/g, char => char.toUpperCase()).trim()

function prettifyBase(base: string): string {
Expand All @@ -72,6 +76,12 @@ function prettifyBase(base: string): string {
/** Split a model id into a clean display name plus an optional grayed variant
* tag, so distinct ids (e.g. `…-4.8` vs `…-4.8-fast`) don't collapse. */
export function modelDisplayParts(model: string): { name: string; tag: string } {
const override = MODEL_DISPLAY_NAMES[model.trim().toLowerCase()]

if (override) {
return { name: override, tag: '' }
}

let base = modelBaseId(model)
let tag = ''

Expand Down