mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 10:43:36 +00:00
Snapshot Assistant queued model routes
This commit is contained in:
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user