From 73b4cf25c38f94de61f8ad5739a7a2840183d4f6 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 7 May 2026 08:52:38 +0100 Subject: [PATCH] Add Patrol assessment Assistant handoff --- .../v6/internal/subsystems/api-contracts.md | 5 + .../subsystems/frontend-primitives.md | 7 +- .../subsystems/patrol-intelligence.md | 18 + .../patrol/PatrolIntelligenceSummary.tsx | 57 ++- .../PatrolIntelligenceSummary.test.tsx | 141 ++++++ .../patrolInvestigationContextModel.test.ts | 123 +++++ .../patrol/patrolInvestigationContextModel.ts | 428 +++++++++++++++++- 7 files changed, 769 insertions(+), 10 deletions(-) create mode 100644 frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 73a754611..049947412 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -431,6 +431,11 @@ the canonical monitored-system blocked payload. and the shared dashboard-load bundle inside `frontend-modern/src/stores/aiIntelligence.ts`, so the page orchestration stays on the store-owned bundle instead of enumerating the AI fetches inline and the shared `frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx` card, so the AI summary page renders the governed policy-posture counts while the resource drawer stays on per-resource policy lines instead of carrying duplicate posture UI loops and the dedicated `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` owner, so recent-change, learned-correlation, and policy-coverage summary text stays derived from the canonical AI payload in one place instead of as hook-local count and pluralization logic + and that same Patrol investigation-context owner, so the current Patrol + assessment summary may open Assistant with bounded model-only assessment, + verification, latest-run, supporting-context, active-finding, and resource + reference context instead of pasting page-local UI text or raw command + payloads into chat and that same Patrol investigation-context owner, so visible Assistant drawer handoffs may include live pending-approval metadata only as safe operator context: approval ID, status, risk, requested/expiry timestamps, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index ec83c1c3c..8cfcf9fe8 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -809,7 +809,12 @@ 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. The drawer may render a generic + Patrol-specific prompt formatter. Patrol assessment-level handoffs must use + that same feature helper to attach bounded model-only assessment, + verification, latest-run, supporting-context, active-finding, and resource + reference context while forcing request-local approval-required mode, so the + shared drawer stays a generic shell rather than a Patrol summary prompt + builder. 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, attention reason, evidence summaries, operator-decision copy, action copy, and safety note. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 8326f773b..c9a60a835 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -188,6 +188,16 @@ Patrol-specific presentation helpers. structured `investigationRecord` as investigation data, even when the legacy investigation-detail endpoint has no separate session payload, so it must not render empty-state copy above a durable Patrol record. + The primary Patrol assessment summary may open Assistant as a whole-surface + review handoff, but that handoff must also flow through + `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`. + The summary action may pass the current assessment title, health score, + verification recency, latest run, secondary investigation context, bounded + active-finding summaries, and structured resource references as model-only + context. It must force request-local approval-required mode, keep raw command + and approval payloads out of prompt and drawer copy, and frame Assistant as + explanation, prioritization, and safe next-step review rather than a generic + reactive chat box. ## Current State @@ -235,6 +245,14 @@ render contract: the header chip, primary summary card, and status bar must all route through the shared `frontend-modern/src/utils/patrolRuntimePresentation.ts` helper plus the backend `runtime_state` payload instead of inferring operator state from the last healthy summary snapshot or run history alone. +The primary summary card now also has a Patrol-owned Assistant assessment +handoff. `frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx` +opens Assistant through +`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`, which +packages the current Patrol assessment, verification posture, latest run, +secondary investigation context, bounded active-finding summaries, and deduped +resource references as model-only context while forcing `autonomousMode:false` +and summarizing proposed-fix command-bearing records by count only. That active-runtime label must stay operational rather than verdict-like: the header chip should communicate that Patrol is enabled or available, not imply that infrastructure health is currently good merely because the runtime is on. diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx index 62e34d2cf..7bbe3dab8 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx @@ -4,6 +4,7 @@ import ShieldAlertIcon from 'lucide-solid/icons/shield-alert'; import CheckCircleIcon from 'lucide-solid/icons/check-circle'; import AlertCircleIcon from 'lucide-solid/icons/alert-circle'; import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle'; +import MessageSquareIcon from 'lucide-solid/icons/message-square'; import { getPatrolAssessmentAction, getPatrolAssessmentShellPresentation, @@ -20,6 +21,8 @@ import { } from '@/utils/patrolRunPresentation'; import { getPatrolRuntimePresentation } from '@/utils/patrolRuntimePresentation'; import { formatRelativeTime } from '@/utils/format'; +import { aiChatStore } from '@/stores/aiChat'; +import { buildPatrolAssessmentAssistantHandoff } from './patrolInvestigationContextModel'; import type { PatrolIntelligenceState } from './usePatrolIntelligenceState'; function PatrolAssessmentLoadingShell() { @@ -212,6 +215,30 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat return metrics; }); + const assessmentAssistantHandoff = createMemo(() => + buildPatrolAssessmentAssistantHandoff({ + assessment: assessment(), + overallHealth: state.intelligenceSummary()?.overall_health, + scoreChipLabel: scoreChipLabel(), + metricState: metricState(), + verification: verification(), + recency: recency(), + latestRun: latestRun(), + investigationContext: { + recentChangeCount: state.recentChangeCount(), + correlationCount: state.correlationTotal(), + governedResourceCount: state.policyPosture()?.total_resources ?? 0, + hasContext: state.hasInvestigationContext(), + summaryText: state.investigationContextSummary(), + }, + activeFindings: state.activePatrolFindings(), + }), + ); + + const handleDiscussAssessment = () => { + const handoff = assessmentAssistantHandoff(); + aiChatStore.openWithPrompt(handoff.prompt, handoff.context); + }; const renderAssessmentIcon = () => { const iconClass = `w-5 h-5 ${assessmentShellPresentation().iconClass}`; @@ -307,18 +334,28 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat

