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) => (
-
+
- )}
-
+ )}
+
+
+
+ Discuss with Assistant
+
+
@@ -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;
}