Preserve Patrol assessment action kind

This commit is contained in:
rcourtman
2026-05-08 01:40:50 +01:00
parent 94ae660035
commit 475429def2
10 changed files with 76 additions and 57 deletions
@@ -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
@@ -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.
@@ -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.
@@ -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',
},
};
+1
View File
@@ -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;
}
@@ -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',
@@ -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,
+34 -29
View File
@@ -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 = ""
+6 -2
View File
@@ -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)
}
}
+18 -17
View File
@@ -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