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
This commit is contained in:
程序员阿江(Relakkes) 2026-07-02 21:01:25 +08:00
parent 2e52133a64
commit 78d85f0c64
5 changed files with 242 additions and 30 deletions

View File

@ -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<ModelSelectorHandle, Props>(function ModelSelector({
value,
onChange,

View File

@ -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,
}
}

View File

@ -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(<EmptySession />)
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 = {

View File

@ -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<PermissionMode>(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')

View File

@ -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<void>((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({