diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index c49fbe413..d7d888b4d 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -363,6 +363,10 @@ runtime cost control, and shared AI transport surfaces. 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. + 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 + 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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 4192863ef..d19921101 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -516,6 +516,8 @@ the canonical monitored-system blocked payload. counts instead of pasting page-local UI text or raw command payloads into chat, and may derive visible action labels, safety notes, and approval-aware suggested prompts plus first-turn prompt emphasis from that same safe metadata + plus the current disabled reason for a Patrol-owned recommended action when + the visible summary action is unavailable, so the drawer does not fall back to generic assessment copy when governed actions are present or when the summary already exposes a concrete recommended next step diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 098812016..ec77204c9 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -913,7 +913,10 @@ prompt explain the same operator-facing priority. a Patrol summary prompt builder. The Patrol helper may turn those same safe references into visible action labels, safety notes, and approval-aware suggested prompts or first-turn prompt emphasis for assessment and - finding-level handoffs; finding-level drawer opens may also pass one bounded + finding-level handoffs; if a source-owned Patrol recommendation action is + currently disabled, the helper must pass the bounded disabled reason in + visible briefing and model-only context instead of presenting that action as + available. Finding-level drawer opens may also pass one bounded model-only finding context, one target resource reference, and one `handoff_actions` reference for a live approval or proposed fix. It must not expose raw command or execution payloads. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 8209367b3..6dfb75dde 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -244,8 +244,10 @@ Patrol-specific presentation helpers. safe source-owned suggested prompts as model-only context. The same handoff must also carry the Patrol-owned recommended next step as safe bounded metadata, including its title, detail copy, action label, and known action - kind when present, so Assistant explains the same operator-facing priority - shown in the summary card instead of inventing a separate next step. Active-finding + kind when present, plus the current action-disabled reason when the visible + Patrol-owned action is unavailable, so Assistant explains the same + operator-facing priority and current availability shown in the summary card + instead of inventing a separate next step. Active-finding summaries may include live pending approval posture only as safe metadata: approval ID, pending status, risk, target, requested/expiry timestamps, action plan identity, approval policy, plan expiry, dry-run posture, and @@ -351,7 +353,9 @@ dry-run posture when available. When no approval or governed action outranks the summary recommendation, the briefing action label and initial prompt may lead with that recommendation, but Assistant remains explanatory and may not start Patrol runs, settings changes, diagnostics, remediation, or approvals -from the handoff. Its initial prompt must prioritize approvals or action +from the handoff; if the recommended action is currently disabled, the prompt +and briefing must say why instead of describing it as an available action. Its +initial prompt must prioritize approvals or action references before broader assessment discussion while command payloads stay out of the drawer. Run-history rows now follow that same Assistant handoff model. A selected diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx index 8fa26d84c..134160dcf 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx @@ -186,6 +186,9 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat state.patrolStream.isStreaming()) ); }); + const recommendedNextStepActionDisabledReason = createMemo(() => + recommendedNextStepActionDisabled() ? state.triggerPatrolDisabledReason() : '', + ); const recommendedNextStepActionLabel = createMemo(() => { const action = recommendedNextStepAction(); if (action?.kind !== 'run_patrol') { @@ -305,6 +308,7 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat description: recommendation.description, actionLabel: recommendation.action?.label, actionKind: recommendation.action?.kind, + actionDisabledReason: recommendedNextStepActionDisabledReason(), }, activeFindings: activeFindingsWithApprovalContext(), }); diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx index b4ae66437..e7a146cee 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx +++ b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx @@ -129,6 +129,72 @@ describe('PatrolIntelligenceSummary', () => { ); expect(JSON.stringify(context)).not.toContain('systemctl restart workload.service'); }); + + it('passes current run-action availability into assessment Assistant handoff', async () => { + const openWithPrompt = vi.spyOn(aiChatStore, 'openWithPrompt').mockImplementation(() => {}); + vi.spyOn(aiIntelligenceStore, 'loadPendingApprovals').mockResolvedValue(undefined); + vi.spyOn(aiIntelligenceStore, 'patrolPendingApprovals', 'get').mockReturnValue([]); + + const patrolState = { + ...createPatrolState(), + activePatrolFindings: () => [], + canTriggerPatrol: () => false, + correlationTotal: () => 0, + correlations: () => [], + intelligenceSummary: () => ({ + overall_health: { + grade: 'C', + score: 65, + factors: [{ category: 'coverage' }], + prediction: 'Patrol coverage is incomplete.', + }, + recent_changes_count: 0, + recent_changes: [], + policy_posture: { + total_resources: 4, + sensitivity_counts: {}, + routing_counts: {}, + }, + }), + manualRunRequested: () => false, + patrolStream: { + isStreaming: () => true, + }, + runtimeState: () => 'running', + summaryStats: () => ({ + criticalFindings: 0, + warningFindings: 0, + totalActive: 0, + fixedCount: 0, + hasAnyPatrolFindings: false, + }), + triggerPatrolDisabledReason: () => 'Patrol is already running', + } satisfies PatrolIntelligenceState; + + render(() => ); + + fireEvent.click(screen.getByTestId('patrol-assessment-assistant-button')); + + await waitFor(() => expect(openWithPrompt).toHaveBeenCalledTimes(1)); + const [prompt, context] = openWithPrompt.mock.calls[0] as [string, Record]; + + expect(prompt).toContain( + 'Patrol-owned action "Run Patrol" is currently unavailable: Patrol is already running', + ); + expect(context.handoffContext).toContain( + 'Recommended Next Step Action Status: unavailable - Patrol is already running', + ); + expect(context.context).toMatchObject({ + recommendedNextStepActionKind: 'run_patrol', + recommendedNextStepActionDisabledReason: 'Patrol is already running', + }); + expect(context.briefing).toMatchObject({ + actionLabel: 'Recommended: Run Patrol', + safetyNote: expect.stringContaining( + 'Run Patrol is currently unavailable: Patrol is already running.', + ), + }); + }); }); function createPatrolState(): PatrolIntelligenceState { diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index 9ad6cfb4b..00bad9f8c 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -368,6 +368,46 @@ describe('patrolInvestigationContextModel', () => { }); }); + it('marks unavailable recommended Patrol actions in assessment handoffs', () => { + const handoff = buildPatrolAssessmentAssistantHandoff({ + assessment: { + title: 'Coverage incomplete', + description: 'Patrol coverage is incomplete.', + }, + overallHealth: { + grade: 'C', + score: 65, + factors: [{ category: 'coverage' }], + }, + recommendedNextStep: { + title: 'Verify full coverage', + description: 'Run a full Patrol sweep before treating this assessment as an all-clear.', + actionLabel: 'Run Patrol', + actionKind: 'run_patrol', + actionDisabledReason: 'Patrol is already running', + }, + }); + + expect(handoff.prompt).toContain( + 'Patrol-owned action "Run Patrol" is currently unavailable: Patrol is already running', + ); + expect(handoff.context.handoffContext).toContain( + 'Recommended Next Step Action Status: unavailable - Patrol is already running', + ); + expect(handoff.context.context).toMatchObject({ + recommendedNextStepActionKind: 'run_patrol', + recommendedNextStepActionDisabledReason: 'Patrol is already running', + }); + expect(handoff.context.briefing).toMatchObject({ + actionLabel: 'Recommended: Run Patrol', + safetyNote: + 'Assistant can explain the gap; full Patrol runs, diagnostics, and remediation remain operator-controlled. Run Patrol is currently unavailable: Patrol is already running.', + }); + expect((handoff.context.briefing?.detailLines ?? []).join(' ')).toContain( + 'action Run Patrol unavailable: Patrol is already running', + ); + }); + it('withholds unsafe recommendation text from 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 a3c38d511..d768acd9f 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -206,6 +206,7 @@ export interface PatrolAssessmentRecommendedNextStepInput { description?: string | null; actionLabel?: string | null; actionKind?: PatrolAssessmentRecommendedNextStepActionKind | string | null; + actionDisabledReason?: string | null; } export interface PatrolAssessmentAssistantHandoffInput { @@ -311,6 +312,7 @@ interface NormalizedPatrolAssessmentRecommendedNextStep { description?: string; actionLabel?: string; actionKind?: PatrolAssessmentRecommendedNextStepActionKind; + actionDisabledReason?: string; actionSummary?: string; } @@ -571,6 +573,7 @@ export function buildPatrolAssessmentAssistantHandoff( ? { recommendedNextStepTitle: recommendedNextStep.title, recommendedNextStepActionKind: recommendedNextStep.actionKind, + recommendedNextStepActionDisabledReason: recommendedNextStep.actionDisabledReason, } : {}), }, @@ -842,14 +845,48 @@ function buildPatrolAssessmentRecommendationPromptInstruction( recommendedNextStep.description ? `detail: ${truncateContextText(recommendedNextStep.description, 180)}` : undefined, - recommendedNextStep.actionLabel - ? `available Patrol-owned action: ${recommendedNextStep.actionLabel}` - : undefined, + formatAssessmentRecommendedNextStepActionInstruction(recommendedNextStep), ].filter(isNonEmptyString); return `${parts.join('; ')}. Explain that recommendation before alternatives, but keep Patrol runs, settings changes, diagnostics, remediation, and approvals in governed controls.`; } +function formatAssessmentRecommendedNextStepActionInstruction( + recommendedNextStep: NormalizedPatrolAssessmentRecommendedNextStep, +): string | undefined { + if (!recommendedNextStep.actionLabel) return undefined; + if (recommendedNextStep.actionDisabledReason) { + return `Patrol-owned action "${recommendedNextStep.actionLabel}" is currently unavailable: ${recommendedNextStep.actionDisabledReason}`; + } + return `available Patrol-owned action: ${recommendedNextStep.actionLabel}`; +} + +function formatAssessmentRecommendedNextStepActionDetail( + recommendedNextStep: NormalizedPatrolAssessmentRecommendedNextStep, +): string | undefined { + if (!recommendedNextStep.actionLabel) return undefined; + return recommendedNextStep.actionDisabledReason + ? `action ${recommendedNextStep.actionLabel} unavailable: ${recommendedNextStep.actionDisabledReason}` + : `action ${recommendedNextStep.actionLabel}`; +} + +function formatAssessmentRecommendedNextStepActionAvailability( + recommendedNextStep?: NormalizedPatrolAssessmentRecommendedNextStep, +): string | undefined { + if (!recommendedNextStep?.actionDisabledReason) return undefined; + return `unavailable - ${recommendedNextStep.actionDisabledReason}`; +} + +function formatAssessmentRecommendationSafetyNote( + base: string, + recommendedNextStep?: NormalizedPatrolAssessmentRecommendedNextStep, +): string { + if (!recommendedNextStep?.actionLabel || !recommendedNextStep.actionDisabledReason) { + return base; + } + return `${base} ${recommendedNextStep.actionLabel} is currently unavailable: ${recommendedNextStep.actionDisabledReason}.`; +} + function formatAssessmentRecommendedNextStepDetailLine( recommendedNextStep?: NormalizedPatrolAssessmentRecommendedNextStep, ): string | undefined { @@ -859,7 +896,7 @@ function formatAssessmentRecommendedNextStepDetailLine( [ `Recommended next step: ${recommendedNextStep.title}`, recommendedNextStep.description, - recommendedNextStep.actionLabel ? `action ${recommendedNextStep.actionLabel}` : undefined, + formatAssessmentRecommendedNextStepActionDetail(recommendedNextStep), ], 3, 'recommendation facts', @@ -917,16 +954,20 @@ function buildPatrolAssessmentActionPosture( actionLabel: recommendedNextStep?.actionLabel ? `Recommended: ${recommendedNextStep.actionLabel}` : 'Review coverage gap', - safetyNote: + safetyNote: formatAssessmentRecommendationSafetyNote( 'Assistant can explain the gap; full Patrol runs, diagnostics, and remediation remain operator-controlled.', + recommendedNextStep, + ), }; } if (recommendedNextStep?.actionLabel || recommendedNextStep?.title) { return { actionLabel: `Recommended: ${recommendedNextStep.actionLabel || recommendedNextStep.title}`, - safetyNote: + safetyNote: formatAssessmentRecommendationSafetyNote( 'Assistant can explain the Patrol recommendation; Patrol runs, settings changes, diagnostics, and remediation remain operator-controlled.', + recommendedNextStep, + ), }; } @@ -1088,6 +1129,10 @@ function buildPatrolAssessmentAssistantModelContext( formatContextLine('Recommended Next Step', recommendedNextStep?.title), formatContextLine('Recommended Next Step Detail', recommendedNextStep?.description), formatContextLine('Recommended Next Step Action', recommendedNextStep?.actionSummary), + formatContextLine( + 'Recommended Next Step Action Status', + formatAssessmentRecommendedNextStepActionAvailability(recommendedNextStep), + ), formatContextLine('Verification', formatAssessmentVerification(input)), formatContextLine('Last Patrol', formatAssessmentRecency(input)), formatContextLine('Latest Run', formatAssessmentLatestRun(input)), @@ -2744,6 +2789,10 @@ function normalizeAssessmentRecommendedNextStep( safeActionLabel === WITHHELD_RECOMMENDATION_TEXT && fallbackActionLabel ? fallbackActionLabel : safeActionLabel || fallbackActionLabel; + const actionDisabledReason = formatSafeAssessmentRecommendationText( + input.actionDisabledReason, + 140, + ); const effectiveTitle = title === WITHHELD_RECOMMENDATION_TEXT && actionLabel ? actionLabel : title || actionLabel; const actionSummary = formatAssessmentRecommendedNextStepActionSummary(actionLabel, actionKind); @@ -2757,6 +2806,7 @@ function normalizeAssessmentRecommendedNextStep( description, actionLabel, actionKind, + actionDisabledReason, actionSummary, }; }