From 92ee9c3b1efb7f17b3760808343e0ee8a62801ac Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 7 May 2026 13:15:54 +0100 Subject: [PATCH] Keep Assistant session transitions success-bound --- .../v6/internal/subsystems/ai-runtime.md | 6 +- .../subsystems/frontend-primitives.md | 4 +- .../AI/Chat/__tests__/AIChat.test.tsx | 67 ++++++++++++++++++- .../AI/Chat/__tests__/useChat.test.ts | 6 +- .../src/components/AI/Chat/hooks/useChat.ts | 4 +- .../src/components/AI/Chat/index.tsx | 6 +- 6 files changed, 85 insertions(+), 8 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 88560b066..383c471b7 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -289,7 +289,11 @@ runtime cost control, and shared AI transport surfaces. safe visible briefing: the next chat turn must carry `autonomous_mode:false` even when the summary is context-only and has no queued action, while the visible badge/action copy must still reflect the - actual last-known action state instead of inventing a pending approval. + actual last-known action state instead of inventing a pending approval. That + restoration is success-bound: if the underlying session message load fails, + the drawer must leave the current context untouched instead of applying + summary-derived Patrol or approval state for a session the operator is not + actually viewing. Before `/api/ai/sessions` returns summaries with stored handoff action references, the chat runtime must refresh their safe approval/action status from the canonical approval store and action-audit store. Session listing is diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 03d310041..eaaa8ce9c 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -189,7 +189,9 @@ work extends shared components instead of creating new local variants. context. The drawer must treat `handoff_summary.requires_approval` as a current pending-decision flag, not a historical action marker, so completed or rejected handoff actions render as action context rather than pending - approval. + approval. Session-load and new-conversation transitions must be + success-bound: if the underlying session operation fails, the shared drawer + store must not clear or replace the current scoped handoff context. 9. `frontend-modern/src/utils/platformSupportManifest.generated.ts` shared with `unified-resources`: the generated platform support projection is both a canonical unified-resource platform union boundary and a shared frontend source/platform vocabulary boundary. 10. `frontend-modern/src/utils/sourcePlatforms.ts` shared with `unified-resources`: the source platform normalizer is both a canonical unified-resource source adapter boundary and a shared frontend source/platform vocabulary boundary. That shared boundary must preserve `availability` as the agentless diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index 07190201c..273af7dfb 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -23,7 +23,7 @@ const { sendMessage: vi.fn().mockResolvedValue(true), stop: vi.fn(), clearMessages: vi.fn(), - loadSession: vi.fn().mockResolvedValue(undefined), + loadSession: vi.fn().mockResolvedValue(true), newSession: vi.fn().mockResolvedValue({ id: 'new-sess', title: '', @@ -569,6 +569,27 @@ describe('AIChat', () => { }); }); + it('keeps scoped handoff context when starting a new session fails', async () => { + mockChat.newSession.mockResolvedValueOnce(null); + mockAiChatStore.context = { + findingId: 'finding-old', + autonomousMode: false, + briefing: { + sourceLabel: 'Pulse Patrol', + title: 'Old finding handoff', + }, + }; + + renderChat(); + fireEvent.click(screen.getByText('New')); + + await waitFor(() => { + expect(mockChat.newSession).toHaveBeenCalledTimes(1); + }); + expect(mockAiChatStore.clearContext).not.toHaveBeenCalled(); + expect(mockAiChatStore.context.findingId).toBe('finding-old'); + }); + it('opens session picker on click', async () => { renderChat(); fireEvent.click(screen.getByTitle('Pulse Assistant sessions')); @@ -700,6 +721,50 @@ describe('AIChat', () => { expect(restoredContext.handoffActions).toBeUndefined(); }); + it('does not restore handoff context when loading a session fails', async () => { + mockChat.loadSession.mockResolvedValueOnce(false); + mockAIChatAPI.listSessions.mockResolvedValue([ + { + id: 's-patrol', + title: 'High CPU follow-up', + created_at: '', + updated_at: '', + message_count: 4, + handoff_summary: { + kind: 'patrol_finding', + finding_id: 'finding-operator-briefing', + has_model_context: true, + resource_count: 1, + primary_resource: { + id: 'host:web-server', + name: 'web-server', + type: 'host', + node: 'pve-1', + }, + action_count: 1, + requires_approval: true, + }, + }, + ]); + + renderChat(); + await waitFor(() => { + expect(mockAIChatAPI.listSessions).toHaveBeenCalled(); + }); + fireEvent.click(screen.getByTitle('Pulse Assistant sessions')); + await waitFor(() => { + expect(screen.getByText('High CPU follow-up')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('High CPU follow-up')); + + await waitFor(() => { + expect(mockChat.loadSession).toHaveBeenCalledWith('s-patrol'); + }); + expect(mockAiChatStore.setContext).not.toHaveBeenCalled(); + expect(mockAiChatStore.clearContext).not.toHaveBeenCalled(); + }); + it('shows completed Patrol actions as action context instead of pending approval', async () => { mockAIChatAPI.listSessions.mockResolvedValue([ { 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 b17bb95f8..8e3c1618f 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -1010,8 +1010,9 @@ describe('useChat', () => { ]); const { value: chat, dispose } = withRoot(() => useChat()); - await chat.loadSession('sess-42'); + const loaded = await chat.loadSession('sess-42'); + expect(loaded).toBe(true); expect(chat.sessionId()).toBe('sess-42'); const msgs = chat.messages(); expect(msgs).toHaveLength(2); @@ -1026,8 +1027,9 @@ describe('useChat', () => { mockGetMessages.mockRejectedValue(new Error('not found')); const { value: chat, dispose } = withRoot(() => useChat()); - await chat.loadSession('bad-id'); + const loaded = await chat.loadSession('bad-id'); + expect(loaded).toBe(false); expect(mockNotifyError).toHaveBeenCalledWith('Failed to load session'); expect(chat.messages()).toEqual([]); dispose(); diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 19c4528bd..bdd9c65df 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -607,7 +607,7 @@ export function useChat(options: UseChatOptions = {}) { }; // Load session messages - const loadSession = async (id: string) => { + const loadSession = async (id: string): Promise => { try { const msgs = await AIChatAPI.getMessages(id); setMessages( @@ -620,9 +620,11 @@ export function useChat(options: UseChatOptions = {}) { })), ); setSessionId(id); + return true; } catch (error) { logger.error('[useChat] Failed to load session:', error); notificationStore.error('Failed to load session'); + return false; } }; diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 799a7fbfa..ec0d82fb3 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -913,7 +913,8 @@ export const AIChat: Component = (props) => { // New conversation const handleNewConversation = async () => { - await chat.newSession(); + const session = await chat.newSession(); + if (!session) return; aiChatStore.clearContext?.(); setShowSessions(false); }; @@ -947,7 +948,8 @@ export const AIChat: Component = (props) => { // Load session const handleLoadSession = async (sessionId: string) => { const session = sessions().find((candidate) => candidate.id === sessionId); - await chat.loadSession(sessionId); + const loaded = await chat.loadSession(sessionId); + if (!loaded) return; const restoredContext = buildSessionHandoffContext(session); if (restoredContext) { aiChatStore.setContext(restoredContext);