Snapshot Assistant queued model routes

This commit is contained in:
rcourtman
2026-06-06 07:50:24 +01:00
parent 5b85611516
commit 79ce28ae3b
6 changed files with 137 additions and 22 deletions
@@ -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
@@ -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],
@@ -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<void>((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(
@@ -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<QueuedFollowUp[]>([]);
const effectiveModelRoute = () => {
const effectiveModelRoute = (sendOptions?: Pick<SendMessageOptions, 'model'>) => {
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,
);
};
@@ -1047,7 +1047,7 @@ export const AIChat: Component<AIChatProps> = (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<AIChatProps> = (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)
@@ -122,6 +122,7 @@ export interface WorkflowStatus {
export interface ChatMessageRequestContext {
mentions?: ChatMention[];
findingId?: string;
model?: string;
autonomousMode?: boolean;
handoffContext?: string;
handoffResources?: ChatHandoffResource[];