mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-25 04:33:03 +00:00
Carry Patrol action availability into handoff
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user