diff --git a/desktop/src/components/chat/PermissionDialog.tsx b/desktop/src/components/chat/PermissionDialog.tsx index 93f0fb09..cbb72a88 100644 --- a/desktop/src/components/chat/PermissionDialog.tsx +++ b/desktop/src/components/chat/PermissionDialog.tsx @@ -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, diff --git a/desktop/src/components/chat/PlanModePermissionDialog.test.tsx b/desktop/src/components/chat/PlanModePermissionDialog.test.tsx index a23f5b1a..c61fb119 100644 --- a/desktop/src/components/chat/PlanModePermissionDialog.test.tsx +++ b/desktop/src/components/chat/PlanModePermissionDialog.test.tsx @@ -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 () => { diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 63a4efbd..1fba0bf3 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -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', () => { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index abf09421..45779dd0 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -4855,6 +4855,7 @@ export const useChatStore = create((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((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 = { id: nextId(), type: 'error', diff --git a/desktop/src/stores/sessionRuntimeStore.test.ts b/desktop/src/stores/sessionRuntimeStore.test.ts index 241512d1..84b42d84 100644 --- a/desktop/src/stores/sessionRuntimeStore.test.ts +++ b/desktop/src/stores/sessionRuntimeStore.test.ts @@ -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', diff --git a/desktop/src/stores/sessionRuntimeStore.ts b/desktop/src/stores/sessionRuntimeStore.ts index a1850c89..416540f7 100644 --- a/desktop/src/stores/sessionRuntimeStore.ts +++ b/desktop/src/stores/sessionRuntimeStore.ts @@ -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() 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) => void } function normalizeSelection(selection: RuntimeSelection): RuntimeSelection | null { @@ -105,8 +111,10 @@ export const useSessionRuntimeStore = create((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((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 } diff --git a/desktop/src/stores/sessionStore.test.ts b/desktop/src/stores/sessionStore.test.ts index 662df59b..15c6c7f1 100644 --- a/desktop/src/stores/sessionStore.test.ts +++ b/desktop/src/stores/sessionStore.test.ts @@ -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() + 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 diff --git a/desktop/src/stores/sessionStore.ts b/desktop/src/stores/sessionStore.ts index cce893e9..2319603f 100644 --- a/desktop/src/stores/sessionStore.ts +++ b/desktop/src/stores/sessionStore.ts @@ -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 loadMoreProjectSessions: (projectRoot: string) => Promise releaseProjectHistory: (projectRoot: string) => void - hydrateHistoricalSessions: (sessions: SessionListItem[]) => SessionListItem[] + hydrateHistoricalSessions: (sessions: SessionListItem[], runtimeSelections?: Record) => SessionListItem[] openHistoricalSession: (session: SessionListItem) => void createSession: (workDir?: string, options?: CreateSessionOptions) => Promise branchSession: ( @@ -106,13 +107,14 @@ export const useSessionStore = create((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((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, diff --git a/desktop/src/stores/tabStore.test.ts b/desktop/src/stores/tabStore.test.ts index d99cad62..187e7604 100644 --- a/desktop/src/stores/tabStore.test.ts +++ b/desktop/src/stores/tabStore.test.ts @@ -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>) => 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', diff --git a/desktop/src/stores/tabStore.ts b/desktop/src/stores/tabStore.ts index c6184270..56d720be 100644 --- a/desktop/src/stores/tabStore.ts +++ b/desktop/src/stores/tabStore.ts @@ -430,6 +430,7 @@ export const useTabStore = create((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((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) => {