Preserve Assistant retry request context

This commit is contained in:
rcourtman
2026-06-05 14:50:53 +01:00
parent 58769c3be9
commit e977db434f
4 changed files with 172 additions and 1 deletions
@@ -99,6 +99,11 @@ runtime cost control, and shared AI transport surfaces.
replay one-shot finding handoff, approval, autonomous-mode, or other scoped
send options. Scoped context replay remains owned by explicit session
handoff metadata or queued follow-up edit state.
Failed-turn retry is part of that same local chat-runtime boundary: a
retryable in-memory assistant error may replay the original user turn's
structured mentions, finding id, approval override, handoff resources,
handoff actions, and handoff metadata, but must not reconstruct scoped
context from prompt history or saved transcript prose.
Assistant output hygiene is part of the same boundary: provider reasoning
and raw serialized tool-call artifacts must never render as assistant
transcript prose. Reasoning/thinking deltas may update neutral progress
@@ -265,6 +265,72 @@ describe('useChat', () => {
dispose();
});
it('retryMessage preserves mentions, finding handoff, and scoped send options', async () => {
mockChat.mockRejectedValueOnce(new Error('server error')).mockResolvedValueOnce(undefined);
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 'sess' }));
await chat.sendMessage(
'check this resource',
[{ id: 'vm-100', name: 'web-1', type: 'vm', node: 'pve-1' }],
'finding-42',
{
autonomousMode: false,
handoffContext: '[Patrol Finding Context]\nFinding ID: finding-42',
handoffResources: [
{
id: 'vm-100',
name: 'web-1',
type: 'vm',
node: 'pve-1',
},
],
handoffActions: [
{
findingId: 'finding-42',
approvalId: 'approval-1',
approvalStatus: 'pending',
},
],
handoffMetadata: {
kind: 'patrol_finding',
},
},
);
const failed = chat.messages().find((m) => m.role === 'assistant');
expect(failed?.error).toContain('server error');
chat.retryMessage(failed!.id);
await new Promise((r) => setTimeout(r, 0));
expect(mockChat).toHaveBeenCalledTimes(2);
const retryCall = mockChat.mock.calls[1];
expect(retryCall[0]).toBe('check this resource');
expect(retryCall[5]).toEqual([{ id: 'vm-100', name: 'web-1', type: 'vm', node: 'pve-1' }]);
expect(retryCall[6]).toBe('finding-42');
expect(retryCall[7]).toBe(false);
expect(retryCall[8]).toBe('[Patrol Finding Context]\nFinding ID: finding-42');
expect(retryCall[9]).toEqual([
{
id: 'vm-100',
name: 'web-1',
type: 'vm',
node: 'pve-1',
},
]);
expect(retryCall[10]).toEqual([
{
findingId: 'finding-42',
approvalId: 'approval-1',
approvalStatus: 'pending',
},
]);
expect(retryCall[11]).toEqual({
kind: 'patrol_finding',
});
dispose();
});
it('handles AbortError silently (returns false, no notification)', async () => {
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
@@ -19,6 +19,7 @@ import type {
PendingQuestion,
PendingTool,
WorkflowStatus,
ChatMessageRequestContext,
} from '../types';
const generateId = () => Math.random().toString(36).substring(2, 9);
@@ -257,6 +258,80 @@ export function useChat(options: UseChatOptions = {}) {
return typeof id === 'string' ? id.trim() : '';
};
const cloneMentions = (mentions?: ChatMention[]): ChatMention[] | undefined =>
mentions?.map((mention) => ({ ...mention }));
const cloneSendOptions = (
sendOptions?: SendMessageOptions,
): ChatMessageRequestContext | undefined => {
if (!sendOptions) return undefined;
const requestContext: ChatMessageRequestContext = {};
if (typeof sendOptions.autonomousMode === 'boolean') {
requestContext.autonomousMode = sendOptions.autonomousMode;
}
if (sendOptions.handoffContext) {
requestContext.handoffContext = sendOptions.handoffContext;
}
if (sendOptions.handoffResources?.length) {
requestContext.handoffResources = sendOptions.handoffResources.map((resource) => ({
...resource,
}));
}
if (sendOptions.handoffActions?.length) {
requestContext.handoffActions = sendOptions.handoffActions.map((action) => ({ ...action }));
}
if (sendOptions.handoffMetadata) {
requestContext.handoffMetadata = { ...sendOptions.handoffMetadata };
}
return Object.keys(requestContext).length > 0 ? requestContext : undefined;
};
const buildRequestContext = (
mentions?: ChatMention[],
findingId?: string,
sendOptions?: SendMessageOptions,
): ChatMessageRequestContext | undefined => {
const requestContext: ChatMessageRequestContext = {
...(cloneSendOptions(sendOptions) || {}),
};
const clonedMentions = cloneMentions(mentions);
if (clonedMentions?.length) {
requestContext.mentions = clonedMentions;
}
if (findingId) {
requestContext.findingId = findingId;
}
return Object.keys(requestContext).length > 0 ? requestContext : undefined;
};
const sendOptionsFromRequestContext = (
request?: ChatMessageRequestContext,
): SendMessageOptions | undefined => {
if (!request) return undefined;
const sendOptions: SendMessageOptions = {};
if (typeof request.autonomousMode === 'boolean') {
sendOptions.autonomousMode = request.autonomousMode;
}
if (request.handoffContext) {
sendOptions.handoffContext = request.handoffContext;
}
if (request.handoffResources?.length) {
sendOptions.handoffResources = request.handoffResources.map((resource) => ({ ...resource }));
}
if (request.handoffActions?.length) {
sendOptions.handoffActions = request.handoffActions.map((action) => ({ ...action }));
}
if (request.handoffMetadata) {
sendOptions.handoffMetadata = { ...request.handoffMetadata };
}
return Object.keys(sendOptions).length > 0 ? sendOptions : undefined;
};
const applyStreamSessionId = (streamSessionId: string) => {
if (!streamSessionId) return;
const previousSessionId = sessionId();
@@ -633,6 +708,7 @@ export function useChat(options: UseChatOptions = {}) {
content: trimmedPrompt,
timestamp,
delivery: 'queued',
request: buildRequestContext(mentions, findingId, sendOptions),
};
const queuedFollowUp: QueuedFollowUp = {
@@ -674,6 +750,7 @@ export function useChat(options: UseChatOptions = {}) {
role: 'user',
content: trimmedPrompt,
timestamp: new Date(),
request: buildRequestContext(mentions, findingId, sendOptions),
};
const assistantId = generateId();
@@ -1026,9 +1103,15 @@ export function useChat(options: UseChatOptions = {}) {
if (userIdx < 0) return;
const prompt = msgs[userIdx].content;
if (!prompt.trim()) return;
const request = msgs[userIdx].request;
const removeIds = new Set([msgs[userIdx].id, assistantMessageId]);
setMessages((prev) => prev.filter((m) => !removeIds.has(m.id)));
void sendMessage(prompt);
void sendMessage(
prompt,
cloneMentions(request?.mentions),
request?.findingId,
sendOptionsFromRequestContext(request),
);
};
return {
@@ -1,4 +1,10 @@
// Chat component types
import type {
ChatHandoffAction,
ChatHandoffMetadata,
ChatHandoffResource,
ChatMention,
} from '@/api/aiChat';
export interface ToolExecution {
name: string;
@@ -109,11 +115,22 @@ export interface WorkflowStatus {
tool?: string;
}
export interface ChatMessageRequestContext {
mentions?: ChatMention[];
findingId?: string;
autonomousMode?: boolean;
handoffContext?: string;
handoffResources?: ChatHandoffResource[];
handoffActions?: ChatHandoffAction[];
handoffMetadata?: ChatHandoffMetadata;
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
delivery?: 'sent' | 'queued';
request?: ChatMessageRequestContext;
interruption?: 'stopped' | 'replaced';
// Clean, user-facing error for a failed turn. Rendered as a distinct error
// block (not as answer content) so partial streamed content is preserved and