Show Patrol record briefings in Assistant handoff

This commit is contained in:
rcourtman 2026-05-06 17:20:59 +01:00
parent b84fc2301a
commit e992eb43c1
13 changed files with 251 additions and 8 deletions

View file

@ -177,6 +177,9 @@ runtime cost control, and shared AI transport surfaces.
runtime must enrich the prompt from that durable record before provider
transport while keeping proposed-fix command text out of the chat prompt;
command payloads remain approval-context data, not conversational copy.
The Assistant drawer may also render an attached context briefing for that
handoff, but the briefing is runtime context visibility only: it must not
mutate chat control settings, execute tools, or reveal raw command payloads.
## Current State

View file

@ -747,7 +747,9 @@ the canonical monitored-system blocked payload.
and the Assistant finding-context request contract, so `/api/ai/chat`
payloads carrying `finding_id` may hydrate a structured investigation
summary from the unified finding, but raw proposed-fix commands must stay
out of the prompt and inside governed approval/remediation context.
out of the prompt and inside governed approval/remediation context; frontend
handoff briefings must derive from the same shared investigation payload
rather than inventing a second finding-context transport shape.
7. Keep Patrol summary payload consumers aligned on one assessment hierarchy: transport-driven Patrol summary surfaces may show supporting counts and outcomes, but the canonical assessment and verification states must remain singular and not be repeated as a second compact verdict strip
8. Keep Patrol verification and activity facts unified on one transport-backed secondary status area: when frontend consumers combine Patrol status payloads (`runtime_state`, `last_patrol_at`, `last_activity_at`, `trigger_status`) with run-history transport, the latest run result, activity mix, scoped-trigger state, and circuit-breaker context must read as one supporting explanation beneath the primary assessment instead of being re-expanded into a separate full-width status strip plus duplicate summary layers
and the main Patrol page composition boundary, so once that governed

View file

@ -809,7 +809,10 @@ frontend primitive boundary.
investigation-record framing must derive that prompt copy through
`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`
so shared drawer primitives stay shell-owned rather than becoming a
Patrol-specific prompt formatter.
Patrol-specific prompt formatter. The drawer may render a generic
context-briefing band from `frontend-modern/src/stores/aiChat.ts`, but
feature-owned helpers must provide the source labels, evidence summaries,
action copy, and safety note.
11. Keep shared filter primitives coherent with route-owned option hydration.
Feature shells such as `frontend-modern/src/features/infrastructure/`
must keep a route-owned canonical option visible in shared selects like

View file

@ -98,7 +98,10 @@ Patrol-specific presentation helpers.
record when transport carries one. `frontend-modern/src/stores/aiIntelligence.ts`
may retain `investigationRecord` as data for Assistant handoff and Patrol
presentation, but visible Patrol copy and Assistant handoff prompt framing
must flow through the governed Patrol investigation-context helpers.
must flow through the governed Patrol investigation-context helpers. Those
helpers also own the Assistant drawer briefing content for Patrol records,
including the rule that proposed-fix commands are summarized by count only
and never rendered as raw command text in the handoff surface.
## Current State

View file

