diff --git a/extensions/github1s-ai/assets/chat.css b/extensions/github1s-ai/assets/chat.css index bcc309530..3370fc370 100644 --- a/extensions/github1s-ai/assets/chat.css +++ b/extensions/github1s-ai/assets/chat.css @@ -1144,6 +1144,16 @@ summary:focus-visible, font: inherit; } +.message-retry { + gap: 4px; + margin-top: 6px; +} + +.message-retry .icon { + width: 12px; + height: 12px; +} + .message-status { margin: 5px 0 0; color: var(--vscode-descriptionForeground); diff --git a/extensions/github1s-ai/src/common/conversation.ts b/extensions/github1s-ai/src/common/conversation.ts index 43eff9c89..c9f5455a2 100644 --- a/extensions/github1s-ai/src/common/conversation.ts +++ b/extensions/github1s-ai/src/common/conversation.ts @@ -34,6 +34,22 @@ export interface Conversation extends ConversationSummary { messages: ConversationMessage[]; } +export const isInterruptedMessage = (message: ConversationMessage): boolean => + message.metadata.status === 'failed' || + message.metadata.status === 'aborted' || + message.metadata.status === 'unknown'; + +export const getRetryableMessage = (messages: readonly ConversationMessage[]): ConversationMessage | undefined => { + const assistant = messages.at(-1); + const user = messages.at(-2); + return assistant?.role === 'assistant' && + isInterruptedMessage(assistant) && + user?.role === 'user' && + user.metadata.turnId === assistant.metadata.turnId + ? assistant + : undefined; +}; + export const createUserMessage = ( turnId: string, text: string, diff --git a/extensions/github1s-ai/src/common/protocol.ts b/extensions/github1s-ai/src/common/protocol.ts index b7c79329b..9b521d02f 100644 --- a/extensions/github1s-ai/src/common/protocol.ts +++ b/extensions/github1s-ai/src/common/protocol.ts @@ -41,6 +41,7 @@ type AllViewEvents = | { type: 'app.openSettings' } | { type: 'app.openFile'; source: string } | { type: 'chat.send'; text: string } + | { type: 'chat.retry'; id: string } | { type: 'chat.runQuickAction'; action: ChatQuickAction } | { type: 'chat.addContextAttachment'; action: ContextAttachmentAction } | { type: 'chat.addContextAttachment'; action: 'descriptor'; descriptor: ContextAttachmentDescriptor } @@ -96,6 +97,7 @@ export const parseViewEvent = (value: unknown): ViewEvent | undefined => { ? { type: event.type, enabled: event.enabled } : undefined; + case 'chat.retry': case 'chat.removeContextAttachment': case 'history.selectConversation': case 'history.deleteConversation': diff --git a/extensions/github1s-ai/src/controllers/chat.ts b/extensions/github1s-ai/src/controllers/chat.ts index 260fee6c2..d1bbd0f82 100644 --- a/extensions/github1s-ai/src/controllers/chat.ts +++ b/extensions/github1s-ai/src/controllers/chat.ts @@ -20,6 +20,11 @@ export class ChatController extends Controller { await this.runner.send({ text: event.text }); } + @Controller.handler('chat.retry') + async handleRetry(event: ViewEvent<'chat.retry'>): Promise { + await this.runner.send({ retryMessageId: event.id }); + } + @Controller.handler('chat.cancel') async handleCancel(_event: ViewEvent<'chat.cancel'>): Promise { await this.runner.cancelCurrent(); diff --git a/extensions/github1s-ai/src/controllers/runner.ts b/extensions/github1s-ai/src/controllers/runner.ts index 3f905bf74..a51403722 100644 --- a/extensions/github1s-ai/src/controllers/runner.ts +++ b/extensions/github1s-ai/src/controllers/runner.ts @@ -4,6 +4,7 @@ import { addLanguageModelUsage, createNullLanguageModelUsage } from 'ai/internal import { createAssistantMessage, createUserMessage, + getRetryableMessage, withMessageStatus, type Conversation, type ConversationMessage, @@ -19,7 +20,7 @@ import { resolveContextAttachments } from './context'; type ActiveRequest = { controller: AbortController; - turnId: string; + messageId: string; }; export class ConversationRunner { @@ -31,7 +32,7 @@ export class ConversationRunner { private readonly publishState: () => Promise, ) {} - async send(input: { text: string } | { action: ChatQuickAction }): Promise { + async send(input: { text: string } | { action: ChatQuickAction } | { retryMessageId: string }): Promise { if (('text' in input && !input.text.trim()) || this.preparation) return; const [promptsConfig, config] = await Promise.all([ @@ -39,7 +40,8 @@ export class ConversationRunner { this.stores.modelConfigs.getSelected(), ]); const prompts = resolvePrompts(promptsConfig); - const text = 'text' in input ? input.text : prompts.quickActions[input.action]; + const text = 'text' in input ? input.text : 'action' in input ? prompts.quickActions[input.action] : ''; + const retry = 'retryMessageId' in input; if (!config) { await this.stores.runtime.setIn('chat.notice', { @@ -50,15 +52,25 @@ export class ConversationRunner { } const runtime = await this.stores.runtime.get(); if (this.preparation) return; + const previous = runtime.chat.conversation; + if (retry && (!previous || getRetryableMessage(previous.messages)?.id !== input.retryMessageId)) return; + const conversationId = previous?.id ?? globalThis.crypto.randomUUID(); + if (this.requests.has(conversationId)) return; const descriptors = (runtime.chat.pendingAttachments ?? []).map((descriptor) => ({ ...descriptor })); const preparation = Symbol('conversation-preparation'); this.preparation = preparation; await this.stores.runtime.setIn('chat', { ...runtime.chat, preparing: true, notice: undefined }); - let attachments; + let userMessage: ConversationMessage; try { await this.publishState(); - attachments = await resolveContextAttachments(descriptors); + if (retry) { + userMessage = previous!.messages.at(-2)!; + } else { + const attachments = await resolveContextAttachments(descriptors); + const recentFiles = runtime.chat.includeRecentFiles === false ? [] : runtime.chat.recentFiles; + userMessage = createUserMessage(globalThis.crypto.randomUUID(), text, attachments, recentFiles); + } } catch (error) { const detail = error instanceof Error ? error.message : 'Unknown error.'; await this.finishPreparation(preparation, `Unable to read the selected context attachment. ${detail}`); @@ -66,20 +78,12 @@ export class ConversationRunner { } if (this.preparation !== preparation) return; - const previous = runtime.chat.conversation; const startedAt = Date.now(); - const conversationId = previous?.id ?? globalThis.crypto.randomUUID(); - if (this.requests.has(conversationId)) { - await this.finishPreparation(preparation); - return; - } - - const turnId = globalThis.crypto.randomUUID(); - const recentFiles = runtime.chat.includeRecentFiles === false ? [] : runtime.chat.recentFiles; - const userMessage = createUserMessage(turnId, text, attachments, recentFiles); + const turnId = userMessage.metadata.turnId; + const history = retry ? previous!.messages.slice(0, -2) : (previous?.messages ?? []); let providerMessages; try { - providerMessages = await buildModelMessages(previous?.messages ?? [], [userMessage]); + providerMessages = await buildModelMessages(history, [userMessage]); } catch (error) { const detail = error instanceof Error ? error.message : 'Unknown error.'; await this.finishPreparation(preparation, `Unable to prepare the conversation. ${detail}`); @@ -91,7 +95,7 @@ export class ConversationRunner { const conversation: Conversation = previous ? { ...previous, - messages: [...previous.messages, userMessage, assistantMessage], + messages: [...history, userMessage, assistantMessage], updatedAt: startedAt, } : { @@ -116,7 +120,7 @@ export class ConversationRunner { return; } if (this.preparation !== preparation) { - await this.finishTurn(conversationId, turnId, 'aborted'); + await this.setMessageStatus(conversationId, assistantMessage.id, 'aborted'); return; } @@ -124,7 +128,7 @@ export class ConversationRunner { const abortController = new AbortController(); const request: ActiveRequest = { controller: abortController, - turnId, + messageId: assistantMessage.id, }; this.requests.set(conversationId, request); let mcp: Awaited> | undefined; @@ -134,7 +138,7 @@ export class ConversationRunner { await this.stores.runtime.setIn('chat', { ...currentRuntime.chat, conversation, - pendingAttachments: [], + pendingAttachments: retry ? currentRuntime.chat.pendingAttachments : [], preparing: false, }); await this.stores.conversations.select(conversationId); @@ -154,16 +158,16 @@ export class ConversationRunner { if (!abortController.signal.aborted && this.requests.get(conversationId) === request) { const usage = await agentStream.usage; if (!abortController.signal.aborted && this.requests.get(conversationId) === request) { - await this.completeTurn(conversationId, turnId, usage); + await this.setMessageStatus(conversationId, request.messageId, 'completed', undefined, usage); } } } catch (error) { if (this.requests.get(conversationId) !== request) return; if (abortController.signal.aborted) { - await this.finishTurn(conversationId, turnId, 'aborted'); + await this.setMessageStatus(conversationId, request.messageId, 'aborted'); } else { const message = error instanceof Error ? error.message : 'Unknown error.'; - await this.finishTurn(conversationId, turnId, 'failed', message); + await this.setMessageStatus(conversationId, request.messageId, 'failed', message); } } finally { if (this.requests.get(conversationId) === request) this.requests.delete(conversationId); @@ -185,7 +189,7 @@ export class ConversationRunner { if (!request) return; this.requests.delete(conversationId); request.controller.abort(); - await this.finishTurn(conversationId, request.turnId, 'aborted'); + await this.setMessageStatus(conversationId, request.messageId, 'aborted'); } isRequestActive(conversationId: string | undefined): boolean { @@ -218,32 +222,17 @@ export class ConversationRunner { await this.publishState(); } - private async completeTurn(conversationId: string, turnId: string, usage: LanguageModelUsage): Promise { - await this.setTurnStatus(conversationId, turnId, 'completed', undefined, usage); - } - - private async finishTurn( - conversationId: string, - turnId: string, - status: 'aborted' | 'failed', - error?: string, - ): Promise { - await this.setTurnStatus(conversationId, turnId, status, error); - } - - private async setTurnStatus( + private async setMessageStatus( conversationId: string, - turnId: string, + messageId: string, status: 'completed' | 'aborted' | 'failed', error?: string, usage?: LanguageModelUsage, ): Promise { const conversation = await this.stores.conversations.get(conversationId); - if (!conversation) return; + if (!conversation?.messages.some((message) => message.id === messageId)) return; const messages = conversation.messages.map((message) => - message.role === 'assistant' && message.metadata.turnId === turnId - ? withMessageStatus(message, status, error) - : message, + message.id === messageId ? withMessageStatus(message, status, error) : message, ); const updatedAt = Date.now(); const nextUsage = usage === undefined ? undefined : addUsage(conversation.usage, usage); diff --git a/extensions/github1s-ai/src/llm/helpers.ts b/extensions/github1s-ai/src/llm/helpers.ts index 8a8109a7d..f9e64d47d 100644 --- a/extensions/github1s-ai/src/llm/helpers.ts +++ b/extensions/github1s-ai/src/llm/helpers.ts @@ -1,7 +1,7 @@ -import { convertToModelMessages, type ModelMessage } from 'ai'; +import { convertToModelMessages, getToolName, isToolUIPart, type ModelMessage } from 'ai'; import { contextReferencePath, type ContextAttachment, type ContextReference } from '@/common/context'; -import type { ConversationMessage } from '@/common/conversation'; +import { isInterruptedMessage, type ConversationMessage } from '@/common/conversation'; const MAX_PROMPT_HISTORY_CHARACTERS = 128 * 1024; @@ -10,7 +10,7 @@ export const buildModelMessages = ( input: readonly ConversationMessage[], ): Promise => convertToModelMessages( - [...selectWholeRecentTurns(history, MAX_PROMPT_HISTORY_CHARACTERS), ...input], + [...selectWholeRecentTurns(history, MAX_PROMPT_HISTORY_CHARACTERS).map(repairInterruptedMessage), ...input], { ignoreIncompleteToolCalls: true, convertDataPart: (part) => { @@ -24,6 +24,39 @@ export const buildModelMessages = ( }, ); +// Repair only the model input. Keep the original partial response intact for display. +const repairInterruptedMessage = (message: ConversationMessage): ConversationMessage => { + if (message.role !== 'assistant' || !isInterruptedMessage(message)) return message; + let endsWithToolStep = false; + const parts = message.parts.flatMap((part) => { + // Interrupted provider items and reasoning signatures may not be replayable. + if (part.type === 'text') return part.text.trim() ? [{ type: 'text', text: part.text }] : []; + if (part.type === 'step-start') { + endsWithToolStep = false; + return [part]; + } + if (!isToolUIPart(part) || part.state === 'input-streaming') return []; + endsWithToolStep = true; + const tool = { + type: 'dynamic-tool' as const, + toolName: getToolName(part), + toolCallId: part.toolCallId, + input: part.state === 'output-error' && 'rawInput' in part ? (part.input ?? part.rawInput) : part.input, + }; + if (part.state === 'output-available' && !part.preliminary) + return [{ ...tool, state: 'output-available', output: part.output }]; + if (part.state === 'output-error') return [{ ...tool, state: 'output-error', errorText: part.errorText }]; + if (part.state === 'output-denied' || (part.state === 'approval-responded' && !part.approval.approved)) + return [{ ...tool, state: 'output-error', errorText: part.approval.reason ?? 'Tool execution was denied.' }]; + return [{ ...tool, state: 'output-error', errorText: 'Tool execution was interrupted; its result is unknown.' }]; + }); + // Tool results follow all text in their step, so close that step before the next user turn. + if (endsWithToolStep || parts.at(-1)?.type !== 'text') { + parts.push({ type: 'step-start' }, { type: 'text', text: '[The previous assistant response was interrupted.]' }); + } + return { ...message, parts }; +}; + const selectWholeRecentTurns = ( history: readonly ConversationMessage[], maxCharacters: number, diff --git a/extensions/github1s-ai/src/webview/components/ChatPage.ts b/extensions/github1s-ai/src/webview/components/ChatPage.ts index 66407776a..7e97881fc 100644 --- a/extensions/github1s-ai/src/webview/components/ChatPage.ts +++ b/extensions/github1s-ai/src/webview/components/ChatPage.ts @@ -1,7 +1,7 @@ import { html } from 'htm/preact'; import { useLayoutEffect, useRef } from 'preact/hooks'; -import type { ConversationMessage } from '@/common/conversation'; +import { getRetryableMessage, type ConversationMessage } from '@/common/conversation'; import type { ViewEvent, ViewState } from '@/common/protocol'; import { QUICK_ACTIONS } from '@/common/quick-actions'; @@ -47,6 +47,7 @@ interface TranscriptProps extends ChatContentProps { } const Transcript = ({ state, messages, busy, post }: TranscriptProps) => { + const retryableMessage = busy ? undefined : getRetryableMessage(messages); const transcript = useRef(null); const content = useRef(null); const followOutput = useRef(true); @@ -78,7 +79,15 @@ const Transcript = ({ state, messages, busy, post }: TranscriptProps) => {
${messages.length === 0 ? html`<${EmptyChat} state=${state} busy=${busy} post=${post} />` - : messages.map((message) => html`<${MessageView} key=${message.id} message=${message} post=${post} />`)} + : messages.map( + (message) => + html`<${MessageView} + key=${message.id} + message=${message} + canRetry=${message === retryableMessage} + post=${post} + />`, + )}
`; }; diff --git a/extensions/github1s-ai/src/webview/components/Icon.ts b/extensions/github1s-ai/src/webview/components/Icon.ts index c0f489adb..0bd98f943 100644 --- a/extensions/github1s-ai/src/webview/components/Icon.ts +++ b/extensions/github1s-ai/src/webview/components/Icon.ts @@ -1,25 +1,24 @@ import { html } from 'htm/preact'; const ICON_PATHS = { - add: 'M8 1.5A.5.5 0 0 0 7 1.5V7H1.5a.5.5 0 0 0 0 1H7v5.5a.5.5 0 0 0 1 0V8h5.5a.5.5 0 0 0 0-1H8V1.5Z', - 'arrow-left': 'M6.7 3.3 2 8l4.7 4.7.8-.8L4.2 8.6H14V7.4H4.2l3.3-3.3-.8-.8Z', - check: 'M13.2 4.3 6.5 11 2.8 7.3l.8-.8 2.9 2.9 5.9-5.9.8.8Z', - 'chevron-right': 'M5.7 3.3 10.4 8l-4.7 4.7-.8-.8L8.8 8 4.9 4.1l.8-.8Z', - close: - 'M8.70701 8.00001L12.353 4.35401C12.548 4.15901 12.548 3.84201 12.353 3.64701C12.158 3.45201 11.841 3.45201 11.646 3.64701L8.00001 7.29301L4.35401 3.64701C4.15901 3.45201 3.84201 3.45201 3.64701 3.64701C3.45201 3.84201 3.45201 4.15901 3.64701 4.35401L7.29301 8.00001L3.64701 11.646C3.45201 11.841 3.45201 12.158 3.64701 12.353C3.74501 12.451 3.87301 12.499 4.00101 12.499C4.12901 12.499 4.25701 12.45 4.35501 12.353L8.00101 8.70701L11.647 12.353C11.745 12.451 11.873 12.499 12.001 12.499C12.129 12.499 12.257 12.45 12.355 12.353C12.55 12.158 12.55 11.841 12.355 11.646L8.70901 8.00001H8.70701Z', - download: 'M7.5 1h1v8.1l2.8-2.8.7.7-4 4-4-4 .7-.7 2.8 2.8V1ZM2 12h1v2h10v-2h1v3H2v-3Z', - edit: 'M14.236 1.76386C13.2123 0.740172 11.5525 0.740171 10.5289 1.76386L2.65722 9.63549C2.28304 10.0097 2.01623 10.4775 1.88467 10.99L1.01571 14.3755C0.971767 14.5467 1.02148 14.7284 1.14646 14.8534C1.27144 14.9783 1.45312 15.028 1.62432 14.9841L5.00978 14.1151C5.52234 13.9836 5.99015 13.7168 6.36433 13.3426L14.236 5.47097C15.2596 4.44728 15.2596 2.78755 14.236 1.76386ZM11.236 2.47097C11.8691 1.8378 12.8957 1.8378 13.5288 2.47097C14.162 3.10413 14.162 4.1307 13.5288 4.76386L12.75 5.54269L10.4571 3.24979L11.236 2.47097ZM9.75002 3.9569L12.0429 6.24979L5.65722 12.6355C5.40969 12.883 5.10023 13.0595 4.76117 13.1465L2.19447 13.8053L2.85327 11.2386C2.9403 10.8996 3.1168 10.5901 3.36433 10.3426L9.75002 3.9569Z', - file: 'M3 1h6l4 4v10H3V1Zm6 1.4V5h2.6L9 2.4ZM4 2v12h8V6H8V2H4Z', - history: - 'M8 1a7 7 0 1 1-6.7 9h1.06A6 6 0 1 0 2.8 5H5v1H1V2h1v2.4A7 7 0 0 1 8 1Zm-.5 3h1v4.2l2.85 1.65-.5.86L7.5 8.8V4Z', - repo: 'M2 2h5l1 2h6v10H2V2Zm1 1v10h10V5H7.4l-1-2H3Z', - selection: 'M2 2h4v1H3v3H2V2Zm8 0h4v4h-1V3h-3V2ZM2 10h1v3h3v1H2v-4Zm11 0h1v4h-4v-1h3v-3Z', - send: 'M8 2.5 3.5 7l.8.8 3.1-3.1V14h1.2V4.7l3.1 3.1.8-.8L8 2.5Z', - sparkle: 'M8 1.5 9.35 6.65 14.5 8l-5.15 1.35L8 14.5 6.65 9.35 1.5 8l5.15-1.35L8 1.5Z', - stop: 'M4 4h8v8H4V4Z', - terminal: 'M2 3h12v10H2V3Zm1 1v8h10V4H3Zm1.5 2 .7-.7L7.9 8l-2.7 2.7-.7-.7 2-2-2-2Zm4 4H12v1H8.5v-1Z', - trash: 'M5 2h6l.5 1.5H14v1H2v-1h2.5L5 2Zm-1 4h1l.5 7h5l.5-7h1l-.6 8H4.6L4 6Z', - warning: 'M8 1.5 15 14H1L8 1.5Zm0 2.3L2.9 13h10.2L8 3.8Zm-.6 2.7h1.2v3.8H7.4V6.5Zm0 5h1.2v1.2H7.4v-1.2Z', + add: 'M8 1.5a.5.5 0 0 0-1 0V7H1.5a.5.5 0 0 0 0 1H7v5.5a.5.5 0 0 0 1 0V8h5.5a.5.5 0 0 0 0-1H8z', + 'arrow-left': 'M6.7 3.3 2 8l4.7 4.7.8-.8-3.3-3.3H14V7.4H4.2l3.3-3.3z', + check: 'M13.2 4.3 6.5 11 2.8 7.3l.8-.8 2.9 2.9 5.9-5.9z', + 'chevron-right': 'M5.7 3.3 10.4 8l-4.7 4.7-.8-.8L8.8 8 4.9 4.1z', + close: 'M4 4l8 8m0-8-8 8', + download: 'M7.5 1h1v8.1l2.8-2.8.7.7-4 4-4-4 .7-.7 2.8 2.8zM2 12h1v2h10v-2h1v3H2z', + edit: 'M10.9 2.1a2.1 2.1 0 0 1 3 3L6 13q-.5.5-1.1.6l-3.4.9.9-3.4q.1-.6.6-1.1zM10.1 3.6l2.3 2.3', + file: 'M3 1h6l4 4v10H3zm6 1.4V5h2.6zM4 2v12h8V6H8V2z', + history: 'M8 1a7 7 0 1 1-6.7 9h1.06a6 6 0 1 0 .44-5H5v1H1V2h1v2.4A7 7 0 0 1 8 1m-.5 3h1v4.2l2.85 1.65-.5.86L7.5 8.8z', + refresh: 'M8 1a7 7 0 1 1-6.7 9h1.06a6 6 0 1 0 .44-5H5v1H1V2h1v2.4A7 7 0 0 1 8 1', + repo: 'M2 2h5l1 2h6v10H2zm1 1v10h10V5H7.4l-1-2z', + selection: 'M2 2h4v1H3v3H2zm8 0h4v4h-1V3h-3zm-8 8h1v3h3v1H2zm11 0h1v4h-4v-1h3z', + send: 'M8 2.5 3.5 7l.8.8 3.1-3.1V14h1.2V4.7l3.1 3.1.8-.8z', + sparkle: 'm8 1.5 1.35 5.15L14.5 8 9.35 9.35 8 14.5 6.65 9.35 1.5 8l5.15-1.35z', + stop: 'M4 4h8v8H4z', + terminal: 'M2 3h12v10H2zm1 1v8h10V4zm1.5 2 .7-.7L7.9 8l-2.7 2.7-.7-.7 2-2zm4 4H12v1H8.5z', + trash: 'M5 2h6l.5 1.5H14v1H2v-1h2.5zM4 6h1l.5 7h5l.5-7h1l-.6 8H4.6z', + warning: 'M8 1.5 15 14H1zm0 2.3L2.9 13h10.2zm-.6 2.7h1.2v3.8H7.4zm0 5h1.2v1.2H7.4z', } as const; export type IconName = keyof typeof ICON_PATHS; @@ -30,12 +29,19 @@ interface IconProps { } export const Icon = ({ name, className }: IconProps) => { + const stroked = name === 'close' || name === 'edit'; return html``; }; diff --git a/extensions/github1s-ai/src/webview/components/MessageView.ts b/extensions/github1s-ai/src/webview/components/MessageView.ts index 121c5340a..a837be1dc 100644 --- a/extensions/github1s-ai/src/webview/components/MessageView.ts +++ b/extensions/github1s-ai/src/webview/components/MessageView.ts @@ -6,6 +6,7 @@ import type { ViewEvent } from '@/common/protocol'; import { messageStatusLabel } from '../helpers/presentation'; import { AssistantActivity, isAssistantActivityPart, type AssistantActivityPart } from './AssistantActivity'; import { AttachmentChips } from './AttachmentChips'; +import { Icon } from './Icon'; import { Markdown } from './Markdown'; import { RecentFilesTooltip } from './RecentFiles'; @@ -15,10 +16,11 @@ type AssistantSegment = interface MessageViewProps { message: ConversationMessage; + canRetry?: boolean; post: (event: ViewEvent) => void; } -export const MessageView = ({ message, post }: MessageViewProps) => { +export const MessageView = ({ message, canRetry, post }: MessageViewProps) => { if (message.role !== 'user' && message.role !== 'assistant') return null; const segments = message.role === 'assistant' ? assistantSegments(message) : []; const activityRunning = segments.some( @@ -44,6 +46,16 @@ export const MessageView = ({ message, post }: MessageViewProps) => { `} ${status ? html`<${MessageStatus} label=${status} generating=${message.metadata.status === 'streaming'} />` : null} ${message.metadata.error ? html`` : null} + ${canRetry + ? html`` + : null} `; }; diff --git a/extensions/github1s-ai/test/conversation.test.ts b/extensions/github1s-ai/test/conversation.test.ts index 5bc269d0f..f8809f9ed 100644 --- a/extensions/github1s-ai/test/conversation.test.ts +++ b/extensions/github1s-ai/test/conversation.test.ts @@ -5,12 +5,20 @@ import type { ContextAttachment } from '@/common/context'; import { createAssistantMessage, createUserMessage, + getRetryableMessage, markStreamingMessagesUnknown, validateConversationMessages, withMessageStatus, type Conversation, } from '@/common/conversation'; +test('the latest failed assistant can be retried until completed', () => { + const user = createUserMessage('turn-1', 'Hello', []); + const assistant = withMessageStatus(createAssistantMessage('assistant-1', 'turn-1'), 'failed'); + assert.equal(getRetryableMessage([user, assistant]), assistant); + assert.equal(getRetryableMessage([user, withMessageStatus(assistant, 'completed')]), undefined); +}); + test('user messages capture attachments and pass conversation validation', async () => { const attachment: ContextAttachment = { id: 'file-1', diff --git a/extensions/github1s-ai/test/model-messages.test.ts b/extensions/github1s-ai/test/model-messages.test.ts index bf7316296..67119ca9c 100644 --- a/extensions/github1s-ai/test/model-messages.test.ts +++ b/extensions/github1s-ai/test/model-messages.test.ts @@ -1,7 +1,12 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { createAssistantMessage, createUserMessage, type ConversationMessage } from '@/common/conversation'; +import { + createAssistantMessage, + createUserMessage, + withMessageStatus, + type ConversationMessage, +} from '@/common/conversation'; import { buildModelMessages } from '@/llm/helpers'; const completedTurn = (turnId: string, text: string): ConversationMessage[] => [ @@ -101,7 +106,7 @@ test('model history keeps the latest turn even when that turn exceeds the budget ]); }); -test('model history omits unfinished tool calls from aborted turns', async () => { +test('model history pairs a tool with an unknown result when execution was interrupted', async () => { const assistant: ConversationMessage = { id: 'assistant-1', role: 'assistant', @@ -123,7 +128,48 @@ test('model history omits unfinished tool calls from aborted turns', async () => ); assert.deepEqual(messages, [ { role: 'user', content: [{ type: 'text', text: 'Read a.ts' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'Reading the file.' }] }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'Reading the file.' }, + { + type: 'tool-call', + toolName: 'read', + toolCallId: 'call-1', + input: { path: 'a.ts' }, + providerExecuted: undefined, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolName: 'read', + toolCallId: 'call-1', + output: { + type: 'error-text', + value: 'Tool execution was interrupted; its result is unknown.', + }, + }, + ], + }, + { role: 'assistant', content: [{ type: 'text', text: '[The previous assistant response was interrupted.]' }] }, { role: 'user', content: [{ type: 'text', text: 'Continue' }] }, ]); }); + +test('an empty failed reply gets an interruption marker before the next question', async () => { + const assistant = withMessageStatus(createAssistantMessage('assistant-1', 'turn-1'), 'failed'); + const messages = await buildModelMessages( + [createUserMessage('turn-1', 'Question', []), assistant], + [createUserMessage('turn-2', 'Next', [])], + ); + assert.deepEqual(messages, [ + { role: 'user', content: [{ type: 'text', text: 'Question' }] }, + { role: 'assistant', content: [{ type: 'text', text: '[The previous assistant response was interrupted.]' }] }, + { role: 'user', content: [{ type: 'text', text: 'Next' }] }, + ]); + assert.deepEqual(assistant.parts, []); +}); diff --git a/extensions/github1s-ai/test/protocol.test.ts b/extensions/github1s-ai/test/protocol.test.ts index 73e8b489e..eccff276a 100644 --- a/extensions/github1s-ai/test/protocol.test.ts +++ b/extensions/github1s-ai/test/protocol.test.ts @@ -7,6 +7,7 @@ test('view events accept chat commands and context descriptors', () => { for (const event of [ { type: 'chat.send', text: 'Explain this repository.' }, { type: 'chat.cancel' }, + { type: 'chat.retry', id: 'assistant-1' }, { type: 'chat.setIncludeRecentFiles', enabled: false }, { type: 'chat.setIncludeRecentFiles', enabled: true }, { type: 'chat.runQuickAction', action: 'repositoryOverview' }, diff --git a/extensions/github1s-ai/test/runner.test.ts b/extensions/github1s-ai/test/runner.test.ts new file mode 100644 index 000000000..9c9ecd735 --- /dev/null +++ b/extensions/github1s-ai/test/runner.test.ts @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { before, test } from 'node:test'; + +import { createAssistantMessage, createUserMessage, withMessageStatus, type Conversation } from '@/common/conversation'; +import type { ModelConfig } from '@/common/model-config'; +import type { Stores } from '@/stores'; +import { createRuntimeStateStore } from '@/stores/runtime-state'; + +let ConversationRunner: typeof import('@/controllers/runner').ConversationRunner; + +before(async () => { + // Retrying snapshots must not read VS Code documents or execute repository tools. + const hooks = registerHooks({ + resolve: (specifier, context, next) => + specifier === 'vscode' ? { url: 'mock:vscode', shortCircuit: true } : next(specifier, context), + load: (url, context, next) => + url === 'mock:vscode' + ? { format: 'commonjs', source: 'module.exports = {};', shortCircuit: true } + : next(url, context), + }); + try { + ({ ConversationRunner } = await import('@/controllers/runner')); + } finally { + hooks.deregister(); + } +}); + +const setup = async () => { + const user = createUserMessage( + 'turn-1', + 'Original question', + [{ id: 'original-file', type: 'file', label: 'a.ts', source: 'github1s:/a.ts', content: 'original file snapshot' }], + [{ source: 'github1s:/original.ts' }], + ); + const assistant = withMessageStatus(createAssistantMessage('old-assistant', 'turn-1'), 'failed', 'Old error'); + assistant.parts.push({ type: 'text', text: 'Old partial answer' }); + let conversation: Conversation = { + id: 'conversation-1', + title: 'Original title', + createdAt: 1, + updatedAt: 1, + messages: [user, assistant], + }; + const runtime = createRuntimeStateStore(); + await runtime.set({ + page: 'chat', + chat: { + conversation, + pendingAttachments: [{ id: 'pending-file', type: 'file', label: 'draft.ts', source: 'github1s:/draft.ts' }], + recentFiles: [{ source: 'github1s:/current.ts' }], + }, + history: {}, + settings: {}, + }); + const config: ModelConfig = { + id: 'current-config', + name: 'Current config', + provider: 'openai', + protocol: 'openai-chat', + modelId: 'current-model', + apiKey: 'test-key', + baseURL: 'https://model.example/v1', + }; + const stores = { + runtime, + modelConfigs: { getSelected: async () => config }, + promptsConfig: { get: async () => ({ instructions: 'Current instructions', userRules: '', quickActions: {} }) }, + mcpConfig: { get: async () => ({ mcpServers: {} }) }, + conversations: { + get: async () => conversation, + update: async (_id: string, update: Partial) => { + conversation = { ...conversation, ...update }; + }, + select: async () => {}, + }, + } as unknown as Stores; + return { stores, user, runner: new ConversationRunner(stores, async () => {}), getConversation: () => conversation }; +}; + +const response = () => + new Response( + [ + { + id: 'response-1', + object: 'chat.completion.chunk', + created: 1, + model: 'current-model', + choices: [{ index: 0, delta: { role: 'assistant', content: 'New answer' }, finish_reason: null }], + }, + { + id: 'response-1', + object: 'chat.completion.chunk', + created: 1, + model: 'current-model', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + }, + ] + .map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`) + .join('') + 'data: [DONE]\n\n', + { headers: { 'Content-Type': 'text/event-stream' } }, + ); + +test('retry replaces the failed reply using the original context and current settings', async (t) => { + const { stores, user, runner, getConversation } = await setup(); + const fetch = t.mock.method(globalThis, 'fetch', async () => response()); + await runner.send({ retryMessageId: 'old-assistant' }); + assert.equal(fetch.mock.callCount(), 1); + const request = JSON.parse(fetch.mock.calls[0].arguments[1]?.body as string); + assert.equal(request.model, 'current-model'); + assert.deepEqual( + request.messages.map((message: { role: string }) => message.role), + ['system', 'user'], + ); + assert.match(JSON.stringify(request.messages), /Current instructions/); + assert.match(JSON.stringify(request.messages), /original file snapshot/); + assert.match(JSON.stringify(request.messages), /original\.ts/); + assert.doesNotMatch(JSON.stringify(request.messages), /Old partial answer|draft\.ts|current\.ts/); + const conversation = getConversation(); + assert.equal(conversation.messages.length, 2); + assert.deepEqual(conversation.messages[0], user); + assert.notEqual(conversation.messages[1].id, 'old-assistant'); + assert.equal(conversation.messages[1].metadata.turnId, user.metadata.turnId); + assert.equal(conversation.messages[1].metadata.status, 'completed'); + assert.equal(conversation.messages[1].parts.find((part) => part.type === 'text')?.text, 'New answer'); + assert.equal(conversation.messages[1].metadata.error, undefined); + assert.equal((await stores.runtime.get()).chat.pendingAttachments?.[0].id, 'pending-file'); +});