Keep Assistant session transitions success-bound

This commit is contained in:
rcourtman
2026-05-07 13:15:54 +01:00
parent a3973f19ee
commit 92ee9c3b1e
6 changed files with 85 additions and 8 deletions
@@ -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
@@ -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
@@ -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([
{
@@ -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();
@@ -607,7 +607,7 @@ export function useChat(options: UseChatOptions = {}) {
};
// Load session messages
const loadSession = async (id: string) => {
const loadSession = async (id: string): Promise<boolean> => {
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;
}
};
@@ -913,7 +913,8 @@ export const AIChat: Component<AIChatProps> = (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<AIChatProps> = (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);