mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
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:
commit
3e7c563ddd
18 changed files with 454 additions and 27 deletions
76
apps/desktop/src/app/chat/composer/model-pill.test.tsx
Normal file
76
apps/desktop/src/app/chat/composer/model-pill.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
49
apps/desktop/src/app/chat/profile-tag.test.tsx
Normal file
49
apps/desktop/src/app/chat/profile-tag.test.tsx
Normal 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)')
|
||||
})
|
||||
})
|
||||
36
apps/desktop/src/app/chat/profile-tag.tsx
Normal file
36
apps/desktop/src/app/chat/profile-tag.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
161
tests/tui_gateway/test_fast_session_scope.py
Normal file
161
tests/tui_gateway/test_fast_session_scope.py
Normal 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"
|
||||
|
|
@ -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"}:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue