mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
Keep Assistant session transitions success-bound
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user