Merge pull request #66449 from NousResearch/audit/desktop-model-picker

fix(desktop): session-scope fast mode, surface profile ownership + pinned model override
This commit is contained in:
brooklyn! 2026-07-17 14:36:21 -04:00 committed by GitHub
commit 3e7c563ddd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 454 additions and 27 deletions

View file

@ -0,0 +1,76 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { ChatBarState } from '@/app/chat/composer/types'
import {
$activeSessionId,
$currentModel,
setCurrentModel,
setCurrentModelSource
} from '@/store/session'
import { ModelPill } from './model-pill'
const modelState = (over: Partial<ChatBarState['model']> = {}): ChatBarState['model'] => ({
canSwitch: true,
model: 'gpt-6',
provider: 'openai',
...over
})
afterEach(() => {
cleanup()
$activeSessionId.set(null)
setCurrentModel('')
setCurrentModelSource('')
})
// #62055: a manual composer pick is sticky and silently overrides the
// Settings → Model default for every NEW chat. The pill must say so.
describe('ModelPill pinned-override badge', () => {
it('shows the pin dot on a draft running a manual pick', () => {
setCurrentModel('deepseek/deepseek-v4-flash')
setCurrentModelSource('manual')
$activeSessionId.set(null)
render(<ModelPill disabled={false} model={modelState()} />)
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
})
it('stays quiet when the composer reflects the profile default', () => {
setCurrentModel('google/gemma-4-26b-a4b-it:free')
setCurrentModelSource('default')
$activeSessionId.set(null)
render(<ModelPill disabled={false} model={modelState()} />)
expect(screen.queryByTestId('model-pinned-dot')).toBeNull()
})
it('stays quiet on a live session (footer shows that session, not the pin)', () => {
setCurrentModel('deepseek/deepseek-v4-flash')
setCurrentModelSource('manual')
$activeSessionId.set('live-1')
render(<ModelPill disabled={false} model={modelState()} />)
expect(screen.queryByTestId('model-pinned-dot')).toBeNull()
})
it('is exercised in both render paths', () => {
setCurrentModel('deepseek/deepseek-v4-flash')
setCurrentModelSource('manual')
$activeSessionId.set(null)
// Fallback (no live menu) path.
const { unmount } = render(<ModelPill disabled={false} model={modelState()} />)
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
unmount()
// Live-menu (dropdown) path.
render(<ModelPill disabled={false} model={modelState({ modelMenuContent: <div /> })} />)
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
expect($currentModel.get()).toBe('deepseek/deepseek-v4-flash')
})
})

View file

