Skip to content

Commit 8c92f3a

Browse files
authored
feat: add retry for failed message (conwnet#757)
1 parent bff073b commit 8c92f3a

13 files changed

Lines changed: 337 additions & 72 deletions

File tree

‎extensions/github1s-ai/assets/chat.css‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,6 +1144,16 @@ summary:focus-visible,
11441144
font: inherit;
11451145
}
11461146

1147+
.message-retry {
1148+
gap: 4px;
1149+
margin-top: 6px;
1150+
}
1151+
1152+
.message-retry .icon {
1153+
width: 12px;
1154+
height: 12px;
1155+
}
1156+
11471157
.message-status {
11481158
margin: 5px 0 0;
11491159
color: var(--vscode-descriptionForeground);

‎extensions/github1s-ai/src/common/conversation.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@ export interface Conversation extends ConversationSummary {
3434
messages: ConversationMessage[];
3535
}
3636

37+
export const isInterruptedMessage = (message: ConversationMessage): boolean =>
38+
message.metadata.status === 'failed' ||
39+
message.metadata.status === 'aborted' ||
40+
message.metadata.status === 'unknown';
41+
42+
export const getRetryableMessage = (messages: readonly ConversationMessage[]): ConversationMessage | undefined => {
43+
const assistant = messages.at(-1);
44+
const user = messages.at(-2);
45+
return assistant?.role === 'assistant' &&
46+
isInterruptedMessage(assistant) &&
47+
user?.role === 'user' &&
48+
user.metadata.turnId === assistant.metadata.turnId
49+
? assistant
50+
: undefined;
51+
};
52+
3753
export const createUserMessage = (
3854
turnId: string,
3955
text: string,

‎extensions/github1s-ai/src/common/protocol.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type AllViewEvents =
4141
| { type: 'app.openSettings' }
4242
| { type: 'app.openFile'; source: string }
4343
| { type: 'chat.send'; text: string }
44+
| { type: 'chat.retry'; id: string }
4445
| { type: 'chat.runQuickAction'; action: ChatQuickAction }
4546
| { type: 'chat.addContextAttachment'; action: ContextAttachmentAction }
4647
| { type: 'chat.addContextAttachment'; action: 'descriptor'; descriptor: ContextAttachmentDescriptor }
@@ -96,6 +97,7 @@ export const parseViewEvent = (value: unknown): ViewEvent | undefined => {
9697
? { type: event.type, enabled: event.enabled }
9798
: undefined;
9899

100+
case 'chat.retry':
99101
case 'chat.removeContextAttachment':
100102
case 'history.selectConversation':
101103
case 'history.deleteConversation':

‎extensions/github1s-ai/src/controllers/chat.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ export class ChatController extends Controller {
2020
await this.runner.send({ text: event.text });
2121
}
2222

23+
@Controller.handler('chat.retry')
24+
async handleRetry(event: ViewEvent<'chat.retry'>): Promise<void> {
25+
await this.runner.send({ retryMessageId: event.id });
26+
}
27+
2328
@Controller.handler('chat.cancel')
2429
async handleCancel(_event: ViewEvent<'chat.cancel'>): Promise<void> {
2530
await this.runner.cancelCurrent();

‎extensions/github1s-ai/src/controllers/runner.ts‎

Lines changed: 32 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { addLanguageModelUsage, createNullLanguageModelUsage } from 'ai/internal
44
import {
55
createAssistantMessage,
66
createUserMessage,
7+
getRetryableMessage,
78
withMessageStatus,
89
type Conversation,
910
type ConversationMessage,
@@ -19,7 +20,7 @@ import { resolveContextAttachments } from './context';
1920

2021
type ActiveRequest = {
2122
controller: AbortController;
22-
turnId: string;
23+
messageId: string;
2324
};
2425

2526
export class ConversationRunner {
@@ -31,15 +32,16 @@ export class ConversationRunner {
3132
private readonly publishState: () => Promise<void>,
3233
) {}
3334

34-
async send(input: { text: string } | { action: ChatQuickAction }): Promise<void> {
35+
async send(input: { text: string } | { action: ChatQuickAction } | { retryMessageId: string }): Promise<void> {
3536
if (('text' in input && !input.text.trim()) || this.preparation) return;
3637

3738
const [promptsConfig, config] = await Promise.all([
3839
this.stores.promptsConfig.get(),
3940
this.stores.modelConfigs.getSelected(),
4041
]);
4142
const prompts = resolvePrompts(promptsConfig);
42-
const text = 'text' in input ? input.text : prompts.quickActions[input.action];
43+
const text = 'text' in input ? input.text : 'action' in input ? prompts.quickActions[input.action] : '';
44+
const retry = 'retryMessageId' in input;
4345

4446
if (!config) {
4547
await this.stores.runtime.setIn('chat.notice', {
@@ -50,36 +52,38 @@ export class ConversationRunner {
5052
}
5153
const runtime = await this.stores.runtime.get();
5254
if (this.preparation) return;
55+
const previous = runtime.chat.conversation;
56+
if (retry && (!previous || getRetryableMessage(previous.messages)?.id !== input.retryMessageId)) return;
57+
const conversationId = previous?.id ?? globalThis.crypto.randomUUID();
58+
if (this.requests.has(conversationId)) return;
5359
const descriptors = (runtime.chat.pendingAttachments ?? []).map((descriptor) => ({ ...descriptor }));
5460
const preparation = Symbol('conversation-preparation');
5561
this.preparation = preparation;
5662
await this.stores.runtime.setIn('chat', { ...runtime.chat, preparing: true, notice: undefined });
5763

58-
let attachments;
64+
let userMessage: ConversationMessage;
5965
try {
6066
await this.publishState();
61-
attachments = await resolveContextAttachments(descriptors);
67+
if (retry) {
68+
userMessage = previous!.messages.at(-2)!;
69+
} else {
70+
const attachments = await resolveContextAttachments(descriptors);
71+
const recentFiles = runtime.chat.includeRecentFiles === false ? [] : runtime.chat.recentFiles;
72+
userMessage = createUserMessage(globalThis.crypto.randomUUID(), text, attachments, recentFiles);
73+
}
6274
} catch (error) {
6375
const detail = error instanceof Error ? error.message : 'Unknown error.';
6476
await this.finishPreparation(preparation, `Unable to read the selected context attachment. ${detail}`);
6577
return;
6678
}
6779
if (this.preparation !== preparation) return;
6880

69-
const previous = runtime.chat.conversation;
7081
const startedAt = Date.now();
71-
const conversationId = previous?.id ?? globalThis.crypto.randomUUID();
72-
if (this.requests.has(conversationId)) {
73-
await this.finishPreparation(preparation);
74-
return;
75-
}
76-
77-
const turnId = globalThis.crypto.randomUUID();
78-
const recentFiles = runtime.chat.includeRecentFiles === false ? [] : runtime.chat.recentFiles;
79-
const userMessage = createUserMessage(turnId, text, attachments, recentFiles);
82+
const turnId = userMessage.metadata.turnId;
83+
const history = retry ? previous!.messages.slice(0, -2) : (previous?.messages ?? []);
8084
let providerMessages;
8185
try {
82-
providerMessages = await buildModelMessages(previous?.messages ?? [], [userMessage]);
86+
providerMessages = await buildModelMessages(history, [userMessage]);
8387
} catch (error) {
8488
const detail = error instanceof Error ? error.message : 'Unknown error.';
8589
await this.finishPreparation(preparation, `Unable to prepare the conversation. ${detail}`);
@@ -91,7 +95,7 @@ export class ConversationRunner {
9195
const conversation: Conversation = previous
9296
? {
9397
...previous,
94-
messages: [...previous.messages, userMessage, assistantMessage],
98+
messages: [...history, userMessage, assistantMessage],
9599
updatedAt: startedAt,
96100
}
97101
: {
@@ -116,15 +120,15 @@ export class ConversationRunner {
116120
return;
117121
}
118122
if (this.preparation !== preparation) {
119-
await this.finishTurn(conversationId, turnId, 'aborted');
123+
await this.setMessageStatus(conversationId, assistantMessage.id, 'aborted');
120124
return;
121125
}
122126

123127
this.preparation = undefined;
124128
const abortController = new AbortController();
125129
const request: ActiveRequest = {
126130
controller: abortController,
127-
turnId,
131+
messageId: assistantMessage.id,
128132
};
129133
this.requests.set(conversationId, request);
130134
let mcp: Awaited<ReturnType<typeof connectMcpTools>> | undefined;
@@ -134,7 +138,7 @@ export class ConversationRunner {
134138
await this.stores.runtime.setIn('chat', {
135139
...currentRuntime.chat,
136140
conversation,
137-
pendingAttachments: [],
141+
pendingAttachments: retry ? currentRuntime.chat.pendingAttachments : [],
138142
preparing: false,
139143
});
140144
await this.stores.conversations.select(conversationId);
@@ -154,16 +158,16 @@ export class ConversationRunner {
154158
if (!abortController.signal.aborted && this.requests.get(conversationId) === request) {
155159
const usage = await agentStream.usage;
156160
if (!abortController.signal.aborted && this.requests.get(conversationId) === request) {
157-
await this.completeTurn(conversationId, turnId, usage);
161+
await this.setMessageStatus(conversationId, request.messageId, 'completed', undefined, usage);
158162
}
159163
}
160164
} catch (error) {
161165
if (this.requests.get(conversationId) !== request) return;
162166
if (abortController.signal.aborted) {
163-
await this.finishTurn(conversationId, turnId, 'aborted');
167+
await this.setMessageStatus(conversationId, request.messageId, 'aborted');
164168
} else {
165169
const message = error instanceof Error ? error.message : 'Unknown error.';
166-
await this.finishTurn(conversationId, turnId, 'failed', message);
170+
await this.setMessageStatus(conversationId, request.messageId, 'failed', message);
167171
}
168172
} finally {
169173
if (this.requests.get(conversationId) === request) this.requests.delete(conversationId);
@@ -185,7 +189,7 @@ export class ConversationRunner {
185189
if (!request) return;
186190
this.requests.delete(conversationId);
187191
request.controller.abort();
188-
await this.finishTurn(conversationId, request.turnId, 'aborted');
192+
await this.setMessageStatus(conversationId, request.messageId, 'aborted');
189193
}
190194

191195
isRequestActive(conversationId: string | undefined): boolean {
@@ -218,32 +222,17 @@ export class ConversationRunner {
218222
await this.publishState();
219223
}
220224

221-
private async completeTurn(conversationId: string, turnId: string, usage: LanguageModelUsage): Promise<void> {
222-
await this.setTurnStatus(conversationId, turnId, 'completed', undefined, usage);
223-
}
224-
225-
private async finishTurn(
226-
conversationId: string,
227-
turnId: string,
228-
status: 'aborted' | 'failed',
229-
error?: string,
230-
): Promise<void> {
231-
await this.setTurnStatus(conversationId, turnId, status, error);
232-
}
233-
234-
private async setTurnStatus(
225+
private async setMessageStatus(
235226
conversationId: string,
236-
turnId: string,
227+
messageId: string,
237228
status: 'completed' | 'aborted' | 'failed',
238229
error?: string,
239230
usage?: LanguageModelUsage,
240231
): Promise<void> {
241232
const conversation = await this.stores.conversations.get(conversationId);
242-
if (!conversation) return;
233+
if (!conversation?.messages.some((message) => message.id === messageId)) return;
243234
const messages = conversation.messages.map((message) =>
244-
message.role === 'assistant' && message.metadata.turnId === turnId
245-
? withMessageStatus(message, status, error)
246-
: message,
235+
message.id === messageId ? withMessageStatus(message, status, error) : message,
247236
);
248237
const updatedAt = Date.now();
249238
const nextUsage = usage === undefined ? undefined : addUsage(conversation.usage, usage);

‎extensions/github1s-ai/src/llm/helpers.ts‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { convertToModelMessages, type ModelMessage } from 'ai';
1+
import { convertToModelMessages, getToolName, isToolUIPart, type ModelMessage } from 'ai';
22

33
import { contextReferencePath, type ContextAttachment, type ContextReference } from '@/common/context';
4-
import type { ConversationMessage } from '@/common/conversation';
4+
import { isInterruptedMessage, type ConversationMessage } from '@/common/conversation';
55

66
const MAX_PROMPT_HISTORY_CHARACTERS = 128 * 1024;
77

@@ -10,7 +10,7 @@ export const buildModelMessages = (
1010
input: readonly ConversationMessage[],
1111
): Promise<ModelMessage[]> =>
1212
convertToModelMessages<ConversationMessage>(
13-
[...selectWholeRecentTurns(history, MAX_PROMPT_HISTORY_CHARACTERS), ...input],
13+
[...selectWholeRecentTurns(history, MAX_PROMPT_HISTORY_CHARACTERS).map(repairInterruptedMessage), ...input],
1414
{
1515
ignoreIncompleteToolCalls: true,
1616
convertDataPart: (part) => {
@@ -24,6 +24,39 @@ export const buildModelMessages = (
2424
},
2525
);
2626

27+
// Repair only the model input. Keep the original partial response intact for display.
28+
const repairInterruptedMessage = (message: ConversationMessage): ConversationMessage => {
29+
if (message.role !== 'assistant' || !isInterruptedMessage(message)) return message;
30+
let endsWithToolStep = false;
31+
const parts = message.parts.flatMap<ConversationMessage['parts'][number]>((part) => {
32+
// Interrupted provider items and reasoning signatures may not be replayable.
33+
if (part.type === 'text') return part.text.trim() ? [{ type: 'text', text: part.text }] : [];
34+
if (part.type === 'step-start') {
35+
endsWithToolStep = false;
36+
return [part];
37+
}
38+
if (!isToolUIPart(part) || part.state === 'input-streaming') return [];
39+
endsWithToolStep = true;
40+
const tool = {
41+
type: 'dynamic-tool' as const,
42+
toolName: getToolName(part),
43+
toolCallId: part.toolCallId,
44+
input: part.state === 'output-error' && 'rawInput' in part ? (part.input ?? part.rawInput) : part.input,
45+
};
46+
if (part.state === 'output-available' && !part.preliminary)
47+
return [{ ...tool, state: 'output-available', output: part.output }];
48+
if (part.state === 'output-error') return [{ ...tool, state: 'output-error', errorText: part.errorText }];
49+
if (part.state === 'output-denied' || (part.state === 'approval-responded' && !part.approval.approved))
50+
return [{ ...tool, state: 'output-error', errorText: part.approval.reason ?? 'Tool execution was denied.' }];
51+
return [{ ...tool, state: 'output-error', errorText: 'Tool execution was interrupted; its result is unknown.' }];
52+
});
53+
// Tool results follow all text in their step, so close that step before the next user turn.
54+
if (endsWithToolStep || parts.at(-1)?.type !== 'text') {
55+
parts.push({ type: 'step-start' }, { type: 'text', text: '[The previous assistant response was interrupted.]' });
56+
}
57+
return { ...message, parts };
58+
};
59+
2760
const selectWholeRecentTurns = (
2861
history: readonly ConversationMessage[],
2962
maxCharacters: number,

‎extensions/github1s-ai/src/webview/components/ChatPage.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { html } from 'htm/preact';
22
import { useLayoutEffect, useRef } from 'preact/hooks';
33

4-
import type { ConversationMessage } from '@/common/conversation';
4+
import { getRetryableMessage, type ConversationMessage } from '@/common/conversation';
55
import type { ViewEvent, ViewState } from '@/common/protocol';
66
import { QUICK_ACTIONS } from '@/common/quick-actions';
77

@@ -47,6 +47,7 @@ interface TranscriptProps extends ChatContentProps {
4747
}
4848

4949
const Transcript = ({ state, messages, busy, post }: TranscriptProps) => {
50+
const retryableMessage = busy ? undefined : getRetryableMessage(messages);
5051
const transcript = useRef<HTMLElement>(null);
5152
const content = useRef<HTMLDivElement>(null);
5253
const followOutput = useRef(true);
@@ -78,7 +79,15 @@ const Transcript = ({ state, messages, busy, post }: TranscriptProps) => {
7879
<div ref=${content} class="transcript-content">
7980
${messages.length === 0
8081
? html`<${EmptyChat} state=${state} busy=${busy} post=${post} />`
81-
: messages.map((message) => html`<${MessageView} key=${message.id} message=${message} post=${post} />`)}
82+
: messages.map(
83+
(message) =>
84+
html`<${MessageView}
85+
key=${message.id}
86+
message=${message}
87+
canRetry=${message === retryableMessage}
88+
post=${post}
89+
/>`,
90+
)}
8291
</div>
8392
</section>`;
8493
};

0 commit comments

Comments
 (0)