From 475429def2c6581802f99ca3a48fef0b108eddb0 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 8 May 2026 01:40:50 +0100 Subject: [PATCH] Preserve Patrol assessment action kind --- .../v6/internal/subsystems/ai-runtime.md | 10 +-- .../v6/internal/subsystems/api-contracts.md | 4 +- .../subsystems/frontend-primitives.md | 7 ++- .../src/api/__tests__/aiChat.test.ts | 1 + frontend-modern/src/api/aiChat.ts | 1 + .../AI/Chat/__tests__/AIChat.test.tsx | 2 + .../src/components/AI/Chat/index.tsx | 2 + internal/ai/chat/session.go | 63 ++++++++++--------- internal/ai/chat/session_additional_test.go | 8 ++- internal/ai/chat/types.go | 35 ++++++----- 10 files changed, 76 insertions(+), 57 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index da34c1ad0..c49fbe413 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -330,8 +330,9 @@ runtime cost control, and shared AI transport surfaces. 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 only when they can be safely extracted from the stored assessment - handoff, but it must not expose model-only handoff text, + 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, 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 @@ -359,8 +360,9 @@ runtime cost control, and shared AI transport surfaces. 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 instead - of reducing the saved session to generic assessment context. + 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. 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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index cd41f018c..4192863ef 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -2007,8 +2007,8 @@ 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 when safely extractable from the stored assessment -handoff, and summary timestamp; it must +next-step/action labels plus the safe recommendation action kind when safely +extractable from the stored assessment 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 5a7c080ca..098812016 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -204,9 +204,10 @@ work extends shared components instead of creating new local variants. 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 or action label, the - session picker plus restored drawer briefing and action copy must use that - recommendation rather than falling back to generic assessment copy. + 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. 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/frontend-modern/src/api/__tests__/aiChat.test.ts b/frontend-modern/src/api/__tests__/aiChat.test.ts index 19c9b7478..299036f68 100644 --- a/frontend-modern/src/api/__tests__/aiChat.test.ts +++ b/frontend-modern/src/api/__tests__/aiChat.test.ts @@ -52,6 +52,7 @@ describe('AIChatAPI', () => { last_known_action_risk: 'high', recommended_next_step: 'Review pending Patrol approval', recommended_next_step_action: 'Review approvals', + recommended_next_step_action_kind: 'review_approvals', updated_at: '2026-05-06T12:08:00Z', }, }; diff --git a/frontend-modern/src/api/aiChat.ts b/frontend-modern/src/api/aiChat.ts index b32c9af54..fd06f03fe 100644 --- a/frontend-modern/src/api/aiChat.ts +++ b/frontend-modern/src/api/aiChat.ts @@ -55,6 +55,7 @@ export interface ChatSessionHandoffSummary { last_known_action_risk?: string; recommended_next_step?: string; recommended_next_step_action?: string; + recommended_next_step_action_kind?: 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 8d88022a4..029cc59cf 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -889,6 +889,7 @@ describe('AIChat', () => { requires_approval: false, recommended_next_step: 'Verify full coverage', recommended_next_step_action: 'Run Patrol', + recommended_next_step_action_kind: 'run_patrol', }, }, ]); @@ -919,6 +920,7 @@ describe('AIChat', () => { findingId: undefined, recommendedNextStep: 'Verify full coverage', recommendedNextStepAction: 'Run Patrol', + recommendedNextStepActionKind: 'run_patrol', }), briefing: expect.objectContaining({ sourceLabel: 'Pulse Patrol', diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 30fcc89a3..ad1ee096f 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -163,6 +163,7 @@ const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | unde const statusLabel = formatSessionHandoffStatus(summary); 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 actionCount = summary.action_count ?? 0; const resourceCount = summary.resource_count ?? 0; const findingId = summary.finding_id?.trim() || undefined; @@ -223,6 +224,7 @@ const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | unde lastKnownActionRisk: summary.last_known_action_risk, recommendedNextStep, recommendedNextStepAction, + recommendedNextStepActionKind, updatedAt: summary.updated_at, }, findingId, diff --git a/internal/ai/chat/session.go b/internal/ai/chat/session.go index 70c7dd55d..59ff2289e 100644 --- a/internal/ai/chat/session.go +++ b/internal/ai/chat/session.go @@ -223,20 +223,21 @@ func inferPatrolRunHandoffMetadata(handoffContext string) HandoffMetadata { return NormalizeHandoffMetadata(metadata) } -func patrolAssessmentRecommendedNextStepSummary(kind string, handoffContext string) (string, string) { +func patrolAssessmentRecommendedNextStepSummary(kind string, handoffContext string) (string, string, string) { if kind != sessionHandoffKindPatrolAssessment { - return "", "" + return "", "", "" } lines := strings.Split(strings.TrimSpace(handoffContext), "\n") if len(lines) == 0 { - return "", "" + return "", "", "" } sawAssessmentContext := false sawPatrolSource := false var recommendedNextStep string var recommendedNextStepAction string + var recommendedNextStepActionKind string for _, line := range lines { line = strings.TrimSpace(line) if line == "[Patrol Assessment Context]" { @@ -261,36 +262,39 @@ func patrolAssessmentRecommendedNextStepSummary(kind string, handoffContext stri case "recommended next step": recommendedNextStep = safeSessionHandoffSummaryText(value, 160) case "recommended next step action": - recommendedNextStepAction = safePatrolAssessmentRecommendationAction(value) + recommendedNextStepAction, recommendedNextStepActionKind = safePatrolAssessmentRecommendationAction(value) } } if !sawAssessmentContext || !sawPatrolSource { - return "", "" + return "", "", "" } if recommendedNextStep == "" && recommendedNextStepAction != "" { recommendedNextStep = recommendedNextStepAction } - return recommendedNextStep, recommendedNextStepAction + return recommendedNextStep, recommendedNextStepAction, recommendedNextStepActionKind } -func safePatrolAssessmentRecommendationAction(value string) string { +func safePatrolAssessmentRecommendationAction(value string) (string, string) { action := safeSessionHandoffSummaryText(value, 120) if action == "" { - return "" + return "", "" } - for _, suffix := range []string{ - " (discuss_assessment)", - " (open_provider_settings)", - " (review_approvals)", - " (review_findings)", - " (run_patrol)", + for _, candidate := range []struct { + suffix string + kind string + }{ + {" (discuss_assessment)", "discuss_assessment"}, + {" (open_provider_settings)", "open_provider_settings"}, + {" (review_approvals)", "review_approvals"}, + {" (review_findings)", "review_findings"}, + {" (run_patrol)", "run_patrol"}, } { - if strings.HasSuffix(action, suffix) { - return strings.TrimSpace(strings.TrimSuffix(action, suffix)) + if strings.HasSuffix(action, candidate.suffix) { + return strings.TrimSpace(strings.TrimSuffix(action, candidate.suffix)), candidate.kind } } - return action + return action, "" } func safeSessionHandoffSummaryText(value string, maxRunes int) string { @@ -396,23 +400,24 @@ func modelContextHandoffSummary(modelContext *sessionModelContext) *SessionHando } else if findingID != "" { kind = sessionHandoffKindPatrolFinding } - recommendedNextStep, recommendedNextStepAction := patrolAssessmentRecommendedNextStepSummary( + recommendedNextStep, recommendedNextStepAction, recommendedNextStepActionKind := patrolAssessmentRecommendedNextStepSummary( kind, modelContext.HandoffContext, ) summary := &SessionHandoffSummary{ - Kind: kind, - FindingID: findingID, - RunID: metadata.RunID, - RunType: metadata.RunType, - RunStatus: metadata.RunStatus, - RuntimeFailure: metadata.RuntimeFailure, - HasModelContext: strings.TrimSpace(modelContext.HandoffContext) != "", - ResourceCount: len(resources), - ActionCount: len(actions), - RecommendedNextStep: recommendedNextStep, - RecommendedNextStepAction: recommendedNextStepAction, + Kind: kind, + FindingID: findingID, + RunID: metadata.RunID, + RunType: metadata.RunType, + RunStatus: metadata.RunStatus, + RuntimeFailure: metadata.RuntimeFailure, + HasModelContext: strings.TrimSpace(modelContext.HandoffContext) != "", + ResourceCount: len(resources), + ActionCount: len(actions), + RecommendedNextStep: recommendedNextStep, + RecommendedNextStepAction: recommendedNextStepAction, + RecommendedNextStepActionKind: recommendedNextStepActionKind, } if kind != sessionHandoffKindPatrolRun { summary.RunID = "" diff --git a/internal/ai/chat/session_additional_test.go b/internal/ai/chat/session_additional_test.go index 0b2dfbd5c..098613c75 100644 --- a/internal/ai/chat/session_additional_test.go +++ b/internal/ai/chat/session_additional_test.go @@ -567,7 +567,9 @@ func TestSessionStore_ListKeepsPatrolAssessmentHandoffIdentity(t *testing.T) { if summary.LastKnownApprovalStatus != "pending" || summary.LastKnownActionRisk != "high" { t.Fatalf("approval posture = %#v, want safe action summary", summary) } - if summary.RecommendedNextStep != "Verify full coverage" || summary.RecommendedNextStepAction != "Run Patrol" { + if summary.RecommendedNextStep != "Verify full coverage" || + summary.RecommendedNextStepAction != "Run Patrol" || + summary.RecommendedNextStepActionKind != "run_patrol" { t.Fatalf("recommended next step summary = %#v, want safe Patrol recommendation", summary) } payload, err := json.Marshal(sessions) @@ -618,7 +620,9 @@ func TestSessionStore_ListWithholdsUnsafePatrolAssessmentRecommendationSummary(t } summary := sessions[0].HandoffSummary - if summary.RecommendedNextStep != "" || summary.RecommendedNextStepAction != "" { + if summary.RecommendedNextStep != "" || + summary.RecommendedNextStepAction != "" || + summary.RecommendedNextStepActionKind != "" { t.Fatalf("unsafe recommendation summary = %#v, want withheld recommendation fields", summary) } } diff --git a/internal/ai/chat/types.go b/internal/ai/chat/types.go index 139a3b5a8..4728403f2 100644 --- a/internal/ai/chat/types.go +++ b/internal/ai/chat/types.go @@ -25,23 +25,24 @@ type Session struct { // session-scoped model handoff metadata. It intentionally omits model-only // context text and raw remediation details. type SessionHandoffSummary struct { - Kind string `json:"kind,omitempty"` - FindingID string `json:"finding_id,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"` - HasModelContext bool `json:"has_model_context"` - ResourceCount int `json:"resource_count,omitempty"` - PrimaryResource *HandoffResource `json:"primary_resource,omitempty"` - ActionCount int `json:"action_count,omitempty"` - RequiresApproval bool `json:"requires_approval,omitempty"` - LastKnownApprovalStatus string `json:"last_known_approval_status,omitempty"` - LastKnownActionState string `json:"last_known_action_state,omitempty"` - LastKnownActionRisk string `json:"last_known_action_risk,omitempty"` - RecommendedNextStep string `json:"recommended_next_step,omitempty"` - RecommendedNextStepAction string `json:"recommended_next_step_action,omitempty"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` + Kind string `json:"kind,omitempty"` + FindingID string `json:"finding_id,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"` + HasModelContext bool `json:"has_model_context"` + ResourceCount int `json:"resource_count,omitempty"` + PrimaryResource *HandoffResource `json:"primary_resource,omitempty"` + ActionCount int `json:"action_count,omitempty"` + RequiresApproval bool `json:"requires_approval,omitempty"` + LastKnownApprovalStatus string `json:"last_known_approval_status,omitempty"` + LastKnownActionState string `json:"last_known_action_state,omitempty"` + LastKnownActionRisk string `json:"last_known_action_risk,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"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` } // Message represents a chat message