diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index da45fd1b7..91a851d52 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -467,7 +467,11 @@ the canonical monitored-system blocked payload. drawer handoffs may include live pending-approval metadata only as safe operator context: approval ID, status, risk, requested/expiry timestamps, target label, generated approval summary, and command count are allowed, while - approval command payloads stay inside governed approval/remediation surfaces + approval command payloads stay inside governed approval/remediation surfaces, + and finding-level first-turn prompt emphasis may be derived from the same + bounded metadata only so Assistant starts by reviewing approval status, risk, + dry-run posture, proposed-fix posture, and safest next step without receiving + raw command or execution payloads and the dedicated `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts` owner, so recent-change counts and governed policy-posture fallbacks normalize once at the shared store boundary instead of as Patrol-hook-local payload repair and the shared `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` card, so learned correlations and correlation context stay rendered through one governed frontend card instead of separate page-local list loops and the same shared correlation card's ordering and truncation rule, so callers pass raw correlations instead of encoding their own top-N sort behavior diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 607cc4534..d7490f773 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -821,8 +821,9 @@ frontend primitive boundary. model-only refresh, but the shared drawer stays a generic shell rather than 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, but it must not expose raw - command or execution payloads. + suggested prompts or first-turn prompt emphasis for assessment and + finding-level handoffs, but it must not expose raw command or execution + payloads. The drawer may render a generic context-briefing band from `frontend-modern/src/stores/aiChat.ts`, but diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index e9885ba3a..066385e0b 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -161,7 +161,11 @@ Patrol-specific presentation helpers. not copy the approval command payload into Assistant drawer prose. The model-only runtime briefing must apply that same recovered approval reference when framing the operator decision and action - posture. Inline Patrol approval actions that open Assistant must follow the + posture. The initial visible prompt for a Patrol finding must lead with that + governed approval or proposed-fix review instruction when safe metadata is + attached, so the operator starts from approval status, risk, dry-run posture, + and safest-next-step review instead of generic incident discussion. Inline + Patrol approval actions that open Assistant must follow the same rule: pass approval ID/status/risk/target plus safe summary/count metadata as review context, force the request-local approval-required mode, attach the Patrol-owned visible drawer briefing for the pending approval, and diff --git a/frontend-modern/src/components/AI/FindingsPanel.tsx b/frontend-modern/src/components/AI/FindingsPanel.tsx index 6f4861132..112240714 100644 --- a/frontend-modern/src/components/AI/FindingsPanel.tsx +++ b/frontend-modern/src/components/AI/FindingsPanel.tsx @@ -492,6 +492,10 @@ export const FindingsPanel: Component = (props) => { title, subject, description: finding.description, + investigationOutcome: finding.investigationOutcome, + remediationId: finding.remediationPlanId, + pendingApproval: pendingApprovalBriefing, + proposedFix, investigationRecord: finding.investigationRecord, }); const briefing = buildPatrolAssistantFindingBriefing({ diff --git a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts index 13da25d62..c65dbcf6f 100644 --- a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts +++ b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts @@ -48,6 +48,8 @@ describe('FindingsPanel assistant handoff', () => { expect(findingsPanelSource).toContain('buildPatrolAssistantApprovalBriefingInput'); expect(findingsPanelSource).toContain('buildPatrolAssistantProposedFixBriefingInput'); expect(findingsPanelSource).toContain('briefing,'); + expect(findingsPanelSource).toContain('investigationOutcome: finding.investigationOutcome'); + expect(findingsPanelSource).toContain('remediationId: finding.remediationPlanId'); expect(findingsPanelSource).toContain('investigationRecord: finding.investigationRecord'); expect(findingsPanelSource).toContain('pendingApproval: pendingApprovalBriefing'); expect(findingsPanelSource).toContain('proposedFix,'); diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index 9b5e66cba..206545da5 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -455,6 +455,61 @@ describe('patrolInvestigationContextModel', () => { ).toContain('Use that record as the main context before suggesting next actions.'); }); + it('leads finding prompts with live governed approval context without command text', () => { + const prompt = buildPatrolAssistantFindingPrompt({ + title: 'High CPU usage', + subject: 'web-server', + description: 'CPU stayed above 95%.', + pendingApproval: { + id: 'approval-1', + status: 'pending', + riskLevel: 'high', + targetName: 'web-server', + actionApprovalPolicy: 'operator', + actionPreflight: 'Service restart would be attempted after health checks.', + actionDryRunSummary: 'Dry run completed with one restart action.', + }, + proposedFix: buildPatrolAssistantProposedFixBriefingInput({ + description: 'Restart the workload service', + commands: ['systemctl restart workload.service'], + riskLevel: 'high', + targetHost: 'web-server', + }), + }); + + expect(prompt).toContain('Start by reviewing governed approval approval-1'); + expect(prompt).toContain('approval status pending'); + expect(prompt).toContain('high risk'); + expect(prompt).toContain('approval policy attached'); + expect(prompt).toContain('dry-run posture attached'); + expect(prompt).toContain('safest next step'); + expect(prompt).not.toContain('systemctl restart workload.service'); + }); + + it('leads finding prompts with proposed-fix posture without command text', () => { + const prompt = buildPatrolAssistantFindingPrompt({ + title: 'Nginx down', + subject: 'node-1', + description: 'The service stopped responding.', + investigationOutcome: 'fix_queued', + remediationId: 'remediation-1', + proposedFix: buildPatrolAssistantProposedFixBriefingInput({ + description: 'Restart nginx', + commands: ['systemctl restart nginx'], + riskLevel: 'medium', + targetHost: 'node-1', + destructive: true, + }), + }); + + expect(prompt).toContain('Start by reviewing the governed proposed fix'); + expect(prompt).toContain('medium risk'); + expect(prompt).toContain('1 command recorded for approval context'); + expect(prompt).toContain('destructive action'); + expect(prompt).toContain('safest next step'); + expect(prompt).not.toContain('systemctl restart nginx'); + }); + it('builds a drawer briefing for Assistant handoff without exposing raw commands', () => { const approvalRequestedAt = new Date(Date.now() - 60_000).toISOString(); const approvalExpiresAt = new Date(Date.now() + 10 * 60_000).toISOString(); diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index 659787f4e..d4db5a1ce 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -61,6 +61,10 @@ export interface PatrolAssistantFindingPromptInput { title: string; subject: string; description: string; + investigationOutcome?: string | null; + remediationId?: string | null; + pendingApproval?: PatrolAssistantApprovalBriefingInput | null; + proposedFix?: PatrolAssistantProposedFixBriefingInput | null; investigationRecord?: InvestigationRecord | null; } @@ -343,18 +347,79 @@ export function buildPatrolAssistantFindingPrompt( const subject = normalizeText(input.subject) || 'the affected resource'; const description = normalizeText(input.description); const hasRecord = Boolean(input.investigationRecord?.id); + const actionInstruction = buildPatrolAssistantFindingActionPromptInstruction(input); let prompt = `I'd like to discuss this Patrol finding: "${title}" on ${subject}.`; if (hasRecord) { prompt += '\n\nPulse Patrol has a structured investigation record for this finding. Use that record as the main context before suggesting next actions.'; } + if (actionInstruction) { + prompt += `\n\n${actionInstruction}`; + } if (description) { prompt += `\n\n${description}`; } return prompt; } +function buildPatrolAssistantFindingActionPromptInstruction( + input: PatrolAssistantFindingPromptInput, +): string | undefined { + const pendingApproval = normalizeApprovalBriefing(input.pendingApproval); + const record = buildPatrolInvestigationRecordPresentation(input.investigationRecord); + const proposedFix = record.proposedFix || normalizeProposedFixBriefing(input.proposedFix); + const remediationId = normalizeText(input.remediationId); + const outcome = normalizeText(input.investigationOutcome || input.investigationRecord?.outcome); + const normalizedOutcome = outcome.toLowerCase(); + + if (pendingApproval.id) { + const contextParts = [ + pendingApproval.status ? `approval status ${pendingApproval.status}` : undefined, + pendingApproval.riskLevel ? `${pendingApproval.riskLevel} risk` : undefined, + pendingApproval.targetName + ? `target ${truncateContextText(pendingApproval.targetName, 120)}` + : undefined, + pendingApproval.actionApprovalPolicy ? 'approval policy attached' : undefined, + pendingApproval.actionPreflight || pendingApproval.actionDryRunSummary + ? 'dry-run posture attached' + : undefined, + ].filter(isNonEmptyString); + + return `Start by reviewing governed approval ${pendingApproval.id}${ + contextParts.length > 0 ? ` (${contextParts.join('; ')})` : '' + }. Use the attached Patrol context to explain prerequisites, risk, and the safest next step before any execution.`; + } + + const hasGovernedActionPosture = Boolean( + proposedFix || + remediationId || + normalizeText(input.investigationRecord?.approval_id) || + GOVERNED_ACTION_OUTCOMES.has(normalizedOutcome), + ); + if (!hasGovernedActionPosture) { + return undefined; + } + + const contextParts = [ + proposedFix?.description + ? `proposed fix ${truncateContextText(proposedFix.description, 120)}` + : undefined, + proposedFix?.riskLabel ? `${proposedFix.riskLabel.toLowerCase()} risk` : undefined, + proposedFix?.targetHost + ? `target ${truncateContextText(proposedFix.targetHost, 120)}` + : undefined, + proposedFix?.commandSummary, + proposedFix?.destructive ? 'destructive action' : undefined, + remediationId ? `remediation ${truncateContextText(remediationId, 120)}` : undefined, + outcome ? `outcome ${formatIdentifierLabel(outcome)?.toLowerCase() || outcome}` : undefined, + ].filter(isNonEmptyString); + + return `Start by reviewing the governed proposed fix or action posture${ + contextParts.length > 0 ? ` (${contextParts.join('; ')})` : '' + }. Use the attached Patrol context to explain risk, prerequisites, and the safest next step without repeating command text.`; +} + export function buildPatrolAssessmentAssistantHandoff( input: PatrolAssessmentAssistantHandoffInput, ): PatrolAssessmentAssistantHandoff {