mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 10:43:36 +00:00
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:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user