From 671431a862a8eddbbae1c58d5c8ef6466c9dd199 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 7 May 2026 22:56:06 +0100 Subject: [PATCH] Make Patrol handoffs context-first - frame coverage-incomplete assessments as verification gaps - keep Assistant empty states aligned with attached handoff context - cover Patrol briefing prompts and chat empty-state presentation --- .../v6/internal/subsystems/ai-runtime.md | 5 +- .../v6/internal/subsystems/api-contracts.md | 6 ++ .../subsystems/frontend-primitives.md | 9 ++- .../subsystems/patrol-intelligence.md | 6 ++ .../AI/Chat/__tests__/AIChat.test.tsx | 26 ++++++++ .../src/components/AI/Chat/index.tsx | 16 +++-- .../patrolInvestigationContextModel.test.ts | 64 +++++++++++++++++++ .../patrol/patrolInvestigationContextModel.ts | 58 ++++++++++++++++- .../__tests__/aiChatPresentation.test.ts | 22 +++++++ .../src/utils/aiChatPresentation.ts | 39 +++++++++++ 10 files changed, 240 insertions(+), 11 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 68b8ad1cb..a1f043189 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -86,7 +86,10 @@ runtime cost control, and shared AI transport surfaces. instead of generic dashboard briefs. Source-owned handoff helpers may attach bounded suggested prompts to that briefing, but those prompts are only input starters; they must not auto-submit, bypass approval mode, or carry raw - command payloads. Reloaded Assistant sessions may consume the backend-owned + command payloads. While such a handoff is attached, the Assistant empty + message state must also remain source-named and must not fall back to generic + cluster/system starter prompts that compete with the attached briefing. + Reloaded Assistant sessions may consume the backend-owned `handoff_summary` only as safe presentation state and a Patrol finding pointer; hidden model context, command payloads, preflight data, and action results stay backend-owned and must not be reconstructed in the browser. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index af189fe4b..fdce72f79 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -501,6 +501,12 @@ the canonical monitored-system blocked payload. suggested prompts plus first-turn prompt emphasis from that same safe metadata so the drawer does not fall back to generic assessment copy when governed actions are present + and that same Patrol investigation-context owner, so coverage-incomplete + assessments with no active infrastructure findings are serialized as a + verification-gap handoff: prompt and visible briefing copy must explain what + scoped activity did and did not prove, keep latest-run and supporting-context + facts model-only, and avoid introducing backend fields beyond the existing + Patrol status plus run-history contracts 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 34ae153d8..13e4481d7 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -726,7 +726,10 @@ frontend primitive boundary. provider-settings action instead of hiding that diagnosis behind Assistant alone. If that inline state opens Assistant, the Patrol feature must hand off a source-named, model-only briefing and close the popover so the shared - Assistant drawer is not visually hidden behind feature chrome. + Assistant drawer is not visually hidden behind feature chrome. When a Patrol + assessment handoff is attached, the shared Assistant drawer empty state must + stay aligned with that source-named briefing and must not render generic + cluster/system starter prompts below the Patrol-owned context. 19. Keep the shared `system-ai` settings shell product-first. `frontend-modern/src/components/Settings/AISettings.tsx`, `frontend-modern/src/components/Settings/settingsHeaderMeta.ts`, @@ -2915,6 +2918,10 @@ persistence belong there, while backend settings/model reads stay on `tests/integration/tests/11-first-session.spec.ts` must continue to assert that plain settings routes render without assistant bootstrap traffic or console noise. +When an owned Patrol or alert surface attaches a source-named Assistant +handoff, that same drawer shell must keep the empty conversation state aligned +with the attached briefing instead of rendering generic cluster/system starter +prompts below the source-owned context. Shared table, disclosure, and form primitives must also stay explicitly typed at the browser edge. Summary rows may memoize repeated pending-update reads, shared buttons must preserve discriminated disclosure props, toggle and a11y diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index e6ca4e8e2..c8c019ade 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -255,6 +255,12 @@ Patrol-specific presentation helpers. as current-assessment context instead of becoming generic scoped context or an accidental single-finding session because one bounded action reference names a finding. + When the current Patrol assessment is coverage-incomplete with no active + infrastructure finding, the same handoff model must frame the briefing as a + verification gap: the prompt leads with what scoped activity did and did not + prove, visible drawer copy names the coverage gap, suggested prompts focus + on full-run verification and early warning signals, and execution or retry + remains operator-controlled. Patrol run-history entries may also open Assistant for a selected run, but that handoff must flow through the same Patrol-owned investigation-context model rather than a row-local prompt. The browser-visible prompt and drawer 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 284a2ab20..5c71e37f3 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -370,6 +370,32 @@ describe('AIChat', () => { expect(screen.queryByText('systemctl restart workload.service')).not.toBeInTheDocument(); }); + it('uses Patrol briefing context instead of generic empty-state suggestions', () => { + mockAiChatStore.context = { + initialPrompt: undefined, + autonomousMode: false, + briefing: { + sourceLabel: 'Pulse Patrol', + title: 'Patrol assessment attached', + subject: 'Coverage incomplete', + suggestedPrompts: [ + 'Explain why coverage is incomplete', + 'Explain scoped activity and full-run gap', + ], + }, + }; + + renderChat(); + + expect(screen.getByText('Review Pulse Patrol context')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Explain why coverage is incomplete' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Summarize cluster health' }), + ).not.toBeInTheDocument(); + }); + it('renders New button', () => { renderChat(); expect(screen.getByText('New')).toBeInTheDocument(); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index c703f24f1..d18616197 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -28,15 +28,13 @@ import { AI_CHAT_DISCOVERY_HINT_BODY, AI_CHAT_DISCOVERY_HINT_TITLE, AI_CHAT_DRAWER_TITLE, - AI_CHAT_EMPTY_STATE_SUBTITLE, - AI_CHAT_EMPTY_STATE_TITLE, AI_CHAT_INPUT_PLACEHOLDER, AI_CHAT_NEW_SESSION_BUTTON_TITLE, AI_CHAT_NEW_SESSION_MENU_LABEL, AI_CHAT_NEW_SESSION_SHORT_LABEL, AI_CHAT_SESSION_MENU_TITLE, AI_CHAT_SESSION_EMPTY_STATE, - getAIChatEmptyStateSuggestions, + getAIChatEmptyStatePresentation, } from '@/utils/aiChatPresentation'; import { getAIChatControlLevelPresentation, @@ -548,6 +546,12 @@ export const AIChat: Component = (props) => { const contextBriefingSuggestedPrompts = createMemo( () => contextBriefing()?.suggestedPrompts ?? [], ); + const emptyStatePresentation = createMemo(() => + getAIChatEmptyStatePresentation({ + briefing: contextBriefing(), + isCluster: isCluster(), + }), + ); const scopedApprovalHandoffLabel = createMemo(() => { const source = contextBriefing()?.sourceLabel?.toLowerCase() || ''; if (source.includes('patrol')) return 'this Patrol handoff'; @@ -1589,9 +1593,9 @@ export const AIChat: Component = (props) => { .slice(0, 3)} onLoadSession={handleLoadSession} emptyState={{ - title: AI_CHAT_EMPTY_STATE_TITLE, - subtitle: AI_CHAT_EMPTY_STATE_SUBTITLE, - suggestions: getAIChatEmptyStateSuggestions(isCluster()), + title: emptyStatePresentation().title, + subtitle: emptyStatePresentation().subtitle, + suggestions: emptyStatePresentation().suggestions, onSuggestionClick: (s) => setInput(s), }} /> diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index f5a4990dc..3776c9d93 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -284,6 +284,70 @@ describe('patrolInvestigationContextModel', () => { expect(JSON.stringify(handoff)).not.toContain('systemctl restart workload.service'); }); + it('frames coverage-incomplete assessment handoffs as a verification gap', () => { + const handoff = buildPatrolAssessmentAssistantHandoff({ + assessment: { + title: 'Coverage incomplete', + description: + 'Patrol coverage is incomplete: recent activity was limited to scoped runs, so overall infrastructure health is not fully verified.', + eyebrow: 'Patrol assessment', + }, + overallHealth: { + grade: 'C', + score: 65, + prediction: 'Patrol coverage is incomplete.', + factors: [{ category: 'coverage' }], + }, + scoreChipLabel: 'Assessment', + metricState: { + primaryLabel: 'Active findings', + primaryValue: 0, + secondaryLabel: 'Warnings', + secondaryValue: 0, + fixedLabel: 'Fixed', + fixedValue: 0, + }, + verification: { + title: 'Recently verified', + description: 'The most recent full patrol completed successfully.', + lastFullRunAt: '2026-05-04T21:38:51Z', + activityMixLabel: '8 full, 3 alert-triggered', + }, + latestRun: { + kindLabel: 'Scoped run', + status: { label: 'issues found' }, + timestamp: '2026-05-07T21:39:18Z', + coverageSummary: 'Checked 1 of 2 scoped resources', + findingsSnapshotAvailable: true, + }, + investigationContext: { + recentChangeCount: 100, + correlationCount: 29, + governedResourceCount: 116, + hasContext: true, + summaryText: '100 recent changes · 29 correlations · 116 policy-covered resources', + }, + activeFindings: [], + }); + + expect(handoff.prompt).toContain('why Patrol coverage is incomplete'); + expect(handoff.prompt).toContain('what the latest scoped activity did and did not prove'); + expect(handoff.context.briefing).toMatchObject({ + actionLabel: 'Review coverage gap', + safetyNote: + 'Assistant can explain the gap; full Patrol runs, diagnostics, and remediation remain operator-controlled.', + suggestedPrompts: [ + 'Explain why coverage is incomplete', + 'Explain scoped activity and full-run gap', + 'Identify early warning signals before full verification', + ], + }); + expect(handoff.context.handoffContext).toContain('Assessment: Coverage incomplete'); + expect(handoff.context.handoffContext).toContain( + 'Supporting Context: 100 recent changes · 29 correlations · 116 policy-covered resources', + ); + }); + it('carries live governed approval posture into assessment finding handoffs', () => { const handoff = buildPatrolAssessmentAssistantHandoff({ assessment: { diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index ffa548a5d..6d9bd3a01 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -200,7 +200,9 @@ export interface PatrolAssessmentAssistantHandoffInput { eyebrow?: string | null; } | null; overallHealth?: { + factors?: Array<{ category?: string | null }> | null; grade?: string | null; + prediction?: string | null; score?: number | null; } | null; scoreChipLabel?: string | null; @@ -712,6 +714,7 @@ function buildPatrolAssessmentAssistantPrompt( ): string { const pendingApprovalCount = normalizeAssessmentPendingApprovalCount(input.activeFindings); const actionCount = handoffActions.length; + const hasCoverageGap = assessmentHasCoverageGap(input); const reviewInstruction = pendingApprovalCount > 0 ? `Start by reviewing ${formatAssessmentMetricCount( @@ -723,7 +726,9 @@ function buildPatrolAssessmentAssistantPrompt( 'governed action references', actionCount, )}, risk, and the safest next step from the attached context.` - : 'Use the attached model-only Patrol assessment context before suggesting next actions. Help me understand priority, risk, and safe next steps.'; + : hasCoverageGap + ? 'Start by explaining why Patrol coverage is incomplete, what the latest scoped activity did and did not prove, and whether a full Patrol verification should run before action.' + : 'Use the attached model-only Patrol assessment context before suggesting next actions. Help me understand priority, risk, and safe next steps.'; return [ `Discuss the current Pulse Patrol assessment: ${title}.`, @@ -783,6 +788,7 @@ function buildPatrolAssessmentActionPosture( ): { actionLabel: string; safetyNote: string } { const pendingApprovalCount = normalizeAssessmentPendingApprovalCount(input.activeFindings); const actionCount = handoffActions.length; + const hasCoverageGap = assessmentHasCoverageGap(input); const hasDryRunPosture = handoffActions.some((action) => Boolean(normalizeText(action.actionDryRunSummary) || normalizeText(action.actionPreflight)), ); @@ -821,6 +827,14 @@ function buildPatrolAssessmentActionPosture( }; } + if (hasCoverageGap) { + return { + actionLabel: 'Review coverage gap', + safetyNote: + 'Assistant can explain the gap; full Patrol runs, diagnostics, and remediation remain operator-controlled.', + }; + } + return { actionLabel: 'Discuss Patrol assessment', safetyNote: 'Diagnostics and remediation require governed approval.', @@ -857,17 +871,29 @@ function buildPatrolAssessmentSuggestedPrompts( ): string[] { const prompts: string[] = []; const activeFindingCount = normalizeNonNegativeCount(input.activeFindings?.length); + const hasCoverageGap = assessmentHasCoverageGap(input); + const hasCoverageOnlyGap = hasCoverageGap && activeFindingCount === 0; const hasSupportingEvidence = - normalized.recentChanges.length > 0 || normalized.correlations.length > 0; + normalized.recentChanges.length > 0 || + normalized.correlations.length > 0 || + input.investigationContext?.hasContext === true; const hasGovernedAction = normalized.findings.some(assessmentFindingHasGovernedAction); if (activeFindingCount > 0) { prompts.push('Prioritize findings and safest next step'); + } else if (hasCoverageOnlyGap) { + prompts.push('Explain why coverage is incomplete'); } else { prompts.push('Explain current health and what to watch'); } - if (hasSupportingEvidence) { + if (hasCoverageOnlyGap) { + prompts.push( + input.verification?.activityMixLabel || input.latestRun?.kindLabel + ? 'Explain scoped activity and full-run gap' + : 'What should a full Patrol verify next?', + ); + } else if (hasSupportingEvidence) { prompts.push('Explain recent changes and correlations'); } @@ -879,6 +905,12 @@ function buildPatrolAssessmentSuggestedPrompts( ); } else if (activeFindingCount > 0) { prompts.push('List evidence to verify before action'); + } else if (hasCoverageOnlyGap) { + prompts.push( + hasSupportingEvidence + ? 'Identify early warning signals before full verification' + : 'What should a full Patrol verify next?', + ); } else if (hasSupportingEvidence) { prompts.push('Identify early warning signals'); } @@ -886,6 +918,26 @@ function buildPatrolAssessmentSuggestedPrompts( return formatPatrolSuggestedPrompts(prompts); } +function assessmentHasCoverageGap(input: PatrolAssessmentAssistantHandoffInput): boolean { + const title = normalizeText(input.assessment?.title).toLowerCase(); + const description = normalizeText(input.assessment?.description).toLowerCase(); + const prediction = normalizeText(input.overallHealth?.prediction).toLowerCase(); + const hasCoverageFactor = Boolean( + input.overallHealth?.factors?.some( + (factor) => normalizeText(factor.category).toLowerCase() === 'coverage', + ), + ); + + return ( + hasCoverageFactor || + title.includes('coverage incomplete') || + description.includes('coverage incomplete') || + description.includes('coverage is incomplete') || + prediction.includes('coverage incomplete') || + prediction.includes('coverage is incomplete') + ); +} + function assessmentFindingHasGovernedAction( finding: PatrolAssessmentAssistantFindingInput, ): boolean { diff --git a/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts b/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts index 63a99eb80..33a938abd 100644 --- a/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts @@ -18,6 +18,7 @@ import { AI_CHAT_SUGGESTIONS_LABEL, getAIChatLauncherTitle, getAIChatEmptyStateSuggestions, + getAIChatEmptyStatePresentation, } from '@/utils/aiChatPresentation'; describe('aiChatPresentation', () => { @@ -64,4 +65,25 @@ describe('aiChatPresentation', () => { 'Explain recent Patrol findings', ]); }); + + it('uses attached briefing context for scoped assistant handoff empty states', () => { + expect( + getAIChatEmptyStatePresentation({ + isCluster: true, + briefing: { + sourceLabel: 'Pulse Patrol', + title: 'Patrol assessment attached', + subject: 'Coverage incomplete', + suggestedPrompts: [ + 'Explain why coverage is incomplete', + 'Explain scoped activity and full-run gap', + ], + }, + }), + ).toEqual({ + title: 'Review Pulse Patrol context', + subtitle: 'Patrol assessment attached · Coverage incomplete', + suggestions: [], + }); + }); }); diff --git a/frontend-modern/src/utils/aiChatPresentation.ts b/frontend-modern/src/utils/aiChatPresentation.ts index d4ea06b19..490934a65 100644 --- a/frontend-modern/src/utils/aiChatPresentation.ts +++ b/frontend-modern/src/utils/aiChatPresentation.ts @@ -22,6 +22,19 @@ export const AI_CHAT_QUESTION_CARD_PLACEHOLDER = 'Type your answer...'; export const AI_CHAT_ASSISTANT_MESSAGE_LABEL = 'Pulse Assistant'; export const AI_CHAT_CONTEXT_USED_LABEL = 'Context used'; +export interface AIChatEmptyStateBriefingInput { + sourceLabel?: string; + subject?: string; + suggestedPrompts?: string[]; + title?: string; +} + +export interface AIChatEmptyStatePresentation { + suggestions: string[]; + subtitle?: string; + title: string; +} + const AI_CHAT_CLUSTER_EMPTY_STATE_SUGGESTIONS = [ 'Summarize cluster health', 'Find failed services', @@ -47,3 +60,29 @@ export function getAIChatEmptyStateSuggestions(isCluster: boolean) { ? AI_CHAT_CLUSTER_EMPTY_STATE_SUGGESTIONS : AI_CHAT_SINGLE_SYSTEM_EMPTY_STATE_SUGGESTIONS; } + +export function getAIChatEmptyStatePresentation(args: { + briefing?: AIChatEmptyStateBriefingInput; + isCluster: boolean; +}): AIChatEmptyStatePresentation { + const sourceLabel = args.briefing?.sourceLabel?.trim(); + const title = args.briefing?.title?.trim(); + const subject = args.briefing?.subject?.trim(); + const hasSuggestedPrompts = (args.briefing?.suggestedPrompts ?? []).some( + (prompt) => prompt.trim().length > 0, + ); + + if (args.briefing && (sourceLabel || title || subject || hasSuggestedPrompts)) { + return { + title: sourceLabel ? `Review ${sourceLabel} context` : 'Review attached context', + subtitle: [title, subject].filter(Boolean).join(' · ') || undefined, + suggestions: [], + }; + } + + return { + title: AI_CHAT_EMPTY_STATE_TITLE, + subtitle: AI_CHAT_EMPTY_STATE_SUBTITLE, + suggestions: getAIChatEmptyStateSuggestions(args.isCluster), + }; +}