@ -78,6 +78,19 @@ const {
initialPrompt: undefined as string | undefined,
findingId: undefined as string | undefined,
autonomousMode: undefined as boolean | undefined,
briefing: undefined as
| {
sourceLabel: string;
title: string;
subject?: string;
statusLabel?: string;
detailLines?: string[];
evidence?: string[];
actionLabel?: string;
commandSummary?: string;
safetyNote?: string;
}
| undefined,
},
clearInitialPrompt: vi.fn(),
clearFindingId: vi.fn(),
@ -223,6 +236,7 @@ beforeEach(() => {
initialPrompt: undefined,
findingId: undefined,
autonomousMode: undefined,
briefing: undefined,
};
mockChat.messages.mockReturnValue([]);
mockChat.isLoading.mockReturnValue(false);
@ -280,6 +294,34 @@ describe('AIChat', () => {
expect(screen.getByText('to mention resources')).toBeInTheDocument();
});
it('renders attached context briefing without raw command text', () => {
mockAiChatStore.context = {
initialPrompt: undefined,
findingId: 'finding-1',
autonomousMode: undefined,
briefing: {
sourceLabel: 'Pulse Patrol',
title: 'Investigation record attached',
subject: 'High CPU usage on web-server',
statusLabel: 'Completed · Fix Queued · High confidence',
detailLines: ['Backup job saturated CPU.'],
evidence: ['CPU stayed above 95% for 10 minutes'],
actionLabel: 'Restart the workload service',
commandSummary: '1 command recorded for approval context',
safetyNote: 'Command details stay in approval context.',
},
};
renderChat();
expect(screen.getByLabelText('Assistant context')).toBeInTheDocument();
expect(screen.getByText('Pulse Patrol')).toBeInTheDocument();
expect(screen.getByText('High CPU usage on web-server')).toBeInTheDocument();
expect(screen.getByText('Backup job saturated CPU.')).toBeInTheDocument();
expect(screen.getByText('1 command recorded for approval context')).toBeInTheDocument();
expect(screen.queryByText('systemctl restart workload.service')).not.toBeInTheDocument();
});
it('renders New button', () => {
renderChat();
expect(screen.getByText('New')).toBeInTheDocument();
@ -961,6 +1003,7 @@ describe('AIChat', () => {
initialPrompt: undefined,
findingId: undefined,
autonomousMode: false,
briefing: undefined,
};
renderChat();
@ -1070,6 +1113,7 @@ describe('AIChat', () => {
initialPrompt: undefined,
findingId: 'finding-123',
autonomousMode: undefined,
briefing: undefined,
};
renderChat();
const textarea = screen.getByPlaceholderText('Ask about your infrastructure...');

View file

@ -311,6 +311,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
});
const hasScopedApprovalHandoff = createMemo(() => aiChatStore.context.autonomousMode === false);
const contextBriefing = createMemo(() => aiChatStore.context.briefing);
const contextBriefingEvidence = createMemo(() => contextBriefing()?.evidence ?? []);
const contextBriefingDetails = createMemo(() => contextBriefing()?.detailLines ?? []);
// Compute current status for display
const currentStatus = createMemo(() => {
@ -1179,6 +1182,58 @@ export const AIChat: Component<AIChatProps> = (props) => {
</div>
</Show>
<Show when={contextBriefing()}>
<section
class="border-b border-border bg-surface px-4 py-3"
aria-label="Assistant context"
>
<div class="flex flex-wrap items-center gap-2 text-[10px] font-medium uppercase text-muted">
<span>{contextBriefing()!.sourceLabel}</span>
<Show when={contextBriefing()!.statusLabel}>
<span class="h-1 w-1 rounded-full bg-border" />
<span class="normal-case">{contextBriefing()!.statusLabel}</span>
</Show>
</div>
<div class="mt-1 text-sm font-semibold text-base-content">
{contextBriefing()!.title}
</div>
<Show when={contextBriefing()!.subject}>
<div class="mt-0.5 text-xs text-muted">{contextBriefing()!.subject}</div>
</Show>
<Show when={contextBriefingDetails().length > 0}>
<div class="mt-2 space-y-1">
<For each={contextBriefingDetails()}>
{(line) => <div class="text-xs text-base-content leading-relaxed">{line}</div>}
</For>
</div>
</Show>
<Show when={contextBriefingEvidence().length > 0}>
<div class="mt-2 flex flex-wrap gap-1.5">
<For each={contextBriefingEvidence()}>
{(item) => (
<span class="rounded border border-border bg-surface-alt px-2 py-1 text-[11px] text-muted">
{item}
</span>
)}
</For>
</div>
</Show>
<Show when={contextBriefing()!.actionLabel || contextBriefing()!.commandSummary}>
<div class="mt-2 rounded border border-border bg-surface-alt px-2.5 py-2 text-[11px] text-muted">
<Show when={contextBriefing()!.actionLabel}>
<div class="font-medium text-base-content">{contextBriefing()!.actionLabel}</div>
</Show>
<Show when={contextBriefing()!.commandSummary}>
<div>{contextBriefing()!.commandSummary}</div>
</Show>
<Show when={contextBriefing()!.safetyNote}>
<div>{contextBriefing()!.safetyNote}</div>
</Show>
</div>
</Show>
</section>
</Show>
{/* Messages */}
<ChatMessages
messages={chat.messages()}

View file

@ -17,7 +17,10 @@ import { FormSelect } from '@/components/shared/FormSelect';
import { aiIntelligenceStore, type UnifiedFinding } from '@/stores/aiIntelligence';
import { notificationStore } from '@/stores/notifications';
import { aiChatStore } from '@/stores/aiChat';
import { buildPatrolAssistantFindingPrompt } from '@/features/patrol/patrolInvestigationContextModel';
import {
buildPatrolAssistantFindingBriefing,
buildPatrolAssistantFindingPrompt,
} from '@/features/patrol/patrolInvestigationContextModel';
import { useResources } from '@/hooks/useResources';
import { InvestigationSection, ApprovalSection } from '@/components/patrol';
import type { RemediationPlan } from '@/api/ai';
@ -424,10 +427,16 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
description: finding.description,
investigationRecord: finding.investigationRecord,
});
const briefing = buildPatrolAssistantFindingBriefing({
title,
subject,
investigationRecord: finding.investigationRecord,
});
aiChatStore.openWithPrompt(prompt, {
targetType: finding.resourceType,
targetId: finding.resourceId,
findingId: finding.id,
briefing,
});
};

View file

@ -42,6 +42,14 @@ const patrolWorkspaceSource = readFileSync(
'utf-8',
);
describe('FindingsPanel assistant handoff', () => {
it('routes Patrol investigation records into the Assistant briefing context', () => {
expect(findingsPanelSource).toContain('buildPatrolAssistantFindingBriefing');
expect(findingsPanelSource).toContain('briefing,');
expect(findingsPanelSource).toContain('investigationRecord: finding.investigationRecord');
});
});
describe('aiFindingPresentation', () => {
describe('severity presentation', () => {
it('has correct sort order for critical', () => {
@ -679,7 +687,7 @@ describe('aiFindingPresentation', () => {
status: 'pending',
toolId: 'investigation_fix',
targetId: 'finding-1',
expiresAt: '2026-04-30T00:06:00Z',
expiresAt: '2026-12-30T00:06:00Z',
},
] as never),
).toBe(true);
@ -707,7 +715,7 @@ describe('aiFindingPresentation', () => {
status: 'pending',
toolId: 'investigation_fix',
targetId: 'finding-1',
expiresAt: '2026-04-30T00:06:00Z',
expiresAt: '2026-12-30T00:06:00Z',
},
] as never,
),

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
buildPatrolAssistantFindingBriefing,
buildPatrolAssistantFindingPrompt,
buildPatrolInvestigationContextSummary,
buildPatrolInvestigationRecordPresentation,
@ -155,4 +156,49 @@ describe('patrolInvestigationContextModel', () => {
}),
).toContain('Use that record as the main context before suggesting next actions.');
});
it('builds a drawer briefing for Assistant handoff without exposing raw commands', () => {
const briefing = buildPatrolAssistantFindingBriefing({
title: 'High CPU usage',
subject: 'web-server',
investigationRecord: {
id: 'record-1',
finding_id: 'finding-1',
subject: { resource_id: 'vm-100' },
trigger: { detected_at: '2026-05-06T12:00:00Z' },
status: 'completed',
outcome: 'fix_queued',
confidence: 'high',
conclusion: 'Backup job saturated CPU.',
recommended_action: 'Approve a controlled restart after the backup completes.',
evidence: [{ kind: 'metrics', summary: 'CPU stayed above 95% for 10 minutes' }],
proposed_fix: {
id: 'fix-1',
description: 'Restart the workload service',
commands: ['systemctl restart workload.service'],
risk_level: 'medium',
destructive: false,
},
verification: ['CPU returned below 50%'],
tools_used: [],
started_at: '2026-05-06T12:00:00Z',
},
});
expect(briefing).toEqual({
sourceLabel: 'Pulse Patrol',
title: 'Investigation record attached',
subject: 'High CPU usage on web-server',
statusLabel: 'Completed · Fix Queued · High confidence',
detailLines: [
'Backup job saturated CPU.',
'Approve a controlled restart after the backup completes.',
],
evidence: ['CPU stayed above 95% for 10 minutes', 'Verified: CPU returned below 50%'],
actionLabel: 'Restart the workload service',
commandSummary: '1 command recorded for approval context',
safetyNote: 'Command details stay in approval context.',
});
expect(JSON.stringify(briefing)).not.toContain('systemctl restart workload.service');
});
});

View file

@ -3,6 +3,7 @@ import type {
IntelligencePolicyPostureSummary,
} from '@/types/aiIntelligence';
import type { InvestigationRecord } from '@/api/ai';
import type { AIChatContextBriefing } from '@/stores/aiChat';
export interface PatrolInvestigationContextSummaryInput {
recentChangesCount?: number | null;
@ -46,6 +47,12 @@ export interface PatrolAssistantFindingPromptInput {
investigationRecord?: InvestigationRecord | null;
}
export interface PatrolAssistantFindingBriefingInput {
title: string;
subject: string;
investigationRecord?: InvestigationRecord | null;
}
export function buildPatrolInvestigationContextSummary(
input: PatrolInvestigationContextSummaryInput,
): PatrolInvestigationContextSummary {
@ -146,6 +153,41 @@ export function buildPatrolAssistantFindingPrompt(
return prompt;
}
export function buildPatrolAssistantFindingBriefing(
input: PatrolAssistantFindingBriefingInput,
): AIChatContextBriefing | undefined {
const record = buildPatrolInvestigationRecordPresentation(input.investigationRecord);
if (!record.hasRecord) {
return undefined;
}
const title = normalizeText(input.title) || 'Patrol finding';
const subject = normalizeText(input.subject) || 'affected resource';
const statusParts = [
record.statusLabel,
record.outcomeLabel,
record.confidenceLabel,
].filter(isNonEmptyString);
const detailLines = [record.conclusion, record.recommendedAction]
.filter(isNonEmptyString)
.slice(0, 2);
const verificationLines = record.verificationSummaries.map((summary) => `Verified: ${summary}`);
return {
sourceLabel: 'Pulse Patrol',
title: 'Investigation record attached',
subject: `${title} on ${subject}`,
statusLabel: statusParts.join(' · ') || undefined,
detailLines,
evidence: [...record.evidenceSummaries, ...verificationLines].slice(0, 4),
actionLabel: record.proposedFix?.description,
commandSummary: record.proposedFix?.commandSummary,
safetyNote: record.proposedFix?.commandSummary
? 'Command details stay in approval context.'
: undefined,
};
}
function normalizeCorrelationCount(correlations?: CorrelationsResponse | null): number {
if (!correlations) return 0;
if (typeof correlations.count === 'number' && Number.isFinite(correlations.count)) {
@ -193,6 +235,10 @@ function normalizeText(value?: string | null): string {
return value.trim();
}
function isNonEmptyString(value: string | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
const PATROL_TOOL_LABELS: Record<string, string> = {
'metrics.history': 'Metrics history',
'ssh.exec': 'SSH exec',

View file

@ -95,10 +95,18 @@ describe('aiChatStore', () => {
});
it('opens with a pre-filled prompt', () => {
aiChatStore.openWithPrompt('hello', { targetType: 'vm', targetId: 'vm-101' });
aiChatStore.openWithPrompt('hello', {
targetType: 'vm',
targetId: 'vm-101',
briefing: {
sourceLabel: 'Pulse Patrol',
title: 'Investigation record attached',
},
});
expect(aiChatStore.isOpen).toBe(true);
expect(aiChatStore.context.initialPrompt).toBe('hello');
expect(aiChatStore.context.targetId).toBe('vm-101');
expect(aiChatStore.context.briefing?.title).toBe('Investigation record attached');
});
it('preserves scoped autonomous-mode overrides for pre-filled prompts', () => {

View file

@ -4,12 +4,25 @@ import { eventBus } from '@/stores/events';
// NOTE: AIAPI import removed - session management is handled by Pulse AI's embedded UI
import type { AIChatSessionSummary } from '@/types/ai';
interface AIChatContext {
export interface AIChatContextBriefing {
sourceLabel: string;
title: string;
subject?: string;
statusLabel?: string;
detailLines?: string[];
evidence?: string[];
actionLabel?: string;
commandSummary?: string;
safetyNote?: string;
}
export interface AIChatContext {
targetType?: string;
targetId?: string;
context?: Record<string, unknown>;
initialPrompt?: string;
findingId?: string; // If opened from AI Insights "Get Help", the finding ID to resolve on success
briefing?: AIChatContextBriefing;
// Per-request execution mode override; false keeps scoped handoffs approval-required.
autonomousMode?: boolean;
}

View file

@ -4348,6 +4348,9 @@ describe('frontend resource type boundaries', () => {
expect(patrolInvestigationContextModelSource).toContain(
'export function buildPatrolAssistantFindingPrompt',
);
expect(patrolInvestigationContextModelSource).toContain(
'export function buildPatrolAssistantFindingBriefing',
);
expect(patrolInvestigationContextModelSource).toContain(
'formatCommandSummary(record.proposed_fix.commands?.length ?? 0)',
);