From 3e9cefafaf5b00283069efd41c9e2cf3d4ee3081 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 8 May 2026 15:01:09 +0100 Subject: [PATCH] Structure Patrol Assistant next-step metadata --- .../v6/internal/subsystems/ai-runtime.md | 4 +- .../v6/internal/subsystems/api-contracts.md | 7 +- .../subsystems/frontend-primitives.md | 6 +- .../subsystems/patrol-intelligence.md | 6 +- .../src/api/__tests__/aiChat.test.ts | 51 +++++++++++++ frontend-modern/src/api/aiChat.ts | 8 +++ .../patrolInvestigationContextModel.test.ts | 19 +++++ .../patrol/patrolInvestigationContextModel.ts | 14 ++++ .../src/stores/__tests__/aiChat.test.ts | 10 +++ frontend-modern/src/stores/aiChat.ts | 4 ++ internal/ai/chat/session.go | 72 ++++++++++++++++--- internal/ai/chat/session_additional_test.go | 43 +++++++++++ internal/ai/chat/types.go | 14 ++-- 13 files changed, 238 insertions(+), 20 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index d2682474c..44eef4b22 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 315125b22..baaec8304 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index ed8c13460..8978839be 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 1f8e83bb3..8adea4bc9 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -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 diff --git a/frontend-modern/src/api/__tests__/aiChat.test.ts b/frontend-modern/src/api/__tests__/aiChat.test.ts index 80a72a1f3..4cb7e3912 100644 --- a/frontend-modern/src/api/__tests__/aiChat.test.ts +++ b/frontend-modern/src/api/__tests__/aiChat.test.ts @@ -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(); diff --git a/frontend-modern/src/api/aiChat.ts b/frontend-modern/src/api/aiChat.ts index eccc5476b..9220767a0 100644 --- a/frontend-modern/src/api/aiChat.ts +++ b/frontend-modern/src/api/aiChat.ts @@ -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, }; } diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index 2a294e6b5..27af4fc6e 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -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', diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index e6979e215..12cb51a40 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -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, diff --git a/frontend-modern/src/stores/__tests__/aiChat.test.ts b/frontend-modern/src/stores/__tests__/aiChat.test.ts index 96a0ebaec..eb586f891 100644 --- a/frontend-modern/src/stores/__tests__/aiChat.test.ts +++ b/frontend-modern/src/stores/__tests__/aiChat.test.ts @@ -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', () => { diff --git a/frontend-modern/src/stores/aiChat.ts b/frontend-modern/src/stores/aiChat.ts index 8f12b2c65..2b5263007 100644 --- a/frontend-modern/src/stores/aiChat.ts +++ b/frontend-modern/src/stores/aiChat.ts @@ -63,6 +63,10 @@ export interface AIChatHandoffMetadata { runType?: string; runStatus?: string; runtimeFailure?: boolean; + recommendedNextStep?: string; + recommendedNextStepAction?: string; + recommendedNextStepActionKind?: string; + recommendedNextStepActionHref?: string; } export interface AIChatContext { diff --git a/internal/ai/chat/session.go b/internal/ai/chat/session.go index 0449bf9b9..3aa116c9f 100644 --- a/internal/ai/chat/session.go +++ b/internal/ai/chat/session.go @@ -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{ diff --git a/internal/ai/chat/session_additional_test.go b/internal/ai/chat/session_additional_test.go index 99e1f51ae..931916a62 100644 --- a/internal/ai/chat/session_additional_test.go +++ b/internal/ai/chat/session_additional_test.go @@ -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 { diff --git a/internal/ai/chat/types.go b/internal/ai/chat/types.go index 71e542522..dab79291e 100644 --- a/internal/ai/chat/types.go +++ b/internal/ai/chat/types.go @@ -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