Structure Patrol Assistant next-step metadata

This commit is contained in:
rcourtman
2026-05-08 15:01:09 +01:00
parent cb772737e7
commit 3e9cefafaf
13 changed files with 238 additions and 20 deletions
@@ -382,7 +382,9 @@ runtime cost control, and shared AI transport surfaces.
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
for the first successful chat turn. Safe Patrol next-step labels and
route-owned hrefs belong in `handoff_metadata` first, with model-context
text parsing only as a legacy fallback. After that send succeeds, the drawer
must clear those request payloads while preserving the safe visible
briefing and request-local
approval-required posture; later turns must rely on backend-owned session
@@ -2022,9 +2022,12 @@ and timeline hydration, and `handoff_actions` are structured approval/action
references used to seed canonical approval and action-audit refresh.
`handoff_metadata` is the browser-safe identity envelope for restoring saved
product handoffs, currently including Patrol run kind, run ID, safe run
type/status, and a runtime-failure boolean rather than runtime failure detail.
type/status, a runtime-failure boolean rather than runtime failure detail, and
safe Patrol recommended next-step/action labels plus whitelisted app-route
hrefs when the browser handoff already owns that route.
Frontend handoff builders may send these fields for owned alert, incident,
Patrol assessment, or Patrol run-history context, but the backend must not
Patrol assessment, Patrol finding, or Patrol run-history context, but the
backend must not
persist them as user-authored message text and must treat them as
explanation/review context only. When a Patrol `finding_id` resolves,
backend-refreshed durable finding context remains canonical; the handler may
@@ -186,7 +186,11 @@ work extends shared components instead of creating new local variants.
`handoffContext`, `handoffResources`, `handoffActions`, and safe
`handoffMetadata` while preserving the safe visible briefing and scoped
approval-required posture, so later turns rely on backend session hydration
instead of resending stale browser context. The drawer must treat
instead of resending stale browser context. When a Patrol handoff includes a
safe next-step label, action kind, or whitelisted app-route href, those
reload-safe fields must travel through `handoffMetadata` rather than relying
on the browser to reconstruct them from model-only handoff text. The drawer
must treat
`handoff_summary.requires_approval` as a current pending-decision flag, not a
historical action marker, so completed or rejected handoff actions render as
action context rather than pending approval. A restored Patrol run summary
@@ -274,8 +274,10 @@ Patrol-specific presentation helpers.
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.
text is withheld; live handoffs must send those safe fields through
structured `handoff_metadata` where available, and the browser must use
them 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
@@ -200,6 +200,57 @@ describe('AIChatAPI', () => {
);
});
it('includes browser-safe Patrol recommendation metadata when supplied', async () => {
const read = vi.fn().mockResolvedValueOnce({ done: true, value: undefined });
const releaseLock = vi.fn();
apiFetchMock.mockResolvedValueOnce({
ok: true,
body: {
getReader: () => ({ read, releaseLock }),
},
} as unknown as Response);
await AIChatAPI.chat(
'discuss finding',
'session-finding',
undefined,
vi.fn(),
undefined,
undefined,
'finding-provider-settings',
false,
undefined,
undefined,
undefined,
{
kind: 'patrol_finding',
recommendedNextStep: 'Open Patrol provider settings',
recommendedNextStepAction: 'Open Patrol provider settings',
recommendedNextStepActionHref: '/settings/system-ai',
},
);
expect(apiFetchMock).toHaveBeenCalledWith(
'/api/ai/chat',
expect.objectContaining({
body: JSON.stringify({
prompt: 'discuss finding',
session_id: 'session-finding',
model: undefined,
finding_id: 'finding-provider-settings',
autonomous_mode: false,
handoff_metadata: {
kind: 'patrol_finding',
recommended_next_step: 'Open Patrol provider settings',
recommended_next_step_action: 'Open Patrol provider settings',
recommended_next_step_action_href: '/settings/system-ai',
},
}),
}),
);
});
it('includes a Patrol finding id when supplied for Assistant context', async () => {
const read = vi.fn().mockResolvedValueOnce({ done: true, value: undefined });
const releaseLock = vi.fn();
+8
View File
@@ -66,6 +66,10 @@ export interface ChatHandoffMetadata {
runType?: string;
runStatus?: string;
runtimeFailure?: boolean;
recommendedNextStep?: string;
recommendedNextStepAction?: string;
recommendedNextStepActionKind?: string;
recommendedNextStepActionHref?: string;
}
export interface ChatHandoffAction {
@@ -314,6 +318,10 @@ export class AIChatAPI {
run_type: handoffMetadata.runType,
run_status: handoffMetadata.runStatus,
runtime_failure: handoffMetadata.runtimeFailure,
recommended_next_step: handoffMetadata.recommendedNextStep,
recommended_next_step_action: handoffMetadata.recommendedNextStepAction,
recommended_next_step_action_kind: handoffMetadata.recommendedNextStepActionKind,
recommended_next_step_action_href: handoffMetadata.recommendedNextStepActionHref,
};
}
@@ -438,6 +438,12 @@ describe('patrolInvestigationContextModel', () => {
recommendedNextStepTitle: 'Verify full coverage',
recommendedNextStepActionKind: 'run_patrol',
});
expect(handoff.context.handoffMetadata).toMatchObject({
kind: 'patrol_assessment',
recommendedNextStep: 'Verify full coverage',
recommendedNextStepAction: 'Run Patrol',
recommendedNextStepActionKind: 'run_patrol',
});
});
it('links route-owned Patrol assessment recommendations in Assistant briefing', () => {
@@ -470,6 +476,13 @@ describe('patrolInvestigationContextModel', () => {
expect(handoff.context.context).toMatchObject({
recommendedNextStepActionKind: 'open_provider_settings',
});
expect(handoff.context.handoffMetadata).toMatchObject({
kind: 'patrol_assessment',
recommendedNextStep: 'Restore Patrol visibility',
recommendedNextStepAction: 'Open Patrol provider settings',
recommendedNextStepActionKind: 'open_provider_settings',
recommendedNextStepActionHref: '/settings/system-ai',
});
});
it('marks unavailable recommended Patrol actions in assessment handoffs', () => {
@@ -1328,6 +1341,12 @@ describe('patrolInvestigationContextModel', () => {
'Check prerequisites before next step',
],
});
expect(handoff.context.handoffMetadata).toMatchObject({
kind: 'patrol_finding',
recommendedNextStep: 'Open Patrol provider settings',
recommendedNextStepAction: 'Open Patrol provider settings',
recommendedNextStepActionHref: '/settings/system-ai',
});
expect(handoff.context.handoffActions).toBeUndefined();
expect(handoff.context.handoffContext).toContain(
'Patrol Next Step: Open Patrol provider settings',
@@ -574,6 +574,8 @@ export function buildPatrolAssessmentAssistantHandoff(
const title = normalizeText(input.assessment?.title) || 'Pulse Patrol assessment';
const description = normalizeText(input.assessment?.description);
const recommendedNextStep = normalizeAssessmentRecommendedNextStep(input.recommendedNextStep);
const recommendedNextStepActionHref =
getAssessmentRecommendedNextStepActionHref(recommendedNextStep);
const handoffContext = buildPatrolAssessmentAssistantModelContext(input);
const recentChanges = normalizeAssessmentRecentChanges(input.supportingEvidence?.recentChanges);
const correlations = normalizeAssessmentCorrelations(input.supportingEvidence?.correlations);
@@ -590,6 +592,10 @@ export function buildPatrolAssessmentAssistantHandoff(
handoffActions: handoffActions.length > 0 ? handoffActions : undefined,
handoffMetadata: {
kind: 'patrol_assessment',
recommendedNextStep: recommendedNextStep?.title,
recommendedNextStepAction: recommendedNextStep?.actionLabel,
recommendedNextStepActionKind: recommendedNextStep?.actionKind,
recommendedNextStepActionHref,
},
briefing: buildPatrolAssessmentAssistantBriefing(input),
context: {
@@ -642,6 +648,14 @@ export function buildPatrolAssistantFindingHandoff(
handoffContext: buildPatrolAssistantFindingModelContext(input),
handoffResources: handoffResources.length > 0 ? handoffResources : undefined,
handoffActions: handoffActions.length > 0 ? handoffActions : undefined,
handoffMetadata: nextStepAction.label
? {
kind: 'patrol_finding',
recommendedNextStep: nextStepAction.label,
recommendedNextStepAction: nextStepAction.label,
recommendedNextStepActionHref: nextStepAction.href || undefined,
}
: undefined,
briefing: buildPatrolAssistantFindingBriefing({
title: input.title,
subject: input.subject,
@@ -111,6 +111,12 @@ describe('aiChatStore', () => {
actionHref: '/settings/system-ai',
suggestedPrompts: ['Explain recent changes and correlations'],
},
handoffMetadata: {
kind: 'patrol_finding',
recommendedNextStep: 'Open Patrol provider settings',
recommendedNextStepAction: 'Open Patrol provider settings',
recommendedNextStepActionHref: '/settings/system-ai',
},
});
expect(aiChatStore.isOpen).toBe(true);
expect(aiChatStore.context.initialPrompt).toBe('hello');
@@ -121,6 +127,10 @@ describe('aiChatStore', () => {
expect(aiChatStore.context.briefing?.suggestedPrompts).toEqual([
'Explain recent changes and correlations',
]);
expect(aiChatStore.context.handoffMetadata).toMatchObject({
kind: 'patrol_finding',
recommendedNextStepActionHref: '/settings/system-ai',
});
});
it('preserves scoped autonomous-mode overrides for pre-filled prompts', () => {
+4
View File
@@ -63,6 +63,10 @@ export interface AIChatHandoffMetadata {
runType?: string;
runStatus?: string;
runtimeFailure?: boolean;
recommendedNextStep?: string;
recommendedNextStepAction?: string;
recommendedNextStepActionKind?: string;
recommendedNextStepActionHref?: string;
}
export interface AIChatContext {
+63 -9
View File
@@ -153,6 +153,7 @@ func NormalizeHandoffMetadata(metadata HandoffMetadata) HandoffMetadata {
switch kind {
case sessionHandoffKindPatrolAssessment,
sessionHandoffKindPatrolConfigurationFailure,
sessionHandoffKindPatrolFinding,
sessionHandoffKindPatrolRun:
default:
return HandoffMetadata{}
@@ -168,6 +169,25 @@ func NormalizeHandoffMetadata(metadata HandoffMetadata) HandoffMetadata {
if normalized.Kind == sessionHandoffKindPatrolRun && normalized.RunID == "" {
return HandoffMetadata{}
}
if normalized.Kind != sessionHandoffKindPatrolRun {
normalized.RunID = ""
normalized.RunType = ""
normalized.RunStatus = ""
}
if normalized.Kind != sessionHandoffKindPatrolRun && normalized.Kind != sessionHandoffKindPatrolConfigurationFailure {
normalized.RuntimeFailure = false
}
if normalized.Kind == sessionHandoffKindPatrolAssessment || normalized.Kind == sessionHandoffKindPatrolFinding {
normalized.RecommendedNextStep = safeSessionHandoffSummaryText(metadata.RecommendedNextStep, 160)
normalized.RecommendedNextStepAction = safeSessionHandoffSummaryText(metadata.RecommendedNextStepAction, 120)
normalized.RecommendedNextStepActionKind = safePatrolRecommendationActionKind(metadata.RecommendedNextStepActionKind)
if normalized.RecommendedNextStepAction != "" {
normalized.RecommendedNextStepActionHref = safePatrolHandoffRoute(metadata.RecommendedNextStepActionHref)
}
if normalized.RecommendedNextStep == "" && normalized.RecommendedNextStepAction != "" {
normalized.RecommendedNextStep = normalized.RecommendedNextStepAction
}
}
return normalized
}
@@ -297,6 +317,20 @@ func safePatrolAssessmentRecommendationAction(value string) (string, string) {
return action, ""
}
func safePatrolRecommendationActionKind(value string) string {
normalized := strings.ToLower(trimHandoffMetadataField(value, 64))
switch normalized {
case "discuss_assessment",
"open_provider_settings",
"review_approvals",
"review_findings",
"run_patrol":
return normalized
default:
return ""
}
}
func patrolFindingNextStepSummary(kind string, handoffContext string) (string, string) {
if kind != sessionHandoffKindPatrolFinding {
return "", ""
@@ -458,19 +492,39 @@ func modelContextHandoffSummary(modelContext *sessionModelContext) *SessionHando
} else if findingID != "" {
kind = sessionHandoffKindPatrolFinding
}
recommendedNextStep, recommendedNextStepAction, recommendedNextStepActionKind := patrolAssessmentRecommendedNextStepSummary(
kind,
modelContext.HandoffContext,
)
recommendedNextStepActionHref := ""
if recommendedNextStepAction == "" {
recommendedNextStepAction, recommendedNextStepActionHref = patrolFindingNextStepSummary(
recommendedNextStep := metadata.RecommendedNextStep
recommendedNextStepAction := metadata.RecommendedNextStepAction
recommendedNextStepActionKind := metadata.RecommendedNextStepActionKind
recommendedNextStepActionHref := metadata.RecommendedNextStepActionHref
if recommendedNextStep == "" || recommendedNextStepAction == "" || recommendedNextStepActionKind == "" {
contextNextStep, contextNextStepAction, contextNextStepActionKind := patrolAssessmentRecommendedNextStepSummary(
kind,
modelContext.HandoffContext,
)
if recommendedNextStep == "" && recommendedNextStepAction != "" {
recommendedNextStep = recommendedNextStepAction
if recommendedNextStep == "" {
recommendedNextStep = contextNextStep
}
if recommendedNextStepAction == "" {
recommendedNextStepAction = contextNextStepAction
}
if recommendedNextStepActionKind == "" {
recommendedNextStepActionKind = contextNextStepActionKind
}
}
if recommendedNextStepAction == "" || recommendedNextStepActionHref == "" {
contextNextStepAction, contextNextStepActionHref := patrolFindingNextStepSummary(
kind,
modelContext.HandoffContext,
)
if recommendedNextStepAction == "" {
recommendedNextStepAction = contextNextStepAction
}
if recommendedNextStepActionHref == "" {
recommendedNextStepActionHref = contextNextStepActionHref
}
}
if recommendedNextStep == "" && recommendedNextStepAction != "" {
recommendedNextStep = recommendedNextStepAction
}
summary := &SessionHandoffSummary{
@@ -632,6 +632,49 @@ func TestSessionStore_ListKeepsSafePatrolFindingNextStepRoute(t *testing.T) {
}
}
func TestSessionStore_ListPrefersStructuredPatrolFindingNextStepMetadata(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: sudo restart",
"Patrol Next Step Route: javascript:alert(1)",
}, "\n")
if err := store.SetModelHandoffEnvelope(session.ID, "finding-provider-settings", handoffContext, nil, nil, HandoffMetadata{
Kind: "patrol_finding",
RecommendedNextStep: "Open Patrol provider settings",
RecommendedNextStepAction: "Open Patrol provider settings",
RecommendedNextStepActionHref: "/settings/system-ai",
}); 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.RecommendedNextStep != "Open Patrol provider settings" ||
summary.RecommendedNextStepAction != "Open Patrol provider settings" ||
summary.RecommendedNextStepActionHref != "/settings/system-ai" {
t.Fatalf("next step summary = %#v, want structured safe Patrol next step", summary)
}
}
func TestSessionStore_ListWithholdsUnsafePatrolFindingNextStepRoute(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
if err != nil {
+9 -5
View File
@@ -165,11 +165,15 @@ type HandoffAction struct {
// model-only handoff. It must not include model context text, command payloads,
// remediation details, or provider/runtime error detail.
type HandoffMetadata struct {
Kind string `json:"kind,omitempty"`
RunID string `json:"run_id,omitempty"`
RunType string `json:"run_type,omitempty"`
RunStatus string `json:"run_status,omitempty"`
RuntimeFailure bool `json:"runtime_failure,omitempty"`
Kind string `json:"kind,omitempty"`
RunID string `json:"run_id,omitempty"`
RunType string `json:"run_type,omitempty"`
RunStatus string `json:"run_status,omitempty"`
RuntimeFailure bool `json:"runtime_failure,omitempty"`
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"`
}
// ExecuteRequest represents a chat execution request