Carry Patrol action availability into handoff

This commit is contained in:
rcourtman
2026-05-08 01:53:55 +01:00
parent 475429def2
commit f7155dace9
8 changed files with 183 additions and 10 deletions
@@ -363,6 +363,10 @@ runtime cost control, and shared AI transport surfaces.
next-step/action label from `handoff_summary` when one is available and
restore the safe recommendation action kind as context metadata instead of
reducing the saved session to generic assessment context.
Live Patrol assessment handoffs that include a currently unavailable
Patrol-owned recommendation action must carry the bounded disabled reason in
the model-only handoff and visible briefing so Assistant explains the
current availability state instead of treating the action as executable.
Browser-originated `handoff_context`, `handoff_resources`, and
`handoff_actions` plus safe `handoff_metadata` are one-shot request seeds
for the first successful chat turn. After that send succeeds, the drawer
@@ -516,6 +516,8 @@ the canonical monitored-system blocked payload.
counts instead of pasting page-local UI text or raw command payloads into chat,
and may derive visible action labels, safety notes, and approval-aware
suggested prompts plus first-turn prompt emphasis from that same safe metadata
plus the current disabled reason for a Patrol-owned recommended action when
the visible summary action is unavailable,
so the drawer does not fall back to generic assessment copy when governed
actions are present or when the summary already exposes a concrete
recommended next step
@@ -913,7 +913,10 @@ prompt explain the same operator-facing priority.
a Patrol summary prompt builder. The Patrol helper may turn those same safe
references into visible action labels, safety notes, and approval-aware
suggested prompts or first-turn prompt emphasis for assessment and
finding-level handoffs; finding-level drawer opens may also pass one bounded
finding-level handoffs; if a source-owned Patrol recommendation action is
currently disabled, the helper must pass the bounded disabled reason in
visible briefing and model-only context instead of presenting that action as
available. Finding-level drawer opens may also pass one bounded
model-only finding context, one target resource reference, and one
`handoff_actions` reference for a live approval or proposed fix. It must not
expose raw command or execution payloads.
@@ -244,8 +244,10 @@ Patrol-specific presentation helpers.
safe source-owned suggested prompts as model-only context. The same handoff
must also carry the Patrol-owned recommended next step as safe bounded
metadata, including its title, detail copy, action label, and known action
kind when present, so Assistant explains the same operator-facing priority
shown in the summary card instead of inventing a separate next step. Active-finding
kind when present, plus the current action-disabled reason when the visible
Patrol-owned action is unavailable, so Assistant explains the same
operator-facing priority and current availability shown in the summary card
instead of inventing a separate next step. Active-finding
summaries may include live pending approval posture only as safe metadata:
approval ID, pending status, risk, target, requested/expiry timestamps,
action plan identity, approval policy, plan expiry, dry-run posture, and
@@ -351,7 +353,9 @@ dry-run posture when available. When no approval or governed action outranks
the summary recommendation, the briefing action label and initial prompt may
lead with that recommendation, but Assistant remains explanatory and may not
start Patrol runs, settings changes, diagnostics, remediation, or approvals
from the handoff. Its initial prompt must prioritize approvals or action
from the handoff; if the recommended action is currently disabled, the prompt
and briefing must say why instead of describing it as an available action. Its
initial prompt must prioritize approvals or action
references before broader assessment discussion while command payloads stay
out of the drawer.
Run-history rows now follow that same Assistant handoff model. A selected
@@ -186,6 +186,9 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
state.patrolStream.isStreaming())
);
});
const recommendedNextStepActionDisabledReason = createMemo(() =>
recommendedNextStepActionDisabled() ? state.triggerPatrolDisabledReason() : '',
);
const recommendedNextStepActionLabel = createMemo(() => {
const action = recommendedNextStepAction();
if (action?.kind !== 'run_patrol') {
@@ -305,6 +308,7 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
description: recommendation.description,
actionLabel: recommendation.action?.label,
actionKind: recommendation.action?.kind,
actionDisabledReason: recommendedNextStepActionDisabledReason(),
},
activeFindings: activeFindingsWithApprovalContext(),
});
@@ -129,6 +129,72 @@ describe('PatrolIntelligenceSummary', () => {
);
expect(JSON.stringify(context)).not.toContain('systemctl restart workload.service');
});
it('passes current run-action availability into assessment Assistant handoff', async () => {
const openWithPrompt = vi.spyOn(aiChatStore, 'openWithPrompt').mockImplementation(() => {});
vi.spyOn(aiIntelligenceStore, 'loadPendingApprovals').mockResolvedValue(undefined);
vi.spyOn(aiIntelligenceStore, 'patrolPendingApprovals', 'get').mockReturnValue([]);
const patrolState = {
...createPatrolState(),
activePatrolFindings: () => [],
canTriggerPatrol: () => false,
correlationTotal: () => 0,
correlations: () => [],
intelligenceSummary: () => ({
overall_health: {
grade: 'C',
score: 65,
factors: [{ category: 'coverage' }],
prediction: 'Patrol coverage is incomplete.',
},
recent_changes_count: 0,
recent_changes: [],
policy_posture: {
total_resources: 4,
sensitivity_counts: {},
routing_counts: {},
},
}),
manualRunRequested: () => false,
patrolStream: {
isStreaming: () => true,
},
runtimeState: () => 'running',
summaryStats: () => ({
criticalFindings: 0,
warningFindings: 0,
totalActive: 0,
fixedCount: 0,
hasAnyPatrolFindings: false,
}),
triggerPatrolDisabledReason: () => 'Patrol is already running',
} satisfies PatrolIntelligenceState;
render(() => <PatrolIntelligenceSummary state={patrolState} />);
fireEvent.click(screen.getByTestId('patrol-assessment-assistant-button'));
await waitFor(() => expect(openWithPrompt).toHaveBeenCalledTimes(1));
const [prompt, context] = openWithPrompt.mock.calls[0] as [string, Record<string, unknown>];
expect(prompt).toContain(
'Patrol-owned action "Run Patrol" is currently unavailable: Patrol is already running',
);
expect(context.handoffContext).toContain(
'Recommended Next Step Action Status: unavailable - Patrol is already running',
);
expect(context.context).toMatchObject({
recommendedNextStepActionKind: 'run_patrol',
recommendedNextStepActionDisabledReason: 'Patrol is already running',
});
expect(context.briefing).toMatchObject({
actionLabel: 'Recommended: Run Patrol',
safetyNote: expect.stringContaining(
'Run Patrol is currently unavailable: Patrol is already running.',
),
});
});
});
function createPatrolState(): PatrolIntelligenceState {
@@ -368,6 +368,46 @@ describe('patrolInvestigationContextModel', () => {
});
});
it('marks unavailable recommended Patrol actions in assessment handoffs', () => {
const handoff = buildPatrolAssessmentAssistantHandoff({
assessment: {
title: 'Coverage incomplete',
description: 'Patrol coverage is incomplete.',
},
overallHealth: {
grade: 'C',
score: 65,
factors: [{ category: 'coverage' }],
},
recommendedNextStep: {
title: 'Verify full coverage',
description: 'Run a full Patrol sweep before treating this assessment as an all-clear.',
actionLabel: 'Run Patrol',
actionKind: 'run_patrol',
actionDisabledReason: 'Patrol is already running',
},
});
expect(handoff.prompt).toContain(
'Patrol-owned action "Run Patrol" is currently unavailable: Patrol is already running',
);
expect(handoff.context.handoffContext).toContain(
'Recommended Next Step Action Status: unavailable - Patrol is already running',
);
expect(handoff.context.context).toMatchObject({
recommendedNextStepActionKind: 'run_patrol',
recommendedNextStepActionDisabledReason: 'Patrol is already running',
});
expect(handoff.context.briefing).toMatchObject({
actionLabel: 'Recommended: Run Patrol',
safetyNote:
'Assistant can explain the gap; full Patrol runs, diagnostics, and remediation remain operator-controlled. Run Patrol is currently unavailable: Patrol is already running.',
});
expect((handoff.context.briefing?.detailLines ?? []).join(' ')).toContain(
'action Run Patrol unavailable: Patrol is already running',
);
});
it('withholds unsafe recommendation text from assessment handoffs', () => {
const handoff = buildPatrolAssessmentAssistantHandoff({
assessment: {
@@ -206,6 +206,7 @@ export interface PatrolAssessmentRecommendedNextStepInput {
description?: string | null;
actionLabel?: string | null;
actionKind?: PatrolAssessmentRecommendedNextStepActionKind | string | null;
actionDisabledReason?: string | null;
}
export interface PatrolAssessmentAssistantHandoffInput {
@@ -311,6 +312,7 @@ interface NormalizedPatrolAssessmentRecommendedNextStep {
description?: string;
actionLabel?: string;
actionKind?: PatrolAssessmentRecommendedNextStepActionKind;
actionDisabledReason?: string;
actionSummary?: string;
}
@@ -571,6 +573,7 @@ export function buildPatrolAssessmentAssistantHandoff(
? {
recommendedNextStepTitle: recommendedNextStep.title,
recommendedNextStepActionKind: recommendedNextStep.actionKind,
recommendedNextStepActionDisabledReason: recommendedNextStep.actionDisabledReason,
}
: {}),
},
@@ -842,14 +845,48 @@ function buildPatrolAssessmentRecommendationPromptInstruction(
recommendedNextStep.description
? `detail: ${truncateContextText(recommendedNextStep.description, 180)}`
: undefined,
recommendedNextStep.actionLabel
? `available Patrol-owned action: ${recommendedNextStep.actionLabel}`
: undefined,
formatAssessmentRecommendedNextStepActionInstruction(recommendedNextStep),
].filter(isNonEmptyString);
return `${parts.join('; ')}. Explain that recommendation before alternatives, but keep Patrol runs, settings changes, diagnostics, remediation, and approvals in governed controls.`;
}
function formatAssessmentRecommendedNextStepActionInstruction(
recommendedNextStep: NormalizedPatrolAssessmentRecommendedNextStep,
): string | undefined {
if (!recommendedNextStep.actionLabel) return undefined;
if (recommendedNextStep.actionDisabledReason) {
return `Patrol-owned action "${recommendedNextStep.actionLabel}" is currently unavailable: ${recommendedNextStep.actionDisabledReason}`;
}
return `available Patrol-owned action: ${recommendedNextStep.actionLabel}`;
}
function formatAssessmentRecommendedNextStepActionDetail(
recommendedNextStep: NormalizedPatrolAssessmentRecommendedNextStep,
): string | undefined {
if (!recommendedNextStep.actionLabel) return undefined;
return recommendedNextStep.actionDisabledReason
? `action ${recommendedNextStep.actionLabel} unavailable: ${recommendedNextStep.actionDisabledReason}`
: `action ${recommendedNextStep.actionLabel}`;
}
function formatAssessmentRecommendedNextStepActionAvailability(
recommendedNextStep?: NormalizedPatrolAssessmentRecommendedNextStep,
): string | undefined {
if (!recommendedNextStep?.actionDisabledReason) return undefined;
return `unavailable - ${recommendedNextStep.actionDisabledReason}`;
}
function formatAssessmentRecommendationSafetyNote(
base: string,
recommendedNextStep?: NormalizedPatrolAssessmentRecommendedNextStep,
): string {
if (!recommendedNextStep?.actionLabel || !recommendedNextStep.actionDisabledReason) {
return base;
}
return `${base} ${recommendedNextStep.actionLabel} is currently unavailable: ${recommendedNextStep.actionDisabledReason}.`;
}
function formatAssessmentRecommendedNextStepDetailLine(
recommendedNextStep?: NormalizedPatrolAssessmentRecommendedNextStep,
): string | undefined {
@@ -859,7 +896,7 @@ function formatAssessmentRecommendedNextStepDetailLine(
[
`Recommended next step: ${recommendedNextStep.title}`,
recommendedNextStep.description,
recommendedNextStep.actionLabel ? `action ${recommendedNextStep.actionLabel}` : undefined,
formatAssessmentRecommendedNextStepActionDetail(recommendedNextStep),
],
3,
'recommendation facts',
@@ -917,16 +954,20 @@ function buildPatrolAssessmentActionPosture(
actionLabel: recommendedNextStep?.actionLabel
? `Recommended: ${recommendedNextStep.actionLabel}`
: 'Review coverage gap',
safetyNote:
safetyNote: formatAssessmentRecommendationSafetyNote(
'Assistant can explain the gap; full Patrol runs, diagnostics, and remediation remain operator-controlled.',
recommendedNextStep,
),
};
}
if (recommendedNextStep?.actionLabel || recommendedNextStep?.title) {
return {
actionLabel: `Recommended: ${recommendedNextStep.actionLabel || recommendedNextStep.title}`,
safetyNote:
safetyNote: formatAssessmentRecommendationSafetyNote(
'Assistant can explain the Patrol recommendation; Patrol runs, settings changes, diagnostics, and remediation remain operator-controlled.',
recommendedNextStep,
),
};
}
@@ -1088,6 +1129,10 @@ function buildPatrolAssessmentAssistantModelContext(
formatContextLine('Recommended Next Step', recommendedNextStep?.title),
formatContextLine('Recommended Next Step Detail', recommendedNextStep?.description),
formatContextLine('Recommended Next Step Action', recommendedNextStep?.actionSummary),
formatContextLine(
'Recommended Next Step Action Status',
formatAssessmentRecommendedNextStepActionAvailability(recommendedNextStep),
),
formatContextLine('Verification', formatAssessmentVerification(input)),
formatContextLine('Last Patrol', formatAssessmentRecency(input)),
formatContextLine('Latest Run', formatAssessmentLatestRun(input)),
@@ -2744,6 +2789,10 @@ function normalizeAssessmentRecommendedNextStep(
safeActionLabel === WITHHELD_RECOMMENDATION_TEXT && fallbackActionLabel
? fallbackActionLabel
: safeActionLabel || fallbackActionLabel;
const actionDisabledReason = formatSafeAssessmentRecommendationText(
input.actionDisabledReason,
140,
);
const effectiveTitle =
title === WITHHELD_RECOMMENDATION_TEXT && actionLabel ? actionLabel : title || actionLabel;
const actionSummary = formatAssessmentRecommendedNextStepActionSummary(actionLabel, actionKind);
@@ -2757,6 +2806,7 @@ function normalizeAssessmentRecommendedNextStep(
description,
actionLabel,
actionKind,
actionDisabledReason,
actionSummary,
};
}