mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-29 16:03:34 +08:00
Preserve explicit desktop runtime selection
Desktop sessions can prewarm before the user-selected runtime reaches the server, and an empty-session draft could previously persist an inferred default runtime as if the user had explicitly selected it. The fix keeps new-session defaults implicit while preserving explicit draft selections, and waits for queued runtime restarts before the first turn is delivered to the CLI process. Constraint: Runtime selection is session-scoped and may arrive while prewarm or first-turn startup is already in flight Rejected: Always persist the current default provider/model into new sessions | this masks whether the user actually selected a runtime and can launch stale providers Confidence: high Scope-risk: moderate Directive: Do not send user turns across pending runtime restarts without re-checking the session runtime override Tested: bun test src/server/__tests__/conversations.test.ts Tested: cd desktop && bun run test -- src/pages/EmptySession.test.tsx Tested: git diff --check
This commit is contained in:
parent
12f6cb1b63
commit
a31b3045fd
@ -197,6 +197,43 @@ describe('EmptySession', () => {
|
|||||||
attachments: [],
|
attachments: [],
|
||||||
})
|
})
|
||||||
expect(mocks.wsConnect).toHaveBeenCalledWith('draft-session')
|
expect(mocks.wsConnect).toHaveBeenCalledWith('draft-session')
|
||||||
|
expect(useSessionRuntimeStore.getState().selections['draft-session']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores and replays a draft runtime only when the user explicitly selected one', async () => {
|
||||||
|
useSessionRuntimeStore.getState().setSelection('__draft__', {
|
||||||
|
providerId: 'provider-explicit',
|
||||||
|
modelId: 'model-explicit',
|
||||||
|
})
|
||||||
|
|
||||||
|
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({})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(useSessionRuntimeStore.getState().selections['draft-session']).toEqual({
|
||||||
|
providerId: 'provider-explicit',
|
||||||
|
modelId: 'model-explicit',
|
||||||
|
})
|
||||||
|
expect(useSessionRuntimeStore.getState().selections['__draft__']).toBeUndefined()
|
||||||
|
expect(mocks.wsSend.mock.calls.slice(0, 2)).toEqual([
|
||||||
|
[
|
||||||
|
'draft-session',
|
||||||
|
{
|
||||||
|
type: 'set_runtime_config',
|
||||||
|
providerId: 'provider-explicit',
|
||||||
|
modelId: 'model-explicit',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
['draft-session', { type: 'prewarm_session' }],
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows an actionable repository error when direct branch switching is blocked', async () => {
|
it('shows an actionable repository error when direct branch switching is blocked', async () => {
|
||||||
|
|||||||
@ -4,12 +4,10 @@ import { skillsApi } from '../api/skills'
|
|||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
import { useSessionStore } from '../stores/sessionStore'
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import { useProviderStore } from '../stores/providerStore'
|
|
||||||
import { useSessionRuntimeStore, DRAFT_RUNTIME_SELECTION_KEY } from '../stores/sessionRuntimeStore'
|
import { useSessionRuntimeStore, DRAFT_RUNTIME_SELECTION_KEY } from '../stores/sessionRuntimeStore'
|
||||||
import { useSettingsStore } from '../stores/settingsStore'
|
import { useSettingsStore } from '../stores/settingsStore'
|
||||||
import { useUIStore } from '../stores/uiStore'
|
import { useUIStore } from '../stores/uiStore'
|
||||||
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
|
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
|
||||||
import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog'
|
|
||||||
import { RepositoryLaunchControls } from '../components/shared/RepositoryLaunchControls'
|
import { RepositoryLaunchControls } from '../components/shared/RepositoryLaunchControls'
|
||||||
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
|
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
|
||||||
import { ModelSelector } from '../components/controls/ModelSelector'
|
import { ModelSelector } from '../components/controls/ModelSelector'
|
||||||
@ -207,31 +205,6 @@ export function EmptySession() {
|
|||||||
[slashCommands],
|
[slashCommands],
|
||||||
)
|
)
|
||||||
|
|
||||||
const resolveDraftRuntimeSelection = async () => {
|
|
||||||
const settings = useSettingsStore.getState()
|
|
||||||
let providerState = useProviderStore.getState()
|
|
||||||
if (
|
|
||||||
settings.activeProviderName &&
|
|
||||||
providerState.providers.length === 0 &&
|
|
||||||
!providerState.isLoading
|
|
||||||
) {
|
|
||||||
await providerState.fetchProviders()
|
|
||||||
providerState = useProviderStore.getState()
|
|
||||||
}
|
|
||||||
const inferredProviderId = providerState.activeId ?? (
|
|
||||||
settings.activeProviderName
|
|
||||||
? providerState.providers.find((provider) => provider.name === settings.activeProviderName)?.id ?? null
|
|
||||||
: null
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
useSessionRuntimeStore.getState().selections[DRAFT_RUNTIME_SELECTION_KEY]
|
|
||||||
?? {
|
|
||||||
providerId: inferredProviderId,
|
|
||||||
modelId: settings.currentModel?.id ?? OFFICIAL_DEFAULT_MODEL_ID,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleWorkDirChange = (newWorkDir: string) => {
|
const handleWorkDirChange = (newWorkDir: string) => {
|
||||||
setWorkDir(newWorkDir)
|
setWorkDir(newWorkDir)
|
||||||
setSelectedBranch(null)
|
setSelectedBranch(null)
|
||||||
@ -297,15 +270,17 @@ export function EmptySession() {
|
|||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const draftSelection = await resolveDraftRuntimeSelection()
|
const explicitDraftSelection = useSessionRuntimeStore.getState().selections[DRAFT_RUNTIME_SELECTION_KEY]
|
||||||
const sessionId = await createSession(
|
const sessionId = await createSession(
|
||||||
workDir || undefined,
|
workDir || undefined,
|
||||||
selectedBranch
|
selectedBranch
|
||||||
? { repository: { branch: selectedBranch, worktree: useWorktree } }
|
? { repository: { branch: selectedBranch, worktree: useWorktree } }
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection)
|
if (explicitDraftSelection) {
|
||||||
useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY)
|
useSessionRuntimeStore.getState().setSelection(sessionId, explicitDraftSelection)
|
||||||
|
useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY)
|
||||||
|
}
|
||||||
setActiveView('code')
|
setActiveView('code')
|
||||||
useTabStore.getState().openTab(sessionId, 'New Session')
|
useTabStore.getState().openTab(sessionId, 'New Session')
|
||||||
connectToSession(sessionId)
|
connectToSession(sessionId)
|
||||||
|
|||||||
@ -1726,6 +1726,136 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
}
|
}
|
||||||
}, 20_000)
|
}, 20_000)
|
||||||
|
|
||||||
|
it('should wait for a runtime restart queued during first-turn startup before sending that turn', async () => {
|
||||||
|
const providerService = new ProviderService()
|
||||||
|
const provider = await providerService.addProvider({
|
||||||
|
presetId: 'custom',
|
||||||
|
name: 'Provider First Turn Runtime',
|
||||||
|
apiKey: 'key-first-turn-runtime',
|
||||||
|
baseUrl: 'http://127.0.0.1:1/anthropic',
|
||||||
|
apiFormat: 'anthropic',
|
||||||
|
models: {
|
||||||
|
main: 'first-turn-main',
|
||||||
|
haiku: 'first-turn-haiku',
|
||||||
|
sonnet: 'first-turn-sonnet',
|
||||||
|
opus: 'first-turn-opus',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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 originalSendMessage = conversationService.sendMessage.bind(conversationService)
|
||||||
|
const startCalls: Array<{
|
||||||
|
sessionId: string
|
||||||
|
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
|
||||||
|
}> = []
|
||||||
|
const sendCalls: Array<{ content: string; startCallCount: number }> = []
|
||||||
|
let releaseFirstStart!: () => void
|
||||||
|
const firstStartGate = new Promise<void>((resolve) => {
|
||||||
|
releaseFirstStart = resolve
|
||||||
|
})
|
||||||
|
let markFirstStart!: () => void
|
||||||
|
const firstStartEntered = new Promise<void>((resolve) => {
|
||||||
|
markFirstStart = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
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 })
|
||||||
|
if (startCalls.length === 1) {
|
||||||
|
markFirstStart()
|
||||||
|
await firstStartGate
|
||||||
|
}
|
||||||
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
||||||
|
}) as typeof conversationService.startSession
|
||||||
|
|
||||||
|
conversationService.sendMessage = (function patchedSendMessage(
|
||||||
|
sid: string,
|
||||||
|
content: string,
|
||||||
|
attachments?: any,
|
||||||
|
) {
|
||||||
|
sendCalls.push({ content, startCallCount: startCalls.length })
|
||||||
|
return originalSendMessage(sid, content, attachments)
|
||||||
|
}) as typeof conversationService.sendMessage
|
||||||
|
|
||||||
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
||||||
|
const messages: any[] = []
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error(`Timed out waiting for first-turn runtime synchronization for session ${sessionId}`))
|
||||||
|
}, 15_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' }))
|
||||||
|
void firstStartEntered.then(() => {
|
||||||
|
ws.send(JSON.stringify({ type: 'user_message', content: 'first turn while runtime changes' }))
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'set_runtime_config',
|
||||||
|
providerId: provider.id,
|
||||||
|
modelId: 'first-turn-sonnet',
|
||||||
|
}))
|
||||||
|
releaseFirstStart()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
reject(new Error(msg.message))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'message_complete') {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
reject(new Error(`WebSocket error for first-turn runtime synchronization session ${sessionId}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(startCalls).toHaveLength(2)
|
||||||
|
expect(startCalls[0]).toMatchObject({ sessionId })
|
||||||
|
expect(startCalls[0]?.options?.providerId).toBeNull()
|
||||||
|
expect(startCalls[1]).toMatchObject({
|
||||||
|
sessionId,
|
||||||
|
options: {
|
||||||
|
providerId: provider.id,
|
||||||
|
model: 'first-turn-sonnet',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(sendCalls).toEqual([{
|
||||||
|
content: 'first turn while runtime changes',
|
||||||
|
startCallCount: 2,
|
||||||
|
}])
|
||||||
|
expect(messages.some((msg) => msg.type === 'content_delta')).toBe(true)
|
||||||
|
} finally {
|
||||||
|
ws.close()
|
||||||
|
conversationService.startSession = originalStartSession
|
||||||
|
conversationService.sendMessage = originalSendMessage
|
||||||
|
conversationService.stopSession(sessionId)
|
||||||
|
}
|
||||||
|
}, 20_000)
|
||||||
|
|
||||||
it('should keep the session idle in the UI while applying a runtime-only model switch', async () => {
|
it('should keep the session idle in the UI while applying a runtime-only model switch', async () => {
|
||||||
const providerService = new ProviderService()
|
const providerService = new ProviderService()
|
||||||
const provider = await providerService.addProvider({
|
const provider = await providerService.addProvider({
|
||||||
|
|||||||
@ -252,28 +252,10 @@ async function handleUserMessage(
|
|||||||
// Send thinking status
|
// Send thinking status
|
||||||
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
|
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
|
||||||
|
|
||||||
const pendingRuntimeTransition = runtimeTransitionPromises.get(sessionId)
|
const initialRuntimeTransition = await waitForRuntimeTransitionBeforeUserTurn(ws, sessionId)
|
||||||
if (pendingRuntimeTransition) {
|
if (!initialRuntimeTransition.ok) return
|
||||||
try {
|
if (initialRuntimeTransition.waited) {
|
||||||
await pendingRuntimeTransition
|
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
|
||||||
} catch (err) {
|
|
||||||
const errMsg = err instanceof Error ? err.message : String(err)
|
|
||||||
void diagnosticsService.recordEvent({
|
|
||||||
type: 'runtime_transition_failed',
|
|
||||||
severity: 'error',
|
|
||||||
sessionId,
|
|
||||||
summary: errMsg,
|
|
||||||
details: err,
|
|
||||||
})
|
|
||||||
console.error(`[WS] Runtime transition failed before handling user message for ${sessionId}: ${errMsg}`)
|
|
||||||
sendMessage(ws, {
|
|
||||||
type: 'error',
|
|
||||||
message: `Failed to switch provider/model: ${errMsg}`,
|
|
||||||
code: 'CLI_RESTART_FAILED',
|
|
||||||
})
|
|
||||||
sendMessage(ws, { type: 'status', state: 'idle' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track and emit the first placeholder title before CLI startup/streaming.
|
// Track and emit the first placeholder title before CLI startup/streaming.
|
||||||
@ -314,6 +296,15 @@ async function handleUserMessage(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startupRuntimeTransition = await waitForRuntimeTransitionBeforeUserTurn(ws, sessionId)
|
||||||
|
if (startupRuntimeTransition.ok) {
|
||||||
|
if (startupRuntimeTransition.waited) {
|
||||||
|
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Register the callback before sending the turn so startup errors are not lost.
|
// Register the callback before sending the turn so startup errors are not lost.
|
||||||
// Keep output muted until the current user turn is enqueued to avoid forwarding
|
// Keep output muted until the current user turn is enqueued to avoid forwarding
|
||||||
// any pre-turn SDK chatter as fresh chat history.
|
// any pre-turn SDK chatter as fresh chat history.
|
||||||
@ -1483,6 +1474,45 @@ function enqueueRuntimeTransition(
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForRuntimeTransitionBeforeUserTurn(
|
||||||
|
ws: ServerWebSocket<WebSocketData>,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<{ ok: boolean; waited: boolean }> {
|
||||||
|
let waited = false
|
||||||
|
let pendingRuntimeTransition = runtimeTransitionPromises.get(sessionId)
|
||||||
|
while (pendingRuntimeTransition) {
|
||||||
|
waited = true
|
||||||
|
try {
|
||||||
|
await pendingRuntimeTransition
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err)
|
||||||
|
void diagnosticsService.recordEvent({
|
||||||
|
type: 'runtime_transition_failed',
|
||||||
|
severity: 'error',
|
||||||
|
sessionId,
|
||||||
|
summary: errMsg,
|
||||||
|
details: err,
|
||||||
|
})
|
||||||
|
console.error(`[WS] Runtime transition failed before handling user message for ${sessionId}: ${errMsg}`)
|
||||||
|
sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
message: `Failed to switch provider/model: ${errMsg}`,
|
||||||
|
code: 'CLI_RESTART_FAILED',
|
||||||
|
})
|
||||||
|
sendMessage(ws, { type: 'status', state: 'idle' })
|
||||||
|
return { ok: false, waited }
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTransition = runtimeTransitionPromises.get(sessionId)
|
||||||
|
pendingRuntimeTransition =
|
||||||
|
nextTransition && nextTransition !== pendingRuntimeTransition
|
||||||
|
? nextTransition
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, waited }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message to a specific session's WebSocket (for use by services)
|
* Send a message to a specific session's WebSocket (for use by services)
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user