Move Patrol run Assistant handoffs server-side

- rehydrate Patrol run context from Patrol history for chat requests and follow-up sessions
- send only browser-safe run metadata from the frontend
- classify and redact provider runtime failures in handoff prompts and briefings
This commit is contained in:
rcourtman
2026-05-07 21:58:06 +01:00
parent f7992e8e78
commit d9331d39e4
26 changed files with 1093 additions and 99 deletions
@@ -1028,6 +1028,12 @@ approval lifecycle metadata, action plan identity, approval policy, or dry-run
posture, those fields remain Assistant review context and must not be treated
as an agent command grant, enrollment state, update readiness, or fleet-control
decision.
Patrol run handoffs through that same shared handler and `internal/api/router.go`
remain metadata-driven Assistant review context only. The router may expose a
run-history lookup so AI runtime can rebuild model-only run context, but the
resulting scoped resources, runtime failure summaries, and follow-up session
metadata must not become agent command authorization, reachability proof,
install state, profile assignment, or fleet lifecycle evidence.
That same shared dependency now also assumes hosted cloud handoff authorizes
tenant org access before browser lifecycle continues. Lifecycle-adjacent opens
into hosted workspaces may depend on `internal/api/cloud_handoff_handlers.go`,
@@ -217,7 +217,15 @@ runtime cost control, and shared AI transport surfaces.
preserving the user's authored prompt as the persisted conversation
message; the model-only handoff may persist as session metadata so
same-session follow-up turns keep the Patrol finding context without
mutating saved user messages. When the handoff identifies a resource, the
mutating saved user messages. Patrol run-history handoffs follow the same
backend-owned context rule: the browser may seed only safe `patrol_run`
metadata such as run ID/type/status/runtime-failure posture, while
`/api/ai/chat` must rehydrate model-only run context, scoped resources, and
safe failure detail from the current Patrol run record before model
execution and again on same-session follow-up turns. If the Patrol run no
longer resolves, browser-authored run context, resources, and actions must
be dropped rather than used as fallback provider context. When the handoff
identifies a resource, the
runtime may also seed the session's resolved-resource scope, but only through
canonical unified-resource tool registration so allowed actions, executors,
and explicit-access checks stay governed. Structured handoff resource
@@ -215,7 +215,14 @@ product API routes free of maintainer commercial analytics.
collapsed into one finding because a safe action reference contains a
finding ID. `patrol_configuration_failure` may carry only the
runtime-failure boolean needed for drawer/session presentation, and
run-specific fields remain reserved for `patrol_run`.
run-specific fields remain reserved for `patrol_run`. Chat requests that
carry `handoff_metadata.kind=patrol_run` are identity envelopes, not
browser-authored model context: the API handler must resolve the run ID
through the backend Patrol service, rebuild the model-only run context and
resources server-side, and ignore request-side run context, resources, or
actions when the run cannot be resolved. Stored session metadata must be
readable by the handler so follow-up turns can rehydrate the same backend
context without asking the browser to resend provider-bound payloads.
34. `internal/api/ai_handlers.go` shared with `ai-runtime`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary.
Provider test responses from `/api/ai/test` and provider-specific
`/api/ai/test/{provider}` preflight responses must return one safe
@@ -192,7 +192,12 @@ work extends shared components instead of creating new local variants.
action context rather than pending approval. A restored Patrol run summary
must remain visibly sourced to Pulse Patrol, restore a `patrol-run` target
plus run ID/type/status/runtime-failure presentation only, and must not
rehydrate model-only runtime failure detail into browser context. Restored
rehydrate model-only runtime failure detail into browser context. New Patrol
run requests follow the same drawer boundary: source-owned prompt text and
briefing copy may show classified, redacted failure summaries for operator
review, but `handoffContext`, `handoffResources`, and `handoffActions` for
run-history context must stay absent from the browser request so the backend
can rebuild model-bound context from the stored Patrol run. Restored
Patrol assessment and Patrol configuration-failure sessions follow the same
safe-summary rule: the drawer may restore source label, title, target type,
status badge, action label, and suggested prompts from `handoff_summary`, but
@@ -257,14 +257,17 @@ Patrol-specific presentation helpers.
names a finding.
Patrol run-history entries may also open Assistant for a selected run, but
that handoff must flow through the same Patrol-owned investigation-context
model rather than a row-local prompt. The handoff may pass bounded run
history facts such as run ID/type/status, trigger, timing, coverage, scope,
findings snapshot availability, outcome counts, tool/triage effort,
`error_summary`, `error_detail`, sanitized Patrol analysis, and scoped
resource references as model-only context. It must also send safe
model rather than a row-local prompt. The browser-visible prompt and drawer
briefing may include only classified, redacted runtime-failure summaries and
safe run identity facts. The browser request must send safe
`handoff_metadata` for the saved-session identity envelope: kind
`patrol_run`, run ID, safe run type/status, and a runtime-failure boolean,
never runtime failure detail or analysis text. It must force request-local
never provider-bound runtime failure detail, analysis text, tool traces, raw
provider payloads, or scoped resource context. Backend Assistant runtime owns
the model-only run context: it resolves the run ID from Patrol history,
rebuilds bounded run facts, scoped resource references, sanitized analysis,
and classified failure detail server-side, and rehydrates the same context
from stored metadata on follow-up turns. It must force request-local
approval-required mode, present a source-named visible drawer briefing, and
frame Assistant as explanation and next-step review rather than execution or
automatic retry authority.
@@ -174,6 +174,12 @@ regression protection.
the same bounded-work rule: the callback may copy the already-materialized
durable record into unified findings, but it must not add broad resource
scans, model calls, or persistence walks to protected request setup paths.
Patrol run Assistant handoff wiring in `internal/api/router.go` follows the
same protected hot-path rule: the shared callback may resolve one requested
Patrol run ID from the already-owned Patrol service and strip tool traces
before returning it to AI runtime, but it must not scan run history broadly,
hydrate resource inventories, call models, or perform persistence fan-out as
part of router setup or generic request admission.
Retiring self-hosted trial acquisition follows that same rule: removing
`/auth/trial-activate` and `POST /api/license/trial/start` from public-path
and CSRF inventories must stay as constant-time route-table absence rather
@@ -416,6 +416,12 @@ bypass the API fail-closed execution gate.
model-only context remains secondary to backend-refreshed finding context
and must not become storage freshness, restore eligibility, recovery
execution authority, or a storage-local approval shortcut.
Patrol run chat handoffs through that same shared handler follow the same
adjacent-boundary rule. A run ID may let AI runtime rebuild model-only run
context from Patrol history, but scoped storage resources or runtime failure
details attached to that briefing are review context only and must not
become backup freshness evidence, restore eligibility, storage health truth,
or recovery execution authority.
Alert, incident, and Patrol assessment Assistant handoffs that send bounded
model-only `handoff_context`, `handoff_resources`, or `handoff_actions`
through `/api/ai/chat` without a `finding_id` stay on that same adjacent
@@ -8,6 +8,7 @@ import { aiChatStore } from '@/stores/aiChat';
import { RunToolCallTrace } from './RunToolCallTrace';
import {
formatDurationMs,
formatPatrolRuntimeFailureDetail,
formatTriggerReason,
formatScope,
getCanonicalScopeResourceIds,
@@ -162,8 +163,8 @@ export function RunHistoryEntry(props: RunHistoryEntryProps) {
run.error_count,
hasFindingsSnapshot,
);
const runErrorSummary = String(run.error_summary || '').trim();
const runErrorDetail = String(run.error_detail || '').trim();
const runErrorSummary = formatPatrolRuntimeFailureDetail(run.error_summary);
const runErrorDetail = formatPatrolRuntimeFailureDetail(run.error_detail);
const hasRunErrorDetail = run.error_count > 0 && (runErrorSummary || runErrorDetail);
const primaryAction = getPatrolRunPrimaryActionPresentation(run);
const handleDiscussRun = (event: Event) => {
@@ -223,7 +223,9 @@ describe('RunHistoryEntry', () => {
));
expect(screen.getByText('Selected model does not support Patrol tools')).toBeInTheDocument();
expect(screen.getByText(/tool_choice/)).toBeInTheDocument();
expect(screen.getByText(/Provider rejected Patrol tool calls/)).toBeInTheDocument();
expect(screen.queryByText(/tool_choice/)).toBeNull();
expect(screen.queryByText(/No endpoints found/)).toBeNull();
expect(screen.getByText('error')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Open Patrol provider settings' })).toHaveAttribute(
'href',
@@ -280,11 +282,20 @@ describe('RunHistoryEntry', () => {
const [prompt, context] = openWithPromptMock.mock.calls[0];
expect(prompt).toContain('Discuss this Pulse Patrol run');
expect(prompt).toContain('Start by explaining the Patrol runtime failure');
expect(prompt).toContain('Provider rejected Patrol tool calls');
expect(prompt).not.toContain('tool_choice');
expect(prompt).not.toContain('No endpoints found');
expect(context).toMatchObject({
targetType: 'patrol-run',
targetId: 'run-runtime-error',
autonomousMode: false,
handoffResources: [{ id: 'vm-100', type: 'vm' }],
handoffMetadata: {
kind: 'patrol_run',
runId: 'run-runtime-error',
runType: 'Scoped run',
runStatus: 'error',
runtimeFailure: true,
},
context: {
source: 'pulse-patrol-run',
runId: 'run-runtime-error',
@@ -292,13 +303,14 @@ describe('RunHistoryEntry', () => {
handoffResourceCount: 1,
},
});
expect(context.handoffContext).toContain('[Patrol Run Context]');
expect(context.handoffContext).toContain('Runtime Failure: Selected model');
expect(context.handoffContext).toContain('tool_choice');
expect(context.handoffContext).toBeUndefined();
expect(context.handoffResources).toBeUndefined();
expect(context.briefing).toMatchObject({
title: 'Patrol run attached',
actionLabel: 'Review Patrol runtime failure',
});
expect(JSON.stringify(context)).not.toContain('tool_choice');
expect(JSON.stringify(context)).not.toContain('No endpoints found');
});
it('keeps zero-coverage scoped runs on the shared coverage narrative', () => {
@@ -413,6 +413,9 @@ describe('patrolInvestigationContextModel', () => {
expect(handoff.prompt).toContain('Discuss this Pulse Patrol run');
expect(handoff.prompt).toContain('Start by explaining the Patrol runtime failure');
expect(handoff.prompt).toContain('Provider rejected Patrol tool calls');
expect(handoff.prompt).not.toContain('tool_choice');
expect(handoff.prompt).not.toContain('No endpoints found');
expect(handoff.context.autonomousMode).toBe(false);
expect(handoff.context).toMatchObject({
targetType: 'patrol-run',
@@ -427,7 +430,7 @@ describe('patrolInvestigationContextModel', () => {
handoffResourceCount: 1,
},
});
expect(handoff.context.handoffResources).toEqual([{ id: 'vm-100', type: 'vm' }]);
expect(handoff.context.handoffResources).toBeUndefined();
expect(handoff.context.handoffMetadata).toEqual({
kind: 'patrol_run',
runId: 'run-runtime-error',
@@ -435,12 +438,7 @@ describe('patrolInvestigationContextModel', () => {
runStatus: 'error',
runtimeFailure: true,
});
expect(handoff.context.handoffContext).toContain('[Patrol Run Context]');
expect(handoff.context.handoffContext).toContain('Source: Pulse Patrol run history');
expect(handoff.context.handoffContext).toContain('Run Type: Scoped run');
expect(handoff.context.handoffContext).toContain('Runtime Failure: Selected model');
expect(handoff.context.handoffContext).toContain('tool_choice');
expect(handoff.context.handoffContext).toContain('Patrol Analysis: Visible runtime summary.');
expect(handoff.context.handoffContext).toBeUndefined();
expect(handoff.context.briefing).toMatchObject({
sourceLabel: 'Pulse Patrol',
title: 'Patrol run attached',
@@ -452,6 +450,8 @@ describe('patrolInvestigationContextModel', () => {
],
});
expect(JSON.stringify(handoff)).not.toContain('provider trace');
expect(JSON.stringify(handoff)).not.toContain('tool_choice');
expect(JSON.stringify(handoff)).not.toContain('No endpoints found');
});
it('builds a model-only Assistant handoff for a Patrol configuration failure', () => {
@@ -24,6 +24,7 @@ import {
} from '@/utils/resourceCorrelationPresentation';
import {
formatDurationMs,
formatPatrolRuntimeFailureSummary,
formatScope,
formatTriggerReason,
getCanonicalScopeResourceIds,
@@ -611,8 +612,6 @@ export function buildPatrolRunAssistantHandoff(run: PatrolRunRecord): PatrolRunA
targetType: 'patrol-run',
targetId: runId || undefined,
autonomousMode: false,
handoffContext: buildPatrolRunAssistantModelContext(run, kindLabel, statusLabel),
handoffResources: handoffResources.length > 0 ? handoffResources : undefined,
handoffMetadata: {
kind: 'patrol_run',
runId: runId || undefined,
@@ -993,41 +992,6 @@ function buildPatrolRunAssistantPrompt(
.join('\n\n');
}
function buildPatrolRunAssistantModelContext(
run: PatrolRunRecord,
kindLabel: string,
statusLabel: string,
): string {
const scope = formatScope(run);
const coverage = formatPatrolRunCoverage(run);
const runtimeFailure = formatPatrolRunRuntimeFailure(run);
const outcomes = formatPatrolRunOutcomes(run);
const timing = formatPatrolRunTiming(run);
const effort = formatPatrolRunEffort(run);
const analysis = truncateContextText(sanitizeAnalysis(run.ai_analysis), 500);
return [
'[Patrol Run Context]',
'Source: Pulse Patrol run history',
formatContextLine('Run ID', run.id),
formatContextLine('Run Type', kindLabel),
formatContextLine('Status', statusLabel),
formatContextLine('Trigger', formatTriggerReason(run.trigger_reason)),
formatContextLine('Timing', timing),
formatContextLine('Coverage', coverage),
formatContextLine('Scope', scope),
formatContextLine('Findings Snapshot', formatPatrolRunFindingsSnapshot(run)),
formatContextLine('Outcomes', outcomes),
formatContextLine('Runtime Failure', runtimeFailure),
formatContextLine('Effort', effort),
formatContextLine('Findings Summary', run.findings_summary),
formatContextLine('Patrol Analysis', analysis),
'Operator Boundary: This Patrol run handoff is model-only context for explanation and review. Configuration changes, diagnostics, remediation, and command execution require explicit governed operator action.',
]
.filter(isNonEmptyString)
.join('\n');
}
function buildPatrolRunAssistantBriefing(
run: PatrolRunRecord,
kindLabel: string,
@@ -1184,19 +1148,11 @@ function buildPatrolRunHandoffResources(run: PatrolRunRecord): AIChatHandoffReso
}
function formatPatrolRunRuntimeFailure(run: PatrolRunRecord): string | undefined {
const summary = normalizeText(run.error_summary);
const detail = normalizeText(run.error_detail);
if (summary && detail && summary !== detail) {
return `${summary}: ${truncateContextText(detail, 260)}`;
}
if (summary || detail) {
return summary || detail;
}
const errorCount = normalizeNonNegativeCount(run.error_count);
if (errorCount > 0) {
return `${errorCount} Patrol runtime error${errorCount === 1 ? '' : 's'} recorded`;
}
return undefined;
return formatPatrolRuntimeFailureSummary({
errorSummary: run.error_summary,
errorDetail: run.error_detail,
errorCount: run.error_count,
});
}
function formatPatrolRunCoverage(run: PatrolRunRecord): string | undefined {
@@ -1235,14 +1191,6 @@ function formatPatrolRunCoverage(run: PatrolRunRecord): string | undefined {
);
}
function formatPatrolRunFindingsSnapshot(run: PatrolRunRecord): string {
if (run.finding_ids === undefined) {
return 'unavailable for this run';
}
const count = run.finding_ids.length;
return `${count} finding ID${count === 1 ? '' : 's'} captured`;
}
function formatPatrolRunOutcomes(run: PatrolRunRecord): string | undefined {
return formatBriefingStringList(
[
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
import {
formatDurationMs,
formatPatrolRuntimeFailureDetail,
formatPatrolRuntimeFailureSummary,
formatTriggerReason,
formatScope,
getCanonicalScopeResourceIds,
@@ -179,6 +181,48 @@ describe('patrolFormat', () => {
});
});
describe('formatPatrolRuntimeFailureSummary', () => {
it('classifies provider billing payloads without exposing raw account details', () => {
const summary = formatPatrolRuntimeFailureSummary({
errorSummary: 'Provider billing or quota issue',
errorDetail:
'API error (402): {"error":{"message":"This request requires more credits. Visit https://openrouter.ai/settings/keys"},"user_id":"user_2rLolwcxpOSbgPEKJZuIxdW76pi"}',
errorCount: 1,
});
expect(summary).toContain('Provider billing or quota issue');
expect(summary).toContain('insufficient credits or token budget');
expect(summary).not.toContain('openrouter.ai/settings/keys');
expect(summary).not.toContain('user_2rLolwcxpOSbgPEKJZuIxdW76pi');
});
it('classifies tool-call failures without leaking provider routing payloads', () => {
const summary = formatPatrolRuntimeFailureSummary({
errorSummary: 'Selected model does not support Patrol tools',
errorDetail: "No endpoints found that support the provided 'tool_choice' value.",
errorCount: 1,
});
expect(summary).toContain('Selected model does not support Patrol tools');
expect(summary).toContain('Provider rejected Patrol tool calls');
expect(summary).not.toContain('tool_choice');
expect(summary).not.toContain('No endpoints found');
});
it('redacts unknown provider details defensively', () => {
const detail = formatPatrolRuntimeFailureDetail(
'provider returned Authorization: Bearer sk-live-secret and url https://example.invalid/path?token=abc for user_abc123',
);
expect(detail).toContain('[redacted-url]');
expect(detail).toContain('Bearer [redacted-secret]');
expect(detail).toContain('[redacted-user]');
expect(detail).not.toContain('sk-live-secret');
expect(detail).not.toContain('token=abc');
expect(detail).not.toContain('user_abc123');
});
});
describe('groupModelsByProvider', () => {
it('groups models by provider prefix', () => {
const models = [
+100 -3
View File
@@ -15,9 +15,7 @@ interface PartialRunRecord {
type?: string;
}
export function getCanonicalScopeResourceIds(
run?: PartialRunRecord | null,
): string[] | undefined {
export function getCanonicalScopeResourceIds(run?: PartialRunRecord | null): string[] | undefined {
if (!run) return undefined;
if (run.effective_scope_resource_ids !== undefined) {
return run.effective_scope_resource_ids;
@@ -75,6 +73,105 @@ export function sanitizeAnalysis(text: string | undefined): string {
.trim();
}
export function formatPatrolRuntimeFailureDetail(text: string | undefined): string {
const raw = String(text || '')
.trim()
.replace(/\s+/g, ' ');
if (!raw) return '';
const lower = raw.toLowerCase();
if (
[
'provider billing or quota issue',
'selected model does not support patrol tools',
'provider rate limited',
'provider authentication issue',
'provider connection issue',
'provider not ready',
'selected model unavailable',
'selected model context window too small',
].includes(lower)
) {
return raw;
}
if (
lower.includes('tool_choice') ||
lower.includes('tool calling') ||
lower.includes('tools are not supported') ||
(lower.includes('no endpoints found') && lower.includes('tool'))
) {
return 'Provider rejected Patrol tool calls. Choose a Patrol model and endpoint with tool-call support.';
}
if (
lower.includes('insufficient balance') ||
lower.includes('402') ||
lower.includes('payment required') ||
lower.includes('quota') ||
lower.includes('credit') ||
lower.includes('max_tokens')
) {
return 'Provider reported insufficient credits or token budget for the requested Patrol analysis.';
}
if (
lower.includes('rate limit') ||
lower.includes('429') ||
lower.includes('too many requests')
) {
return 'Provider rate limit reached. Wait for capacity or adjust provider limits before retrying.';
}
if (
lower.includes('401') ||
lower.includes('403') ||
lower.includes('unauthorized') ||
lower.includes('forbidden') ||
lower.includes('api key')
) {
return 'Provider authentication failed. Check the configured provider key and account access.';
}
if (
lower.includes('failed to connect') ||
lower.includes('connection refused') ||
lower.includes('no such host') ||
lower.includes('i/o timeout') ||
lower.includes('context deadline exceeded') ||
lower.includes('timeout') ||
lower.includes('returned status 5')
) {
return 'Provider connection failed. Check provider reachability before retrying Patrol.';
}
return raw
.replace(/https?:\/\/[^\s"')]+/gi, '[redacted-url]')
.replace(/\buser_[A-Za-z0-9_-]+\b/g, '[redacted-user]')
.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, '[redacted-secret]')
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted-secret]')
.replace(
/"((?:api[_-]?key|apikey|access[_-]?token|token|authorization|x-api-key|user[_-]?id))"\s*:\s*"[^"]+"/gi,
'"$1":"[redacted]"',
)
.trim();
}
export function formatPatrolRuntimeFailureSummary(input: {
errorSummary?: string;
errorDetail?: string;
errorCount?: number;
}): string | undefined {
const summary = formatPatrolRuntimeFailureDetail(input.errorSummary);
const detail = formatPatrolRuntimeFailureDetail(input.errorDetail);
if (summary && detail && summary !== detail) {
return `${summary}: ${detail}`;
}
if (summary || detail) return summary || detail;
const errorCount = Math.max(0, input.errorCount || 0);
if (errorCount > 0) {
return `${errorCount} Patrol runtime error${errorCount === 1 ? '' : 's'} recorded`;
}
return undefined;
}
export function groupModelsByProvider(models: ModelInfo[]): Map<string, ModelInfo[]> {
const groups = new Map<string, ModelInfo[]>();
for (const model of models) {
+14
View File
@@ -2165,6 +2165,20 @@ func (s *Service) GetModelHandoffFindingID(ctx context.Context, sessionID string
return sessions.GetModelHandoffFindingID(sessionID)
}
// GetModelHandoffMetadata returns the session-scoped product handoff identity
// used to refresh model-only Patrol context on follow-up turns.
func (s *Service) GetModelHandoffMetadata(ctx context.Context, sessionID string) (HandoffMetadata, error) {
s.mu.RLock()
sessions := s.sessions
s.mu.RUnlock()
if sessions == nil {
return HandoffMetadata{}, fmt.Errorf("service not started")
}
return sessions.GetModelHandoffMetadata(sessionID)
}
// ClearModelHandoffContext invalidates product-originated model-only handoff
// state after its source record can no longer be resolved. Unpinned resolved
// resources are cleared with it so stale Patrol handoffs cannot remain action
+16
View File
@@ -775,6 +775,22 @@ func (s *SessionStore) GetModelHandoffFindingID(id string) (string, error) {
return strings.TrimSpace(data.ModelContext.HandoffFindingID), nil
}
// GetModelHandoffMetadata returns the browser-safe handoff identity stored for
// a session.
func (s *SessionStore) GetModelHandoffMetadata(id string) (HandoffMetadata, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := s.readSession(id)
if err != nil {
return HandoffMetadata{}, err
}
if data.ModelContext == nil {
return HandoffMetadata{}, nil
}
return NormalizeHandoffMetadata(data.ModelContext.HandoffMetadata), nil
}
// GetModelHandoffContext returns model-only handoff context for a session.
func (s *SessionStore) GetModelHandoffContext(id string) (string, error) {
s.mu.RLock()
@@ -452,6 +452,20 @@ func TestSessionStore_ListIncludesSafePatrolRunHandoffSummary(t *testing.T) {
t.Fatalf("SetModelHandoffEnvelope failed: %v", err)
}
metadata, err := store.GetModelHandoffMetadata(session.ID)
if err != nil {
t.Fatalf("GetModelHandoffMetadata failed: %v", err)
}
if metadata != (HandoffMetadata{
Kind: sessionHandoffKindPatrolRun,
RunID: "run-runtime-error",
RunType: "Scoped run",
RunStatus: "error",
RuntimeFailure: true,
}) {
t.Fatalf("handoff metadata = %#v, want normalized Patrol run identity", metadata)
}
sessions, err := store.List()
if err != nil {
t.Fatalf("List failed: %v", err)
+379
View File
@@ -0,0 +1,379 @@
package ai
import (
"fmt"
"regexp"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/chat"
)
const maxPatrolRunAssistantHandoffResources = 8
var patrolRunDSMLTracePattern = regexp.MustCompile(`<DSML[^>]*>[\s\S]*?</DSML[^>]*>|<DSML[^>]*>`)
// PatrolRunAssistantHandoff is the backend-owned, model-only Assistant context
// for a persisted Patrol run. The fields are safe to store as chat session
// metadata, but still carry no approval or execution authority.
type PatrolRunAssistantHandoff struct {
Context string
Resources []chat.HandoffResource
Metadata chat.HandoffMetadata
}
// BuildPatrolRunAssistantHandoff converts a durable Patrol run record into the
// canonical Assistant handoff envelope. Frontend callers should pass only the
// run identity; the backend rehydrates this context from Patrol history.
func BuildPatrolRunAssistantHandoff(run PatrolRunRecord) PatrolRunAssistantHandoff {
run = normalizePatrolRunRecord(run)
runID := strings.TrimSpace(run.ID)
runType := patrolRunKindLabel(run.Type)
status := patrolRunStatusLabel(run)
runtimeFailure := patrolRunRuntimeFailureSummary(run)
resources := patrolRunAssistantHandoffResources(run)
return PatrolRunAssistantHandoff{
Context: buildPatrolRunAssistantContext(run, runType, status, runtimeFailure),
Resources: resources,
Metadata: chat.HandoffMetadata{
Kind: "patrol_run",
RunID: runID,
RunType: runType,
RunStatus: status,
RuntimeFailure: runtimeFailure != "",
},
}
}
func buildPatrolRunAssistantContext(run PatrolRunRecord, runType, status, runtimeFailure string) string {
lines := []string{
"[Patrol Run Context]",
"Source: Pulse Patrol run history",
formatPatrolRunContextLine("Run ID", run.ID),
formatPatrolRunContextLine("Run Type", runType),
formatPatrolRunContextLine("Status", status),
formatPatrolRunContextLine("Trigger", patrolTriggerReasonLabel(run.TriggerReason)),
formatPatrolRunContextLine("Timing", patrolRunTimingSummary(run)),
formatPatrolRunContextLine("Coverage", patrolRunCoverageSummary(run)),
formatPatrolRunContextLine("Scope", patrolRunScopeSummary(run)),
formatPatrolRunContextLine("Findings Snapshot", patrolRunFindingsSnapshot(run)),
formatPatrolRunContextLine("Outcomes", patrolRunOutcomeSummary(run)),
formatPatrolRunContextLine("Runtime Failure", runtimeFailure),
formatPatrolRunContextLine("Effort", patrolRunEffortSummary(run)),
formatPatrolRunContextLine("Findings Summary", run.FindingsSummary),
formatPatrolRunContextLine("Patrol Analysis", truncatePatrolRunContextText(sanitizePatrolRunAnalysis(run.AIAnalysis), 500)),
"Operator Boundary: This Patrol run handoff is model-only context for explanation and review. Configuration changes, diagnostics, remediation, and command execution require explicit governed operator action.",
}
filtered := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
filtered = append(filtered, line)
}
}
return strings.Join(filtered, "\n")
}
func patrolRunAssistantHandoffResources(run PatrolRunRecord) []chat.HandoffResource {
ids := run.EffectiveScopeResourceIDs
if ids == nil {
ids = run.ScopeResourceIDs
}
if len(ids) == 0 {
return nil
}
resourceType := ""
if len(run.ScopeResourceTypes) == 1 {
resourceType = strings.TrimSpace(run.ScopeResourceTypes[0])
}
resources := make([]chat.HandoffResource, 0, min(len(ids), maxPatrolRunAssistantHandoffResources))
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
key := strings.ToLower(id)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
resources = append(resources, chat.HandoffResource{
ID: id,
Type: resourceType,
})
if len(resources) >= maxPatrolRunAssistantHandoffResources {
break
}
}
if len(resources) == 0 {
return nil
}
return resources
}
func patrolRunRuntimeFailureSummary(run PatrolRunRecord) string {
summary := strings.TrimSpace(redactPatrolRuntimeFailureDetail(run.ErrorSummary))
detail := strings.TrimSpace(summarizePatrolRuntimeFailureDetail(run.ErrorDetail))
if summary != "" && detail != "" && summary != detail {
return summary + ": " + truncatePatrolRunContextText(detail, 260)
}
if summary != "" {
return summary
}
if detail != "" {
return truncatePatrolRunContextText(detail, 260)
}
if run.ErrorCount > 0 {
suffix := ""
if run.ErrorCount != 1 {
suffix = "s"
}
return fmt.Sprintf("%d Patrol runtime error%s recorded", run.ErrorCount, suffix)
}
return ""
}
func patrolRunKindLabel(runType string) string {
switch strings.ToLower(strings.TrimSpace(runType)) {
case "scoped":
return "Scoped run"
case "verification":
return "Verification check"
case "", "patrol", "full", "scheduled":
return "Full patrol"
default:
return "Patrol run"
}
}
func patrolRunStatusLabel(run PatrolRunRecord) string {
status := strings.ToLower(strings.TrimSpace(run.Status))
if run.ErrorCount > 0 && (status == "" || status == "healthy" || status == "completed") {
status = "error"
}
switch status {
case "issues_found":
return "issues found"
case "critical", "error", "healthy":
return status
case "":
return "unknown"
default:
return strings.ReplaceAll(status, "_", " ")
}
}
func patrolTriggerReasonLabel(reason string) string {
switch strings.TrimSpace(reason) {
case "scheduled":
return "Scheduled"
case "manual":
return "Manual"
case "startup":
return "Startup"
case "alert_fired":
return "Alert fired"
case "alert_cleared":
return "Alert cleared"
case "anomaly":
return "Anomaly"
case "user_action":
return "User action"
case "config_changed":
return "Config change"
case "":
return ""
default:
return strings.ReplaceAll(strings.TrimSpace(reason), "_", " ")
}
}
func patrolRunTimingSummary(run PatrolRunRecord) string {
parts := []string{}
if !run.StartedAt.IsZero() {
parts = append(parts, "started "+run.StartedAt.UTC().Format(time.RFC3339))
}
if !run.CompletedAt.IsZero() {
parts = append(parts, "completed "+run.CompletedAt.UTC().Format(time.RFC3339))
}
durationMs := run.DurationMs
if durationMs == 0 && run.Duration > 0 {
durationMs = run.Duration.Milliseconds()
}
if formatted := formatPatrolRunDurationMs(durationMs); formatted != "" {
parts = append(parts, "duration "+formatted)
}
return strings.Join(parts, "; ")
}
func formatPatrolRunDurationMs(ms int64) string {
if ms <= 0 {
return ""
}
if ms < 1000 {
return fmt.Sprintf("%dms", ms)
}
seconds := (ms + 500) / 1000
if seconds < 60 {
return fmt.Sprintf("%ds", seconds)
}
minutes := (seconds + 30) / 60
return fmt.Sprintf("%dm", minutes)
}
func patrolRunCoverageSummary(run PatrolRunRecord) string {
resourcesChecked := max(run.ResourcesChecked, 0)
scopedResourceCount := len(run.EffectiveScopeResourceIDs)
if run.EffectiveScopeResourceIDs == nil {
scopedResourceCount = len(run.ScopeResourceIDs)
}
if scopedResourceCount > 0 {
if resourcesChecked > 0 && resourcesChecked < scopedResourceCount {
return fmt.Sprintf("Checked %d of %d scoped resources", resourcesChecked, scopedResourceCount)
}
if resourcesChecked > 0 {
return fmt.Sprintf("Checked %s", formatPatrolRunResourceCount(resourcesChecked, "scoped"))
}
}
if resourcesChecked > 0 {
return fmt.Sprintf("Checked %s", formatPatrolRunResourceCount(resourcesChecked, ""))
}
return joinPatrolRunContextParts([]string{
countPatrolRunFact(run.NodesChecked, "nodes"),
countPatrolRunFact(run.GuestsChecked, "VMs"),
countPatrolRunFact(run.DockerChecked, "containers"),
countPatrolRunFact(run.StorageChecked, "storage resources"),
countPatrolRunFact(run.HostsChecked, "agents"),
countPatrolRunFact(run.TrueNASChecked, "TrueNAS systems"),
countPatrolRunFact(run.KubernetesChecked, "Kubernetes resources"),
})
}
func formatPatrolRunResourceCount(count int, qualifier string) string {
label := "resources"
if count == 1 {
label = "resource"
}
if strings.TrimSpace(qualifier) != "" {
return fmt.Sprintf("%d %s %s", count, qualifier, label)
}
return fmt.Sprintf("%d %s", count, label)
}
func patrolRunScopeSummary(run PatrolRunRecord) string {
ids := run.EffectiveScopeResourceIDs
if ids == nil {
ids = run.ScopeResourceIDs
}
if len(ids) > 0 {
suffix := ""
if len(ids) != 1 {
suffix = "s"
}
return fmt.Sprintf("Scoped to %d resource%s", len(ids), suffix)
}
if len(run.ScopeResourceTypes) > 0 {
return "Scoped to " + strings.Join(run.ScopeResourceTypes, ", ")
}
if strings.EqualFold(strings.TrimSpace(run.Type), "scoped") {
return "Scoped"
}
return ""
}
func patrolRunFindingsSnapshot(run PatrolRunRecord) string {
count := len(run.FindingIDs)
suffix := ""
if count != 1 {
suffix = "s"
}
return fmt.Sprintf("%d finding ID%s captured", count, suffix)
}
func patrolRunOutcomeSummary(run PatrolRunRecord) string {
return joinPatrolRunContextParts([]string{
countPatrolRunSingularFact(run.NewFindings, "new finding"),
countPatrolRunSingularFact(run.ExistingFindings, "existing finding"),
countPatrolRunSingularFact(run.ResolvedFindings, "resolved finding"),
countPatrolRunSingularFact(run.RejectedFindings, "rejected finding"),
countPatrolRunSingularFact(run.AutoFixCount, "auto-remediation"),
countPatrolRunSingularFact(run.ErrorCount, "error"),
})
}
func patrolRunEffortSummary(run PatrolRunRecord) string {
tokenCount := max(run.InputTokens, 0) + max(run.OutputTokens, 0)
parts := []string{
countPatrolRunSingularFact(run.ToolCallCount, "tool call"),
countPatrolRunSingularFact(run.TriageFlags, "triage flag"),
}
if run.TriageSkippedLLM {
parts = append(parts, "LLM skipped for deterministic triage")
}
if tokenCount > 0 {
parts = append(parts, fmt.Sprintf("%d tokens", tokenCount))
}
return joinPatrolRunContextParts(parts)
}
func countPatrolRunFact(value int, label string) string {
if value <= 0 {
return ""
}
return fmt.Sprintf("%d %s", value, label)
}
func countPatrolRunSingularFact(value int, singular string) string {
if value <= 0 {
return ""
}
suffix := ""
if value != 1 {
suffix = "s"
}
return fmt.Sprintf("%d %s%s", value, singular, suffix)
}
func joinPatrolRunContextParts(parts []string) string {
filtered := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
filtered = append(filtered, part)
}
}
return strings.Join(filtered, "; ")
}
func formatPatrolRunContextLine(label, value string) string {
value = truncatePatrolRunContextText(value, 500)
if value == "" {
return ""
}
return label + ": " + value
}
func sanitizePatrolRunAnalysis(text string) string {
if strings.TrimSpace(text) == "" {
return ""
}
analysis := patrolRunDSMLTracePattern.ReplaceAllString(text, "")
return strings.TrimSpace(redactPatrolRuntimeFailureDetail(analysis))
}
func truncatePatrolRunContextText(value string, limit int) string {
value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
if value == "" || len(value) <= limit {
return value
}
if limit <= 3 {
return strings.TrimSpace(value[:limit])
}
return strings.TrimSpace(value[:limit-3]) + "..."
}
@@ -0,0 +1,72 @@
package ai
import (
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/chat"
)
func TestBuildPatrolRunAssistantHandoffUsesBackendSafeRunContext(t *testing.T) {
run := PatrolRunRecord{
ID: "run-runtime-error",
StartedAt: time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC),
CompletedAt: time.Date(2026, 5, 7, 12, 0, 3, 0, time.UTC),
DurationMs: 3000,
Type: "scoped",
TriggerReason: "alert_fired",
EffectiveScopeResourceIDs: []string{"vm-100"},
ScopeResourceTypes: []string{"vm"},
ResourcesChecked: 1,
GuestsChecked: 1,
FindingsSummary: "Runtime failure prevented analysis.",
FindingIDs: []string{},
ErrorCount: 1,
ErrorSummary: "Selected model does not support Patrol tools",
ErrorDetail: `API error: No endpoints found that support the provided tool_choice value. Authorization: Bearer sk-live-secret`,
Status: "error",
ToolCallCount: 1,
AIAnalysis: `<DSMLtrace>provider trace</DSMLtrace>Visible runtime summary. {"api_key":"sk-json-secret"}`,
}
handoff := BuildPatrolRunAssistantHandoff(run)
if handoff.Metadata != (chat.HandoffMetadata{
Kind: "patrol_run",
RunID: "run-runtime-error",
RunType: "Scoped run",
RunStatus: "error",
RuntimeFailure: true,
}) {
t.Fatalf("metadata = %+v", handoff.Metadata)
}
if got, want := handoff.Resources, []chat.HandoffResource{{ID: "vm-100", Type: "vm"}}; len(got) != len(want) || got[0] != want[0] {
t.Fatalf("resources = %+v, want %+v", got, want)
}
for _, required := range []string{
"[Patrol Run Context]",
"Source: Pulse Patrol run history",
"Run ID: run-runtime-error",
"Run Type: Scoped run",
"Trigger: Alert fired",
"Runtime Failure: Selected model does not support Patrol tools",
"Provider rejected Patrol tool calls",
"Patrol Analysis: Visible runtime summary.",
"Operator Boundary:",
} {
if !strings.Contains(handoff.Context, required) {
t.Fatalf("context missing %q in:\n%s", required, handoff.Context)
}
}
if strings.Contains(handoff.Context, "provider trace") {
t.Fatalf("context leaked provider trace: %s", handoff.Context)
}
if strings.Contains(handoff.Context, "sk-live-secret") || strings.Contains(handoff.Context, "sk-json-secret") {
t.Fatalf("context leaked secret-shaped provider detail: %s", handoff.Context)
}
if strings.Contains(handoff.Context, "tool_choice") || strings.Contains(handoff.Context, "No endpoints found") {
t.Fatalf("context leaked raw provider routing detail: %s", handoff.Context)
}
}
+58
View File
@@ -30,6 +30,22 @@ var patrolRuntimeFailureDetailRedactors = []struct {
pattern: regexp.MustCompile(`(?i)(https?://)[^\s/@:]+:[^\s/@]+@`),
replacement: `${1}[redacted]@`,
},
{
pattern: regexp.MustCompile(`(?i)https?://[^\s"')]+`),
replacement: `[redacted-url]`,
},
{
pattern: regexp.MustCompile(`\buser_[A-Za-z0-9_-]+\b`),
replacement: `[redacted-user]`,
},
{
pattern: regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{8,}\b`),
replacement: `[redacted-secret]`,
},
{
pattern: regexp.MustCompile(`(?i)("(?:user[_-]?id)"\s*:\s*")[^"]+`),
replacement: `${1}[redacted]`,
},
}
type patrolRuntimeFailure struct {
@@ -171,6 +187,48 @@ func redactPatrolRuntimeFailureDetail(raw string) string {
return redacted
}
func summarizePatrolRuntimeFailureDetail(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
lower := strings.ToLower(raw)
switch {
case strings.Contains(lower, "tool_choice") ||
strings.Contains(lower, "tool calling") ||
strings.Contains(lower, "tools are not supported") ||
strings.Contains(lower, "no endpoints found") && strings.Contains(lower, "tool"):
return "Provider rejected Patrol tool calls. Choose a Patrol model and endpoint with tool-call support."
case strings.Contains(lower, "insufficient balance") ||
strings.Contains(lower, "402") ||
strings.Contains(lower, "payment required") ||
strings.Contains(lower, "quota") ||
strings.Contains(lower, "credit") ||
strings.Contains(lower, "max_tokens"):
return "Provider reported insufficient credits or token budget for the requested Patrol analysis."
case strings.Contains(lower, "rate limit") ||
strings.Contains(lower, "429") ||
strings.Contains(lower, "too many requests"):
return "Provider rate limit reached. Wait for capacity or adjust provider limits before retrying."
case strings.Contains(lower, "401") ||
strings.Contains(lower, "403") ||
strings.Contains(lower, "unauthorized") ||
strings.Contains(lower, "forbidden") ||
strings.Contains(lower, "api key"):
return "Provider authentication failed. Check the configured provider key and account access."
case strings.Contains(lower, "failed to connect") ||
strings.Contains(lower, "connection refused") ||
strings.Contains(lower, "no such host") ||
strings.Contains(lower, "i/o timeout") ||
strings.Contains(lower, "context deadline exceeded") ||
strings.Contains(lower, "timeout") ||
strings.Contains(lower, "returned status 5"):
return "Provider connection failed. Check provider reachability before retrying Patrol."
default:
return strings.TrimSpace(redactPatrolRuntimeFailureDetail(raw))
}
}
func newPatrolRuntimeFailureFinding(failure patrolRuntimeFailure, now time.Time) *Finding {
return &Finding{
ID: generateFindingID(patrolRuntimeResourceID, "reliability", patrolRuntimeFindingKey),
+46
View File
@@ -13,6 +13,7 @@ import (
"fmt"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
airuntime "github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/chat"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
@@ -44,6 +45,7 @@ type AIService interface {
DeleteSession(ctx context.Context, sessionID string) error
GetMessages(ctx context.Context, sessionID string) ([]chat.Message, error)
GetModelHandoffFindingID(ctx context.Context, sessionID string) (string, error)
GetModelHandoffMetadata(ctx context.Context, sessionID string) (chat.HandoffMetadata, error)
ClearModelHandoffContext(ctx context.Context, sessionID string) error
AbortSession(ctx context.Context, sessionID string) error
SummarizeSession(ctx context.Context, sessionID string) (map[string]interface{}, error)
@@ -76,6 +78,8 @@ type AIService interface {
GetBaseURL() string
}
type patrolRunHandoffProvider func(context.Context, string) (airuntime.PatrolRunRecord, bool)
// AIHandler handles all AI endpoints using direct AI integration
type AIHandler struct {
stateMu sync.RWMutex
@@ -101,6 +105,7 @@ type AIHandler struct {
approvalStoreDir string
approvalStoreStop context.CancelFunc
controlLevelResolver func(context.Context, *config.AIConfig) string
patrolRunProvider patrolRunHandoffProvider
}
// newChatService is the factory function for creating the AI service.
@@ -137,6 +142,29 @@ func NewAIHandler(mtp *config.MultiTenantPersistence, mtm *monitoring.MultiTenan
}
}
// SetPatrolRunHandoffProvider wires the Patrol history owner into Assistant
// chat handoffs without making the browser reconstruct model context.
func (h *AIHandler) SetPatrolRunHandoffProvider(provider patrolRunHandoffProvider) {
h.stateMu.Lock()
defer h.stateMu.Unlock()
h.patrolRunProvider = provider
}
func (h *AIHandler) getPatrolRunForHandoff(ctx context.Context, runID string) (airuntime.PatrolRunRecord, bool) {
runID = strings.TrimSpace(runID)
if runID == "" {
return airuntime.PatrolRunRecord{}, false
}
h.stateMu.RLock()
provider := h.patrolRunProvider
h.stateMu.RUnlock()
if provider == nil {
return airuntime.PatrolRunRecord{}, false
}
return provider(ctx, runID)
}
func (h *AIHandler) stateRefs() (
*config.MultiTenantPersistence,
*monitoring.MultiTenantMonitor,
@@ -2521,6 +2549,24 @@ func (h *AIHandler) HandleChat(w http.ResponseWriter, r *http.Request) {
findingID = strings.TrimSpace(storedFindingID)
}
}
if findingID == "" && handoffMetadata == (chat.HandoffMetadata{}) && strings.TrimSpace(req.SessionID) != "" {
if storedMetadata, err := svc.GetModelHandoffMetadata(ctx, req.SessionID); err != nil {
log.Debug().Err(err).Str("session_id", req.SessionID).Msg("Unable to load stored Assistant handoff metadata")
} else {
handoffMetadata = chat.NormalizeHandoffMetadata(storedMetadata)
}
}
if findingID == "" && handoffMetadata.Kind == "patrol_run" {
handoffContext = ""
handoffResources = nil
handoffActions = nil
if run, ok := h.getPatrolRunForHandoff(ctx, handoffMetadata.RunID); ok {
runHandoff := airuntime.BuildPatrolRunAssistantHandoff(run)
handoffContext = runHandoff.Context
handoffResources = runHandoff.Resources
handoffMetadata = chat.NormalizeHandoffMetadata(runHandoff.Metadata)
}
}
if findingID != "" {
findingResolved := false
orgID := GetOrgID(ctx)
@@ -46,6 +46,9 @@ func (s *capturingAIService) GetMessages(ctx context.Context, sessionID string)
func (s *capturingAIService) GetModelHandoffFindingID(ctx context.Context, sessionID string) (string, error) {
return "", nil
}
func (s *capturingAIService) GetModelHandoffMetadata(ctx context.Context, sessionID string) (chat.HandoffMetadata, error) {
return chat.HandoffMetadata{}, nil
}
func (s *capturingAIService) ClearModelHandoffContext(ctx context.Context, sessionID string) error {
return nil
}
+176
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
airuntime "github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/chat"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/unified"
@@ -93,6 +94,19 @@ func (m *MockAIService) GetModelHandoffFindingID(ctx context.Context, sessionID
return "", nil
}
func (m *MockAIService) GetModelHandoffMetadata(ctx context.Context, sessionID string) (chat.HandoffMetadata, error) {
for _, call := range m.ExpectedCalls {
if call.Method == "GetModelHandoffMetadata" {
args := m.Called(ctx, sessionID)
if args.Get(0) == nil {
return chat.HandoffMetadata{}, args.Error(1)
}
return args.Get(0).(chat.HandoffMetadata), args.Error(1)
}
}
return chat.HandoffMetadata{}, nil
}
func (m *MockAIService) ClearModelHandoffContext(ctx context.Context, sessionID string) error {
for _, call := range m.ExpectedCalls {
if call.Method == "ClearModelHandoffContext" {
@@ -662,6 +676,168 @@ func TestHandleChat_PassesPatrolRunHandoffMetadata(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
}
func TestHandleChat_DropsBrowserPatrolRunHandoffContextWhenRunUnavailable(t *testing.T) {
cfg := &config.Config{}
h := newTestAIHandler(cfg, nil, nil)
mockSvc := new(MockAIService)
h.defaultService = mockSvc
mockSvc.On("IsRunning").Return(true)
mockSvc.
On("ExecuteStream", mock.Anything, mock.Anything, mock.Anything).
Return(nil).
Run(func(args mock.Arguments) {
reqArg := args.Get(1).(chat.ExecuteRequest)
assert.Equal(t, "discuss run", reqArg.Prompt)
assert.Empty(t, reqArg.HandoffContext)
assert.Empty(t, reqArg.HandoffResources)
assert.Empty(t, reqArg.HandoffActions)
assert.Equal(t, chat.HandoffMetadata{
Kind: "patrol_run",
RunID: "run-missing",
RunType: "Scoped run",
RunStatus: "error",
RuntimeFailure: true,
}, reqArg.HandoffMetadata)
})
body := `{"prompt":"discuss run","handoff_context":"[Patrol Run Context]\nSource: browser-authored stale context\nRuntime Failure: leaked provider detail","handoff_resources":[{"id":"storage-999","type":"storage"}],"handoff_actions":[{"description":"stale action","target_resource_id":"vm-1"}],"handoff_metadata":{"kind":"patrol_run","run_id":"run-missing","run_type":"Scoped run","run_status":"error","runtime_failure":true}}`
req := httptest.NewRequest("POST", "/api/ai/chat", strings.NewReader(body))
w := httptest.NewRecorder()
h.HandleChat(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestHandleChat_RehydratesPatrolRunHandoffContextFromBackend(t *testing.T) {
cfg := &config.Config{}
h := newTestAIHandler(cfg, nil, nil)
mockSvc := new(MockAIService)
h.defaultService = mockSvc
h.SetPatrolRunHandoffProvider(func(ctx context.Context, runID string) (airuntime.PatrolRunRecord, bool) {
assert.Equal(t, "run-runtime-error", runID)
return airuntime.PatrolRunRecord{
ID: "run-runtime-error",
StartedAt: time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC),
CompletedAt: time.Date(2026, 5, 7, 12, 0, 3, 0, time.UTC),
DurationMs: 3000,
Type: "scoped",
TriggerReason: "alert_fired",
EffectiveScopeResourceIDs: []string{"vm-100"},
ScopeResourceTypes: []string{"vm"},
ResourcesChecked: 1,
GuestsChecked: 1,
FindingsSummary: "Runtime failure prevented analysis.",
FindingIDs: []string{},
ErrorCount: 1,
ErrorSummary: "Selected model does not support Patrol tools",
ErrorDetail: "No endpoints found that support tool_choice.",
Status: "error",
AIAnalysis: "<DSMLtrace>provider trace</DSMLtrace>Visible runtime summary.",
ToolCallCount: 1,
}, true
})
mockSvc.On("IsRunning").Return(true)
mockSvc.
On("ExecuteStream", mock.Anything, mock.Anything, mock.Anything).
Return(nil).
Run(func(args mock.Arguments) {
reqArg := args.Get(1).(chat.ExecuteRequest)
assert.Equal(t, "discuss run", reqArg.Prompt)
assert.Equal(t, "", reqArg.FindingID)
assert.Contains(t, reqArg.HandoffContext, "[Patrol Run Context]")
assert.Contains(t, reqArg.HandoffContext, "Source: Pulse Patrol run history")
assert.Contains(t, reqArg.HandoffContext, "Run ID: run-runtime-error")
assert.Contains(t, reqArg.HandoffContext, "Runtime Failure: Selected model does not support Patrol tools")
assert.Contains(t, reqArg.HandoffContext, "Provider rejected Patrol tool calls")
assert.Contains(t, reqArg.HandoffContext, "Patrol Analysis: Visible runtime summary.")
assert.NotContains(t, reqArg.HandoffContext, "browser-authored stale context")
assert.NotContains(t, reqArg.HandoffContext, "provider trace")
assert.NotContains(t, reqArg.HandoffContext, "tool_choice")
assert.NotContains(t, reqArg.HandoffContext, "No endpoints found")
assert.Equal(t, []chat.HandoffResource{{ID: "vm-100", Type: "vm"}}, reqArg.HandoffResources)
assert.Equal(t, chat.HandoffMetadata{
Kind: "patrol_run",
RunID: "run-runtime-error",
RunType: "Scoped run",
RunStatus: "error",
RuntimeFailure: true,
}, reqArg.HandoffMetadata)
if assert.NotNil(t, reqArg.AutonomousMode) {
assert.False(t, *reqArg.AutonomousMode)
}
})
body := `{"prompt":"discuss run","autonomous_mode":true,"handoff_context":"[Patrol Run Context]\nSource: browser-authored stale context","handoff_resources":[{"id":"storage-999","type":"storage"}],"handoff_metadata":{"kind":"patrol_run","run_id":"run-runtime-error","run_type":"Wrong type","run_status":"healthy","runtime_failure":false}}`
req := httptest.NewRequest("POST", "/api/ai/chat", strings.NewReader(body))
w := httptest.NewRecorder()
h.HandleChat(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestHandleChat_RehydratesStoredPatrolRunHandoffMetadataForFollowUp(t *testing.T) {
cfg := &config.Config{}
h := newTestAIHandler(cfg, nil, nil)
mockSvc := new(MockAIService)
h.defaultService = mockSvc
h.SetPatrolRunHandoffProvider(func(ctx context.Context, runID string) (airuntime.PatrolRunRecord, bool) {
assert.Equal(t, "run-stored", runID)
return airuntime.PatrolRunRecord{
ID: "run-stored",
StartedAt: time.Date(2026, 5, 7, 13, 0, 0, 0, time.UTC),
CompletedAt: time.Date(2026, 5, 7, 13, 0, 5, 0, time.UTC),
DurationMs: 5000,
Type: "verification",
TriggerReason: "user_action",
EffectiveScopeResourceIDs: []string{"storage-1"},
ScopeResourceTypes: []string{"storage"},
ResourcesChecked: 1,
StorageChecked: 1,
FindingsSummary: "Verification completed.",
Status: "healthy",
}, true
})
mockSvc.On("IsRunning").Return(true)
mockSvc.
On("GetModelHandoffFindingID", mock.Anything, "session-run").
Return("", nil)
mockSvc.
On("GetModelHandoffMetadata", mock.Anything, "session-run").
Return(chat.HandoffMetadata{
Kind: "patrol_run",
RunID: "run-stored",
}, nil)
mockSvc.
On("ExecuteStream", mock.Anything, mock.Anything, mock.Anything).
Return(nil).
Run(func(args mock.Arguments) {
reqArg := args.Get(1).(chat.ExecuteRequest)
assert.Equal(t, "what changed?", reqArg.Prompt)
assert.Contains(t, reqArg.HandoffContext, "Run ID: run-stored")
assert.Contains(t, reqArg.HandoffContext, "Run Type: Verification check")
assert.Equal(t, []chat.HandoffResource{{ID: "storage-1", Type: "storage"}}, reqArg.HandoffResources)
assert.Equal(t, chat.HandoffMetadata{
Kind: "patrol_run",
RunID: "run-stored",
RunType: "Verification check",
RunStatus: "healthy",
}, reqArg.HandoffMetadata)
})
body := `{"prompt":"what changed?","session_id":"session-run"}`
req := httptest.NewRequest("POST", "/api/ai/chat", strings.NewReader(body))
w := httptest.NewRecorder()
h.HandleChat(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestHandleChat_IncludesInvestigationRecordContext(t *testing.T) {
cfg := &config.Config{}
h := newTestAIHandler(cfg, nil, nil)
+43 -5
View File
@@ -178,6 +178,10 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
if err != nil {
t.Fatalf("read chat types: %v", err)
}
patrolHandoffSource, err := os.ReadFile(filepath.Clean("../ai/patrol_assistant_handoff.go"))
if err != nil {
t.Fatalf("read Patrol Assistant handoff model: %v", err)
}
toolsQuerySource, err := os.ReadFile(filepath.Clean("../ai/tools/tools_query.go"))
if err != nil {
t.Fatalf("read AI tools query runtime: %v", err)
@@ -186,11 +190,14 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
handlerText := string(handlerSource)
for _, required := range []string{
`svc.GetModelHandoffFindingID(ctx, req.SessionID)`,
`svc.GetModelHandoffMetadata(ctx, req.SessionID)`,
`svc.ClearModelHandoffContext(ctx, sessionID)`,
`HandoffActions []chat.HandoffAction ` + "`json:\"handoff_actions,omitempty\"`",
`HandoffMetadata chat.HandoffMetadata ` + "`json:\"handoff_metadata,omitempty\"`",
"handoffActions := normalizeChatRequestHandoffActions(req.HandoffActions)",
"handoffMetadata := chat.NormalizeHandoffMetadata(req.HandoffMetadata)",
"h.getPatrolRunForHandoff(ctx, handoffMetadata.RunID)",
"airuntime.BuildPatrolRunAssistantHandoff(run)",
"chatRequestHandoffActionLimit",
"requestHandoffContext := handoffContext",
"requestHandoffResources := handoffResources",
@@ -262,6 +269,20 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
t.Fatal("ai_handler.go must not prepend finding context into the persisted prompt")
}
patrolHandoffText := string(patrolHandoffSource)
for _, required := range []string{
"func BuildPatrolRunAssistantHandoff(run PatrolRunRecord) PatrolRunAssistantHandoff",
"[Patrol Run Context]",
"Source: Pulse Patrol run history",
"patrolRunAssistantHandoffResources",
"sanitizePatrolRunAnalysis",
"Operator Boundary",
} {
if !strings.Contains(patrolHandoffText, required) {
t.Fatalf("Patrol runtime must own safe Assistant run handoff context: missing %q", required)
}
}
chatServiceText := string(chatServiceSource)
for _, required := range []string{
"handoffContext := strings.TrimSpace(req.HandoffContext)",
@@ -272,6 +293,7 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
"ClearModelHandoffContext",
"handoffResources := normalizeHandoffResources(req.HandoffResources)",
"GetModelHandoffFindingID",
"GetModelHandoffMetadata",
"sessions.GetModelHandoffContext(session.ID)",
"sessions.GetModelHandoffResources(session.ID)",
"sessions.SetModelHandoffActions(session.ID, handoffActions)",
@@ -316,6 +338,7 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
"SetModelHandoffFindingID",
"SetModelHandoffEnvelope",
"GetModelHandoffFindingID",
"GetModelHandoffMetadata",
"ClearModelHandoffContext",
"HandoffFindingID string",
"SetModelHandoffContext",
@@ -1060,11 +1083,26 @@ func TestContract_AISettingsUpdateProviderResolutionJSONSnapshot(t *testing.T) {
"ollama_configured":true,
"ollama_base_url":%q,
"ollama_password_set":false,
"configured_providers":["ollama"],
"control_level":"read_only",
"protected_guests":[],
"discovery_enabled":false
}`, ollama.URL)
"configured_providers":["ollama"],
"control_level":"read_only",
"protected_guests":[],
"discovery_enabled":false,
"patrol_readiness":{
"status":"warning",
"ready":true,
"cause":"model_tool_support_unverified",
"summary":"Ollama connectivity alone does not prove tool support. Use an Ollama model that returns tool_calls for Patrol verification.",
"provider":"ollama",
"model":"ollama:llama3:latest",
"checks":[{
"id":"configuration",
"status":"warning",
"cause":"model_tool_support_unverified",
"label":"Patrol configuration",
"message":"Ollama connectivity alone does not prove tool support. Use an Ollama model that returns tool_calls for Patrol verification."
}]
}
}`, ollama.URL)
assertJSONSnapshot(t, rec.Body.Bytes(), want)
}
+19
View File
@@ -609,6 +609,25 @@ func (r *Router) setupRoutes() {
})
// Wire AI handler to profile handler for AI-assisted suggestions
r.configProfileHandler.SetAIHandler(r.aiHandler)
r.aiHandler.SetPatrolRunHandoffProvider(func(ctx context.Context, runID string) (ai.PatrolRunRecord, bool) {
if r.aiSettingsHandler == nil {
return ai.PatrolRunRecord{}, false
}
aiService := r.aiSettingsHandler.GetAIService(ctx)
if aiService == nil {
return ai.PatrolRunRecord{}, false
}
patrol := aiService.GetPatrolService()
if patrol == nil {
return ai.PatrolRunRecord{}, false
}
run, ok := patrol.GetRunByID(runID)
if !ok {
return ai.PatrolRunRecord{}, false
}
run.ToolCalls = nil
return run, true
})
// Wire chat handler to AI settings handler for investigation orchestration
r.aiSettingsHandler.SetChatHandler(r.aiHandler)
// Wire license checker for alert manager Pro features (Update Alerts)
@@ -81,12 +81,18 @@ func TestRouteTestConnection_ConnectionFailure(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Success bool `json:"success"`
Message string `json:"message"`
Success bool `json:"success"`
Message string `json:"message"`
Cause string `json:"cause"`
Recommendation string `json:"recommendation"`
Action string `json:"action"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.False(t, resp.Success)
assert.Equal(t, "Connection test failed", resp.Message)
assert.Equal(t, "Provider connection issue", resp.Message)
assert.Equal(t, "provider_connection", resp.Cause)
assert.Contains(t, resp.Recommendation, "provider reachability")
assert.Equal(t, "open_provider_settings", resp.Action)
}
// TestRouteTestConnection_MethodNotAllowed verifies that non-POST methods
@@ -156,10 +162,14 @@ func TestRouteTestConnection_NoConfig(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Success bool `json:"success"`
Message string `json:"message"`
Success bool `json:"success"`
Message string `json:"message"`
Cause string `json:"cause"`
Recommendation string `json:"recommendation"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.False(t, resp.Success)
assert.Equal(t, "Connection test failed", resp.Message)
assert.Equal(t, "Provider not ready", resp.Message)
assert.Equal(t, "provider_not_configured", resp.Cause)
assert.Contains(t, resp.Recommendation, "provider settings")
}
@@ -117,14 +117,20 @@ func TestRouteTestProvider_ConnectionFailure(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Success bool `json:"success"`
Message string `json:"message"`
Provider string `json:"provider"`
Success bool `json:"success"`
Message string `json:"message"`
Provider string `json:"provider"`
Cause string `json:"cause"`
Recommendation string `json:"recommendation"`
Action string `json:"action"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.False(t, resp.Success)
assert.Equal(t, "Connection test failed", resp.Message)
assert.Equal(t, "Provider connection issue", resp.Message)
assert.Equal(t, "ollama", resp.Provider)
assert.Equal(t, "provider_connection", resp.Cause)
assert.Contains(t, resp.Recommendation, "provider reachability")
assert.Equal(t, "open_provider_settings", resp.Action)
}
// TestRouteTestProvider_MethodNotAllowed verifies that GET (and other