From 79ce28ae3bbc4313f3bd3dd5d9bde8ce9a15bcbf Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 6 Jun 2026 07:50:24 +0100 Subject: [PATCH] Snapshot Assistant queued model routes --- .../v6/internal/subsystems/ai-runtime.md | 22 +++++-- .../AI/Chat/__tests__/AIChat.test.tsx | 9 ++- .../AI/Chat/__tests__/useChat.test.ts | 52 +++++++++++++++ .../src/components/AI/Chat/hooks/useChat.ts | 66 +++++++++++++++---- .../src/components/AI/Chat/index.tsx | 9 ++- .../src/components/AI/Chat/types.ts | 1 + 6 files changed, 137 insertions(+), 22 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 5ed5a5732..024cbe895 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -113,12 +113,22 @@ runtime cost control, and shared AI transport surfaces. queued user turn without aborting or replacing the active model stream, must show an itemized composer-adjacent queue with per-follow-up edit/remove controls plus clear-all, and must drain queued turns in order only after the - active stream becomes idle. Stop is the - explicit interruption path: it must abort the active stream, clear queued - follow-ups and pending tool/approval/question affordances, preserve any - partial model text, return focus to the composer, and render a neutral - transcript marker rather than persisting synthetic assistant answer text or - surfacing the interruption as a retryable provider failure. + active stream becomes idle. Queued follow-ups must snapshot the effective + model route at enqueue time so a later model/provider switch cannot silently + reroute an already-queued user turn; explicit failed-turn recovery actions + such as `Retry via OpenRouter` must pass their selected route as an override + instead of relying on ambient selector state. The referenced OpenCode source + at fetched `origin/dev` commit + `1399323b78a04229d9bfe00c7436d7f41770fda8` keeps current, recent, and + selected models as structured `{ providerID, modelID }` values in + `packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx` and + `packages/opencode/src/provider/provider.ts`, so Pulse must preserve the + equivalent route identity for queued and retried turns. Stop is the explicit + interruption path: it must abort the active stream, clear queued follow-ups + and pending tool/approval/question affordances, preserve any partial model + text, return focus to the composer, and render a neutral transcript marker + rather than persisting synthetic assistant answer text or surfacing the + interruption as a retryable provider failure. Composer prompt history is also drawer-local chat-runtime state: the drawer may persist a bounded local history of submitted prompt text and structured mentions for ArrowUp/ArrowDown recall, but that history must not persist or diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index 3a32d4332..e6d6e68d3 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -533,7 +533,9 @@ describe('AIChat', () => { fireEvent.click(await screen.findByTestId('mock-use-model-route')); expect(mockChat.setModel).toHaveBeenCalledWith('openrouter:deepseek/deepseek-v4-pro'); - expect(mockChat.retryMessage).toHaveBeenCalledWith('assistant-error-1'); + expect(mockChat.retryMessage).toHaveBeenCalledWith('assistant-error-1', { + model: 'openrouter:deepseek/deepseek-v4-pro', + }); expect(document.activeElement).toBe( screen.getByPlaceholderText('Ask about your infrastructure...'), ); @@ -609,7 +611,9 @@ describe('AIChat', () => { props.onUseModelRoute?.(alternative!.id, deepSeekFailure.id); expect(mockChat.setModel).toHaveBeenCalledWith('openai:gpt-4o'); - expect(mockChat.retryMessage).toHaveBeenCalledWith('assistant-error-deepseek'); + expect(mockChat.retryMessage).toHaveBeenCalledWith('assistant-error-deepseek', { + model: 'openai:gpt-4o', + }); }); it('checks the selected provider and shows a readiness issue before the first send', async () => { @@ -902,6 +906,7 @@ describe('AIChat', () => { 'summarize the cluster', undefined, undefined, + { model: 'openrouter:deepseek/deepseek-v4-pro' }, ); expect(mockChat.setModel.mock.invocationCallOrder[0]).toBeLessThan( mockChat.sendMessage.mock.invocationCallOrder[0], diff --git a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts index e9f8a420c..96250a9b2 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -400,6 +400,26 @@ describe('useChat', () => { dispose(); }); + it('retryMessage can override the original turn model route', async () => { + mockChat.mockRejectedValueOnce(new Error('server error')).mockResolvedValueOnce(undefined); + const { value: chat, dispose } = withRoot(() => + useChat({ sessionId: 'sess', model: 'deepseek:deepseek-v4-pro' }), + ); + + await chat.sendMessage('check provider'); + + const failed = chat.messages().find((m) => m.role === 'assistant'); + expect(failed?.error).toContain('server error'); + + chat.retryMessage(failed!.id, { model: 'openrouter:deepseek/deepseek-v4-pro' }); + await new Promise((r) => setTimeout(r, 0)); + + expect(mockChat).toHaveBeenCalledTimes(2); + expect(mockChat.mock.calls[0][2]).toBe('deepseek:deepseek-v4-pro'); + expect(mockChat.mock.calls[1][2]).toBe('openrouter:deepseek/deepseek-v4-pro'); + dispose(); + }); + it('handles AbortError silently (returns false, no notification)', async () => { const abortError = new Error('Aborted'); abortError.name = 'AbortError'; @@ -555,6 +575,38 @@ describe('useChat', () => { dispose(); }); + it('snapshots the selected model for queued follow-ups', async () => { + const resolvers: Array<() => void> = []; + mockChat.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + + const { value: chat, dispose } = withRoot(() => + useChat({ sessionId: 'sess', model: 'openrouter:qwen/qwen3.7-plus' }), + ); + const first = chat.sendMessage('first'); + await new Promise((r) => setTimeout(r, 0)); + + chat.setModel('openrouter:deepseek/deepseek-v4-pro'); + await chat.sendMessage('second'); + + const queuedUser = chat.messages().find((message) => message.content === 'second'); + expect(queuedUser?.request?.model).toBe('openrouter:deepseek/deepseek-v4-pro'); + + chat.setModel('gemini:gemini-3.1-flash-lite'); + resolvers[0](); + await first; + await new Promise((r) => setTimeout(r, 0)); + + expect(mockChat).toHaveBeenCalledTimes(2); + expect(mockChat.mock.calls[1][0]).toBe('second'); + expect(mockChat.mock.calls[1][2]).toBe('openrouter:deepseek/deepseek-v4-pro'); + dispose(); + }); + it('preserves queued follow-up order and drains one turn at a time', async () => { const resolvers: Array<() => void> = []; mockChat.mockImplementation( diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 815713281..8ffe5217c 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -46,6 +46,7 @@ export interface UseChatOptions { } export interface SendMessageOptions { + model?: string; autonomousMode?: boolean; handoffContext?: string; handoffResources?: ChatHandoffResource[]; @@ -71,7 +72,9 @@ export function useChat(options: UseChatOptions = {}) { const [model, setModel] = createSignal(options.model || ''); const [queuedFollowUps, setQueuedFollowUps] = createSignal([]); - const effectiveModelRoute = () => { + const effectiveModelRoute = (sendOptions?: Pick) => { + const explicitModel = sendOptions?.model?.trim(); + if (explicitModel) return explicitModel; const selected = model().trim(); if (selected) return selected; return options.defaultModel?.().trim() || ''; @@ -526,6 +529,10 @@ export function useChat(options: UseChatOptions = {}) { if (!sendOptions) return undefined; const requestContext: ChatMessageRequestContext = {}; + const modelRoute = sendOptions.model?.trim(); + if (modelRoute) { + requestContext.model = modelRoute; + } if (typeof sendOptions.autonomousMode === 'boolean') { requestContext.autonomousMode = sendOptions.autonomousMode; } @@ -547,6 +554,31 @@ export function useChat(options: UseChatOptions = {}) { return Object.keys(requestContext).length > 0 ? requestContext : undefined; }; + const snapshotSendOptions = (sendOptions?: SendMessageOptions): SendMessageOptions | undefined => { + const modelRoute = effectiveModelRoute(sendOptions); + const next: SendMessageOptions = {}; + if (modelRoute) { + next.model = modelRoute; + } + if (typeof sendOptions?.autonomousMode === 'boolean') { + next.autonomousMode = sendOptions.autonomousMode; + } + if (sendOptions?.handoffContext) { + next.handoffContext = sendOptions.handoffContext; + } + if (sendOptions?.handoffResources?.length) { + next.handoffResources = sendOptions.handoffResources.map((resource) => ({ ...resource })); + } + if (sendOptions?.handoffActions?.length) { + next.handoffActions = sendOptions.handoffActions.map((action) => ({ ...action })); + } + if (sendOptions?.handoffMetadata) { + next.handoffMetadata = { ...sendOptions.handoffMetadata }; + } + + return Object.keys(next).length > 0 ? next : undefined; + }; + const buildRequestContext = ( mentions?: ChatMention[], findingId?: string, @@ -572,6 +604,9 @@ export function useChat(options: UseChatOptions = {}) { if (!request) return undefined; const sendOptions: SendMessageOptions = {}; + if (request.model) { + sendOptions.model = request.model; + } if (typeof request.autonomousMode === 'boolean') { sendOptions.autonomousMode = request.autonomousMode; } @@ -1094,6 +1129,7 @@ export function useChat(options: UseChatOptions = {}) { const id = generateId(); const messageId = generateId(); const timestamp = new Date(); + const queuedSendOptions = snapshotSendOptions(sendOptions); const queuedUserMessage: ChatMessage = { id: messageId, @@ -1101,7 +1137,7 @@ export function useChat(options: UseChatOptions = {}) { content: trimmedPrompt, timestamp, delivery: 'queued', - request: buildRequestContext(mentions, findingId, sendOptions), + request: buildRequestContext(mentions, findingId, queuedSendOptions), }; const queuedFollowUp: QueuedFollowUp = { @@ -1110,7 +1146,7 @@ export function useChat(options: UseChatOptions = {}) { prompt: trimmedPrompt, mentions, findingId, - sendOptions, + sendOptions: queuedSendOptions, timestamp, }; @@ -1135,6 +1171,9 @@ export function useChat(options: UseChatOptions = {}) { const trimmedPrompt = prompt.trim(); if (!trimmedPrompt) return false; + const requestSendOptions = snapshotSendOptions(sendOptions); + const requestModel = requestSendOptions?.model?.trim() || ''; + // Echo the user's message before any network work. Cold sessions can spend // noticeable time creating the server-side session; the chat surface should // still feel immediate. @@ -1143,11 +1182,10 @@ export function useChat(options: UseChatOptions = {}) { role: 'user', content: trimmedPrompt, timestamp: new Date(), - request: buildRequestContext(mentions, findingId, sendOptions), + request: buildRequestContext(mentions, findingId, requestSendOptions), }; const assistantId = generateId(); - const requestModel = effectiveModelRoute(); const initialWorkflowStatus = createInitialAssistantWorkflowStatus(); const streamingMessage: ChatMessage = { id: assistantId, @@ -1207,11 +1245,11 @@ export function useChat(options: UseChatOptions = {}) { abortController.signal, mentions, findingId, - sendOptions?.autonomousMode, - sendOptions?.handoffContext, - sendOptions?.handoffResources, - sendOptions?.handoffActions, - sendOptions?.handoffMetadata, + requestSendOptions?.autonomousMode, + requestSendOptions?.handoffContext, + requestSendOptions?.handoffResources, + requestSendOptions?.handoffActions, + requestSendOptions?.handoffMetadata, ); if (requestId !== activeRequestId) { return false; @@ -1514,7 +1552,7 @@ export function useChat(options: UseChatOptions = {}) { // Retry a failed assistant turn: drop the failed assistant message and the // user prompt that triggered it from the view, then re-send that prompt so the // conversation shows a single clean attempt instead of a dead-end error. - const retryMessage = (assistantMessageId: string) => { + const retryMessage = (assistantMessageId: string, sendOptionOverrides?: SendMessageOptions) => { const msgs = messages(); const idx = msgs.findIndex((m) => m.id === assistantMessageId); if (idx < 0) return; @@ -1524,13 +1562,17 @@ export function useChat(options: UseChatOptions = {}) { const prompt = msgs[userIdx].content; if (!prompt.trim()) return; const request = msgs[userIdx].request; + const sendOptions = { + ...(sendOptionsFromRequestContext(request) || {}), + ...(sendOptionOverrides || {}), + }; const removeIds = new Set([msgs[userIdx].id, assistantMessageId]); setMessages((prev) => prev.filter((m) => !removeIds.has(m.id))); void sendMessage( prompt, cloneMentions(request?.mentions), request?.findingId, - sendOptionsFromRequestContext(request), + Object.keys(sendOptions).length > 0 ? sendOptions : undefined, ); }; diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 9cda8bc05..8703d11d1 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -1047,7 +1047,7 @@ export const AIChat: Component = (props) => { const switchToModelRoute = (modelId: string, failedMessageId?: string) => { selectModel(modelId); if (failedMessageId) { - chat.retryMessage(failedMessageId); + chat.retryMessage(failedMessageId, { model: modelId }); } focusComposer(); }; @@ -1621,13 +1621,18 @@ export const AIChat: Component = (props) => { sendOptions.handoffMetadata = ctx.handoffMetadata; } } + const routeAlternative = selectProviderReadinessAlternativeForSend(); + if (routeAlternative) { + sendOptions.model = routeAlternative.id; + } + const hasSendOptions = + Boolean(sendOptions.model) || typeof sendOptions.autonomousMode === 'boolean' || Boolean(sendOptions.handoffContext) || Boolean(sendOptions.handoffResources?.length) || Boolean(sendOptions.handoffActions?.length) || Boolean(sendOptions.handoffMetadata); - selectProviderReadinessAlternativeForSend(); const sendPromise = hasSendOptions ? chat.sendMessage(prompt, mentionsForAPI, findingId, sendOptions) diff --git a/frontend-modern/src/components/AI/Chat/types.ts b/frontend-modern/src/components/AI/Chat/types.ts index 8ec049077..90f34b8a9 100644 --- a/frontend-modern/src/components/AI/Chat/types.ts +++ b/frontend-modern/src/components/AI/Chat/types.ts @@ -122,6 +122,7 @@ export interface WorkflowStatus { export interface ChatMessageRequestContext { mentions?: ChatMention[]; findingId?: string; + model?: string; autonomousMode?: boolean; handoffContext?: string; handoffResources?: ChatHandoffResource[];