Restore Patrol Assistant next-step links

This commit is contained in:
rcourtman
2026-05-08 14:51:50 +01:00
parent af99032d9b
commit 6d080a4810
14 changed files with 281 additions and 30 deletions
@@ -341,10 +341,10 @@ runtime cost control, and shared AI transport surfaces.
handoffs. That summary may include the handoff kind, finding ID, resource
and Patrol run ID, safe run type/status/runtime-failure flags, resource and
action counts, a primary resource label, last-known approval/action status,
risk level, timestamp, and Patrol assessment recommended next-step/action
labels plus the safe recommendation action kind only when they can be
safely extracted from the stored assessment handoff, but it must not expose
model-only handoff text,
risk level, timestamp, and Patrol recommended next-step/action labels plus
the safe recommendation action kind or whitelisted app-route href only when
they can be safely extracted from the stored Patrol handoff, but it must not
expose model-only handoff text,
runtime failure detail, action preflight/result bodies, remediation
descriptions, raw commands, or approval command payloads. Its
`requires_approval` field is a current operator-decision flag only: pending
@@ -371,10 +371,11 @@ runtime cost control, and shared AI transport surfaces.
The Assistant drawer must also fetch that current session list before
opening the session picker instead of presenting mount-time cached
summaries as the operator's decision surface. For restored Patrol
assessment sessions, that picker must present the safe recommended
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.
assessment or finding sessions, that picker must present the safe
recommended next-step/action label from `handoff_summary` when one is
available and restore the safe recommendation action kind or route-owned
href as context metadata instead of reducing the saved session to generic
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
@@ -2036,9 +2036,10 @@ The `/api/ai/sessions` response may expose `handoff_summary` for sessions that
carry private Assistant model-context metadata, but that payload is a safe
reload marker only. It may carry the handoff kind, finding ID, Patrol run ID,
safe run type/status/runtime-failure flags, counts, primary-resource label,
last-known approval/action status, risk level, Patrol assessment recommended
next-step/action labels plus the safe recommendation action kind when safely
extractable from the stored assessment handoff, and summary timestamp; it must
last-known approval/action status, risk level, Patrol recommended
next-step/action labels plus the safe recommendation action kind or whitelisted
app-route href when safely extractable from the stored Patrol handoff, and
summary timestamp; it must
not serialize the model-only `handoff_context`, runtime failure detail, action
preflight/result bodies, remediation descriptions, raw commands, or approval
command payloads.
@@ -198,16 +198,18 @@ work extends shared components instead of creating new local variants.
review, but `handoffContext`, `handoffResources`, and `handoffActions` for
run-history context must stay absent from the browser request so the backend
can rebuild model-bound context from the stored Patrol run. Restored
Patrol assessment and Patrol configuration-failure sessions follow the same
safe-summary rule: the drawer may restore source label, title, target type,
status badge, action label, and suggested prompts from `handoff_summary`, but
Patrol assessment, Patrol finding, and Patrol configuration-failure sessions
follow the same safe-summary rule: the drawer may restore source label,
title, target type, status badge, action label, and suggested prompts from
`handoff_summary`, but
it must not infer a finding target from bounded action references or
reconstruct hidden model context, provider details, retry payloads, commands,
preflight output, or action results in the browser. If the safe summary
includes a Patrol assessment recommended next step, action label, or safe
action kind, the session picker plus restored drawer briefing and action
copy must use that recommendation and may carry the safe action kind as
context metadata rather than falling back to generic assessment copy.
includes a Patrol recommended next step, action label, safe action kind, or
whitelisted app-route href, the session picker plus restored drawer briefing
and action copy must use that recommendation and may carry the safe action
kind or href as context metadata rather than falling back to generic handoff
copy.
Session-load and new-conversation transitions must be success-bound: if the
underlying session operation fails, the shared drawer store must not clear or
replace the current scoped handoff context.
@@ -271,11 +271,11 @@ Patrol-specific presentation helpers.
`handoff_metadata.kind=patrol_assessment` so saved Assistant sessions restore
as current-assessment context instead of becoming generic scoped context or
an accidental single-finding session because one bounded action reference
names a finding. Saved assessment sessions may expose the Patrol-owned
recommended next step/action through the safe `handoff_summary` only after
command-like and secret-like text is withheld; the browser must use those
fields for restored drawer copy without receiving the private model-only
handoff context.
names a finding. Saved assessment and finding sessions may expose the
Patrol-owned recommended next step/action and whitelisted app-route href
through the safe `handoff_summary` only after command-like and secret-like
text is withheld; the browser must use those fields for restored drawer copy
without receiving the private model-only handoff context.
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
@@ -53,6 +53,7 @@ describe('AIChatAPI', () => {
recommended_next_step: 'Review pending Patrol approval',
recommended_next_step_action: 'Review approvals',
recommended_next_step_action_kind: 'review_approvals',
recommended_next_step_action_href: '/patrol',
updated_at: '2026-05-06T12:08:00Z',
},
};
+1
View File
@@ -56,6 +56,7 @@ export interface ChatSessionHandoffSummary {
recommended_next_step?: string;
recommended_next_step_action?: string;
recommended_next_step_action_kind?: string;
recommended_next_step_action_href?: string;
updated_at?: string;
}
@@ -1100,6 +1100,9 @@ describe('AIChat', () => {
},
action_count: 0,
requires_approval: false,
recommended_next_step: 'Open Patrol provider settings',
recommended_next_step_action: 'Open Patrol provider settings',
recommended_next_step_action_href: '/settings/system-ai',
},
},
]);
@@ -1113,6 +1116,7 @@ describe('AIChat', () => {
await waitFor(() => {
expect(screen.getByText('Pulse Patrol')).toBeInTheDocument();
expect(screen.getByText('Context attached')).toBeInTheDocument();
expect(screen.getByText('Open Patrol provider settings')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Context-only Patrol follow-up'));
@@ -1126,11 +1130,15 @@ describe('AIChat', () => {
context: expect.objectContaining({
actionCount: 0,
requiresApproval: false,
recommendedNextStep: 'Open Patrol provider settings',
recommendedNextStepAction: 'Open Patrol provider settings',
recommendedNextStepActionHref: '/settings/system-ai',
}),
briefing: expect.objectContaining({
sourceLabel: 'Pulse Patrol',
title: 'Patrol finding on web-server',
actionLabel: undefined,
actionLabel: 'Open Patrol provider settings',
actionHref: '/settings/system-ai',
}),
}),
);
@@ -146,13 +146,32 @@ const getSessionHandoffBadgeLabel = (summary: ChatSessionHandoffSummary) => {
};
const formatSessionHandoffRecommendationLabel = (summary: ChatSessionHandoffSummary) => {
if (!isPatrolAssessmentSessionHandoff(summary)) return '';
if (!isPatrolSessionHandoff(summary)) return '';
const action = summary.recommended_next_step_action?.trim();
if (action) return `Recommended: ${action}`;
if (action) return isPatrolFindingSessionHandoff(summary) ? action : `Recommended: ${action}`;
const nextStep = summary.recommended_next_step?.trim();
return nextStep ? `Recommended: ${nextStep}` : '';
};
const getSessionHandoffRecommendedActionHref = (
summary: ChatSessionHandoffSummary,
): string | undefined => {
const summaryHref = summary.recommended_next_step_action_href?.trim();
if (summaryHref === '/settings/system-ai' || summaryHref === '/patrol') {
return summaryHref;
}
switch (summary.recommended_next_step_action_kind?.trim()) {
case 'open_provider_settings':
return '/settings/system-ai';
case 'review_approvals':
case 'review_findings':
return '/patrol';
default:
return undefined;
}
};
const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | undefined => {
const summary = session?.handoff_summary;
if (!summary) return undefined;
@@ -164,6 +183,7 @@ const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | unde
const recommendedNextStep = summary.recommended_next_step?.trim() || '';
const recommendedNextStepAction = summary.recommended_next_step_action?.trim() || '';
const recommendedNextStepActionKind = summary.recommended_next_step_action_kind?.trim() || '';
const recommendedNextStepActionHref = getSessionHandoffRecommendedActionHref(summary);
const actionCount = summary.action_count ?? 0;
const resourceCount = summary.resource_count ?? 0;
const findingId = summary.finding_id?.trim() || undefined;
@@ -225,6 +245,7 @@ const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | unde
recommendedNextStep,
recommendedNextStepAction,
recommendedNextStepActionKind,
recommendedNextStepActionHref,
updatedAt: summary.updated_at,
},
findingId,
@@ -274,9 +295,19 @@ const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | unde
? 'Review Patrol runtime issue'
: isPatrolRun
? 'Review Patrol run'
: actionCount > 0
? 'Governed action context'
: undefined,
: isPatrolFinding && recommendedNextStepAction
? recommendedNextStepAction
: actionCount > 0
? 'Governed action context'
: undefined,
actionHref:
!summary.requires_approval &&
recommendedNextStepActionHref &&
(isPatrolFinding ||
(isPatrolAssessment &&
Boolean(recommendedNextStepAction || recommendedNextStep)))
? recommendedNextStepActionHref
: undefined,
commandSummary: statusLabel
? isPatrolRun
? `Run state: ${statusLabel}`
@@ -440,6 +440,38 @@ describe('patrolInvestigationContextModel', () => {
});
});
it('links route-owned Patrol assessment recommendations in Assistant briefing', () => {
const handoff = buildPatrolAssessmentAssistantHandoff({
assessment: {
title: 'Patrol runtime issue',
description: 'Patrol coverage is incomplete.',
},
overallHealth: {
grade: 'C',
score: 60,
factors: [{ category: 'coverage' }],
},
recommendedNextStep: {
title: 'Restore Patrol visibility',
description: 'Fix the Patrol runtime issue before treating the assessment as current.',
actionLabel: 'Open Patrol provider settings',
actionKind: 'open_provider_settings',
},
activeFindings: [],
});
expect(handoff.context.briefing).toMatchObject({
actionLabel: 'Recommended: Open Patrol provider settings',
actionHref: '/settings/system-ai',
});
expect(handoff.context.handoffContext).toContain(
'Recommended Next Step Action: Open Patrol provider settings (open_provider_settings)',
);
expect(handoff.context.context).toMatchObject({
recommendedNextStepActionKind: 'open_provider_settings',
});
});
it('marks unavailable recommended Patrol actions in assessment handoffs', () => {
const handoff = buildPatrolAssessmentAssistantHandoff({
assessment: {
@@ -857,6 +857,7 @@ function buildPatrolAssessmentAssistantBriefing(
.slice(0, 5),
evidence: [...findingEvidence.slice(0, 3), ...supportingEvidence].slice(0, 5),
actionLabel: actionPosture.actionLabel,
actionHref: actionPosture.actionHref,
safetyNote: actionPosture.safetyNote,
suggestedPrompts: buildPatrolAssessmentSuggestedPrompts(input, {
findings,
@@ -935,14 +936,30 @@ function formatAssessmentRecommendedNextStepDetailLine(
);
}
function getAssessmentRecommendedNextStepActionHref(
recommendedNextStep?: NormalizedPatrolAssessmentRecommendedNextStep,
): string | undefined {
switch (recommendedNextStep?.actionKind) {
case 'open_provider_settings':
return '/settings/system-ai';
case 'review_approvals':
case 'review_findings':
return '/patrol';
default:
return undefined;
}
}
function buildPatrolAssessmentActionPosture(
input: PatrolAssessmentAssistantHandoffInput,
handoffActions: AIChatHandoffAction[],
): { actionLabel: string; safetyNote: string } {
): { actionLabel: string; actionHref?: string; safetyNote: string } {
const pendingApprovalCount = normalizeAssessmentPendingApprovalCount(input.activeFindings);
const actionCount = handoffActions.length;
const hasCoverageGap = assessmentHasCoverageGap(input);
const recommendedNextStep = normalizeAssessmentRecommendedNextStep(input.recommendedNextStep);
const recommendedNextStepActionHref =
getAssessmentRecommendedNextStepActionHref(recommendedNextStep);
const hasDryRunPosture = handoffActions.some((action) =>
Boolean(normalizeText(action.actionDryRunSummary) || normalizeText(action.actionPreflight)),
);
@@ -986,6 +1003,7 @@ function buildPatrolAssessmentActionPosture(
actionLabel: recommendedNextStep?.actionLabel
? `Recommended: ${recommendedNextStep.actionLabel}`
: 'Review coverage gap',
actionHref: recommendedNextStep?.actionLabel ? recommendedNextStepActionHref : undefined,
safetyNote: formatAssessmentRecommendationSafetyNote(
'Assistant can explain the gap; full Patrol runs, diagnostics, and remediation remain operator-controlled.',
recommendedNextStep,
@@ -996,6 +1014,7 @@ function buildPatrolAssessmentActionPosture(
if (recommendedNextStep?.actionLabel || recommendedNextStep?.title) {
return {
actionLabel: `Recommended: ${recommendedNextStep.actionLabel || recommendedNextStep.title}`,
actionHref: recommendedNextStep.actionLabel ? recommendedNextStepActionHref : undefined,
safetyNote: formatAssessmentRecommendationSafetyNote(
'Assistant can explain the Patrol recommendation; Patrol runs, settings changes, diagnostics, and remediation remain operator-controlled.',
recommendedNextStep,
+2
View File
@@ -394,6 +394,8 @@ export interface AIChatSessionHandoffSummary {
last_known_action_risk?: string;
recommended_next_step?: string;
recommended_next_step_action?: string;
recommended_next_step_action_kind?: string;
recommended_next_step_action_href?: string;
updated_at?: string;
}
+69
View File
@@ -297,6 +297,64 @@ func safePatrolAssessmentRecommendationAction(value string) (string, string) {
return action, ""
}
func patrolFindingNextStepSummary(kind string, handoffContext string) (string, string) {
if kind != sessionHandoffKindPatrolFinding {
return "", ""
}
lines := strings.Split(strings.TrimSpace(handoffContext), "\n")
if len(lines) == 0 {
return "", ""
}
sawFindingContext := false
sawPatrolSource := false
var nextStepAction string
var nextStepActionHref string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "[Patrol Finding Context]" {
sawFindingContext = true
continue
}
if !sawFindingContext {
continue
}
label, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
label = strings.ToLower(strings.TrimSpace(label))
value = strings.TrimSpace(value)
switch label {
case "source":
if strings.EqualFold(value, "Pulse Patrol finding handoff") {
sawPatrolSource = true
}
case "patrol next step":
nextStepAction = safeSessionHandoffSummaryText(value, 120)
case "patrol next step route":
nextStepActionHref = safePatrolHandoffRoute(value)
}
}
if !sawFindingContext || !sawPatrolSource || nextStepAction == "" {
return "", ""
}
return nextStepAction, nextStepActionHref
}
func safePatrolHandoffRoute(value string) string {
route := trimHandoffMetadataField(value, 160)
switch route {
case "/patrol", "/settings/system-ai":
return route
default:
return ""
}
}
func safeSessionHandoffSummaryText(value string, maxRunes int) string {
normalized := trimHandoffMetadataField(value, maxRunes)
if normalized == "" || sessionHandoffSummaryTextShouldBeWithheld(normalized) {
@@ -404,6 +462,16 @@ func modelContextHandoffSummary(modelContext *sessionModelContext) *SessionHando
kind,
modelContext.HandoffContext,
)
recommendedNextStepActionHref := ""
if recommendedNextStepAction == "" {
recommendedNextStepAction, recommendedNextStepActionHref = patrolFindingNextStepSummary(
kind,
modelContext.HandoffContext,
)
if recommendedNextStep == "" && recommendedNextStepAction != "" {
recommendedNextStep = recommendedNextStepAction
}
}
summary := &SessionHandoffSummary{
Kind: kind,
@@ -418,6 +486,7 @@ func modelContextHandoffSummary(modelContext *sessionModelContext) *SessionHando
RecommendedNextStep: recommendedNextStep,
RecommendedNextStepAction: recommendedNextStepAction,
RecommendedNextStepActionKind: recommendedNextStepActionKind,
RecommendedNextStepActionHref: recommendedNextStepActionHref,
}
if kind != sessionHandoffKindPatrolRun {
summary.RunID = ""
@@ -588,6 +588,89 @@ func TestSessionStore_ListKeepsPatrolAssessmentHandoffIdentity(t *testing.T) {
}
}
func TestSessionStore_ListKeepsSafePatrolFindingNextStepRoute(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
if err != nil {
t.Fatalf("failed to create session store: %v", err)
}
session, err := store.Create()
if err != nil {
t.Fatalf("failed to create session: %v", err)
}
handoffContext := strings.Join([]string{
"[Patrol Finding Context]",
"Source: Pulse Patrol finding handoff",
"Finding ID: finding-provider-settings",
"Patrol Next Step: Open Patrol provider settings",
"Patrol Next Step Route: /settings/system-ai",
}, "\n")
if err := store.SetModelHandoffEnvelope(session.ID, "finding-provider-settings", handoffContext, nil, nil, HandoffMetadata{}); err != nil {
t.Fatalf("SetModelHandoffEnvelope failed: %v", err)
}
sessions, err := store.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(sessions) != 1 || sessions[0].HandoffSummary == nil {
t.Fatalf("sessions = %#v, want one session with handoff summary", sessions)
}
summary := sessions[0].HandoffSummary
if summary.Kind != sessionHandoffKindPatrolFinding {
t.Fatalf("handoff kind = %q, want %q", summary.Kind, sessionHandoffKindPatrolFinding)
}
if summary.FindingID != "finding-provider-settings" {
t.Fatalf("finding ID = %q, want finding-provider-settings", summary.FindingID)
}
if summary.RecommendedNextStep != "Open Patrol provider settings" ||
summary.RecommendedNextStepAction != "Open Patrol provider settings" ||
summary.RecommendedNextStepActionHref != "/settings/system-ai" {
t.Fatalf("next step summary = %#v, want safe route-owned Patrol next step", summary)
}
}
func TestSessionStore_ListWithholdsUnsafePatrolFindingNextStepRoute(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
if err != nil {
t.Fatalf("failed to create session store: %v", err)
}
session, err := store.Create()
if err != nil {
t.Fatalf("failed to create session: %v", err)
}
handoffContext := strings.Join([]string{
"[Patrol Finding Context]",
"Source: Pulse Patrol finding handoff",
"Finding ID: finding-provider-settings",
"Patrol Next Step: Open Patrol provider settings",
"Patrol Next Step Route: javascript:alert(1)",
}, "\n")
if err := store.SetModelHandoffEnvelope(session.ID, "finding-provider-settings", handoffContext, nil, nil, HandoffMetadata{}); err != nil {
t.Fatalf("SetModelHandoffEnvelope failed: %v", err)
}
sessions, err := store.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(sessions) != 1 || sessions[0].HandoffSummary == nil {
t.Fatalf("sessions = %#v, want one session with handoff summary", sessions)
}
summary := sessions[0].HandoffSummary
if summary.RecommendedNextStepAction != "Open Patrol provider settings" {
t.Fatalf("next step action = %q, want safe label preserved", summary.RecommendedNextStepAction)
}
if summary.RecommendedNextStepActionHref != "" {
t.Fatalf("next step href = %q, want unsafe route withheld", summary.RecommendedNextStepActionHref)
}
}
func TestSessionStore_ListWithholdsUnsafePatrolAssessmentRecommendationSummary(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
if err != nil {
+1
View File
@@ -42,6 +42,7 @@ type SessionHandoffSummary struct {
RecommendedNextStep string `json:"recommended_next_step,omitempty"`
RecommendedNextStepAction string `json:"recommended_next_step_action,omitempty"`
RecommendedNextStepActionKind string `json:"recommended_next_step_action_kind,omitempty"`
RecommendedNextStepActionHref string `json:"recommended_next_step_action_href,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}