diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 6f4ac3008..a62696006 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts index ccd99acca..29dc64123 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -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'; diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 6689c357a..3f8c437b6 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -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 { diff --git a/frontend-modern/src/components/AI/Chat/types.ts b/frontend-modern/src/components/AI/Chat/types.ts index 185c10559..6e5315662 100644 --- a/frontend-modern/src/components/AI/Chat/types.ts +++ b/frontend-modern/src/components/AI/Chat/types.ts @@ -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