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
This commit is contained in:
rcourtman
2026-05-07 22:56:06 +01:00
parent de8ed71643
commit 671431a862
10 changed files with 240 additions and 11 deletions
@@ -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.
@@ -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,
@@ -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
@@ -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
@@ -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();
@@ -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<AIChatProps> = (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<AIChatProps> = (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),
}}
/>
@@ -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: {
@@ -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 {
@@ -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: [],
});
});
});
@@ -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),
};
}