@ -11,8 +11,10 @@ import { ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import {
$activeSessionId,
$currentFastMode,
$currentModel,
$currentModelSource,
$currentProvider,
$currentReasoningEffort,
setModelPickerOpen
@ -44,8 +46,17 @@ export function ModelPill({
const currentProvider = useStore($currentProvider)
const fastMode = useStore($currentFastMode)
const reasoningEffort = useStore($currentReasoningEffort)
const modelSource = useStore($currentModelSource)
const activeSessionId = useStore($activeSessionId)
const [open, setOpen] = useState(false)
// The composer pick is sticky: a manual selection is pinned and every NEW
// chat uses it instead of the Settings → Model default — silently, which has
// cost users real money on a forgotten paid-model pick (#62055). Surface the
// pin whenever a draft (no live session) is running on a manual override. A
// live session's footer reflects that session's model, so no badge there.
const pinnedOverride = !activeSessionId && modelSource === 'manual' && Boolean(currentModel.trim())
// The model resolves a beat after the gateway/session comes up. Rather than
// flash a literal "No model", show a quiet loader (inherits the pill text
// color at half opacity) until a model lands.
@ -58,6 +69,14 @@ export function ModelPill({
) : (
<GlyphSpinner className="opacity-50" spinner="braille" />
)}
{pinnedOverride && (
<span
aria-label={copy.modelPinned}
className="size-1 shrink-0 rounded-full bg-(--ui-accent)"
data-testid="model-pinned-dot"
role="img"
/>
)}
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</>
)
@ -71,11 +90,12 @@ export function ModelPill({
)
: PILL
const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
const baseTitle = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
const title = pinnedOverride ? `${baseTitle}${copy.modelPinned}` : baseTitle
if (!model.modelMenuContent) {
return (
<Tip label={copy.openModelPicker} side="top">
<Tip label={pinnedOverride ? `${copy.openModelPicker}${copy.modelPinned}` : copy.openModelPicker} side="top">
<Button
aria-label={copy.openModelPicker}
className={pillClass}

View file

@ -23,7 +23,7 @@ import { cn } from '@/lib/utils'
import { $pinnedSessionIds } from '@/store/layout'
import { $petActive } from '@/store/pet'
import { $petOverlayActive } from '@/store/pet-overlay'
import { $gatewaySwapTarget } from '@/store/profile'
import { $gatewaySwapTarget, $profiles } from '@/store/profile'
import {
$contextSuggestions,
$freshDraftReady,
@ -50,6 +50,7 @@ import { useComposerScope } from './composer/scope'
import type { ChatBarState } from './composer/types'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone'
import { ProfileTag } from './profile-tag'
import { useRuntimeMessageRepository } from './runtime-repository'
import { ScrollToBottomButton } from './scroll-to-bottom-button'
import { useSessionView } from './session-view'
@ -101,12 +102,18 @@ function ChatHeader({
}: ChatHeaderProps) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const profiles = useStore($profiles)
const activeStoredSession =
(selectedSessionId && sessions.find(session => sessionMatchesStoredId(session, selectedSessionId))) || null
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
// Which agent/persona owns this chat — glanceable in the header once a
// second profile exists, so the open session's ownership is never ambiguous
// (#66003). Single-profile users see the unchanged header.
const showProfileTag = profiles.length > 1 && Boolean(activeStoredSession)
// Pins live on the durable lineage-root id, but selectedSessionId is the live
// (tip) id — resolve through the loaded row so the menu reflects the pin
// state after auto-compression rotates the id.
@ -126,12 +133,15 @@ function ChatHeader({
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div
className={titlebarHeaderTitleClass}
className={cn(titlebarHeaderTitleClass, showProfileTag && 'flex items-center')}
style={{
maxWidth:
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
}}
>
{showProfileTag && (
<ProfileTag className="pointer-events-auto mr-1.5" profile={activeStoredSession?.profile} />
)}
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}

View file

@ -0,0 +1,49 @@
import { cleanup, render, screen } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
// Keep store/profile's side-effecting imports inert (gateway socket layer +
// REST client) — same seam as store/profile.test.ts.
vi.mock('@/store/gateway', () => ({
$gateway: atom<unknown>(null),
ensureGatewayForProfile: vi.fn(async () => undefined)
}))
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
setApiRequestProfile: vi.fn()
}))
vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() }))
const { ProfileTag } = await import('./profile-tag')
const { setProfileColor } = await import('@/store/profile')
afterEach(cleanup)
describe('ProfileTag', () => {
it('shows the profile initial with an accessible owner label', () => {
render(<ProfileTag profile="xavier" />)
const tag = screen.getByRole('img', { name: 'Profile: xavier' })
expect(tag.textContent).toBe('x')
})
it('normalizes an empty profile to default and stays neutral', () => {
render(<ProfileTag profile="" />)
const tag = screen.getByRole('img', { name: 'Profile: default' })
expect(tag.textContent).toBe('d')
// Default/root profile carries no identity color.
expect(tag.style.color).toBe('')
})
it('uses the profile identity color (user override wins)', () => {
setProfileColor('xavier', 'hsl(120 68% 58%)')
render(<ProfileTag profile="xavier" />)
const tag = screen.getByRole('img', { name: 'Profile: xavier' })
// jsdom normalizes hsl() to rgb(); assert the override landed, not the format.
expect(tag.style.color).toBe('rgb(75, 221, 75)')
})
})

View file

@ -0,0 +1,36 @@
import { useStore } from '@nanostores/react'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import { $profileColors, normalizeProfileKey } from '@/store/profile'
/** Owning-profile chip: soft profile-tint square with the initial, tooltip +
* accessible label carrying the full name. Same visual language as the
* profile rail; the default profile stays neutral. Identity, not status
* session state dots keep their own semantics (#66003). */
export function ProfileTag({ className, profile }: { className?: string; profile: null | string | undefined }) {
const { t } = useI18n()
const colors = useStore($profileColors)
const key = normalizeProfileKey(profile)
const color = resolveProfileColor(key, colors)
const hue = color ?? 'var(--ui-text-quaternary)'
const label = t.sidebar.row.ownedByProfile(key)
return (
<Tip label={label}>
<span
aria-label={label}
className={cn(
'grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none',
className
)}
role="img"
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
>
{key.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</span>
</Tip>
)
}

View file

@ -1232,6 +1232,7 @@ export function ChatSidebar({
pinned={false}
rootClassName="min-h-32 flex-1 overflow-hidden p-0"
sessions={searchResults}
showProfileTags={showAllProfiles}
workingSessionIdSet={workingSessionIdSet}
/>
)}
@ -1254,6 +1255,7 @@ export function ChatSidebar({
pinned
rootClassName="shrink-0 p-0 pb-1"
sessions={pinnedSessions}
showProfileTags={showAllProfiles}
sortable={pinnedSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>

View file

@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { ProfileTag } from '@/app/chat/profile-tag'
import { startSessionDrag } from '@/app/chat/session-drag'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
@ -37,6 +38,10 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
reorderable?: boolean
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
/** Tag the row with its owning profile (initial chip + tooltip). Used by
* flat cross-profile lists Pinned and search results in the All-profiles
* view where no group header communicates ownership (#66003). */
showProfile?: boolean
}
const AGE_KEY = { day: 'ageDay', hour: 'ageHour', minute: 'ageMin' } as const
@ -62,6 +67,7 @@ export function SidebarSessionRow({
reorderable = false,
dragging = false,
dragHandleProps,
showProfile = false,
className,
style,
ref,
@ -253,6 +259,7 @@ export function SidebarSessionRow({
<SidebarRowLabel className="flex-1 font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</SidebarRowLabel>
{showProfile && <ProfileTag profile={session.profile} />}
</SidebarRowBody>
</SidebarRowShell>
</SessionContextMenu>

View file

@ -135,6 +135,10 @@ interface SidebarSessionsSectionProps {
// Rendered atop the entered-project body (a "back to overview" row).
projectBackRow?: React.ReactNode
dndSensors?: ReturnType<typeof useSensors>
// Tag every row with its owning profile. Set on the flat cross-profile
// lists (Pinned / search results) in the All-profiles view, where no group
// header communicates ownership (#66003).
showProfileTags?: boolean
}
export function SidebarSessionsSection({
@ -174,7 +178,8 @@ export function SidebarSessionsSection({
onReorderSessions,
onReorderProjects,
projectBackRow,
dndSensors
dndSensors,
showProfileTags = false
}: SidebarSessionsSectionProps) {
const sectionOpen = collapsible ? open : true
const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0))
@ -203,7 +208,8 @@ export function SidebarSessionsSection({
onPin: () => onTogglePin(sessionPinId(session)),
onResume: () => onResumeSession(session.id),
reorderable: draggable && !branchStem,
session
session,
showProfile: showProfileTags
}
return draggable && !branchStem ? (
@ -311,6 +317,7 @@ export function SidebarSessionsSection({
onResumeSession={onResumeSession}
onTogglePin={onTogglePin}
pinned={pinned}
showProfileTags={showProfileTags}
sortable={sessionsDraggable}
workingSessionIdSet={workingSessionIdSet}
/>

View file

@ -21,6 +21,7 @@ interface SessionRowCommonProps {
onPin: () => void
onResume: () => void
reorderable?: boolean
showProfile?: boolean
}
interface VirtualSessionListProps {
@ -33,6 +34,7 @@ interface VirtualSessionListProps {
onResumeSession: (sessionId: string) => void
onTogglePin: (sessionId: string) => void
pinned: boolean
showProfileTags?: boolean
sortable: boolean
workingSessionIdSet: Set<string>
}
@ -50,6 +52,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
onResumeSession,
onTogglePin,
pinned,
showProfileTags = false,
sortable,
workingSessionIdSet
}) => {
@ -90,7 +93,8 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(sessionPinId(session)),
onResume: () => onResumeSession(session.id),
reorderable
reorderable,
showProfile: showProfileTags
}
return reorderable ? (

View file

@ -1660,6 +1660,7 @@ export const en: Translations = {
finishedUnread: 'Finished — unread',
backgroundRunning: 'Background task running',
handoffOrigin: platform => `Handed off from ${platform}`,
ownedByProfile: profile => `Profile: ${profile}`,
renamed: 'Renamed',
renameFailed: 'Rename failed',
renameTitle: 'Rename session',
@ -2174,6 +2175,7 @@ export const en: Translations = {
noModel: 'no model',
switchModel: 'Switch model',
openModelPicker: 'Open model picker',
modelPinned: 'pinned by you; new chats use this instead of the Settings default',
modelTitle: (provider, model) => `Model · ${provider}: ${model}`,
providerModelTitle: (provider, model) => `${provider} · ${model}`
}

View file

@ -1577,6 +1577,7 @@ export const ja = defineLocale({
finishedUnread: '完了 — 未読',
backgroundRunning: 'バックグラウンドタスク実行中',
handoffOrigin: platform => `${platform} から引き継ぎ`,
ownedByProfile: profile => `プロファイル: ${profile}`,
renamed: '名前を変更しました',
renameFailed: '名前の変更に失敗しました',
renameTitle: 'セッションの名前を変更',
@ -2093,6 +2094,7 @@ export const ja = defineLocale({
noModel: 'モデルなし',
switchModel: 'モデルを切り替え',
openModelPicker: 'モデルピッカーを開く',
modelPinned: '手動で固定中 — 新しいチャットは設定のデフォルトではなくこのモデルを使用します',
modelTitle: (provider, model) => `モデル · ${provider}: ${model}`,
providerModelTitle: (provider, model) => `${provider} · ${model}`
}

View file

@ -1383,6 +1383,7 @@ export interface Translations {
finishedUnread: string
backgroundRunning: string
handoffOrigin: (platform: string) => string
ownedByProfile: (profile: string) => string
renamed: string
renameFailed: string
renameTitle: string
@ -1803,6 +1804,7 @@ export interface Translations {
noModel: string
switchModel: string
openModelPicker: string
modelPinned: string
modelTitle: (provider: string, model: string) => string
providerModelTitle: (provider: string, model: string) => string
}

View file

@ -1527,6 +1527,7 @@ export const zhHant = defineLocale({
finishedUnread: '已完成 — 未讀',
backgroundRunning: '背景任務執行中',
handoffOrigin: platform => `${platform} 轉接`,
ownedByProfile: profile => `設定檔:${profile}`,
renamed: '已重新命名',
renameFailed: '重新命名失敗',
renameTitle: '重新命名工作階段',
@ -2028,6 +2029,7 @@ export const zhHant = defineLocale({
noModel: '無模型',
switchModel: '切換模型',
openModelPicker: '開啟模型選擇器',
modelPinned: '已由你固定;新對話將使用此模型而非「設定」中的預設模型',
modelTitle: (provider, model) => `模型 · ${provider}${model}`,
providerModelTitle: (provider, model) => `${provider} · ${model}`
}

View file

@ -1836,6 +1836,7 @@ export const zh: Translations = {
finishedUnread: '已完成 — 未读',
backgroundRunning: '后台任务运行中',
handoffOrigin: platform => `${platform} 转接`,
ownedByProfile: profile => `配置档:${profile}`,
renamed: '已重命名',
renameFailed: '重命名失败',
renameTitle: '重命名会话',
@ -2339,6 +2340,7 @@ export const zh: Translations = {
noModel: '无模型',
switchModel: '切换模型',
openModelPicker: '打开模型选择器',
modelPinned: '已由你固定;新对话将使用此模型而非“设置”中的默认模型',
modelTitle: (provider, model) => `模型 · ${provider}: ${model}`,
providerModelTitle: (provider, model) => `${provider} · ${model}`
}

View file

@ -363,8 +363,14 @@ export const getCurrentModelSource = (): ComposerModelSource => {
return source === 'default' || source === 'manual' ? source : ''
}
// Reactive mirror of the persisted source so UI (the composer pill's
// override badge) can subscribe. The getter above stays storage-backed —
// it's read cross-window, where this atom wouldn't see writes.
export const $currentModelSource = atom<ComposerModelSource>(getCurrentModelSource())
export const setCurrentModelSource = (source: ComposerModelSource) => {
persistString(COMPOSER_MODEL_SOURCE_KEY, source || null)
$currentModelSource.set(source)
}
export const setCurrentReasoningEffort = (next: Updater<string>) => {

View file

@ -3897,7 +3897,12 @@ def test_config_set_yolo_global_scope_honors_explicit_value(tmp_path, monkeypatc
assert yaml.safe_load(cfg_path.read_text())["approvals"]["mode"] == "off"
def test_config_set_fast_updates_live_agent_and_config(monkeypatch):
def test_config_set_fast_updates_live_agent_session_scoped(monkeypatch):
"""A session-targeted fast toggle updates the live agent + pins the
per-session override, and NEVER writes global config the desktop's
per-model presets call this on every model pick, and a global write
flipped the tier for every other session/profile (the "switch one
session, switches everywhere" class)."""
writes = []
emits = []
agent = types.SimpleNamespace(
@ -3905,7 +3910,8 @@ def test_config_set_fast_updates_live_agent_and_config(monkeypatch):
request_overrides={"foo": "bar", "speed": "slow"},
service_tier=None,
)
server._sessions["sid"] = _session(agent=agent)
session = _session(agent=agent)
server._sessions["sid"] = session
monkeypatch.setattr(
server, "_write_config_key", lambda path, value: writes.append((path, value))
@ -3931,7 +3937,8 @@ def test_config_set_fast_updates_live_agent_and_config(monkeypatch):
"foo": "bar",
"service_tier": "priority",
}
assert ("agent.service_tier", "fast") in writes
assert session["create_service_tier_override"] == "priority"
assert writes == []
assert ("session.info", "sid", {"model": "x"}) in emits
resp_normal = server.handle_request(
@ -3944,7 +3951,10 @@ def test_config_set_fast_updates_live_agent_and_config(monkeypatch):
assert resp_normal["result"]["value"] == "normal"
assert agent.service_tier is None
assert agent.request_overrides == {"foo": "bar"}
assert ("agent.service_tier", "normal") in writes
# "" (not absent) so a rebuild pins normal instead of falling back to
# the global default.
assert session["create_service_tier_override"] == ""
assert writes == []
finally:
server._sessions.pop("sid", None)

View file

@ -0,0 +1,161 @@
"""Fast-mode (service tier) session scoping in the TUI gateway (desktop backend).
Sibling of test_reasoning_session_scope.py the ``reasoning`` key was made
session-scoped when a session is targeted, but ``fast`` kept writing the
global ``agent.service_tier`` to config.yaml on every call. The desktop's
per-model presets call ``config.set key=fast`` on every model selection, so
toggling fast in ONE session silently flipped the tier for every other
session, profile, CLI, and gateway build ("switch one session, switches
everywhere").
Contract under test:
1. ``config.set key=fast`` with a session must NOT write config.yaml; it pins
``create_service_tier_override`` ("priority" / "" for explicit normal) so
lazily-built sessions and rebuilds keep the choice.
2. Without a session it persists globally, unchanged.
3. ``config.get key=fast`` must read a pre-build session's pin.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import tui_gateway.server as server
FAST_OVERRIDES = {"service_tier": "priority"}
def _agent(service_tier=None):
return SimpleNamespace(
reasoning_config=None,
service_tier=service_tier,
request_overrides={},
model="gpt-6",
provider="openai",
session_id="sess-key",
)
def _set(params: dict) -> dict:
return server._methods["config.set"]("rid-1", params)
def _get(params: dict) -> dict:
return server._methods["config.get"]("rid-1", params)
class TestConfigSetFastSessionScope:
"""Session-targeted fast changes must never touch global config."""
def test_session_scoped_fast_skips_global_write(self) -> None:
agent = _agent()
session = {"session_key": "k1", "agent": agent}
with patch.dict(server._sessions, {"s1": session}, clear=False), \
patch.object(server, "_write_config_key") as write_key, \
patch.object(server, "_persist_live_session_runtime"), \
patch.object(server, "_emit"), \
patch(
"hermes_cli.models.resolve_fast_mode_overrides",
return_value=FAST_OVERRIDES,
):
resp = _set({"key": "fast", "session_id": "s1", "value": "fast"})
assert resp["result"]["value"] == "fast"
assert agent.service_tier == "priority"
assert session["create_service_tier_override"] == "priority"
write_key.assert_not_called()
def test_session_scoped_normal_pins_explicit_normal(self) -> None:
agent = _agent(service_tier="priority")
session = {"session_key": "k2", "agent": agent}
with patch.dict(server._sessions, {"s2": session}, clear=False), \
patch.object(server, "_write_config_key") as write_key, \
patch.object(server, "_persist_live_session_runtime"), \
patch.object(server, "_emit"):
resp = _set({"key": "fast", "session_id": "s2", "value": "normal"})
assert resp["result"]["value"] == "normal"
assert agent.service_tier is None
# "" (not absent) so a rebuild pins normal instead of re-reading the
# global default.
assert session["create_service_tier_override"] == ""
write_key.assert_not_called()
def test_lazy_session_pins_create_override(self) -> None:
"""A pre-build (agent=None) session must keep the change for the
deferred agent build instead of dropping it."""
session = {
"session_key": "k3",
"agent": None,
"model_override": {"model": "gpt-6", "provider": "openai"},
}
with patch.dict(server._sessions, {"s3": session}, clear=False), \
patch.object(server, "_write_config_key") as write_key, \
patch(
"hermes_cli.models.resolve_fast_mode_overrides",
return_value=FAST_OVERRIDES,
):
resp = _set({"key": "fast", "session_id": "s3", "value": "fast"})
assert resp["result"]["value"] == "fast"
assert session["create_service_tier_override"] == "priority"
write_key.assert_not_called()
def test_lazy_session_validates_fast_against_session_model(self) -> None:
"""Fast support is checked against the session's picked model, not the
global default the session will never use."""
session = {
"session_key": "k4",
"agent": None,
"model_override": {"model": "session-model", "provider": "openai"},
}
with patch.dict(server._sessions, {"s4": session}, clear=False), \
patch.object(server, "_write_config_key"), \
patch(
"hermes_cli.models.resolve_fast_mode_overrides",
return_value=FAST_OVERRIDES,
) as resolve:
_set({"key": "fast", "session_id": "s4", "value": "fast"})
resolve.assert_called_once_with("session-model")
def test_toggle_flips_prebuild_pin(self) -> None:
"""An empty value toggles from the session's pin, not the global."""
session = {
"session_key": "k5",
"agent": None,
"create_service_tier_override": "priority",
}
with patch.dict(server._sessions, {"s5": session}, clear=False), \
patch.object(server, "_write_config_key") as write_key:
resp = _set({"key": "fast", "session_id": "s5", "value": ""})
assert resp["result"]["value"] == "normal"
assert session["create_service_tier_override"] == ""
write_key.assert_not_called()
def test_no_session_persists_globally(self) -> None:
with patch.object(server, "_write_config_key") as write_key:
resp = _set({"key": "fast", "value": "normal"})
assert resp["result"]["value"] == "normal"
write_key.assert_called_once_with("agent.service_tier", "normal")
class TestConfigGetFastSessionScope:
def test_reads_prebuild_pin(self) -> None:
session = {
"session_key": "k6",
"agent": None,
"create_service_tier_override": "priority",
}
with patch.dict(server._sessions, {"s6": session}, clear=False):
resp = _get({"key": "fast", "session_id": "s6"})
assert resp["result"]["value"] == "fast"
def test_reads_live_agent_tier(self) -> None:
session = {"session_key": "k7", "agent": _agent(service_tier="priority")}
with patch.dict(server._sessions, {"s7": session}, clear=False):
resp = _get({"key": "fast", "session_id": "s7"})
assert resp["result"]["value"] == "fast"
def test_falls_back_to_global(self) -> None:
with patch.object(server, "_load_service_tier", return_value="priority"):
resp = _get({"key": "fast"})
assert resp["result"]["value"] == "fast"

View file

@ -10783,6 +10783,11 @@ def _(rid, params: dict) -> dict:
agent = session.get("agent") if session else None
if agent is not None:
current_fast = getattr(agent, "service_tier", None) == "priority"
elif session is not None and session.get("create_service_tier_override") is not None:
# Pre-build session with a pinned tier (desktop draft pick or an
# earlier session-scoped toggle) — report/toggle from the pin, not
# the global default.
current_fast = session["create_service_tier_override"] == "priority"
else:
current_fast = _load_service_tier() == "priority"
@ -10805,9 +10810,18 @@ def _(rid, params: dict) -> dict:
if nv == "fast":
from hermes_cli.models import resolve_fast_mode_overrides
target_model = (
getattr(agent, "model", None) if agent is not None else _resolve_model()
)
if agent is not None:
target_model = getattr(agent, "model", None)
else:
# A pre-build session may already have a picked model riding in
# model_override (desktop draft) — validate fast support against
# THAT model, not the global default it will never use.
session_override = (session or {}).get("model_override") or {}
target_model = (
session_override.get("model")
if isinstance(session_override, dict)
else None
) or _resolve_model()
if not target_model:
return _err(
rid,
@ -10822,7 +10836,20 @@ def _(rid, params: dict) -> dict:
"fast mode is not available for this model",
)
_write_config_key("agent.service_tier", nv)
if session is not None:
# Session-scoped, like `reasoning` below (global persistence is
# `--global` / Settings → Model territory). Writing config.yaml
# here let every desktop model-menu selection (per-model fast
# preset) rewrite the user's global agent.service_tier — flipping
# fast mode for every OTHER session, profile, CLI, and gateway
# build ("switch one session, switches everywhere"). Pin the
# create override so lazily-built sessions and rebuilds (/new,
# deferred resume) keep the choice; "" pins normal explicitly.
session["create_service_tier_override"] = (
"priority" if nv == "fast" else ""
)
else:
_write_config_key("agent.service_tier", nv)
if agent is not None:
agent.service_tier = "priority" if nv == "fast" else None
current_overrides = dict(getattr(agent, "request_overrides", {}) or {})
@ -11788,18 +11815,20 @@ def _(rid, params: dict) -> dict:
)
return _ok(rid, {"value": effort, "display": display})
if key == "fast":
return _ok(
rid,
{
"value": (
"fast"
if (session := _sessions.get(params.get("session_id", "")))
and getattr(session.get("agent"), "service_tier", None)
== "priority"
else ("fast" if _load_service_tier() == "priority" else "normal")
),
},
)
# Prefer the session's live/pinned value — `config.set fast` is
# session-scoped, so the global key may not reflect this chat. A
# pre-build session keeps its pin in create_service_tier_override.
session = _sessions.get(params.get("session_id", ""))
tier = None
if session is not None:
agent = session.get("agent")
if agent is not None:
tier = getattr(agent, "service_tier", None)
elif session.get("create_service_tier_override") is not None:
tier = session["create_service_tier_override"]
if tier is None:
tier = _load_service_tier()
return _ok(rid, {"value": "fast" if tier == "priority" else "normal"})
if key == "busy":
return _ok(rid, {"value": _load_busy_input_mode()})
if key in {"approval_mode", "approvals.mode"}: