fix(desktop): keep model runtime state per session (#43702)
* fix(desktop): keep model runtime state per session (cherry picked from commit f72ee87d99ee38cb7b5badeb9a8af869bb92073a) * fix(desktop): keep footer model state scoped to active session (cherry picked from commit d91942ebd4671ff857b5c8526dbf133f04782ecb) * fix(desktop): restore stored runtime when resuming sessions (cherry picked from commit 32b3793418257617b8da57e26151f079c2620d00) * fix(desktop): persist live runtime changes for resume (cherry picked from commit c58467779436dcef44a80ad55b52664752dc0837) * fix(desktop): persist resumed endpoint runtime * chore(attribution): map pinguarmy's commit email in AUTHOR_MAP The salvaged commits on this branch preserve @pinguarmy's authorship (郝鹏宇 / peterhao@Peters-MacBook-Air.local). Add the mapping so the check-attribution CI gate resolves the email to the GitHub username. --------- Co-authored-by: 郝鹏宇 <peterhao@Peters-MacBook-Air.local>
This commit is contained in:
parent
07ac185904
commit
6de3963e37
13 changed files with 474 additions and 32 deletions
|
|
@ -743,6 +743,13 @@ export function DesktopController() {
|
|||
}
|
||||
}, [gatewayState, refreshCronJobs])
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayState === 'open' && !activeSessionId && freshDraftReady) {
|
||||
void refreshCurrentModel()
|
||||
void refreshHermesConfig()
|
||||
}
|
||||
}, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig])
|
||||
|
||||
useRouteResume({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
|
|
|
|||
|
|
@ -633,14 +633,21 @@ export function useMessageStream({
|
|||
const runningChanged = typeof payload?.running === 'boolean'
|
||||
|
||||
if (apply) {
|
||||
const runtimeInfo: { branch?: string; cwd?: string } = {}
|
||||
const runtimeInfo: Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
|
||||
>
|
||||
> = {}
|
||||
|
||||
if (modelChanged) {
|
||||
setCurrentModel(payload!.model || '')
|
||||
runtimeInfo.model = payload!.model || ''
|
||||
}
|
||||
|
||||
if (providerChanged) {
|
||||
setCurrentProvider(payload!.provider || '')
|
||||
runtimeInfo.provider = payload!.provider || ''
|
||||
}
|
||||
|
||||
if (typeof payload?.cwd === 'string') {
|
||||
|
|
@ -653,32 +660,32 @@ export function useMessageStream({
|
|||
runtimeInfo.branch = payload.branch
|
||||
}
|
||||
|
||||
if (sessionId && (runtimeInfo.cwd !== undefined || runtimeInfo.branch !== undefined)) {
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
branch: runtimeInfo.branch ?? state.branch,
|
||||
cwd: runtimeInfo.cwd ?? state.cwd
|
||||
}))
|
||||
}
|
||||
|
||||
if (typeof payload?.personality === 'string') {
|
||||
setCurrentPersonality(normalizePersonalityValue(payload.personality))
|
||||
}
|
||||
|
||||
if (typeof payload?.reasoning_effort === 'string') {
|
||||
setCurrentReasoningEffort(payload.reasoning_effort)
|
||||
runtimeInfo.reasoningEffort = payload.reasoning_effort
|
||||
}
|
||||
|
||||
if (typeof payload?.service_tier === 'string') {
|
||||
setCurrentServiceTier(payload.service_tier)
|
||||
runtimeInfo.serviceTier = payload.service_tier
|
||||
}
|
||||
|
||||
if (typeof payload?.fast === 'boolean') {
|
||||
setCurrentFastMode(payload.fast)
|
||||
runtimeInfo.fast = payload.fast
|
||||
}
|
||||
|
||||
if (typeof payload?.yolo === 'boolean') {
|
||||
setYoloActive(payload.yolo)
|
||||
runtimeInfo.yolo = payload.yolo
|
||||
}
|
||||
|
||||
if (sessionId && Object.keys(runtimeInfo).length > 0) {
|
||||
updateSessionState(sessionId, state => ({ ...state, ...runtimeInfo }))
|
||||
}
|
||||
|
||||
if (runningChanged && sessionId) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { renderHook } from '@testing-library/react'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getGlobalModelInfo } from '@/hermes'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
setCurrentModel,
|
||||
setCurrentProvider
|
||||
} from '@/store/session'
|
||||
|
||||
import { useModelControls } from './use-model-controls'
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getGlobalModelInfo: vi.fn(),
|
||||
setGlobalModel: vi.fn()
|
||||
}))
|
||||
|
||||
describe('useModelControls.refreshCurrentModel', () => {
|
||||
beforeEach(() => {
|
||||
$activeSessionId.set(null)
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
$activeSessionId.set(null)
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
})
|
||||
|
||||
it('applies the global model when there is no active runtime session', async () => {
|
||||
vi.mocked(getGlobalModelInfo).mockResolvedValue({
|
||||
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')
|
||||
})
|
||||
|
||||
it('does not clobber the active session footer state with global model info', async () => {
|
||||
setCurrentModel('deepseek/deepseek-v4-pro')
|
||||
setCurrentProvider('deepseek')
|
||||
$activeSessionId.set('runtime-1')
|
||||
vi.mocked(getGlobalModelInfo).mockResolvedValue({
|
||||
model: 'openai/gpt-5.5',
|
||||
provider: 'openai-codex'
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useModelControls({
|
||||
activeSessionId: 'runtime-1',
|
||||
queryClient: new QueryClient(),
|
||||
requestGateway: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
await result.current.refreshCurrentModel()
|
||||
|
||||
expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
|
||||
expect($currentProvider.get()).toBe('deepseek')
|
||||
})
|
||||
})
|
||||
|
|
@ -4,7 +4,13 @@ import { useCallback } from 'react'
|
|||
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
setCurrentModel,
|
||||
setCurrentProvider
|
||||
} from '@/store/session'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
interface ModelSelection {
|
||||
|
|
@ -39,6 +45,13 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
|||
try {
|
||||
const result = await getGlobalModelInfo()
|
||||
|
||||
// A resumed/live session owns the footer model state. Global config
|
||||
// refreshes (gateway boot, profile swap, settings save) must not clobber
|
||||
// the active chat's runtime model/provider in the status bar.
|
||||
if ($activeSessionId.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof result.model === 'string') {
|
||||
setCurrentModel(result.model)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import {
|
|||
$messages,
|
||||
$sessions,
|
||||
$yoloActive,
|
||||
getRememberedWorkspaceCwd,
|
||||
workspaceCwdForNewSession,
|
||||
sessionPinId,
|
||||
setActiveSessionId,
|
||||
|
|
@ -211,14 +210,16 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
|
|||
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
|
||||
}
|
||||
|
||||
function applyRuntimeInfo(
|
||||
info: SessionCreateResponse['info'] | undefined
|
||||
): Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> | null {
|
||||
function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Partial<
|
||||
Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
|
||||
> | null {
|
||||
if (!info) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionState: Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> = {}
|
||||
const sessionState: Partial<
|
||||
Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
|
||||
> = {}
|
||||
|
||||
reportBackendContract(info.desktop_contract)
|
||||
|
||||
|
|
@ -228,10 +229,12 @@ function applyRuntimeInfo(
|
|||
|
||||
if (info.model) {
|
||||
setCurrentModel(info.model)
|
||||
sessionState.model = info.model
|
||||
}
|
||||
|
||||
if (info.provider) {
|
||||
setCurrentProvider(info.provider)
|
||||
sessionState.provider = info.provider
|
||||
}
|
||||
|
||||
if (info.cwd) {
|
||||
|
|
@ -250,18 +253,22 @@ function applyRuntimeInfo(
|
|||
|
||||
if (typeof info.reasoning_effort === 'string') {
|
||||
setCurrentReasoningEffort(info.reasoning_effort)
|
||||
sessionState.reasoningEffort = info.reasoning_effort
|
||||
}
|
||||
|
||||
if (typeof info.service_tier === 'string') {
|
||||
setCurrentServiceTier(info.service_tier)
|
||||
sessionState.serviceTier = info.service_tier
|
||||
}
|
||||
|
||||
if (typeof info.fast === 'boolean') {
|
||||
setCurrentFastMode(info.fast)
|
||||
sessionState.fast = info.fast
|
||||
}
|
||||
|
||||
if (typeof info.yolo === 'boolean') {
|
||||
setYoloActive(info.yolo)
|
||||
sessionState.yolo = info.yolo
|
||||
}
|
||||
|
||||
if (info.usage) {
|
||||
|
|
@ -314,6 +321,12 @@ export function useSessionActions({
|
|||
setTurnStartedAt(null)
|
||||
// New chats start in the configured default project dir when set,
|
||||
// otherwise the sticky last-used workspace (PR #37586).
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
setYoloActive(false)
|
||||
setCurrentCwd(workspaceCwdForNewSession())
|
||||
setCurrentBranch('')
|
||||
clearComposerDraft()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,20 @@ import type { ChatMessage } from '@/lib/chat-messages'
|
|||
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking, setTurnStartedAt } from '@/store/session'
|
||||
import {
|
||||
$busy,
|
||||
$messages,
|
||||
noteSessionActivity,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
setCurrentProvider,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setSessionAttention,
|
||||
setSessionWorking,
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
|
|
@ -124,6 +137,12 @@ export function useSessionStateCache({
|
|||
setMessages(nextMessages)
|
||||
}
|
||||
|
||||
setCurrentModel(pending.state.model)
|
||||
setCurrentProvider(pending.state.provider)
|
||||
setCurrentReasoningEffort(pending.state.reasoningEffort)
|
||||
setCurrentServiceTier(pending.state.serviceTier)
|
||||
setCurrentFastMode(pending.state.fast)
|
||||
setYoloActive(pending.state.yolo)
|
||||
setBusy(pending.state.busy)
|
||||
setMutableRef(busyRef, pending.state.busy)
|
||||
setAwaitingResponse(pending.state.awaitingResponse)
|
||||
|
|
|
|||
|
|
@ -162,8 +162,9 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||
currentFastMode
|
||||
)
|
||||
|
||||
// Grayed text: active row shows live state (Fast + effort);
|
||||
// others show a fast-capability hint.
|
||||
// Grayed text is live session state only. Do not label inactive
|
||||
// rows as "Fast" just because they have a fast-capable sibling:
|
||||
// that makes an off Fast toggle look like it is already on.
|
||||
const meta = isCurrent
|
||||
? [
|
||||
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
|
||||
|
|
@ -171,9 +172,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: caps?.fast || family.fastId
|
||||
? copy.fast
|
||||
: ''
|
||||
: ''
|
||||
|
||||
// Every row is a hover-Edit submenu trigger. Activating it
|
||||
// (pointer or keyboard) switches to the family's base model;
|
||||
|
|
|
|||
|
|
@ -103,6 +103,12 @@ export interface ClientSessionState {
|
|||
messages: ChatMessage[]
|
||||
branch: string
|
||||
cwd: string
|
||||
model: string
|
||||
provider: string
|
||||
reasoningEffort: string
|
||||
serviceTier: string
|
||||
fast: boolean
|
||||
yolo: boolean
|
||||
busy: boolean
|
||||
awaitingResponse: boolean
|
||||
streamId: string | null
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@ export function createClientSessionState(
|
|||
messages,
|
||||
branch: '',
|
||||
cwd: '',
|
||||
model: '',
|
||||
provider: '',
|
||||
reasoningEffort: '',
|
||||
serviceTier: '',
|
||||
fast: false,
|
||||
yolo: false,
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
streamId: null,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from '
|
|||
describe('model-status-label', () => {
|
||||
it('formats display names consistently', () => {
|
||||
expect(displayModelName('anthropic/claude-opus-4.8-fast')).toBe('Opus 4.8')
|
||||
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')
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue