fix(desktop): preserve model selection across stale session refreshes

This commit is contained in:
程序员阿江(Relakkes)
2026-09-20 03:57:36 +08:00
parent e36538f670
commit a800fe0063
10 changed files with 226 additions and 25 deletions
@@ -371,7 +371,10 @@ function ExitPlanModePermissionDialog({
// Local echo so the composer pill reflects the switch immediately; the
// server already received the override inside the permission_response,
// so deliberately do NOT also send set_runtime_config.
useSessionRuntimeStore.getState().setSelection(sessionId, executionRuntime)
useSessionRuntimeStore.getState().setSelection(sessionId, {
providerId: executionRuntime.providerId,
modelId: executionRuntime.modelId,
})
}
respondToPermission(sessionId, requestId, true, {
...options,
@@ -411,8 +411,14 @@ describe('plan mode permission UI', () => {
expect(useSessionRuntimeStore.getState().selections['session-1']).toEqual({
providerId: null,
modelId: 'claude-sonnet-5',
effortLevel: 'max',
})
useChatStore.getState().handleServerMessage('session-1', {
type: 'runtime_config_applied', providerId: null, modelId: 'claude-sonnet-5',
})
useSessionRuntimeStore.getState().syncFromSessions([{
id: 'session-1', runtimeProviderId: 'deepseek', runtimeModelId: 'deepseek-v4-flash',
} as never])
expect(useSessionRuntimeStore.getState().selections['session-1']?.modelId).toBe('deepseek-v4-flash')
})
it('keeps the approval plain when the user re-selects the current model', async () => {
+80 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentTaskNotification, UIMessage } from '../types/chat'
import type { MessageEntry } from '../types/session'
import type { MessageEntry, SessionListItem } from '../types/session'
import type { SavedProvider } from '../types/provider'
import {
buildMainSessionActivityModel,
@@ -8973,6 +8973,63 @@ describe('chatStore history mapping', () => {
expect(sendMock).not.toHaveBeenCalledWith(TEST_SESSION_ID, { type: 'prewarm_session' })
})
it('keeps the selected runtime on reconnect after stopping and receiving stale list metadata', () => {
useChatStore.setState({ sessions: {
[TEST_SESSION_ID]: makeSession({ chatState: 'streaming' }),
} })
const oldMetadata = {
id: TEST_SESSION_ID, runtimeProviderId: null, runtimeModelId: 'k3[1m]',
}
useSessionRuntimeStore.getState().syncFromSessions([oldMetadata as SessionListItem])
useChatStore.getState().stopGeneration(TEST_SESSION_ID)
const next = { providerId: null, modelId: 'deepseek-v4-flash' }
useSessionRuntimeStore.getState().setSelection(TEST_SESSION_ID, next)
useChatStore.getState().setSessionRuntime(TEST_SESSION_ID, next)
useSessionRuntimeStore.getState().syncFromSessions([oldMetadata as SessionListItem])
useChatStore.getState().disconnectSession(TEST_SESSION_ID)
sendMock.mockClear()
useChatStore.getState().connectToSession(TEST_SESSION_ID)
expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, { type: 'set_runtime_config', ...next })
expect(sendMock).not.toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ modelId: 'k3[1m]' }))
})
it.each(['before-send', 'after-send', 'after-confirmation'] as const)(
'keeps the new model when stopping, switching, and sending despite an old response arriving %s',
(arrival) => {
useChatStore.setState({ sessions: {
[TEST_SESSION_ID]: makeSession({ chatState: 'streaming' }),
} })
const runtime = useSessionRuntimeStore.getState()
const oldMetadata = {
id: TEST_SESSION_ID, runtimeProviderId: 'kimi-fixture', runtimeModelId: 'k3[1m]',
} as SessionListItem
runtime.syncFromSessions([oldMetadata])
sendMock.mockClear()
useChatStore.getState().stopGeneration(TEST_SESSION_ID)
const next = { providerId: 'deepseek-fixture', modelId: 'deepseek-v4-flash' }
runtime.setSelection(TEST_SESSION_ID, next)
useChatStore.getState().setSessionRuntime(TEST_SESSION_ID, next)
const pendingList = useSessionRuntimeStore.getState().selections
const deliverOldResponse = () => runtime.syncFromSessions([oldMetadata], pendingList)
if (arrival === 'before-send') deliverOldResponse()
useChatStore.getState().sendMessage(TEST_SESSION_ID, 'Continue with the selected model')
if (arrival === 'after-send') deliverOldResponse()
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'runtime_config_applied', ...next,
})
if (arrival === 'after-confirmation') deliverOldResponse()
expect(useSessionRuntimeStore.getState().selections[TEST_SESSION_ID]).toEqual(next)
expect(sendMock.mock.calls.map(([, message]) => message)).toEqual([
{ type: 'stop_generation' },
{ type: 'set_runtime_config', ...next },
{ type: 'user_message', content: 'Continue with the selected model', attachments: undefined },
])
},
)
it('sends explicit runtime overrides over websocket', () => {
useChatStore.getState().setSessionRuntime(TEST_SESSION_ID, {
providerId: null,
@@ -8999,6 +9056,7 @@ describe('chatStore history mapping', () => {
[TEST_SESSION_ID]: makeSession({ runtimeConfigReadyCount: 0 }),
},
})
const pendingRequest = useSessionRuntimeStore.getState().selections
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'runtime_config_applied',
@@ -9015,6 +9073,27 @@ describe('chatStore history mapping', () => {
effortLevel: 'high',
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.runtimeConfigReadyCount).toBe(1)
const remote = {
id: TEST_SESSION_ID, runtimeProviderId: 'provider-a', runtimeModelId: 'model-a', effortLevel: 'high',
} as SessionListItem
useSessionRuntimeStore.getState().syncFromSessions([remote], pendingRequest)
expect(useSessionRuntimeStore.getState().selections[TEST_SESSION_ID]?.modelId).toBe('model-b')
useSessionRuntimeStore.getState().syncFromSessions([remote], useSessionRuntimeStore.getState().selections)
expect(useSessionRuntimeStore.getState().selections[TEST_SESSION_ID]?.modelId).toBe('model-a')
})
it.each(['RUNTIME_CONFIG_INVALID', 'CLI_RESTART_FAILED'])('allows fresh metadata to correct a rejected selection (%s)', (code) => {
const runtime = useSessionRuntimeStore.getState()
runtime.setSelection(TEST_SESSION_ID, { providerId: null, modelId: 'rejected-model' })
const staleRequest = useSessionRuntimeStore.getState().selections
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'error', code, message: 'Fixture runtime switch failed',
})
const actual = { id: TEST_SESSION_ID, runtimeProviderId: null, runtimeModelId: 'k3' } as SessionListItem
runtime.syncFromSessions([actual], staleRequest)
expect(useSessionRuntimeStore.getState().selections[TEST_SESSION_ID]?.modelId).toBe('rejected-model')
runtime.syncFromSessions([actual], useSessionRuntimeStore.getState().selections)
expect(useSessionRuntimeStore.getState().selections[TEST_SESSION_ID]?.modelId).toBe('k3')
})
it('shows AskUserQuestion when permission arrives before the streamed tool block', () => {
+5
View File
@@ -4855,6 +4855,7 @@ export const useChatStore = create<ChatStore>((setState, get) => {
selected?.modelId === msg.modelId &&
selected?.effortLevel === msg.effortLevel
if (matchesCurrentSelection) {
useSessionRuntimeStore.getState().settleSelection(sessionId)
update((session) => ({
runtimeConfigReadyCount: (session.runtimeConfigReadyCount ?? 0) + 1,
}))
@@ -5540,6 +5541,10 @@ export const useChatStore = create<ChatStore>((setState, get) => {
}
case 'error': {
if (msg.code === 'RUNTIME_CONFIG_INVALID' || msg.code === 'CLI_RESTART_FAILED') {
// Let a fresh server snapshot reconcile a rejected optimistic choice.
useSessionRuntimeStore.getState().settleSelection(sessionId)
}
const errorMessage: Extract<UIMessage, { type: 'error' }> = {
id: nextId(),
type: 'error',
@@ -14,6 +14,53 @@ describe('sessionRuntimeStore runtime cleanup', () => {
useSessionRuntimeStore.setState({ selections: {} })
})
it('keeps an explicit model choice through stale, matching, then stale metadata refreshes', () => {
const store = useSessionRuntimeStore.getState()
const oldSession = {
id: 'switch-session', runtimeProviderId: 'kimi', runtimeModelId: 'k3[1m]',
} as SessionListItem
store.syncFromSessions([oldSession])
const next = { providerId: 'deepseek', modelId: 'deepseek-v4-flash' }
store.setSelection(oldSession.id, next)
const startedWith = useSessionRuntimeStore.getState().selections
for (const metadata of [oldSession, {
...oldSession, runtimeProviderId: next.providerId, runtimeModelId: next.modelId,
}, oldSession]) {
store.syncFromSessions([metadata], startedWith)
expect(useSessionRuntimeStore.getState().selections[oldSession.id]).toEqual(next)
expect(JSON.parse(localStorage.getItem('cc-haha-session-runtime')!)[oldSession.id]).toEqual(next)
}
})
it('accepts later remote changes after confirmation but ignores pre-confirmation requests', () => {
const store = useSessionRuntimeStore.getState()
const next = { providerId: 'deepseek', modelId: 'deepseek-v4-flash' }
store.setSelection('confirmed', next)
const oldRequest = useSessionRuntimeStore.getState().selections
store.settleSelection('confirmed')
const remote = { id: 'confirmed', runtimeProviderId: 'kimi', runtimeModelId: 'k3' } as SessionListItem
store.syncFromSessions([remote], oldRequest)
expect(useSessionRuntimeStore.getState().selections.confirmed).toEqual(next)
store.syncFromSessions([remote], useSessionRuntimeStore.getState().selections)
expect(useSessionRuntimeStore.getState().selections.confirmed).toEqual({ providerId: 'kimi', modelId: 'k3' })
})
it('preserves a moved draft choice and releases local ownership when cleared', () => {
const store = useSessionRuntimeStore.getState()
const next = { providerId: 'deepseek', modelId: 'deepseek-v4-flash' }
const metadata = {
id: 'new-session', runtimeProviderId: 'kimi', runtimeModelId: 'k3',
} as SessionListItem
store.setSelection('__draft__', next)
store.moveSelection('__draft__', metadata.id)
store.syncFromSessions([metadata])
expect(useSessionRuntimeStore.getState().selections[metadata.id]).toEqual(next)
store.clearSelection(metadata.id)
store.syncFromSessions([metadata])
expect(useSessionRuntimeStore.getState().selections[metadata.id]).toEqual({ providerId: 'kimi', modelId: 'k3' })
})
it('discards retired Grok selections before persisting them', () => {
useSessionRuntimeStore.getState().setSelection('session-grok', {
providerId: 'grok-official',
+29 -10
View File
@@ -9,6 +9,11 @@ import {
import { normalizeRuntimeSelection } from '../lib/runtimeSelection'
const STORAGE_KEY = 'cc-haha-session-runtime'
// Session-list metadata can lag behind runtime changes or arrive out of order.
// Protect local choices until the server confirms them. Object identity also
// lets callers discard list responses started before a choice/confirmation.
// This transient state follows moveSelection without changing persisted JSON.
const pendingRuntimes = new WeakSet<RuntimeSelection>()
const RETIRED_GROK_MODEL_IDS = new Set([
'grok-build',
'grok-build-0.1',
@@ -24,7 +29,8 @@ type SessionRuntimeStore = {
setSelection: (key: string, selection: RuntimeSelection) => void
clearSelection: (key: string) => void
moveSelection: (fromKey: string, toKey: string) => void
syncFromSessions: (sessions: SessionListItem[]) => void
settleSelection: (key: string) => void
syncFromSessions: (sessions: SessionListItem[], startedWith?: Record<string, RuntimeSelection>) => void
}
function normalizeSelection(selection: RuntimeSelection): RuntimeSelection | null {
@@ -105,8 +111,10 @@ export const useSessionRuntimeStore = create<SessionRuntimeStore>((set) => ({
set((state) => {
const normalized = normalizeSelection(selection)
const selections = { ...state.selections }
if (normalized) selections[key] = normalized
else delete selections[key]
if (normalized) {
pendingRuntimes.add(normalized)
selections[key] = normalized
} else delete selections[key]
persistSelections(selections)
return { selections }
}),
@@ -132,28 +140,39 @@ export const useSessionRuntimeStore = create<SessionRuntimeStore>((set) => ({
return { selections }
}),
syncFromSessions: (sessions) =>
settleSelection: (key) =>
set((state) => {
const current = state.selections[key]
if (!current || !pendingRuntimes.has(current)) return state
// A new identity invalidates requests started before confirmation/failure.
return { selections: { ...state.selections, [key]: { ...current } } }
}),
syncFromSessions: (sessions, startedWith) =>
set((state) => {
let selections = state.selections
for (const session of sessions) {
const current = selections[session.id]
if (startedWith && startedWith[session.id] !== current) continue
if (!session.runtimeModelId || session.runtimeProviderId === undefined) continue
const selection = normalizeSelection({
providerId: session.runtimeProviderId,
modelId: session.runtimeModelId,
...(session.effortLevel ? { effortLevel: session.effortLevel } : {}),
})
const matchesCurrent = selection &&
current?.providerId === selection.providerId &&
current.modelId === selection.modelId &&
current.effortLevel === selection.effortLevel
const pending = current && pendingRuntimes.has(current)
if (pending && !matchesCurrent) continue
if (!selection) {
if (!(session.id in selections)) continue
if (selections === state.selections) selections = { ...state.selections }
delete selections[session.id]
continue
}
const current = selections[session.id]
if (
current?.providerId === selection.providerId &&
current.modelId === selection.modelId &&
current.effortLevel === selection.effortLevel
) {
if (matchesCurrent && !pending) {
continue
}
if (selections === state.selections) selections = { ...state.selections }
+25 -4
View File
@@ -220,11 +220,12 @@ describe('sessionStore', () => {
})
it('syncs transcript runtime metadata before a session is opened from the sidebar', async () => {
useSessionRuntimeStore.getState().setSelection('session-runtime-1', {
// Simulate a selection loaded from storage, not a choice made this run.
useSessionRuntimeStore.setState({ selections: { 'session-runtime-1': {
providerId: null,
modelId: 'gpt-5.4',
effortLevel: 'max',
})
} } })
listMock.mockResolvedValue({
sessions: [{
...makeSession('session-runtime-1', '2026-07-13T05:57:05.818Z'),
@@ -244,6 +245,26 @@ describe('sessionStore', () => {
})
})
it('does not roll back a model selected while a session-list request is in flight', async () => {
const response = {
sessions: [{
...makeSession('model-switch', '2026-09-20T00:00:00Z'),
runtimeProviderId: 'kimi', runtimeModelId: 'k3[1m]',
}],
total: 1,
}
const request = createDeferred<typeof response>()
listMock.mockReturnValueOnce(request.promise)
const refresh = useSessionStore.getState().fetchSessions()
const selection = { providerId: 'deepseek', modelId: 'deepseek-v4-flash' }
useSessionRuntimeStore.getState().setSelection('model-switch', selection)
request.resolve(response)
await refresh
expect(useSessionRuntimeStore.getState().selections['model-switch']).toEqual(selection)
expect(useSessionStore.getState().sessions[0]?.id).toBe('model-switch')
})
it('updates a session message count without changing other metadata', () => {
useSessionStore.setState({
sessions: [makeSession('session-count-1', '2026-05-07T00:00:00.000Z', 'Working session')],
@@ -270,10 +291,10 @@ describe('sessionStore', () => {
runtimeModelId: 'model-current',
effortLevel: 'high' as const,
}
useSessionRuntimeStore.getState().setSelection(historical.id, {
useSessionRuntimeStore.setState({ selections: { [historical.id]: {
providerId: 'provider-stale',
modelId: 'model-stale',
})
} } })
let observedAtActivation: unknown
const unsubscribe = useTabStore.subscribe((state) => {
if (state.activeTabId !== historical.id) return
+6 -4
View File
@@ -13,6 +13,7 @@ import { useSettingsStore } from './settingsStore'
import { useTabStore } from './tabStore'
import type { LocalIndexStatus, SessionListItem } from '../types/session'
import type { PermissionMode } from '../types/settings'
import type { RuntimeSelection } from '../types/runtime'
import { isPlaceholderSessionTitle } from '../lib/sessionTitle'
import { invalidateRecentProjectsCache } from '../lib/recentProjectsCache'
import { releaseWorkspaceSession } from '../lib/workspace/releaseSession'
@@ -56,7 +57,7 @@ type SessionStore = {
fetchSessions: (project?: string) => Promise<void>
loadMoreProjectSessions: (projectRoot: string) => Promise<void>
releaseProjectHistory: (projectRoot: string) => void
hydrateHistoricalSessions: (sessions: SessionListItem[]) => SessionListItem[]
hydrateHistoricalSessions: (sessions: SessionListItem[], runtimeSelections?: Record<string, RuntimeSelection>) => SessionListItem[]
openHistoricalSession: (session: SessionListItem) => void
createSession: (workDir?: string, options?: CreateSessionOptions) => Promise<string>
branchSession: (
@@ -106,13 +107,14 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
fetchSessions: async (project?: string) => {
const requestId = ++fetchSessionsRequestId
const runtimeSelections = useSessionRuntimeStore.getState().selections
set({ isLoading: true, error: null, sessionListRequestId: requestId })
try {
const response = await sessionsApi.list(buildSessionListParams(project))
if (requestId !== get().sessionListRequestId) return
const raw = response.sessions
const indexStatus = response.index ?? null
useSessionRuntimeStore.getState().syncFromSessions(raw)
useSessionRuntimeStore.getState().syncFromSessions(raw, runtimeSelections)
let syncedSessions: SessionListItem[] = []
set((state) => {
if (requestId !== state.sessionListRequestId) return state
@@ -267,13 +269,13 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
})
},
hydrateHistoricalSessions: (snapshots) => {
hydrateHistoricalSessions: (snapshots, runtimeSelections) => {
if (snapshots.length === 0) return []
const selected = reconcileSessionSnapshots(snapshots, get().sessions)
const selectedIds = new Set(selected.map((session) => session.id))
// Hydrate before activating the tab: connecting immediately applies its
// runtime selection and the composer reads workspace/permission metadata.
useSessionRuntimeStore.getState().syncFromSessions(selected)
useSessionRuntimeStore.getState().syncFromSessions(selected, runtimeSelections)
set((state) => ({
sessions: mergeSessionList([
...selected,
+20 -2
View File
@@ -51,6 +51,24 @@ describe('tabStore', () => {
vi.mocked(sessionsApi.getSummary).mockReset().mockRejectedValue(new ApiError(404, 'Session not found'))
})
it.each([false, true])('preserves a confirmed model switch during tab restoration (historical=%s)', async (historical) => {
const summary = historicalSummary()
localStorage.setItem('cc-haha-open-tabs', JSON.stringify({
openTabs: [{ sessionId: summary.id, title: summary.title }], activeTabId: summary.id,
}))
let resolveList!: (value: Awaited<ReturnType<typeof sessionsApi.list>>) => void
vi.mocked(sessionsApi.list).mockImplementationOnce(() => new Promise((resolve) => { resolveList = resolve }))
vi.mocked(sessionsApi.getSummary).mockResolvedValue(summary)
const restore = useTabStore.getState().restoreTabs()
const next = { providerId: 'deepseek', modelId: 'deepseek-v4-flash' }
useSessionRuntimeStore.getState().setSelection(summary.id, next)
useSessionRuntimeStore.getState().settleSelection(summary.id)
resolveList({ sessions: historical ? [] : [summary], total: historical ? 0 : 1 })
await restore
expect(useSessionRuntimeStore.getState().selections[summary.id]).toEqual(next)
expect(useTabStore.getState().activeTabId).toBe(summary.id)
})
it('refreshes an existing tab title when opening the same session again', () => {
useTabStore.getState().openTab('session-1', '```json {"title":')
useTabStore.getState().openTab('session-1', '使用bash写一个shell,随便写点什么东西')
@@ -473,11 +491,11 @@ describe('tabStore', () => {
)
it('hydrates restored tabs with authoritative transcript runtime metadata', async () => {
useSessionRuntimeStore.getState().setSelection('session-1', {
useSessionRuntimeStore.setState({ selections: { 'session-1': {
providerId: null,
modelId: 'gpt-5.4',
effortLevel: 'max',
})
} } })
localStorage.setItem('cc-haha-open-tabs', JSON.stringify({
openTabs: [{ sessionId: 'session-1', title: 'Runtime session', type: 'session' }],
activeTabId: 'session-1',
+3 -2
View File
@@ -430,6 +430,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
restoreTabs: async () => {
try {
const restoreStartedWith = get()
const runtimeSelections = useSessionRuntimeStore.getState().selections
const restoreStillCurrent = () => {
const current = get()
return current.tabs === restoreStartedWith.tabs &&
@@ -478,10 +479,10 @@ export const useTabStore = create<TabStore>((set, get) => ({
const recentSessions = reconcileSessionSnapshots(sessions, useSessionStore.getState().sessions)
for (const session of recentSessions) sessionsById.set(session.id, session)
if (historicalSessions.length > 0) {
const hydrated = useSessionStore.getState().hydrateHistoricalSessions(historicalSessions)
const hydrated = useSessionStore.getState().hydrateHistoricalSessions(historicalSessions, runtimeSelections)
for (const session of hydrated) sessionsById.set(session.id, session)
}
useSessionRuntimeStore.getState().syncFromSessions(recentSessions)
useSessionRuntimeStore.getState().syncFromSessions(recentSessions, runtimeSelections)
const validTabs: Tab[] = data.openTabs
.filter((t) => {