From 6d080a481089632f7667df320898ded859dea175 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 8 May 2026 14:51:50 +0100 Subject: [PATCH] Restore Patrol Assistant next-step links --- .../v6/internal/subsystems/ai-runtime.md | 17 ++-- .../v6/internal/subsystems/api-contracts.md | 7 +- .../subsystems/frontend-primitives.md | 16 ++-- .../subsystems/patrol-intelligence.md | 10 +-- .../src/api/__tests__/aiChat.test.ts | 1 + frontend-modern/src/api/aiChat.ts | 1 + .../AI/Chat/__tests__/AIChat.test.tsx | 10 ++- .../src/components/AI/Chat/index.tsx | 41 +++++++-- .../patrolInvestigationContextModel.test.ts | 32 +++++++ .../patrol/patrolInvestigationContextModel.ts | 21 ++++- frontend-modern/src/types/ai.ts | 2 + internal/ai/chat/session.go | 69 +++++++++++++++ internal/ai/chat/session_additional_test.go | 83 +++++++++++++++++++ internal/ai/chat/types.go | 1 + 14 files changed, 281 insertions(+), 30 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index cee2fa779..d2682474c 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 45d0f6e4f..315125b22 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 8f24c5c98..ed8c13460 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index d4fbc5c78..1f8e83bb3 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -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 diff --git a/frontend-modern/src/api/__tests__/aiChat.test.ts b/frontend-modern/src/api/__tests__/aiChat.test.ts index 299036f68..80a72a1f3 100644 --- a/frontend-modern/src/api/__tests__/aiChat.test.ts +++ b/frontend-modern/src/api/__tests__/aiChat.test.ts @@ -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', }, }; diff --git a/frontend-modern/src/api/aiChat.ts b/frontend-modern/src/api/aiChat.ts index fd06f03fe..eccc5476b 100644 --- a/frontend-modern/src/api/aiChat.ts +++ b/frontend-modern/src/api/aiChat.ts @@ -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; } diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index 0fc6f7dbf..c9d77f801 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -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', }), }), ); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 5579684bc..9edaf5b98 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -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}` diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index 85b9b07ca..2a294e6b5 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -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: { diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index 813c0c0b2..e6979e215 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -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, diff --git a/frontend-modern/src/types/ai.ts b/frontend-modern/src/types/ai.ts index ae785c3a5..c51a66c5f 100644 --- a/frontend-modern/src/types/ai.ts +++ b/frontend-modern/src/types/ai.ts @@ -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; } diff --git a/internal/ai/chat/session.go b/internal/ai/chat/session.go index 59ff2289e..0449bf9b9 100644 --- a/internal/ai/chat/session.go +++ b/internal/ai/chat/session.go @@ -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 = "" diff --git a/internal/ai/chat/session_additional_test.go b/internal/ai/chat/session_additional_test.go index 098613c75..99e1f51ae 100644 --- a/internal/ai/chat/session_additional_test.go +++ b/internal/ai/chat/session_additional_test.go @@ -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 { diff --git a/internal/ai/chat/types.go b/internal/ai/chat/types.go index 4728403f2..71e542522 100644 --- a/internal/ai/chat/types.go +++ b/internal/ai/chat/types.go @@ -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"` }