{assessment().description}

- - {(action) => ( -
+
+ + {(action) => ( {action().label} -
- )} - + )} + + +
@@ -364,7 +401,9 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat {(latest) => ( <>
- {latest().kindLabel} + + {latest().kindLabel} + @@ -414,7 +453,9 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat - 0}> + 0} + >
{(metric) => { diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx new file mode 100644 index 000000000..e48aa5022 --- /dev/null +++ b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx @@ -0,0 +1,141 @@ +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { aiChatStore } from '@/stores/aiChat'; +import { PatrolIntelligenceSummary } from '../PatrolIntelligenceSummary'; +import type { PatrolIntelligenceState } from '../usePatrolIntelligenceState'; + +describe('PatrolIntelligenceSummary', () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('opens Assistant with model-only context for the current Patrol assessment', () => { + const openWithPrompt = vi.spyOn(aiChatStore, 'openWithPrompt').mockImplementation(() => {}); + + render(() => ); + + fireEvent.click(screen.getByTestId('patrol-assessment-assistant-button')); + + expect(openWithPrompt).toHaveBeenCalledTimes(1); + const [prompt, context] = openWithPrompt.mock.calls[0] as [string, Record]; + expect(prompt).toContain('Discuss the current Pulse Patrol assessment'); + expect(context.autonomousMode).toBe(false); + expect(context.handoffContext).toContain('[Patrol Assessment Context]'); + expect(context.handoffContext).toContain('Source: Pulse Patrol current assessment'); + expect(context.handoffContext).toContain('Supporting Context: 1 recent change'); + expect(context.handoffContext).toContain('Finding 1: High CPU usage'); + expect(context.handoffResources).toEqual([ + { id: 'vm-100', name: 'web-server', type: 'vm', node: 'pve-1' }, + ]); + expect(JSON.stringify(context)).not.toContain('systemctl restart workload.service'); + }); +}); + +function createPatrolState(): PatrolIntelligenceState { + return { + activePatrolFindings: () => [ + { + id: 'finding-1', + source: 'ai-patrol', + resourceId: 'vm-100', + resourceName: 'web-server', + resourceType: 'vm', + category: 'performance', + severity: 'critical', + title: 'High CPU usage', + description: 'CPU stayed above 95%.', + detectedAt: '2026-05-06T12:00:00Z', + lastSeenAt: '2026-05-06T12:10:00Z', + status: 'active', + investigationStatus: 'completed', + investigationOutcome: 'fix_queued', + loopState: 'awaiting_approval', + timesRaised: 3, + regressionCount: 1, + investigationRecord: { + id: 'record-1', + finding_id: 'finding-1', + subject: { + resource_id: 'vm-100', + resource_name: 'web-server', + resource_type: 'vm', + node: 'pve-1', + }, + 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: ['ssh.exec'], + started_at: '2026-05-06T12:00:00Z', + }, + }, + ], + blockedReason: () => undefined, + circuitBreakerStatus: () => undefined, + correlationTotal: () => 2, + hasInvestigationContext: () => true, + initialSurfaceReady: () => true, + intelligenceSummary: () => ({ + overall_health: { + grade: 'B', + score: 84, + factors: [], + prediction: 'Patrol surfaced one active critical finding.', + }, + recent_changes_count: 1, + policy_posture: { + total_resources: 4, + sensitivity_counts: {}, + routing_counts: {}, + }, + }), + investigationContextSummary: () => + '1 recent change · 2 correlations · 4 policy-covered resources', + patrolRunHistory: { + value: () => [ + { + id: 'run-1', + started_at: '2026-05-06T12:00:00Z', + completed_at: '2026-05-06T12:10:00Z', + type: 'full', + status: 'issues_found', + resources_checked: 12, + new_findings: 1, + error_count: 0, + finding_ids: ['finding-1'], + }, + ], + }, + patrolStatus: () => ({ + last_patrol_at: '2026-05-06T12:10:00Z', + last_activity_at: '2026-05-06T12:10:00Z', + }), + policyPosture: () => ({ + total_resources: 4, + sensitivity_counts: {}, + routing_counts: {}, + }), + recentChangeCount: () => 1, + runtimeState: () => 'active', + summaryStats: () => ({ + criticalFindings: 1, + warningFindings: 0, + totalActive: 1, + fixedCount: 0, + hasAnyPatrolFindings: true, + }), + } as unknown as PatrolIntelligenceState; +} diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index 7d61df7c2..b14e5a355 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { RemediationPlan } from '@/api/ai'; import { + buildPatrolAssessmentAssistantHandoff, buildPatrolAssistantFindingBriefing, buildPatrolAssistantFindingPrompt, buildPatrolInvestigationContextSummary, @@ -89,6 +90,128 @@ describe('patrolInvestigationContextModel', () => { }); }); + it('builds a model-only Assistant handoff for the current Patrol assessment', () => { + const handoff = buildPatrolAssessmentAssistantHandoff({ + assessment: { + title: 'Issues detected', + description: + 'Patrol surfaced one active critical finding and recent coverage is incomplete.', + eyebrow: 'Current assessment', + }, + overallHealth: { grade: 'B', score: 84 }, + scoreChipLabel: 'Health', + metricState: { + primaryLabel: 'Infrastructure findings', + primaryValue: 1, + secondaryLabel: 'Runtime issues', + secondaryValue: 1, + fixedLabel: 'Fixed', + fixedValue: 2, + }, + verification: { + title: 'Full patrol verified recently', + description: 'Latest full run completed with findings.', + lastFullRunAt: '2026-05-06T12:00:00Z', + activityMixLabel: '1 full patrol · 2 scoped runs', + }, + recency: { + label: 'Last patrol', + timestamp: '2026-05-06T12:10:00Z', + }, + latestRun: { + kindLabel: 'Full patrol', + status: { label: 'issues found' }, + timestamp: '2026-05-06T12:10:00Z', + coverageSummary: '12 resources checked', + findingsSnapshotAvailable: true, + }, + investigationContext: { + recentChangeCount: 1, + correlationCount: 2, + governedResourceCount: 4, + hasContext: true, + summaryText: '1 recent change · 2 correlations · 4 policy-covered resources', + }, + activeFindings: [ + { + id: 'finding-1', + title: 'High CPU usage', + description: 'CPU stayed above 95% during backup.', + severity: 'critical', + status: 'active', + resourceId: 'vm-100', + resourceName: 'web-server', + resourceType: 'vm', + detectedAt: '2026-05-06T12:00:00Z', + lastSeenAt: '2026-05-06T12:10:00Z', + investigationStatus: 'completed', + investigationOutcome: 'fix_queued', + loopState: 'awaiting_approval', + timesRaised: 3, + regressionCount: 1, + lastRegressionAt: '2026-05-06T12:06:00Z', + investigationRecord: { + id: 'record-1', + finding_id: 'finding-1', + subject: { + resource_id: 'vm-100', + resource_name: 'web-server', + resource_type: 'vm', + node: 'pve-1', + }, + 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: ['ssh.exec'], + started_at: '2026-05-06T12:00:00Z', + }, + }, + { + id: 'finding-2', + title: 'Patrol provider warning', + severity: 'warning', + status: 'active', + resourceId: 'vm-100', + resourceName: 'web-server', + resourceType: 'vm', + }, + ], + }); + + expect(handoff.prompt).toContain('Discuss the current Pulse Patrol assessment'); + expect(handoff.context.autonomousMode).toBe(false); + expect(handoff.context.handoffContext).toContain('[Patrol Assessment Context]'); + expect(handoff.context.handoffContext).toContain('Source: Pulse Patrol current assessment'); + expect(handoff.context.handoffContext).toContain('Health: Health B 84/100'); + expect(handoff.context.handoffContext).toContain( + 'Supporting Context: 1 recent change · 2 correlations · 4 policy-covered resources', + ); + expect(handoff.context.handoffContext).toContain('Finding 1: High CPU usage'); + expect(handoff.context.handoffContext).toContain('1 command recorded for approval context'); + expect(handoff.context.handoffResources).toEqual([ + { id: 'vm-100', name: 'web-server', type: 'vm', node: 'pve-1' }, + ]); + expect(handoff.context.briefing).toMatchObject({ + sourceLabel: 'Pulse Patrol', + title: 'Patrol assessment attached', + subject: 'Issues detected', + safetyNote: 'Diagnostics and remediation require governed approval.', + }); + expect(JSON.stringify(handoff)).not.toContain('systemctl restart workload.service'); + }); + it('builds operator-facing Patrol record presentation without exposing raw commands', () => { const presentation = buildPatrolInvestigationRecordPresentation({ id: 'record-1', diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index 94f8a9b9e..6e669e089 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -3,7 +3,7 @@ import type { IntelligencePolicyPostureSummary, } from '@/types/aiIntelligence'; import type { InvestigationRecord, RemediationPlan } from '@/api/ai'; -import type { AIChatContextBriefing } from '@/stores/aiChat'; +import type { AIChatContext, AIChatContextBriefing, AIChatHandoffResource } from '@/stores/aiChat'; export interface PatrolInvestigationContextSummaryInput { recentChangesCount?: number | null; @@ -83,6 +83,76 @@ export interface PatrolRemediationPlanAssistantInput { plan: RemediationPlan; } +export interface PatrolAssessmentAssistantFindingInput { + id?: string | null; + title?: string | null; + description?: string | null; + severity?: string | null; + status?: string | null; + resourceId?: string | null; + resourceName?: string | null; + resourceType?: string | null; + detectedAt?: string | null; + lastSeenAt?: string | null; + investigationStatus?: string | null; + investigationOutcome?: string | null; + loopState?: string | null; + timesRaised?: number | null; + regressionCount?: number | null; + lastRegressionAt?: string | null; + investigationRecord?: InvestigationRecord | null; +} + +export interface PatrolAssessmentAssistantHandoffInput { + assessment?: { + title?: string | null; + description?: string | null; + eyebrow?: string | null; + } | null; + overallHealth?: { + grade?: string | null; + score?: number | null; + } | null; + scoreChipLabel?: string | null; + metricState?: { + primaryLabel?: string | null; + primaryValue?: number | null; + secondaryLabel?: string | null; + secondaryValue?: number | null; + fixedLabel?: string | null; + fixedValue?: number | null; + } | null; + verification?: { + title?: string | null; + description?: string | null; + lastFullRunAt?: string | null; + activityMixLabel?: string | null; + } | null; + recency?: { + label?: string | null; + timestamp?: string | null; + } | null; + latestRun?: { + kindLabel?: string | null; + status?: { + label?: string | null; + } | null; + timestamp?: string | null; + coverageSummary?: string | null; + findingsSnapshotAvailable?: boolean | null; + } | null; + investigationContext?: PatrolInvestigationContextSummary | null; + activeFindings?: PatrolAssessmentAssistantFindingInput[] | null; +} + +export interface PatrolAssessmentAssistantHandoff { + prompt: string; + context: Omit; +} + +const MAX_ASSESSMENT_FINDINGS = 5; +const MAX_ASSESSMENT_RESOURCES = 8; + export function buildPatrolInvestigationContextSummary( input: PatrolInvestigationContextSummaryInput, ): PatrolInvestigationContextSummary { @@ -183,6 +253,348 @@ export function buildPatrolAssistantFindingPrompt( return prompt; } +export function buildPatrolAssessmentAssistantHandoff( + input: PatrolAssessmentAssistantHandoffInput, +): PatrolAssessmentAssistantHandoff { + const title = normalizeText(input.assessment?.title) || 'Pulse Patrol assessment'; + const description = normalizeText(input.assessment?.description); + const handoffContext = buildPatrolAssessmentAssistantModelContext(input); + + return { + prompt: [ + `Discuss the current Pulse Patrol assessment: ${title}.`, + description, + 'Use the attached model-only Patrol assessment context before suggesting next actions. Help me understand priority, risk, and safe next steps.', + 'Do not infer, repeat, or execute raw command text from this handoff.', + ] + .filter(isNonEmptyString) + .join('\n\n'), + context: { + targetType: 'dashboard', + targetId: 'pulse-patrol-assessment', + autonomousMode: false, + handoffContext, + handoffResources: buildPatrolAssessmentHandoffResources(input.activeFindings ?? []), + briefing: buildPatrolAssessmentAssistantBriefing(input), + context: { + source: 'pulse-patrol-assessment', + activeFindingCount: normalizeNonNegativeCount(input.activeFindings?.length), + recentChangeCount: input.investigationContext?.recentChangeCount ?? 0, + correlationCount: input.investigationContext?.correlationCount ?? 0, + governedResourceCount: input.investigationContext?.governedResourceCount ?? 0, + }, + }, + }; +} + +function buildPatrolAssessmentAssistantBriefing( + input: PatrolAssessmentAssistantHandoffInput, +): AIChatContextBriefing { + const title = normalizeText(input.assessment?.title) || 'Pulse Patrol assessment'; + const description = normalizeText(input.assessment?.description); + const health = formatAssessmentHealth(input); + const attentionSummary = formatAssessmentAttentionSummary(input); + const verification = formatAssessmentVerification(input); + const latestRun = formatAssessmentLatestRun(input); + const contextSummary = normalizeText(input.investigationContext?.summaryText); + const findings = normalizeAssessmentFindings(input.activeFindings); + + return { + sourceLabel: 'Pulse Patrol', + title: 'Patrol assessment attached', + subject: title, + statusLabel: [health, attentionSummary].filter(isNonEmptyString).join(' · ') || undefined, + detailLines: [description, verification, latestRun, contextSummary] + .filter(isNonEmptyString) + .slice(0, 4), + evidence: findings.map(formatAssessmentFindingEvidence).filter(isNonEmptyString).slice(0, 4), + actionLabel: 'Discuss Patrol assessment', + safetyNote: 'Diagnostics and remediation require governed approval.', + }; +} + +function buildPatrolAssessmentAssistantModelContext( + input: PatrolAssessmentAssistantHandoffInput, +): string { + const findings = normalizeAssessmentFindings(input.activeFindings); + const totalFindingCount = normalizeNonNegativeCount(input.activeFindings?.length); + const omittedFindingCount = Math.max(0, totalFindingCount - findings.length); + + return [ + '[Patrol Assessment Context]', + 'Source: Pulse Patrol current assessment', + formatContextLine( + 'Assessment', + normalizeText(input.assessment?.title) || 'Pulse Patrol assessment', + ), + formatContextLine('Assessment Description', input.assessment?.description), + formatContextLine('Assessment Scope', input.assessment?.eyebrow), + formatContextLine('Health', formatAssessmentHealth(input)), + formatContextLine('Attention', formatAssessmentAttentionSummary(input)), + formatContextLine('Verification', formatAssessmentVerification(input)), + formatContextLine('Last Patrol', formatAssessmentRecency(input)), + formatContextLine('Latest Run', formatAssessmentLatestRun(input)), + formatContextLine('Supporting Context', input.investigationContext?.summaryText), + ...findings.map((finding, index) => formatAssessmentFindingContextLine(finding, index + 1)), + omittedFindingCount > 0 + ? `${omittedFindingCount} additional Patrol finding${omittedFindingCount === 1 ? '' : 's'} omitted from this bounded handoff summary.` + : undefined, + 'Operator Boundary: This Patrol assessment handoff is model-only context for explanation and review. Diagnostics, remediation, and command execution require explicit governed approval.', + ] + .filter(isNonEmptyString) + .join('\n'); +} + +function buildPatrolAssessmentHandoffResources( + findings: PatrolAssessmentAssistantFindingInput[], +): AIChatHandoffResource[] { + const resources = new Map(); + + for (const finding of findings) { + const resource = getAssessmentFindingResource(finding); + if (!resource.id) continue; + + const key = [resource.type, resource.id].filter(isNonEmptyString).join(':'); + const existing = resources.get(key); + if (existing) { + resources.set(key, { + ...existing, + name: existing.name || resource.name, + type: existing.type || resource.type, + node: existing.node || resource.node, + }); + continue; + } + resources.set(key, resource); + + if (resources.size >= MAX_ASSESSMENT_RESOURCES) break; + } + + return Array.from(resources.values()); +} + +function normalizeAssessmentFindings( + findings?: PatrolAssessmentAssistantFindingInput[] | null, +): PatrolAssessmentAssistantFindingInput[] { + return (findings ?? []) + .filter((finding) => + Boolean( + normalizeText(finding.id) || + normalizeText(finding.title) || + normalizeText(finding.resourceId) || + normalizeText(finding.investigationRecord?.id), + ), + ) + .slice(0, MAX_ASSESSMENT_FINDINGS); +} + +function formatAssessmentHealth(input: PatrolAssessmentAssistantHandoffInput): string | undefined { + const label = normalizeText(input.scoreChipLabel) || 'Health'; + const grade = normalizeText(input.overallHealth?.grade); + const score = input.overallHealth?.score; + const scoreLabel = + typeof score === 'number' && Number.isFinite(score) ? `${Math.round(score)}/100` : ''; + return [label, grade, scoreLabel].filter(isNonEmptyString).join(' ') || undefined; +} + +function formatAssessmentAttentionSummary( + input: PatrolAssessmentAssistantHandoffInput, +): string | undefined { + const metricState = input.metricState; + const parts: string[] = []; + const primaryValue = normalizeNonNegativeCount(metricState?.primaryValue); + const secondaryValue = normalizeNonNegativeCount(metricState?.secondaryValue); + const fixedValue = normalizeNonNegativeCount(metricState?.fixedValue); + + if (primaryValue > 0) { + parts.push( + formatAssessmentMetricCount(metricState?.primaryLabel || 'Active findings', primaryValue), + ); + } + if ( + secondaryValue > 0 && + normalizeText(metricState?.secondaryLabel) !== normalizeText(metricState?.primaryLabel) + ) { + parts.push( + formatAssessmentMetricCount(metricState?.secondaryLabel || 'Warnings', secondaryValue), + ); + } + if (fixedValue > 0) { + parts.push(formatAssessmentMetricCount(metricState?.fixedLabel || 'Fixed', fixedValue)); + } + + if (parts.length === 0) { + const activeFindingCount = normalizeNonNegativeCount(input.activeFindings?.length); + return activeFindingCount > 0 + ? formatAssessmentMetricCount('Active findings', activeFindingCount) + : 'No active Patrol findings'; + } + + return parts.join(' · '); +} + +function formatAssessmentMetricCount(label: string | null | undefined, value: number): string { + const normalizedLabel = normalizeText(label) || 'Items'; + const displayLabel = value === 1 ? singularizeMetricLabel(normalizedLabel) : normalizedLabel; + return `${value} ${displayLabel.toLowerCase()}`; +} + +function singularizeMetricLabel(label: string): string { + if (label.toLowerCase().endsWith('ies')) { + return `${label.slice(0, -3)}y`; + } + if (label.toLowerCase().endsWith('s')) { + return label.slice(0, -1); + } + return label; +} + +function formatAssessmentVerification( + input: PatrolAssessmentAssistantHandoffInput, +): string | undefined { + const verification = input.verification; + if (!verification) return undefined; + + return [ + normalizeText(verification.title), + normalizeText(verification.description), + normalizeText(verification.lastFullRunAt) + ? `last full run ${normalizeText(verification.lastFullRunAt)}` + : undefined, + normalizeText(verification.activityMixLabel) + ? `recent activity mix ${normalizeText(verification.activityMixLabel)}` + : undefined, + ] + .filter(isNonEmptyString) + .join('; '); +} + +function formatAssessmentRecency(input: PatrolAssessmentAssistantHandoffInput): string | undefined { + const label = normalizeText(input.recency?.label); + const timestamp = normalizeText(input.recency?.timestamp); + if (!label && !timestamp) return undefined; + return [label || 'Last Patrol', timestamp].filter(isNonEmptyString).join(' '); +} + +function formatAssessmentLatestRun( + input: PatrolAssessmentAssistantHandoffInput, +): string | undefined { + const latestRun = input.latestRun; + if (!latestRun) return undefined; + + return [ + normalizeText(latestRun.kindLabel), + normalizeText(latestRun.status?.label), + normalizeText(latestRun.timestamp), + normalizeText(latestRun.coverageSummary), + latestRun.findingsSnapshotAvailable === false ? 'findings snapshot unavailable' : undefined, + ] + .filter(isNonEmptyString) + .join('; '); +} + +function formatAssessmentFindingEvidence( + finding: PatrolAssessmentAssistantFindingInput, +): string | undefined { + const title = normalizeText(finding.title) || 'Patrol finding'; + const resource = getAssessmentFindingResource(finding); + const severityStatus = [ + formatIdentifierLabel(finding.severity), + formatIdentifierLabel(finding.status), + ] + .filter(isNonEmptyString) + .join(' '); + const resourceLabel = formatAssessmentResourceLabel(resource); + return [title, resourceLabel, severityStatus].filter(isNonEmptyString).join(' · '); +} + +function formatAssessmentFindingContextLine( + finding: PatrolAssessmentAssistantFindingInput, + index: number, +): string { + const title = truncateContextText(normalizeText(finding.title) || 'Patrol finding', 120); + const resource = getAssessmentFindingResource(finding); + const record = buildPatrolInvestigationRecordPresentation(finding.investigationRecord); + const statusParts = [ + formatIdentifierLabel(finding.severity), + formatIdentifierLabel(finding.status), + formatIdentifierLabel(finding.investigationStatus), + formatIdentifierLabel(finding.investigationOutcome), + formatIdentifierLabel(finding.loopState), + ].filter(isNonEmptyString); + const raisedParts = [ + normalizeNonNegativeCount(finding.timesRaised) > 1 + ? `raised ${normalizeNonNegativeCount(finding.timesRaised)} times` + : undefined, + normalizeNonNegativeCount(finding.regressionCount) > 0 + ? `regressed ${normalizeNonNegativeCount(finding.regressionCount)} time${ + normalizeNonNegativeCount(finding.regressionCount) === 1 ? '' : 's' + }` + : undefined, + normalizeText(finding.lastRegressionAt) + ? `last regression ${normalizeText(finding.lastRegressionAt)}` + : undefined, + ]; + const recordParts = [ + record.statusLabel, + record.outcomeLabel, + record.confidenceLabel, + record.conclusion ? `conclusion ${truncateContextText(record.conclusion, 180)}` : undefined, + record.recommendedAction + ? `recommended ${truncateContextText(record.recommendedAction, 180)}` + : undefined, + record.proposedFix?.description + ? `proposed fix ${truncateContextText(record.proposedFix.description, 160)}` + : undefined, + record.proposedFix?.commandSummary, + record.proposedFix?.destructive ? 'destructive proposed fix' : undefined, + record.error ? `investigation error ${truncateContextText(record.error, 160)}` : undefined, + ]; + + const parts = [ + `${title} on ${formatAssessmentResourceLabel(resource) || 'affected resource'}`, + normalizeText(finding.id) ? `finding ${normalizeText(finding.id)}` : undefined, + statusParts.length > 0 ? statusParts.join(' ') : undefined, + normalizeText(finding.description) + ? `description ${truncateContextText(finding.description, 180)}` + : undefined, + normalizeText(finding.detectedAt) ? `detected ${normalizeText(finding.detectedAt)}` : undefined, + normalizeText(finding.lastSeenAt) + ? `last seen ${normalizeText(finding.lastSeenAt)}` + : undefined, + ...raisedParts, + ...recordParts, + ].filter(isNonEmptyString); + + return `Finding ${index}: ${parts.join('; ')}`; +} + +function getAssessmentFindingResource( + finding: PatrolAssessmentAssistantFindingInput, +): AIChatHandoffResource { + const subject = finding.investigationRecord?.subject; + return { + id: normalizeText(finding.resourceId) || normalizeText(subject?.resource_id), + name: normalizeText(finding.resourceName) || normalizeText(subject?.resource_name) || undefined, + type: normalizeText(finding.resourceType) || normalizeText(subject?.resource_type) || undefined, + node: normalizeText(subject?.node) || undefined, + }; +} + +function formatAssessmentResourceLabel(resource: AIChatHandoffResource): string | undefined { + const name = normalizeText(resource.name); + const id = normalizeText(resource.id); + const type = normalizeText(resource.type); + const node = normalizeText(resource.node); + const label = name || id; + if (!label) return undefined; + + const qualifiers = [type, id && id !== label ? id : undefined, node ? `node ${node}` : undefined] + .filter(isNonEmptyString) + .join(' '); + return qualifiers ? `${label} (${qualifiers})` : label; +} + export function patrolAssistantFindingHandoffRequiresApprovalMode( input: PatrolAssistantFindingModeInput, ): boolean { @@ -665,6 +1077,20 @@ function normalizeText(value?: string | null): string { return value.trim(); } +function truncateContextText(value?: string | null, limit: number = 240): string { + const normalized = normalizeText(value).replace(/\s+/g, ' '); + if (!normalized || normalized.length <= limit) { + return normalized; + } + return `${normalized.slice(0, Math.max(0, limit - 3)).trim()}...`; +} + +function formatContextLine(label: string, value?: string | null): string | undefined { + const normalized = truncateContextText(value, 500); + if (!normalized) return undefined; + return `${label}: ${normalized}`; +} + function isNonEmptyString(value: string | undefined): value is string { return typeof value === 'string' && value.trim().length > 0; }