mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 11:13:26 +00:00
Structure Patrol Assistant next-step metadata
This commit is contained in:
@@ -382,7 +382,9 @@ runtime cost control, and shared AI transport surfaces.
|
||||
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
|
||||
for the first successful chat turn. Safe Patrol next-step labels and
|
||||
route-owned hrefs belong in `handoff_metadata` first, with model-context
|
||||
text parsing only as a legacy fallback. After that send succeeds, the drawer
|
||||
must clear those request payloads while preserving the safe visible
|
||||
briefing and request-local
|
||||
approval-required posture; later turns must rely on backend-owned session
|
||||
|
||||
@@ -2022,9 +2022,12 @@ and timeline hydration, and `handoff_actions` are structured approval/action
|
||||
references used to seed canonical approval and action-audit refresh.
|
||||
`handoff_metadata` is the browser-safe identity envelope for restoring saved
|
||||
product handoffs, currently including Patrol run kind, run ID, safe run
|
||||
type/status, and a runtime-failure boolean rather than runtime failure detail.
|
||||
type/status, a runtime-failure boolean rather than runtime failure detail, and
|
||||
safe Patrol recommended next-step/action labels plus whitelisted app-route
|
||||
hrefs when the browser handoff already owns that route.
|
||||
Frontend handoff builders may send these fields for owned alert, incident,
|
||||
Patrol assessment, or Patrol run-history context, but the backend must not
|
||||
Patrol assessment, Patrol finding, or Patrol run-history context, but the
|
||||
backend must not
|
||||
persist them as user-authored message text and must treat them as
|
||||
explanation/review context only. When a Patrol `finding_id` resolves,
|
||||
backend-refreshed durable finding context remains canonical; the handler may
|
||||
|
||||
@@ -186,7 +186,11 @@ work extends shared components instead of creating new local variants.
|
||||
`handoffContext`, `handoffResources`, `handoffActions`, and safe
|
||||
`handoffMetadata` while preserving the safe visible briefing and scoped
|
||||
approval-required posture, so later turns rely on backend session hydration
|
||||
instead of resending stale browser context. The drawer must treat
|
||||
instead of resending stale browser context. When a Patrol handoff includes a
|
||||
safe next-step label, action kind, or whitelisted app-route href, those
|
||||
reload-safe fields must travel through `handoffMetadata` rather than relying
|
||||
on the browser to reconstruct them from model-only handoff text. The drawer
|
||||
must treat
|
||||
`handoff_summary.requires_approval` as a current pending-decision flag, not a
|
||||
historical action marker, so completed or rejected handoff actions render as
|
||||
action context rather than pending approval. A restored Patrol run summary
|
||||
|
||||
@@ -274,8 +274,10 @@ Patrol-specific presentation helpers.
|
||||
names a finding. Saved assessment and finding sessions may expose the
|
||||
Patrol-owned recommended next step/action and whitelisted app-route href
|
||||
through the safe `handoff_summary` only after command-like and secret-like
|
||||
text is withheld; the browser must use those fields for restored drawer copy
|
||||
without receiving the private model-only handoff context.
|
||||
text is withheld; live handoffs must send those safe fields through
|
||||
structured `handoff_metadata` where available, and the browser must use
|
||||
them for restored drawer copy without receiving the private model-only
|
||||
handoff context.
|
||||
When the current Patrol assessment is coverage-incomplete with no active
|
||||
infrastructure finding, the same handoff model must frame the briefing as a
|
||||
verification gap: the prompt leads with what scoped activity did and did not
|
||||
|
||||
@@ -200,6 +200,57 @@ describe('AIChatAPI', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('includes browser-safe Patrol recommendation metadata when supplied', async () => {
|
||||
const read = vi.fn().mockResolvedValueOnce({ done: true, value: undefined });
|
||||
const releaseLock = vi.fn();
|
||||
|
||||
apiFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({ read, releaseLock }),
|
||||
},
|
||||
} as unknown as Response);
|
||||
|
||||
await AIChatAPI.chat(
|
||||
'discuss finding',
|
||||
'session-finding',
|
||||
undefined,
|
||||
vi.fn(),
|
||||
undefined,
|
||||
undefined,
|
||||
'finding-provider-settings',
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
kind: 'patrol_finding',
|
||||
recommendedNextStep: 'Open Patrol provider settings',
|
||||
recommendedNextStepAction: 'Open Patrol provider settings',
|
||||
recommendedNextStepActionHref: '/settings/system-ai',
|
||||
},
|
||||
);
|
||||
|
||||
expect(apiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/ai/chat',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
prompt: 'discuss finding',
|
||||
session_id: 'session-finding',
|
||||
model: undefined,
|
||||
finding_id: 'finding-provider-settings',
|
||||
autonomous_mode: false,
|
||||
handoff_metadata: {
|
||||
kind: 'patrol_finding',
|
||||
recommended_next_step: 'Open Patrol provider settings',
|
||||
recommended_next_step_action: 'Open Patrol provider settings',
|
||||
recommended_next_step_action_href: '/settings/system-ai',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('includes a Patrol finding id when supplied for Assistant context', async () => {
|
||||
const read = vi.fn().mockResolvedValueOnce({ done: true, value: undefined });
|
||||
const releaseLock = vi.fn();
|
||||
|
||||
@@ -66,6 +66,10 @@ export interface ChatHandoffMetadata {
|
||||
runType?: string;
|
||||
runStatus?: string;
|
||||
runtimeFailure?: boolean;
|
||||
recommendedNextStep?: string;
|
||||
recommendedNextStepAction?: string;
|
||||
recommendedNextStepActionKind?: string;
|
||||
recommendedNextStepActionHref?: string;
|
||||
}
|
||||
|
||||
export interface ChatHandoffAction {
|
||||
@@ -314,6 +318,10 @@ export class AIChatAPI {
|
||||
run_type: handoffMetadata.runType,
|
||||
run_status: handoffMetadata.runStatus,
|
||||
runtime_failure: handoffMetadata.runtimeFailure,
|
||||
recommended_next_step: handoffMetadata.recommendedNextStep,
|
||||
recommended_next_step_action: handoffMetadata.recommendedNextStepAction,
|
||||
recommended_next_step_action_kind: handoffMetadata.recommendedNextStepActionKind,
|
||||
recommended_next_step_action_href: handoffMetadata.recommendedNextStepActionHref,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -438,6 +438,12 @@ describe('patrolInvestigationContextModel', () => {
|
||||
recommendedNextStepTitle: 'Verify full coverage',
|
||||
recommendedNextStepActionKind: 'run_patrol',
|
||||
});
|
||||
expect(handoff.context.handoffMetadata).toMatchObject({
|
||||
kind: 'patrol_assessment',
|
||||
recommendedNextStep: 'Verify full coverage',
|
||||
recommendedNextStepAction: 'Run Patrol',
|
||||
recommendedNextStepActionKind: 'run_patrol',
|
||||
});
|
||||
});
|
||||
|
||||
it('links route-owned Patrol assessment recommendations in Assistant briefing', () => {
|
||||
@@ -470,6 +476,13 @@ describe('patrolInvestigationContextModel', () => {
|
||||
expect(handoff.context.context).toMatchObject({
|
||||
recommendedNextStepActionKind: 'open_provider_settings',
|
||||
});
|
||||
expect(handoff.context.handoffMetadata).toMatchObject({
|
||||
kind: 'patrol_assessment',
|
||||
recommendedNextStep: 'Restore Patrol visibility',
|
||||
recommendedNextStepAction: 'Open Patrol provider settings',
|
||||
recommendedNextStepActionKind: 'open_provider_settings',
|
||||
recommendedNextStepActionHref: '/settings/system-ai',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks unavailable recommended Patrol actions in assessment handoffs', () => {
|
||||
@@ -1328,6 +1341,12 @@ describe('patrolInvestigationContextModel', () => {
|
||||
'Check prerequisites before next step',
|
||||
],
|
||||
});
|
||||
expect(handoff.context.handoffMetadata).toMatchObject({
|
||||
kind: 'patrol_finding',
|
||||
recommendedNextStep: 'Open Patrol provider settings',
|
||||
recommendedNextStepAction: 'Open Patrol provider settings',
|
||||
recommendedNextStepActionHref: '/settings/system-ai',
|
||||
});
|
||||
expect(handoff.context.handoffActions).toBeUndefined();
|
||||
expect(handoff.context.handoffContext).toContain(
|
||||
'Patrol Next Step: Open Patrol provider settings',
|
||||
|
||||
@@ -574,6 +574,8 @@ export function buildPatrolAssessmentAssistantHandoff(
|
||||
const title = normalizeText(input.assessment?.title) || 'Pulse Patrol assessment';
|
||||
const description = normalizeText(input.assessment?.description);
|
||||
const recommendedNextStep = normalizeAssessmentRecommendedNextStep(input.recommendedNextStep);
|
||||
const recommendedNextStepActionHref =
|
||||
getAssessmentRecommendedNextStepActionHref(recommendedNextStep);
|
||||
const handoffContext = buildPatrolAssessmentAssistantModelContext(input);
|
||||
const recentChanges = normalizeAssessmentRecentChanges(input.supportingEvidence?.recentChanges);
|
||||
const correlations = normalizeAssessmentCorrelations(input.supportingEvidence?.correlations);
|
||||
@@ -590,6 +592,10 @@ export function buildPatrolAssessmentAssistantHandoff(
|
||||
handoffActions: handoffActions.length > 0 ? handoffActions : undefined,
|
||||
handoffMetadata: {
|
||||
kind: 'patrol_assessment',
|
||||
recommendedNextStep: recommendedNextStep?.title,
|
||||
recommendedNextStepAction: recommendedNextStep?.actionLabel,
|
||||
recommendedNextStepActionKind: recommendedNextStep?.actionKind,
|
||||
recommendedNextStepActionHref,
|
||||
},
|
||||
briefing: buildPatrolAssessmentAssistantBriefing(input),
|
||||
context: {
|
||||
@@ -642,6 +648,14 @@ export function buildPatrolAssistantFindingHandoff(
|
||||
handoffContext: buildPatrolAssistantFindingModelContext(input),
|
||||
handoffResources: handoffResources.length > 0 ? handoffResources : undefined,
|
||||
handoffActions: handoffActions.length > 0 ? handoffActions : undefined,
|
||||
handoffMetadata: nextStepAction.label
|
||||
? {
|
||||
kind: 'patrol_finding',
|
||||
recommendedNextStep: nextStepAction.label,
|
||||
recommendedNextStepAction: nextStepAction.label,
|
||||
recommendedNextStepActionHref: nextStepAction.href || undefined,
|
||||
}
|
||||
: undefined,
|
||||
briefing: buildPatrolAssistantFindingBriefing({
|
||||
title: input.title,
|
||||
subject: input.subject,
|
||||
|
||||
@@ -111,6 +111,12 @@ describe('aiChatStore', () => {
|
||||
actionHref: '/settings/system-ai',
|
||||
suggestedPrompts: ['Explain recent changes and correlations'],
|
||||
},
|
||||
handoffMetadata: {
|
||||
kind: 'patrol_finding',
|
||||
recommendedNextStep: 'Open Patrol provider settings',
|
||||
recommendedNextStepAction: 'Open Patrol provider settings',
|
||||
recommendedNextStepActionHref: '/settings/system-ai',
|
||||
},
|
||||
});
|
||||
expect(aiChatStore.isOpen).toBe(true);
|
||||
expect(aiChatStore.context.initialPrompt).toBe('hello');
|
||||
@@ -121,6 +127,10 @@ describe('aiChatStore', () => {
|
||||
expect(aiChatStore.context.briefing?.suggestedPrompts).toEqual([
|
||||
'Explain recent changes and correlations',
|
||||
]);
|
||||
expect(aiChatStore.context.handoffMetadata).toMatchObject({
|
||||
kind: 'patrol_finding',
|
||||
recommendedNextStepActionHref: '/settings/system-ai',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves scoped autonomous-mode overrides for pre-filled prompts', () => {
|
||||
|
||||
@@ -63,6 +63,10 @@ export interface AIChatHandoffMetadata {
|
||||
runType?: string;
|
||||
runStatus?: string;
|
||||
runtimeFailure?: boolean;
|
||||
recommendedNextStep?: string;
|
||||
recommendedNextStepAction?: string;
|
||||
recommendedNextStepActionKind?: string;
|
||||
recommendedNextStepActionHref?: string;
|
||||
}
|
||||
|
||||
export interface AIChatContext {
|
||||
|
||||
@@ -153,6 +153,7 @@ func NormalizeHandoffMetadata(metadata HandoffMetadata) HandoffMetadata {
|
||||
switch kind {
|
||||
case sessionHandoffKindPatrolAssessment,
|
||||
sessionHandoffKindPatrolConfigurationFailure,
|
||||
sessionHandoffKindPatrolFinding,
|
||||
sessionHandoffKindPatrolRun:
|
||||
default:
|
||||
return HandoffMetadata{}
|
||||
@@ -168,6 +169,25 @@ func NormalizeHandoffMetadata(metadata HandoffMetadata) HandoffMetadata {
|
||||
if normalized.Kind == sessionHandoffKindPatrolRun && normalized.RunID == "" {
|
||||
return HandoffMetadata{}
|
||||
}
|
||||
if normalized.Kind != sessionHandoffKindPatrolRun {
|
||||
normalized.RunID = ""
|
||||
normalized.RunType = ""
|
||||
normalized.RunStatus = ""
|
||||
}
|
||||
if normalized.Kind != sessionHandoffKindPatrolRun && normalized.Kind != sessionHandoffKindPatrolConfigurationFailure {
|
||||
normalized.RuntimeFailure = false
|
||||
}
|
||||
if normalized.Kind == sessionHandoffKindPatrolAssessment || normalized.Kind == sessionHandoffKindPatrolFinding {
|
||||
normalized.RecommendedNextStep = safeSessionHandoffSummaryText(metadata.RecommendedNextStep, 160)
|
||||
normalized.RecommendedNextStepAction = safeSessionHandoffSummaryText(metadata.RecommendedNextStepAction, 120)
|
||||
normalized.RecommendedNextStepActionKind = safePatrolRecommendationActionKind(metadata.RecommendedNextStepActionKind)
|
||||
if normalized.RecommendedNextStepAction != "" {
|
||||
normalized.RecommendedNextStepActionHref = safePatrolHandoffRoute(metadata.RecommendedNextStepActionHref)
|
||||
}
|
||||
if normalized.RecommendedNextStep == "" && normalized.RecommendedNextStepAction != "" {
|
||||
normalized.RecommendedNextStep = normalized.RecommendedNextStepAction
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -297,6 +317,20 @@ func safePatrolAssessmentRecommendationAction(value string) (string, string) {
|
||||
return action, ""
|
||||
}
|
||||
|
||||
func safePatrolRecommendationActionKind(value string) string {
|
||||
normalized := strings.ToLower(trimHandoffMetadataField(value, 64))
|
||||
switch normalized {
|
||||
case "discuss_assessment",
|
||||
"open_provider_settings",
|
||||
"review_approvals",
|
||||
"review_findings",
|
||||
"run_patrol":
|
||||
return normalized
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func patrolFindingNextStepSummary(kind string, handoffContext string) (string, string) {
|
||||
if kind != sessionHandoffKindPatrolFinding {
|
||||
return "", ""
|
||||
@@ -458,19 +492,39 @@ func modelContextHandoffSummary(modelContext *sessionModelContext) *SessionHando
|
||||
} else if findingID != "" {
|
||||
kind = sessionHandoffKindPatrolFinding
|
||||
}
|
||||
recommendedNextStep, recommendedNextStepAction, recommendedNextStepActionKind := patrolAssessmentRecommendedNextStepSummary(
|
||||
kind,
|
||||
modelContext.HandoffContext,
|
||||
)
|
||||
recommendedNextStepActionHref := ""
|
||||
if recommendedNextStepAction == "" {
|
||||
recommendedNextStepAction, recommendedNextStepActionHref = patrolFindingNextStepSummary(
|
||||
recommendedNextStep := metadata.RecommendedNextStep
|
||||
recommendedNextStepAction := metadata.RecommendedNextStepAction
|
||||
recommendedNextStepActionKind := metadata.RecommendedNextStepActionKind
|
||||
recommendedNextStepActionHref := metadata.RecommendedNextStepActionHref
|
||||
if recommendedNextStep == "" || recommendedNextStepAction == "" || recommendedNextStepActionKind == "" {
|
||||
contextNextStep, contextNextStepAction, contextNextStepActionKind := patrolAssessmentRecommendedNextStepSummary(
|
||||
kind,
|
||||
modelContext.HandoffContext,
|
||||
)
|
||||
if recommendedNextStep == "" && recommendedNextStepAction != "" {
|
||||
recommendedNextStep = recommendedNextStepAction
|
||||
if recommendedNextStep == "" {
|
||||
recommendedNextStep = contextNextStep
|
||||
}
|
||||
if recommendedNextStepAction == "" {
|
||||
recommendedNextStepAction = contextNextStepAction
|
||||
}
|
||||
if recommendedNextStepActionKind == "" {
|
||||
recommendedNextStepActionKind = contextNextStepActionKind
|
||||
}
|
||||
}
|
||||
if recommendedNextStepAction == "" || recommendedNextStepActionHref == "" {
|
||||
contextNextStepAction, contextNextStepActionHref := patrolFindingNextStepSummary(
|
||||
kind,
|
||||
modelContext.HandoffContext,
|
||||
)
|
||||
if recommendedNextStepAction == "" {
|
||||
recommendedNextStepAction = contextNextStepAction
|
||||
}
|
||||
if recommendedNextStepActionHref == "" {
|
||||
recommendedNextStepActionHref = contextNextStepActionHref
|
||||
}
|
||||
}
|
||||
if recommendedNextStep == "" && recommendedNextStepAction != "" {
|
||||
recommendedNextStep = recommendedNextStepAction
|
||||
}
|
||||
|
||||
summary := &SessionHandoffSummary{
|
||||
|
||||
@@ -632,6 +632,49 @@ func TestSessionStore_ListKeepsSafePatrolFindingNextStepRoute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_ListPrefersStructuredPatrolFindingNextStepMetadata(t *testing.T) {
|
||||
store, err := NewSessionStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create session store: %v", err)
|
||||
}
|
||||
|
||||
session, err := store.Create()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create session: %v", err)
|
||||
}
|
||||
|
||||
handoffContext := strings.Join([]string{
|
||||
"[Patrol Finding Context]",
|
||||
"Source: Pulse Patrol finding handoff",
|
||||
"Finding ID: finding-provider-settings",
|
||||
"Patrol Next Step: sudo restart",
|
||||
"Patrol Next Step Route: javascript:alert(1)",
|
||||
}, "\n")
|
||||
if err := store.SetModelHandoffEnvelope(session.ID, "finding-provider-settings", handoffContext, nil, nil, HandoffMetadata{
|
||||
Kind: "patrol_finding",
|
||||
RecommendedNextStep: "Open Patrol provider settings",
|
||||
RecommendedNextStepAction: "Open Patrol provider settings",
|
||||
RecommendedNextStepActionHref: "/settings/system-ai",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetModelHandoffEnvelope failed: %v", err)
|
||||
}
|
||||
|
||||
sessions, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List failed: %v", err)
|
||||
}
|
||||
if len(sessions) != 1 || sessions[0].HandoffSummary == nil {
|
||||
t.Fatalf("sessions = %#v, want one session with handoff summary", sessions)
|
||||
}
|
||||
|
||||
summary := sessions[0].HandoffSummary
|
||||
if summary.RecommendedNextStep != "Open Patrol provider settings" ||
|
||||
summary.RecommendedNextStepAction != "Open Patrol provider settings" ||
|
||||
summary.RecommendedNextStepActionHref != "/settings/system-ai" {
|
||||
t.Fatalf("next step summary = %#v, want structured safe Patrol next step", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_ListWithholdsUnsafePatrolFindingNextStepRoute(t *testing.T) {
|
||||
store, err := NewSessionStore(t.TempDir())
|
||||
if err != nil {
|
||||
|
||||
@@ -165,11 +165,15 @@ type HandoffAction struct {
|
||||
// model-only handoff. It must not include model context text, command payloads,
|
||||
// remediation details, or provider/runtime error detail.
|
||||
type HandoffMetadata struct {
|
||||
Kind string `json:"kind,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"`
|
||||
Kind string `json:"kind,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"`
|
||||
RecommendedNextStep string `json:"recommended_next_step,omitempty"`
|
||||
RecommendedNextStepAction string `json:"recommended_next_step_action,omitempty"`
|
||||
RecommendedNextStepActionKind string `json:"recommended_next_step_action_kind,omitempty"`
|
||||
RecommendedNextStepActionHref string `json:"recommended_next_step_action_href,omitempty"`
|
||||
}
|
||||
|
||||
// ExecuteRequest represents a chat execution request
|
||||
|
||||
Reference in New Issue
Block a user