From 78d85f0c64aabd7fa1fd88bca22d9089b7980f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 2 Jul 2026 21:01:25 +0800 Subject: [PATCH] fix(desktop): apply active provider before first prompt (#844) Materialize the active provider runtime for empty-session first prompts before the WebSocket prewarm and user message are sent. This keeps a clean install that adds a MiniMax provider from starting the first turn without the selected provider runtime. Tested: bun test src/server/__tests__/conversations.test.ts -t "MiniMax provider turn" Tested: bun test src/server/__tests__/websocket-handler.test.ts src/server/__tests__/conversations.test.ts -t "prewarm|active provider id|MiniMax provider turn|first-turn runtime synchronization" Tested: cd desktop && bun run test -- --run src/pages/EmptySession.test.tsx -t "draft runtime|active provider runtime|creates a new session" Tested: cd desktop && bun run test -- --run src/components/controls/ModelSelector.test.tsx -t "defaults blank provider-scoped runtime selections|selects provider-scoped runtime models" Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Not-tested: bun run check:desktop, unrelated full-suite failures in MessageList timestamp formatting and TabBar AbortSignal cleanup blocked the gate. Confidence: medium Scope-risk: narrow --- .../src/components/controls/ModelSelector.tsx | 28 +---- desktop/src/lib/runtimeSelection.ts | 50 +++++++++ desktop/src/pages/EmptySession.test.tsx | 64 +++++++++++ desktop/src/pages/EmptySession.tsx | 25 ++++- src/server/__tests__/conversations.test.ts | 105 ++++++++++++++++++ 5 files changed, 242 insertions(+), 30 deletions(-) create mode 100644 desktop/src/lib/runtimeSelection.ts diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index 9823fe8c..ef5dd659 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -1,8 +1,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { OFFICIAL_DEFAULT_MODEL_ID, OFFICIAL_MODELS } from '../../constants/modelCatalog' +import { OFFICIAL_MODELS } from '../../constants/modelCatalog' import { - OPENAI_OFFICIAL_DEFAULT_MODEL_ID, OPENAI_OFFICIAL_MODELS, OPENAI_OFFICIAL_PROVIDER_ID, } from '../../constants/openaiOfficialProvider' @@ -16,6 +15,7 @@ import type { RuntimeSelection } from '../../types/runtime' import type { EffortLevel, ModelInfo } from '../../types/settings' import { useMobileViewport } from '../../hooks/useMobileViewport' import { isDesktopRuntime } from '../../lib/desktopRuntime' +import { resolveDefaultRuntimeSelection } from '../../lib/runtimeSelection' import { useHahaOAuthStore } from '../../stores/hahaOAuthStore' import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore' import { MobileBottomSheet } from '../shared/MobileBottomSheet' @@ -144,30 +144,6 @@ function buildProviderChoices( return choices } -function resolveDefaultRuntimeSelection( - activeId: string | null, - activeProviderName: string | null, - providers: SavedProvider[], - currentModelId: string | undefined, -): RuntimeSelection { - const activeProvider = activeId - ? providers.find((provider) => provider.id === activeId) - : activeProviderName - ? providers.find((provider) => provider.name === activeProviderName) - : undefined - const inferredProviderId = activeId ?? activeProvider?.id ?? null - const providerMainModelId = activeProvider?.models.main.trim() - - return { - providerId: inferredProviderId, - modelId: providerMainModelId || currentModelId || ( - inferredProviderId === OPENAI_OFFICIAL_PROVIDER_ID - ? OPENAI_OFFICIAL_DEFAULT_MODEL_ID - : OFFICIAL_DEFAULT_MODEL_ID - ), - } -} - export const ModelSelector = forwardRef(function ModelSelector({ value, onChange, diff --git a/desktop/src/lib/runtimeSelection.ts b/desktop/src/lib/runtimeSelection.ts new file mode 100644 index 00000000..fbf38552 --- /dev/null +++ b/desktop/src/lib/runtimeSelection.ts @@ -0,0 +1,50 @@ +import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog' +import { + OPENAI_OFFICIAL_DEFAULT_MODEL_ID, + OPENAI_OFFICIAL_PROVIDER_ID, +} from '../constants/openaiOfficialProvider' +import type { SavedProvider } from '../types/provider' +import type { RuntimeSelection } from '../types/runtime' + +export function resolveActiveProviderRuntimeSelection( + activeId: string | null, + activeProviderName: string | null, + providers: SavedProvider[], + currentModelId: string | undefined, +): RuntimeSelection | null { + const activeProvider = activeId + ? providers.find((provider) => provider.id === activeId) + : activeProviderName + ? providers.find((provider) => provider.name === activeProviderName) + : undefined + const inferredProviderId = activeId ?? activeProvider?.id ?? null + if (!inferredProviderId) return null + + const providerMainModelId = activeProvider?.models.main.trim() + + return { + providerId: inferredProviderId, + modelId: providerMainModelId || currentModelId || ( + inferredProviderId === OPENAI_OFFICIAL_PROVIDER_ID + ? OPENAI_OFFICIAL_DEFAULT_MODEL_ID + : OFFICIAL_DEFAULT_MODEL_ID + ), + } +} + +export function resolveDefaultRuntimeSelection( + activeId: string | null, + activeProviderName: string | null, + providers: SavedProvider[], + currentModelId: string | undefined, +): RuntimeSelection { + return resolveActiveProviderRuntimeSelection( + activeId, + activeProviderName, + providers, + currentModelId, + ) ?? { + providerId: null, + modelId: currentModelId || OFFICIAL_DEFAULT_MODEL_ID, + } +} diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index 1ed8f1d3..94fadadb 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -131,6 +131,7 @@ vi.mock('../components/controls/ModelSelector', async () => { import { EmptySession } from './EmptySession' import { ApiError } from '../api/client' import { useChatStore } from '../stores/chatStore' +import { useProviderStore } from '../stores/providerStore' import { useSessionRuntimeStore } from '../stores/sessionRuntimeStore' import { useSessionStore } from '../stores/sessionStore' import { useSettingsStore } from '../stores/settingsStore' @@ -186,6 +187,7 @@ describe('EmptySession', () => { const initialRuntimeState = useSessionRuntimeStore.getInitialState() const initialUiState = useUIStore.getInitialState() const initialPluginState = usePluginStore.getInitialState() + const initialProviderState = useProviderStore.getInitialState() beforeEach(() => { vi.clearAllMocks() @@ -199,6 +201,7 @@ describe('EmptySession', () => { useSessionRuntimeStore.setState(initialRuntimeState, true) useUIStore.setState(initialUiState, true) usePluginStore.setState(initialPluginState, true) + useProviderStore.setState(initialProviderState, true) mocks.createSession.mockResolvedValue({ sessionId: 'draft-session' }) mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext()) @@ -238,6 +241,7 @@ describe('EmptySession', () => { useSessionRuntimeStore.setState(initialRuntimeState, true) useUIStore.setState(initialUiState, true) usePluginStore.setState(initialPluginState, true) + useProviderStore.setState(initialProviderState, true) }) it('uses compact composer controls on phone-sized H5 browsers', async () => { @@ -527,6 +531,66 @@ describe('EmptySession', () => { ]) }) + it('materializes the active provider runtime before the first draft message', async () => { + useProviderStore.setState({ + providers: [{ + id: 'provider-minimax', + presetId: 'minimax', + name: 'MiniMax', + apiKey: 'sk-minimax', + baseUrl: 'https://api.minimaxi.com/anthropic', + apiFormat: 'anthropic', + runtimeKind: 'anthropic_compatible', + models: { + main: 'MiniMax-M3[1m]', + haiku: 'MiniMax-M3[1m]', + sonnet: 'MiniMax-M3[1m]', + opus: 'MiniMax-M3[1m]', + }, + toolSearchEnabled: true, + }], + activeId: 'provider-minimax', + providerOrder: ['provider-minimax', 'claude-official', 'openai-official'], + hasLoadedProviders: true, + }) + + render() + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'draft question', selectionStart: 14 }, + }) + + fireEvent.click(screen.getByRole('button', { name: /Run/i })) + + await waitFor(() => { + expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' }) + }) + + expect(useSessionRuntimeStore.getState().selections['draft-session']).toEqual({ + providerId: 'provider-minimax', + modelId: 'MiniMax-M3[1m]', + }) + expect(mocks.wsSend.mock.calls.slice(0, 3)).toEqual([ + [ + 'draft-session', + { + type: 'set_runtime_config', + providerId: 'provider-minimax', + modelId: 'MiniMax-M3[1m]', + }, + ], + ['draft-session', { type: 'prewarm_session' }], + [ + 'draft-session', + { + type: 'user_message', + content: 'draft question', + attachments: [], + }, + ], + ]) + }) + it('uses native desktop file paths for draft attachments', async () => { mocks.isTauriRuntime = true window.desktopHost = { diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index c87c2133..6ef96899 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -6,6 +6,7 @@ import { useTranslation } from '../i18n' import { useSessionStore } from '../stores/sessionStore' import { useChatStore } from '../stores/chatStore' import { usePluginStore } from '../stores/pluginStore' +import { useProviderStore } from '../stores/providerStore' import { useSessionRuntimeStore, DRAFT_RUNTIME_SELECTION_KEY } from '../stores/sessionRuntimeStore' import { useSettingsStore } from '../stores/settingsStore' import { useUIStore } from '../stores/uiStore' @@ -21,6 +22,7 @@ import { LocalSlashCommandPanel, type LocalSlashCommandName } from '../component import { useMobileViewport } from '../hooks/useMobileViewport' import { isDesktopRuntime } from '../lib/desktopRuntime' import { publicAssetPath } from '../lib/publicAsset' +import { resolveActiveProviderRuntimeSelection } from '../lib/runtimeSelection' import { filesToComposerAttachments, selectNativeFileAttachments, @@ -116,8 +118,11 @@ export function EmptySession() { const setActiveView = useUIStore((state) => state.setActiveView) const addToast = useUIStore((state) => state.addToast) const currentModel = useSettingsStore((state) => state.currentModel) + const activeProviderName = useSettingsStore((state) => state.activeProviderName) const chatSendBehavior = useSettingsStore((state) => state.chatSendBehavior) const defaultPermissionMode = useSettingsStore((state) => state.permissionMode) + const providers = useProviderStore((state) => state.providers) + const activeProviderId = useProviderStore((state) => state.activeId) const [draftPermissionMode, setDraftPermissionMode] = useState(defaultPermissionMode) const lastPluginReloadSummary = usePluginStore((state) => state.lastReloadSummary) const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY]) @@ -315,7 +320,17 @@ export function EmptySession() { setIsSubmitting(true) try { - const explicitDraftSelection = useSessionRuntimeStore.getState().selections[DRAFT_RUNTIME_SELECTION_KEY] + const runtimeStore = useSessionRuntimeStore.getState() + const explicitDraftSelection = runtimeStore.selections[DRAFT_RUNTIME_SELECTION_KEY] + const defaultActiveProviderSelection = explicitDraftSelection + ? null + : resolveActiveProviderRuntimeSelection( + activeProviderId, + activeProviderName, + providers, + currentModel?.id, + ) + const runtimeSelection = explicitDraftSelection ?? defaultActiveProviderSelection ?? undefined const sessionId = await createSession( workDir || undefined, { @@ -325,9 +340,11 @@ export function EmptySession() { permissionMode: draftPermissionMode, }, ) - if (explicitDraftSelection) { - useSessionRuntimeStore.getState().setSelection(sessionId, explicitDraftSelection) - useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY) + if (runtimeSelection) { + runtimeStore.setSelection(sessionId, runtimeSelection) + if (explicitDraftSelection) { + runtimeStore.clearSelection(DRAFT_RUNTIME_SELECTION_KEY) + } } setActiveView('code') useTabStore.getState().openTab(sessionId, 'New Session') diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 213c3a03..b2bc897b 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -2192,6 +2192,111 @@ describe('WebSocket Chat Integration', () => { } }, 20_000) + it('does not strand the first MiniMax provider turn when prewarm and user message flush together (#844)', async () => { + const providerService = new ProviderService() + const provider = await providerService.addProvider({ + presetId: 'minimax', + name: 'MiniMax first-turn race', + apiKey: 'key-minimax-first-turn-race', + baseUrl: 'https://api.minimaxi.com/anthropic', + apiFormat: 'anthropic', + models: { + main: 'MiniMax-M3', + haiku: 'MiniMax-M3', + sonnet: 'MiniMax-M3', + opus: 'MiniMax-M3', + }, + model1mSupport: { + main: true, + haiku: true, + sonnet: true, + opus: true, + }, + }) + await providerService.activateProvider(provider.id) + + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir: process.cwd() }), + }) + expect(createRes.status).toBe(201) + const { sessionId } = await createRes.json() as { sessionId: string } + + const originalStartSession = conversationService.startSession.bind(conversationService) + const startCalls: Array<{ + sessionId: string + options: { providerId?: string | null } | undefined + }> = [] + + conversationService.startSession = (async function patchedStartSession( + sid: string, + workDir: string, + sdkUrl: string, + options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null }, + ) { + startCalls.push({ sessionId: sid, options }) + return originalStartSession(sid, workDir, sdkUrl, options) + }) as typeof conversationService.startSession + + const messages: any[] = [] + const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close() + reject(new Error(`Timed out waiting for first MiniMax provider turn for session ${sessionId}`)) + }, 10_000) + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data as string) + messages.push(msg) + + if (msg.type === 'connected') { + ws.send(JSON.stringify({ type: 'prewarm_session' })) + ws.send(JSON.stringify({ type: 'user_message', content: 'first turn without provider test' })) + return + } + + if (msg.type === 'error') { + clearTimeout(timeout) + ws.close() + reject(new Error(msg.message)) + return + } + + if (msg.type === 'message_complete') { + clearTimeout(timeout) + ws.close() + resolve() + } + } + + ws.onerror = () => { + clearTimeout(timeout) + ws.close() + reject(new Error(`WebSocket error for first MiniMax provider turn ${sessionId}`)) + } + }) + + expect(startCalls).toHaveLength(1) + expect(startCalls[0]).toMatchObject({ + sessionId, + options: { + providerId: provider.id, + }, + }) + expect(messages.some((msg) => msg.type === 'content_delta')).toBe(true) + expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true) + expect(messages.some((msg) => msg.type === 'error')).toBe(false) + } finally { + ws.close() + conversationService.startSession = originalStartSession + conversationService.stopSession(sessionId) + } + }, 20_000) + it('should pass the active provider id into default desktop sessions', async () => { const providerService = new ProviderService() const provider = await providerService.addProvider({