mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
2e52133a64
commit
78d85f0c64
@ -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,
|
||||
|
||||
50
desktop/src/lib/runtimeSelection.ts
Normal file
50
desktop/src/lib/runtimeSelection.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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 = {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user