From e992eb43c162dc6053ebd0b67614caf6bd4feeaa Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 6 May 2026 17:20:59 +0100 Subject: [PATCH] Show Patrol record briefings in Assistant handoff --- .../v6/internal/subsystems/ai-runtime.md | 3 + .../v6/internal/subsystems/api-contracts.md | 4 +- .../subsystems/frontend-primitives.md | 5 +- .../subsystems/patrol-intelligence.md | 5 +- .../AI/Chat/__tests__/AIChat.test.tsx | 44 +++++++++++++++ .../src/components/AI/Chat/index.tsx | 55 +++++++++++++++++++ .../src/components/AI/FindingsPanel.tsx | 11 +++- .../AI/__tests__/FindingsPanel.test.ts | 12 +++- .../patrolInvestigationContextModel.test.ts | 46 ++++++++++++++++ .../patrol/patrolInvestigationContextModel.ts | 46 ++++++++++++++++ .../src/stores/__tests__/aiChat.test.ts | 10 +++- frontend-modern/src/stores/aiChat.ts | 15 ++++- .../frontendResourceTypeBoundaries.test.ts | 3 + 13 files changed, 251 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 128930260..f98d460a8 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index b2aecb333..3fddf1af7 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 6659b9116..9b8b1a0a2 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 20c0d9590..765400e05 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -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 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 8cbfb8b9f..2b385fb55 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -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...'); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 57eb401c8..599237fc0 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -311,6 +311,9 @@ export const AIChat: Component = (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 = (props) => { + +
+
+ {contextBriefing()!.sourceLabel} + + + {contextBriefing()!.statusLabel} + +
+
+ {contextBriefing()!.title} +
+ +
{contextBriefing()!.subject}
+
+ 0}> +
+ + {(line) =>
{line}
} +
+
+
+ 0}> +
+ + {(item) => ( + + {item} + + )} + +
+
+ +
+ +
{contextBriefing()!.actionLabel}
+
+ +
{contextBriefing()!.commandSummary}
+
+ +
{contextBriefing()!.safetyNote}
+
+
+
+
+
+ {/* Messages */} = (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, }); }; diff --git a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts index 22d8afac6..7d5e8af52 100644 --- a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts +++ b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts @@ -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, ), diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index 9d0fa4f98..3b4d1a1f8 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -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'); + }); }); diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index 55458e461..e5af82784 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -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 = { 'metrics.history': 'Metrics history', 'ssh.exec': 'SSH exec', diff --git a/frontend-modern/src/stores/__tests__/aiChat.test.ts b/frontend-modern/src/stores/__tests__/aiChat.test.ts index 4af1dd182..aa0f114b4 100644 --- a/frontend-modern/src/stores/__tests__/aiChat.test.ts +++ b/frontend-modern/src/stores/__tests__/aiChat.test.ts @@ -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', () => { diff --git a/frontend-modern/src/stores/aiChat.ts b/frontend-modern/src/stores/aiChat.ts index c520526db..54753b11c 100644 --- a/frontend-modern/src/stores/aiChat.ts +++ b/frontend-modern/src/stores/aiChat.ts @@ -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; 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; } diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 578969663..8d8405ef6 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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)', );