mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-20 01:01:20 +00:00
Show Patrol record briefings in Assistant handoff
This commit is contained in:
parent
b84fc2301a
commit
e992eb43c1
13 changed files with 251 additions and 8 deletions
|
|
@ -177,6 +177,9 @@ runtime cost control, and shared AI transport surfaces.
|
|||
runtime must enrich the prompt from that durable record before provider
|
||||
transport while keeping proposed-fix command text out of the chat prompt;
|
||||
command payloads remain approval-context data, not conversational copy.
|
||||
The Assistant drawer may also render an attached context briefing for that
|
||||
handoff, but the briefing is runtime context visibility only: it must not
|
||||
mutate chat control settings, execute tools, or reveal raw command payloads.
|
||||
|
||||
## Current State
|
||||
|
||||
|
|
|
|||
|
|
@ -747,7 +747,9 @@ the canonical monitored-system blocked payload.
|
|||
and the Assistant finding-context request contract, so `/api/ai/chat`
|
||||
payloads carrying `finding_id` may hydrate a structured investigation
|
||||
summary from the unified finding, but raw proposed-fix commands must stay
|
||||
out of the prompt and inside governed approval/remediation context.
|
||||
out of the prompt and inside governed approval/remediation context; frontend
|
||||
handoff briefings must derive from the same shared investigation payload
|
||||
rather than inventing a second finding-context transport shape.
|
||||
7. Keep Patrol summary payload consumers aligned on one assessment hierarchy: transport-driven Patrol summary surfaces may show supporting counts and outcomes, but the canonical assessment and verification states must remain singular and not be repeated as a second compact verdict strip
|
||||
8. Keep Patrol verification and activity facts unified on one transport-backed secondary status area: when frontend consumers combine Patrol status payloads (`runtime_state`, `last_patrol_at`, `last_activity_at`, `trigger_status`) with run-history transport, the latest run result, activity mix, scoped-trigger state, and circuit-breaker context must read as one supporting explanation beneath the primary assessment instead of being re-expanded into a separate full-width status strip plus duplicate summary layers
|
||||
and the main Patrol page composition boundary, so once that governed
|
||||
|
|
|
|||
|
|
@ -809,7 +809,10 @@ frontend primitive boundary.
|
|||
investigation-record framing must derive that prompt copy through
|
||||
`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`
|
||||
so shared drawer primitives stay shell-owned rather than becoming a
|
||||
Patrol-specific prompt formatter.
|
||||
Patrol-specific prompt formatter. The drawer may render a generic
|
||||
context-briefing band from `frontend-modern/src/stores/aiChat.ts`, but
|
||||
feature-owned helpers must provide the source labels, evidence summaries,
|
||||
action copy, and safety note.
|
||||
11. Keep shared filter primitives coherent with route-owned option hydration.
|
||||
Feature shells such as `frontend-modern/src/features/infrastructure/`
|
||||
must keep a route-owned canonical option visible in shared selects like
|
||||
|
|
|
|||
|
|
@ -98,7 +98,10 @@ Patrol-specific presentation helpers.
|
|||
record when transport carries one. `frontend-modern/src/stores/aiIntelligence.ts`
|
||||
may retain `investigationRecord` as data for Assistant handoff and Patrol
|
||||
presentation, but visible Patrol copy and Assistant handoff prompt framing
|
||||
must flow through the governed Patrol investigation-context helpers.
|
||||
must flow through the governed Patrol investigation-context helpers. Those
|
||||
helpers also own the Assistant drawer briefing content for Patrol records,
|
||||
including the rule that proposed-fix commands are summarized by count only
|
||||
and never rendered as raw command text in the handoff surface.
|
||||
|
||||
## Current State
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,19 @@ const {
|
|||
initialPrompt: undefined as string | undefined,
|
||||
findingId: undefined as string | undefined,
|
||||
autonomousMode: undefined as boolean | undefined,
|
||||
briefing: undefined as
|
||||
| {
|
||||
sourceLabel: string;
|
||||
title: string;
|
||||
subject?: string;
|
||||
statusLabel?: string;
|
||||
detailLines?: string[];
|
||||
evidence?: string[];
|
||||
actionLabel?: string;
|
||||
commandSummary?: string;
|
||||
safetyNote?: string;
|
||||
}
|
||||
| undefined,
|
||||
},
|
||||
clearInitialPrompt: vi.fn(),
|
||||
clearFindingId: vi.fn(),
|
||||
|
|
@ -223,6 +236,7 @@ beforeEach(() => {
|
|||
initialPrompt: undefined,
|
||||
findingId: undefined,
|
||||
autonomousMode: undefined,
|
||||
briefing: undefined,
|
||||
};
|
||||
mockChat.messages.mockReturnValue([]);
|
||||
mockChat.isLoading.mockReturnValue(false);
|
||||
|
|
@ -280,6 +294,34 @@ describe('AIChat', () => {
|
|||
expect(screen.getByText('to mention resources')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders attached context briefing without raw command text', () => {
|
||||
mockAiChatStore.context = {
|
||||
initialPrompt: undefined,
|
||||
findingId: 'finding-1',
|
||||
autonomousMode: undefined,
|
||||
briefing: {
|
||||
sourceLabel: 'Pulse Patrol',
|
||||
title: 'Investigation record attached',
|
||||
subject: 'High CPU usage on web-server',
|
||||
statusLabel: 'Completed · Fix Queued · High confidence',
|
||||
detailLines: ['Backup job saturated CPU.'],
|
||||
evidence: ['CPU stayed above 95% for 10 minutes'],
|
||||
actionLabel: 'Restart the workload service',
|
||||
commandSummary: '1 command recorded for approval context',
|
||||
safetyNote: 'Command details stay in approval context.',
|
||||
},
|
||||
};
|
||||
|
||||
renderChat();
|
||||
|
||||
expect(screen.getByLabelText('Assistant context')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pulse Patrol')).toBeInTheDocument();
|
||||
expect(screen.getByText('High CPU usage on web-server')).toBeInTheDocument();
|
||||
expect(screen.getByText('Backup job saturated CPU.')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 command recorded for approval context')).toBeInTheDocument();
|
||||
expect(screen.queryByText('systemctl restart workload.service')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders New button', () => {
|
||||
renderChat();
|
||||
expect(screen.getByText('New')).toBeInTheDocument();
|
||||
|
|
@ -961,6 +1003,7 @@ describe('AIChat', () => {
|
|||
initialPrompt: undefined,
|
||||
findingId: undefined,
|
||||
autonomousMode: false,
|
||||
briefing: undefined,
|
||||
};
|
||||
|
||||
renderChat();
|
||||
|
|
@ -1070,6 +1113,7 @@ describe('AIChat', () => {
|
|||
initialPrompt: undefined,
|
||||
findingId: 'finding-123',
|
||||
autonomousMode: undefined,
|
||||
briefing: undefined,
|
||||
};
|
||||
renderChat();
|
||||
const textarea = screen.getByPlaceholderText('Ask about your infrastructure...');
|
||||
|
|
|
|||
|
|
@ -311,6 +311,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
});
|
||||
|
||||
const hasScopedApprovalHandoff = createMemo(() => aiChatStore.context.autonomousMode === false);
|
||||
const contextBriefing = createMemo(() => aiChatStore.context.briefing);
|
||||
const contextBriefingEvidence = createMemo(() => contextBriefing()?.evidence ?? []);
|
||||
const contextBriefingDetails = createMemo(() => contextBriefing()?.detailLines ?? []);
|
||||
|
||||
// Compute current status for display
|
||||
const currentStatus = createMemo(() => {
|
||||
|
|
@ -1179,6 +1182,58 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={contextBriefing()}>
|
||||
<section
|
||||
class="border-b border-border bg-surface px-4 py-3"
|
||||
aria-label="Assistant context"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 text-[10px] font-medium uppercase text-muted">
|
||||
<span>{contextBriefing()!.sourceLabel}</span>
|
||||
<Show when={contextBriefing()!.statusLabel}>
|
||||
<span class="h-1 w-1 rounded-full bg-border" />
|
||||
<span class="normal-case">{contextBriefing()!.statusLabel}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-semibold text-base-content">
|
||||
{contextBriefing()!.title}
|
||||
</div>
|
||||
<Show when={contextBriefing()!.subject}>
|
||||
<div class="mt-0.5 text-xs text-muted">{contextBriefing()!.subject}</div>
|
||||
</Show>
|
||||
<Show when={contextBriefingDetails().length > 0}>
|
||||
<div class="mt-2 space-y-1">
|
||||
<For each={contextBriefingDetails()}>
|
||||
{(line) => <div class="text-xs text-base-content leading-relaxed">{line}</div>}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={contextBriefingEvidence().length > 0}>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<For each={contextBriefingEvidence()}>
|
||||
{(item) => (
|
||||
<span class="rounded border border-border bg-surface-alt px-2 py-1 text-[11px] text-muted">
|
||||
{item}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={contextBriefing()!.actionLabel || contextBriefing()!.commandSummary}>
|
||||
<div class="mt-2 rounded border border-border bg-surface-alt px-2.5 py-2 text-[11px] text-muted">
|
||||
<Show when={contextBriefing()!.actionLabel}>
|
||||
<div class="font-medium text-base-content">{contextBriefing()!.actionLabel}</div>
|
||||
</Show>
|
||||
<Show when={contextBriefing()!.commandSummary}>
|
||||
<div>{contextBriefing()!.commandSummary}</div>
|
||||
</Show>
|
||||
<Show when={contextBriefing()!.safetyNote}>
|
||||
<div>{contextBriefing()!.safetyNote}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
{/* Messages */}
|
||||
<ChatMessages
|
||||
messages={chat.messages()}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ import { FormSelect } from '@/components/shared/FormSelect';
|
|||
import { aiIntelligenceStore, type UnifiedFinding } from '@/stores/aiIntelligence';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { buildPatrolAssistantFindingPrompt } from '@/features/patrol/patrolInvestigationContextModel';
|
||||
import {
|
||||
buildPatrolAssistantFindingBriefing,
|
||||
buildPatrolAssistantFindingPrompt,
|
||||
} from '@/features/patrol/patrolInvestigationContextModel';
|
||||
import { useResources } from '@/hooks/useResources';
|
||||
import { InvestigationSection, ApprovalSection } from '@/components/patrol';
|
||||
import type { RemediationPlan } from '@/api/ai';
|
||||
|
|
@ -424,10 +427,16 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
|||
description: finding.description,
|
||||
investigationRecord: finding.investigationRecord,
|
||||
});
|
||||
const briefing = buildPatrolAssistantFindingBriefing({
|
||||
title,
|
||||
subject,
|
||||
investigationRecord: finding.investigationRecord,
|
||||
});
|
||||
aiChatStore.openWithPrompt(prompt, {
|
||||
targetType: finding.resourceType,
|
||||
targetId: finding.resourceId,
|
||||
findingId: finding.id,
|
||||
briefing,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,14 @@ const patrolWorkspaceSource = readFileSync(
|
|||
'utf-8',
|
||||
);
|
||||
|
||||
describe('FindingsPanel assistant handoff', () => {
|
||||
it('routes Patrol investigation records into the Assistant briefing context', () => {
|
||||
expect(findingsPanelSource).toContain('buildPatrolAssistantFindingBriefing');
|
||||
expect(findingsPanelSource).toContain('briefing,');
|
||||
expect(findingsPanelSource).toContain('investigationRecord: finding.investigationRecord');
|
||||
});
|
||||
});
|
||||
|
||||
describe('aiFindingPresentation', () => {
|
||||
describe('severity presentation', () => {
|
||||
it('has correct sort order for critical', () => {
|
||||
|
|
@ -679,7 +687,7 @@ describe('aiFindingPresentation', () => {
|
|||
status: 'pending',
|
||||
toolId: 'investigation_fix',
|
||||
targetId: 'finding-1',
|
||||
expiresAt: '2026-04-30T00:06:00Z',
|
||||
expiresAt: '2026-12-30T00:06:00Z',
|
||||
},
|
||||
] as never),
|
||||
).toBe(true);
|
||||
|
|
@ -707,7 +715,7 @@ describe('aiFindingPresentation', () => {
|
|||
status: 'pending',
|
||||
toolId: 'investigation_fix',
|
||||
targetId: 'finding-1',
|
||||
expiresAt: '2026-04-30T00:06:00Z',
|
||||
expiresAt: '2026-12-30T00:06:00Z',
|
||||
},
|
||||
] as never,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPatrolAssistantFindingBriefing,
|
||||
buildPatrolAssistantFindingPrompt,
|
||||
buildPatrolInvestigationContextSummary,
|
||||
buildPatrolInvestigationRecordPresentation,
|
||||
|
|
@ -155,4 +156,49 @@ describe('patrolInvestigationContextModel', () => {
|
|||
}),
|
||||
).toContain('Use that record as the main context before suggesting next actions.');
|
||||
});
|
||||
|
||||
it('builds a drawer briefing for Assistant handoff without exposing raw commands', () => {
|
||||
const briefing = buildPatrolAssistantFindingBriefing({
|
||||
title: 'High CPU usage',
|
||||
subject: 'web-server',
|
||||
investigationRecord: {
|
||||
id: 'record-1',
|
||||
finding_id: 'finding-1',
|
||||
subject: { resource_id: 'vm-100' },
|
||||
trigger: { detected_at: '2026-05-06T12:00:00Z' },
|
||||
status: 'completed',
|
||||
outcome: 'fix_queued',
|
||||
confidence: 'high',
|
||||
conclusion: 'Backup job saturated CPU.',
|
||||
recommended_action: 'Approve a controlled restart after the backup completes.',
|
||||
evidence: [{ kind: 'metrics', summary: 'CPU stayed above 95% for 10 minutes' }],
|
||||
proposed_fix: {
|
||||
id: 'fix-1',
|
||||
description: 'Restart the workload service',
|
||||
commands: ['systemctl restart workload.service'],
|
||||
risk_level: 'medium',
|
||||
destructive: false,
|
||||
},
|
||||
verification: ['CPU returned below 50%'],
|
||||
tools_used: [],
|
||||
started_at: '2026-05-06T12:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
expect(briefing).toEqual({
|
||||
sourceLabel: 'Pulse Patrol',
|
||||
title: 'Investigation record attached',
|
||||
subject: 'High CPU usage on web-server',
|
||||
statusLabel: 'Completed · Fix Queued · High confidence',
|
||||
detailLines: [
|
||||
'Backup job saturated CPU.',
|
||||
'Approve a controlled restart after the backup completes.',
|
||||
],
|
||||
evidence: ['CPU stayed above 95% for 10 minutes', 'Verified: CPU returned below 50%'],
|
||||
actionLabel: 'Restart the workload service',
|
||||
commandSummary: '1 command recorded for approval context',
|
||||
safetyNote: 'Command details stay in approval context.',
|
||||
});
|
||||
expect(JSON.stringify(briefing)).not.toContain('systemctl restart workload.service');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
IntelligencePolicyPostureSummary,
|
||||
} from '@/types/aiIntelligence';
|
||||
import type { InvestigationRecord } from '@/api/ai';
|
||||
import type { AIChatContextBriefing } from '@/stores/aiChat';
|
||||
|
||||
export interface PatrolInvestigationContextSummaryInput {
|
||||
recentChangesCount?: number | null;
|
||||
|
|
@ -46,6 +47,12 @@ export interface PatrolAssistantFindingPromptInput {
|
|||
investigationRecord?: InvestigationRecord | null;
|
||||
}
|
||||
|
||||
export interface PatrolAssistantFindingBriefingInput {
|
||||
title: string;
|
||||
subject: string;
|
||||
investigationRecord?: InvestigationRecord | null;
|
||||
}
|
||||
|
||||
export function buildPatrolInvestigationContextSummary(
|
||||
input: PatrolInvestigationContextSummaryInput,
|
||||
): PatrolInvestigationContextSummary {
|
||||
|
|
@ -146,6 +153,41 @@ export function buildPatrolAssistantFindingPrompt(
|
|||
return prompt;
|
||||
}
|
||||
|
||||
export function buildPatrolAssistantFindingBriefing(
|
||||
input: PatrolAssistantFindingBriefingInput,
|
||||
): AIChatContextBriefing | undefined {
|
||||
const record = buildPatrolInvestigationRecordPresentation(input.investigationRecord);
|
||||
if (!record.hasRecord) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const title = normalizeText(input.title) || 'Patrol finding';
|
||||
const subject = normalizeText(input.subject) || 'affected resource';
|
||||
const statusParts = [
|
||||
record.statusLabel,
|
||||
record.outcomeLabel,
|
||||
record.confidenceLabel,
|
||||
].filter(isNonEmptyString);
|
||||
const detailLines = [record.conclusion, record.recommendedAction]
|
||||
.filter(isNonEmptyString)
|
||||
.slice(0, 2);
|
||||
const verificationLines = record.verificationSummaries.map((summary) => `Verified: ${summary}`);
|
||||
|
||||
return {
|
||||
sourceLabel: 'Pulse Patrol',
|
||||
title: 'Investigation record attached',
|
||||
subject: `${title} on ${subject}`,
|
||||
statusLabel: statusParts.join(' · ') || undefined,
|
||||
detailLines,
|
||||
evidence: [...record.evidenceSummaries, ...verificationLines].slice(0, 4),
|
||||
actionLabel: record.proposedFix?.description,
|
||||
commandSummary: record.proposedFix?.commandSummary,
|
||||
safetyNote: record.proposedFix?.commandSummary
|
||||
? 'Command details stay in approval context.'
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCorrelationCount(correlations?: CorrelationsResponse | null): number {
|
||||
if (!correlations) return 0;
|
||||
if (typeof correlations.count === 'number' && Number.isFinite(correlations.count)) {
|
||||
|
|
@ -193,6 +235,10 @@ function normalizeText(value?: string | null): string {
|
|||
return value.trim();
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: string | undefined): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
const PATROL_TOOL_LABELS: Record<string, string> = {
|
||||
'metrics.history': 'Metrics history',
|
||||
'ssh.exec': 'SSH exec',
|
||||
|
|
|
|||
|
|
@ -95,10 +95,18 @@ describe('aiChatStore', () => {
|
|||
});
|
||||
|
||||
it('opens with a pre-filled prompt', () => {
|
||||
aiChatStore.openWithPrompt('hello', { targetType: 'vm', targetId: 'vm-101' });
|
||||
aiChatStore.openWithPrompt('hello', {
|
||||
targetType: 'vm',
|
||||
targetId: 'vm-101',
|
||||
briefing: {
|
||||
sourceLabel: 'Pulse Patrol',
|
||||
title: 'Investigation record attached',
|
||||
},
|
||||
});
|
||||
expect(aiChatStore.isOpen).toBe(true);
|
||||
expect(aiChatStore.context.initialPrompt).toBe('hello');
|
||||
expect(aiChatStore.context.targetId).toBe('vm-101');
|
||||
expect(aiChatStore.context.briefing?.title).toBe('Investigation record attached');
|
||||
});
|
||||
|
||||
it('preserves scoped autonomous-mode overrides for pre-filled prompts', () => {
|
||||
|
|
|
|||
|
|
@ -4,12 +4,25 @@ import { eventBus } from '@/stores/events';
|
|||
// NOTE: AIAPI import removed - session management is handled by Pulse AI's embedded UI
|
||||
import type { AIChatSessionSummary } from '@/types/ai';
|
||||
|
||||
interface AIChatContext {
|
||||
export interface AIChatContextBriefing {
|
||||
sourceLabel: string;
|
||||
title: string;
|
||||
subject?: string;
|
||||
statusLabel?: string;
|
||||
detailLines?: string[];
|
||||
evidence?: string[];
|
||||
actionLabel?: string;
|
||||
commandSummary?: string;
|
||||
safetyNote?: string;
|
||||
}
|
||||
|
||||
export interface AIChatContext {
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
findingId?: string; // If opened from AI Insights "Get Help", the finding ID to resolve on success
|
||||
briefing?: AIChatContextBriefing;
|
||||
// Per-request execution mode override; false keeps scoped handoffs approval-required.
|
||||
autonomousMode?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4348,6 +4348,9 @@ describe('frontend resource type boundaries', () => {
|
|||
expect(patrolInvestigationContextModelSource).toContain(
|
||||
'export function buildPatrolAssistantFindingPrompt',
|
||||
);
|
||||
expect(patrolInvestigationContextModelSource).toContain(
|
||||
'export function buildPatrolAssistantFindingBriefing',
|
||||
);
|
||||
expect(patrolInvestigationContextModelSource).toContain(
|
||||
'formatCommandSummary(record.proposed_fix.commands?.length ?? 0)',
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue