mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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', () => {
|
||||
|
||||
+7
-7
@@ -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 = [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: `<|DSML|trace>provider trace</|DSML|trace>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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: "<|DSML|trace>provider trace</|DSML|trace>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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user