diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index e1d69818a..71f599af1 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -2421,6 +2421,115 @@ "kind": "file" } ] + }, + { + "id": "RA27", + "summary": "Patrol status owns server-authored runtime readiness: provider, model, settings-persistence, and tool-calling prerequisites must travel as structured `/api/ai/patrol/status` checks, and known not-ready states must block manual Patrol runs before they become generic interrupted-analysis failures.", + "kind": "invariant", + "blocking_level": "repo-ready", + "proof_type": "automated", + "lane_ids": [ + "L6" + ], + "subsystem_ids": [ + "ai-runtime", + "api-contracts", + "patrol-intelligence" + ], + "release_gate_ids": [], + "proof_commands": [ + { + "id": "patrol-readiness-frontend-tests", + "run": [ + "npm", + "--prefix", + "frontend-modern", + "test", + "--", + "--run", + "src/api/__tests__/patrol.test.ts", + "src/pages/__tests__/AIIntelligence.test.tsx" + ] + }, + { + "id": "patrol-readiness-status-contract-tests", + "run": [ + "go", + "test", + "./internal/api", + "-run", + "PatrolReadiness|PatrolStatus|Contract_PatrolStatusResponseJSONSnapshot", + "-count=1" + ] + } + ], + "evidence": [ + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/status.json", + "kind": "file" + }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/subsystems/ai-runtime.md", + "kind": "file" + }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/subsystems/api-contracts.md", + "kind": "file" + }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/subsystems/patrol-intelligence.md", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/api/__tests__/patrol.test.ts", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/api/patrol.ts", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/api/ai_handlers.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/api/ai_handlers_test.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/api/ai_patrol_handlers_test.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/api/contract_test.go", + "kind": "file" + } + ] } ], "evidence_reference_policy": { diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index a44ef6c12..0fdde379f 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -107,6 +107,10 @@ runtime cost control, and shared AI transport surfaces. Unavailable-provider blocked states must direct operators to Assistant & Patrol provider settings and tool-capable Patrol model selection, not legacy `Settings > Pulse Assistant` copy. + Patrol status must also carry server-authored readiness for provider, + model, settings-persistence, and tool-calling prerequisites so the UI can + block known-bad manual Patrol runs before they become generic runtime + failures. 4. Keep discovery scheduling authoritative through `internal/config/ai.go`: `discovery_enabled` and `discovery_interval_hours` must govern both lightweight infrastructure discovery and deep service-discovery background loops 5. Preserve auditability for outbound model-bound context exports and keep the export record aligned with the prompt boundary that actually reaches the provider External provider-bound unified-resource context must enforce the same diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index edf0885d3..1d65adb0d 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -111,6 +111,9 @@ product API routes free of maintainer commercial analytics. 4. `frontend-modern/src/api/notifications.ts` shared with `notifications`: the notifications frontend client is both a notification delivery control surface and a canonical API payload contract boundary. 5. `frontend-modern/src/api/orgs.ts` shared with `organization-settings`: the organization frontend client is both an organization settings control surface and a canonical API payload contract boundary. 6. `frontend-modern/src/api/patrol.ts` shared with `ai-runtime`: the Patrol frontend client is both an AI runtime control surface and a canonical API payload contract boundary. + The Patrol status payload owns Patrol readiness as structured API state: + provider/model/settings/tool prerequisites must travel as bounded readiness + checks instead of frontend-only heuristics or generic analysis-failed text. 7. `frontend-modern/src/api/rbac.ts` shared with `organization-settings`: the RBAC frontend client is both an organization settings control surface and a canonical API payload contract boundary. 8. `frontend-modern/src/api/security.ts` shared with `security-privacy`: the security frontend client is both a security/privacy control surface and a canonical API payload contract boundary. 9. `frontend-modern/src/api/updates.ts` shared with `deployment-installability`: the updates frontend client is both a deployment-installability control surface and a canonical API payload contract boundary. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 454b72c95..d417e0e62 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -82,6 +82,10 @@ Patrol-specific presentation helpers. header badges, runtime banners, empty states, and settings handoffs must present provider setup as BYOK/local/self-managed rather than promising managed credits or account-backed AI access. + Server-authored Patrol readiness from the status payload is part of the + Patrol product surface: warnings must be visible before a run starts, and + known not-ready states must disable manual Patrol runs instead of letting + operators discover provider/model/tool incompatibility through a failed run. 5. Keep customer-facing Patrol naming product-first: page titles, route chrome, summary copy, actions, and empty states should lead with `Patrol` or `Pulse Patrol` rather than generic `AI` branding. Reserve `AI` terminology diff --git a/frontend-modern/src/api/__tests__/patrol.test.ts b/frontend-modern/src/api/__tests__/patrol.test.ts index 1d62eafbf..ebae98c7b 100644 --- a/frontend-modern/src/api/__tests__/patrol.test.ts +++ b/frontend-modern/src/api/__tests__/patrol.test.ts @@ -64,12 +64,43 @@ describe('patrol api', () => { runtime_state: 'blocked', blocked_reason: 'Connect a provider to power Pulse Assistant and Patrol.', healthy: false, + readiness: { + status: 'not_ready', + ready: false, + summary: + 'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.', + provider: 'ollama', + model: 'ollama:deepseek-r1:7b-llama-distill-q4_K_M', + checks: [ + { + id: 'tools', + status: 'not_ready', + label: 'Patrol tools', + message: + 'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.', + action: 'open_provider_settings', + }, + ], + }, } as any); await expect(getPatrolStatus()).resolves.toMatchObject({ runtime_state: 'blocked', blocked_reason: 'Connect a provider to power Pulse Assistant and Patrol.', healthy: false, + readiness: { + status: 'not_ready', + ready: false, + provider: 'ollama', + model: 'ollama:deepseek-r1:7b-llama-distill-q4_K_M', + checks: [ + { + id: 'tools', + status: 'not_ready', + action: 'open_provider_settings', + }, + ], + }, }); }); diff --git a/frontend-modern/src/api/patrol.ts b/frontend-modern/src/api/patrol.ts index 232c61431..5cb44d1c2 100644 --- a/frontend-modern/src/api/patrol.ts +++ b/frontend-modern/src/api/patrol.ts @@ -141,6 +141,24 @@ export interface FindingsSummary { export type LicenseStatus = 'none' | 'active' | 'expired' | 'grace_period'; export type PatrolRuntimeState = 'unavailable' | 'disabled' | 'running' | 'blocked' | 'active'; +export type PatrolReadinessStatus = 'ready' | 'warning' | 'not_ready'; + +export interface PatrolReadinessCheck { + id: string; + status: PatrolReadinessStatus; + label: string; + message: string; + action?: string; +} + +export interface PatrolReadiness { + status: PatrolReadinessStatus; + ready: boolean; + summary: string; + provider?: string; + model?: string; + checks: PatrolReadinessCheck[]; +} export interface PatrolTriggerStatus { running: boolean; @@ -172,6 +190,7 @@ export interface PatrolStatus { license_required?: boolean; license_status?: LicenseStatus; upgrade_url?: string; + readiness?: PatrolReadiness; summary: FindingsSummary; } diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx index 0d0d6d25b..e48c63858 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx @@ -52,6 +52,69 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat + +
+
+
+
+ +
+
+

+ {state.patrolReadiness()?.status === 'not_ready' + ? 'Patrol readiness issue' + : 'Patrol readiness warning'} +

+

+ {state.patrolReadiness()?.summary} +

+
+
+ + + {PATROL_PROVIDER_SETTINGS_ACTION.label} + +
+
+
+
diff --git a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts index 9149679cb..cafd793be 100644 --- a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts +++ b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts @@ -336,13 +336,20 @@ export function usePatrolIntelligenceState() { normalizePatrolRuntimeBlockedReason(patrolStatus()?.blocked_reason), ); const blockedAt = createMemo(() => patrolStatus()?.blocked_at); + const patrolReadiness = createMemo(() => patrolStatus()?.readiness ?? null); + const readinessBlocksPatrol = createMemo(() => patrolReadiness()?.status === 'not_ready'); const showBlockedBanner = createMemo(() => runtimeState() === 'blocked'); - const canTriggerPatrol = createMemo(() => runtimeState() === 'active'); + const showReadinessBanner = createMemo(() => { + const readiness = patrolReadiness(); + return runtimeState() === 'active' && readiness !== null && readiness.status !== 'ready'; + }); + const canTriggerPatrol = createMemo(() => runtimeState() === 'active' && !readinessBlocksPatrol()); const triggerPatrolDisabledReason = createMemo(() => { if (runtimeState() === 'disabled') return 'Patrol is disabled'; if (runtimeState() === 'blocked') return blockedReason() || 'Patrol is paused'; if (runtimeState() === 'running') return 'Patrol is already running'; if (runtimeState() === 'unavailable') return 'Patrol service is unavailable'; + if (readinessBlocksPatrol()) return patrolReadiness()?.summary || 'Patrol is not ready'; return ''; }); @@ -683,6 +690,7 @@ export function usePatrolIntelligenceState() { patrolAnomalyTriggers, patrolInterval, patrolModel, + patrolReadiness, patrolRunHistory, runtimeState, patrolStatus, @@ -710,6 +718,7 @@ export function usePatrolIntelligenceState() { }, showAdvancedSettings, showBlockedBanner, + showReadinessBanner, showInvestigationContext, shouldSurfaceInvestigationContext, summaryStats, diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index c8fbc918d..5639e43dd 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -187,6 +187,9 @@ vi.mock('@/stores/aiIntelligence', () => { get circuitBreakerStatus() { return intelligenceState.circuitBreakerStatus; }, + get patrolPendingApprovals() { + return []; + }, get correlations() { return correlationsState(); }, @@ -408,6 +411,101 @@ describe('AIIntelligence entitlement gating', () => { expect(screen.getByTestId('pulse-patrol-logo')).toHaveAttribute('aria-hidden', 'true'); }); + it('surfaces Patrol readiness issues before a manual run can start', async () => { + getPatrolStatusMock.mockResolvedValue( + defaultPatrolStatus({ + readiness: { + status: 'not_ready', + ready: false, + summary: + 'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.', + provider: 'ollama', + model: 'ollama:deepseek-r1:7b-llama-distill-q4_K_M', + checks: [ + { + id: 'tools', + status: 'not_ready', + label: 'Patrol tools', + message: + 'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.', + action: 'open_provider_settings', + }, + ], + }, + }), + ); + + render(() => ); + + await waitFor(() => { + expect(screen.getByText('Patrol readiness issue')).toBeInTheDocument(); + }); + expect( + screen.getByText( + 'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.', + ), + ).toBeInTheDocument(); + for (const button of screen.getAllByRole('button', { name: /Run Patrol/i })) { + expect(button).toBeDisabled(); + } + expect(triggerPatrolRunMock).not.toHaveBeenCalled(); + }); + + it('surfaces Patrol readiness warnings without blocking manual runs', async () => { + getPatrolStatusMock.mockResolvedValue( + defaultPatrolStatus({ + readiness: { + status: 'warning', + ready: true, + 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', + checks: [ + { + id: 'tools', + status: 'warning', + label: 'Patrol tools', + message: + 'Ollama connectivity alone does not prove tool support. Use an Ollama model that returns tool_calls for Patrol verification.', + action: 'open_provider_settings', + }, + ], + }, + }), + ); + + render(() => ); + + await waitFor(() => { + expect(screen.getByText('Patrol readiness warning')).toBeInTheDocument(); + }); + const runButtons = screen.getAllByRole('button', { name: /Run Patrol/i }); + for (const button of runButtons) { + expect(button).not.toBeDisabled(); + } + + fireEvent.click(runButtons[0]); + await waitFor(() => { + expect(triggerPatrolRunMock).toHaveBeenCalled(); + }); + }); + + it('keeps legacy Patrol status payloads without readiness compatible', async () => { + getPatrolStatusMock.mockResolvedValue(defaultPatrolStatus({ readiness: undefined })); + + render(() => ); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Patrol' })).toBeInTheDocument(); + }); + expect(screen.queryByText('Patrol readiness issue')).not.toBeInTheDocument(); + expect(screen.queryByText('Patrol readiness warning')).not.toBeInTheDocument(); + for (const button of screen.getAllByRole('button', { name: /Run Patrol/i })) { + expect(button).not.toBeDisabled(); + } + }); + it('renders canonical learned correlations in the summary page through the correlation card', async () => { hasFeatureMock.mockReturnValue(true); licenseStatusMock.mockReturnValue({ subscription_state: 'active' }); diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index c30877597..ed73dd069 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -4804,6 +4804,145 @@ type PatrolStatusResponse struct { Watch int `json:"watch"` Info int `json:"info"` } `json:"summary"` + Readiness *PatrolReadinessResponse `json:"readiness,omitempty"` +} + +type PatrolReadinessResponse struct { + Status string `json:"status"` + Ready bool `json:"ready"` + Summary string `json:"summary"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + Checks []PatrolReadinessCheck `json:"checks"` +} + +type PatrolReadinessCheck struct { + ID string `json:"id"` + Status string `json:"status"` + Label string `json:"label"` + Message string `json:"message"` + Action string `json:"action,omitempty"` +} + +const ( + patrolReadinessReady = "ready" + patrolReadinessWarning = "warning" + patrolReadinessNotReady = "not_ready" +) + +func (h *AISettingsHandler) buildPatrolReadiness(ctx context.Context, aiService *ai.Service, patrolAvailable bool) PatrolReadinessResponse { + checks := make([]PatrolReadinessCheck, 0, 4) + addCheck := func(id, status, label, message, action string) { + checks = append(checks, PatrolReadinessCheck{ + ID: id, + Status: status, + Label: label, + Message: message, + Action: action, + }) + } + + if aiService == nil { + addCheck("service", patrolReadinessNotReady, "AI runtime service", "Pulse AI runtime service is not available.", "restart_service") + return summarizePatrolReadiness("", "", checks) + } + if !patrolAvailable { + addCheck("service", patrolReadinessNotReady, "Patrol service", "Pulse Patrol service is not available.", "restart_service") + return summarizePatrolReadiness("", "", checks) + } + addCheck("service", patrolReadinessReady, "Patrol service", "Pulse Patrol service is available.", "") + + cfg, err := h.loadAIConfig(ctx) + if err != nil || cfg == nil { + addCheck("settings", patrolReadinessNotReady, "Settings persistence", "Pulse Assistant settings could not be loaded from persistence.", "open_provider_settings") + return summarizePatrolReadiness("", "", checks) + } + addCheck("settings", patrolReadinessReady, "Settings persistence", "Pulse Assistant and Patrol settings are readable.", "") + + if !cfg.Enabled { + addCheck("enabled", patrolReadinessNotReady, "Assistant enabled", "Pulse Assistant is disabled, so Patrol cannot run model-backed verification.", "open_provider_settings") + } else { + addCheck("enabled", patrolReadinessReady, "Assistant enabled", "Pulse Assistant is enabled for Patrol verification.", "") + } + + if !cfg.IsConfigured() { + addCheck("provider", patrolReadinessNotReady, "Provider configured", "No AI provider is configured for Patrol.", "open_provider_settings") + return summarizePatrolReadiness("", "", checks) + } + addCheck("provider", patrolReadinessReady, "Provider configured", "At least one AI provider is configured.", "") + + model := strings.TrimSpace(cfg.GetPatrolModel()) + if model == "" { + model = strings.TrimSpace(cfg.GetChatModel()) + } + provider, _ := config.ParseModelString(model) + if model == "" || provider == "" || provider == config.AIProviderQuickstart { + addCheck("model", patrolReadinessNotReady, "Patrol model", "No concrete Patrol model is selected.", "open_provider_settings") + return summarizePatrolReadiness(provider, model, checks) + } + if !cfg.HasProvider(provider) { + addCheck("model", patrolReadinessNotReady, "Patrol model", fmt.Sprintf("The selected Patrol model uses %s, but that provider is not configured.", provider), "open_provider_settings") + return summarizePatrolReadiness(provider, model, checks) + } + addCheck("model", patrolReadinessReady, "Patrol model", "Patrol has a model selected from a configured provider.", "") + + toolStatus, toolMessage := patrolToolReadinessForModel(provider, model) + toolAction := "" + if toolStatus != patrolReadinessReady { + toolAction = "open_provider_settings" + } + addCheck("tools", toolStatus, "Patrol tools", toolMessage, toolAction) + + return summarizePatrolReadiness(provider, model, checks) +} + +func summarizePatrolReadiness(provider, model string, checks []PatrolReadinessCheck) PatrolReadinessResponse { + status := patrolReadinessReady + summary := "Patrol is ready to run tool-backed verification." + for _, check := range checks { + if check.Status == patrolReadinessNotReady { + status = patrolReadinessNotReady + summary = check.Message + break + } + if check.Status == patrolReadinessWarning && status == patrolReadinessReady { + status = patrolReadinessWarning + summary = check.Message + } + } + + return PatrolReadinessResponse{ + Status: status, + Ready: status != patrolReadinessNotReady, + Summary: summary, + Provider: provider, + Model: model, + Checks: checks, + } +} + +func ptrToPatrolReadiness(readiness PatrolReadinessResponse) *PatrolReadinessResponse { + return &readiness +} + +func patrolToolReadinessForModel(provider, model string) (string, string) { + normalizedModel := strings.ToLower(strings.TrimSpace(model)) + switch { + case strings.Contains(normalizedModel, "deepseek-r1") || + strings.Contains(normalizedModel, "/r1") || + strings.Contains(normalizedModel, ":r1") || + strings.Contains(normalizedModel, "reasoner") || + strings.Contains(normalizedModel, "qwq"): + return patrolReadinessNotReady, "The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls. Patrol needs tool calling to inspect resources and create governed findings." + case provider == config.AIProviderOpenRouter: + return patrolReadinessWarning, "OpenRouter routes vary by model and endpoint. Patrol will fail closed if the routed model rejects tools or tool_choice." + case provider == config.AIProviderOllama: + return patrolReadinessWarning, "Ollama connectivity alone does not prove tool support. Use an Ollama model that returns tool_calls for Patrol verification." + case provider == config.AIProviderDeepSeek: + return patrolReadinessWarning, "DeepSeek model capability varies by model. Patrol requires a model that supports tool calling." + default: + return patrolReadinessReady, "The selected provider path supports Patrol's tool-backed analysis contract." + } } func (h *AISettingsHandler) getPatrolService(ctx context.Context) *ai.PatrolService { @@ -4858,6 +4997,7 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http LicenseRequired: true, LicenseStatus: "none", UpgradeURL: upgradeURLForFeatureFromLicensing(featureAIAutoFixValue), + Readiness: ptrToPatrolReadiness(h.buildPatrolReadiness(r.Context(), nil, false)), } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write patrol status response (no AI service)") @@ -4877,6 +5017,7 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http Healthy: true, LicenseRequired: !hasAutoFixFeature, LicenseStatus: licenseStatus, + Readiness: ptrToPatrolReadiness(h.buildPatrolReadiness(r.Context(), aiService, false)), } if !hasAutoFixFeature { response.UpgradeURL = upgradeURLForFeatureFromLicensing(featureAIAutoFixValue) @@ -4921,6 +5062,7 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http BlockedAt: status.BlockedAt, LicenseRequired: !hasAutoFixFeature, LicenseStatus: licenseStatus, + Readiness: ptrToPatrolReadiness(h.buildPatrolReadiness(r.Context(), aiService, true)), } if !hasAutoFixFeature { response.UpgradeURL = upgradeURLForFeatureFromLicensing(featureAIAutoFixValue) diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index 574b82723..dd17ef138 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -101,6 +101,195 @@ func TestAISettingsHandler_PatrolAutonomyMonitorOnlyAllowsMonitor(t *testing.T) require.Contains(t, premiumRec.Body.String(), "limited to Monitor") } +func TestAISettingsHandler_PatrolReadinessFlagsReasoningOnlyModel(t *testing.T) { + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + + aiCfg := config.NewDefaultAIConfig() + aiCfg.Enabled = true + aiCfg.Model = "ollama:llama3" + aiCfg.PatrolModel = "ollama:deepseek-r1:7b-llama-distill-q4_K_M" + aiCfg.OllamaBaseURL = "http://127.0.0.1:11434" + require.NoError(t, persistence.SaveAIConfig(*aiCfg)) + + handler := newTestAISettingsHandler(cfg, persistence, nil) + readiness := handler.buildPatrolReadiness(context.Background(), handler.GetAIService(context.Background()), true) + + require.Equal(t, patrolReadinessNotReady, readiness.Status) + require.False(t, readiness.Ready) + require.Equal(t, "ollama", readiness.Provider) + require.Equal(t, "ollama:deepseek-r1:7b-llama-distill-q4_K_M", readiness.Model) + require.Contains(t, readiness.Summary, "reasoning-only model family") + + var toolCheck *PatrolReadinessCheck + for i := range readiness.Checks { + if readiness.Checks[i].ID == "tools" { + toolCheck = &readiness.Checks[i] + break + } + } + require.NotNil(t, toolCheck) + require.Equal(t, patrolReadinessNotReady, toolCheck.Status) +} + +func TestAISettingsHandler_PatrolReadinessBranches(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + patrolAvailable bool + configure func(*config.AIConfig) + wantStatus string + wantReady bool + wantCheckID string + wantCheck string + wantSummary string + }{ + { + name: "disabled assistant blocks patrol", + patrolAvailable: true, + wantStatus: patrolReadinessNotReady, + wantReady: false, + wantCheckID: "enabled", + wantCheck: patrolReadinessNotReady, + wantSummary: "disabled", + configure: func(aiCfg *config.AIConfig) { + aiCfg.Enabled = false + aiCfg.Model = "ollama:llama3" + aiCfg.OllamaBaseURL = "http://127.0.0.1:11434" + }, + }, + { + name: "provider configured without model blocks patrol", + patrolAvailable: true, + wantStatus: patrolReadinessNotReady, + wantReady: false, + wantCheckID: "model", + wantCheck: patrolReadinessNotReady, + wantSummary: "No concrete Patrol model", + configure: func(aiCfg *config.AIConfig) { + aiCfg.Enabled = true + aiCfg.OllamaBaseURL = "http://127.0.0.1:11434" + }, + }, + { + name: "selected model provider must be configured", + patrolAvailable: true, + wantStatus: patrolReadinessNotReady, + wantReady: false, + wantCheckID: "model", + wantCheck: patrolReadinessNotReady, + wantSummary: "provider is not configured", + configure: func(aiCfg *config.AIConfig) { + aiCfg.Enabled = true + aiCfg.Model = "anthropic:claude-3-5-sonnet-latest" + aiCfg.OllamaBaseURL = "http://127.0.0.1:11434" + }, + }, + { + name: "ollama tool support is a warning", + patrolAvailable: true, + wantStatus: patrolReadinessWarning, + wantReady: true, + wantCheckID: "tools", + wantCheck: patrolReadinessWarning, + wantSummary: "Ollama connectivity alone does not prove tool support", + configure: func(aiCfg *config.AIConfig) { + aiCfg.Enabled = true + aiCfg.Model = "ollama:llama3" + aiCfg.OllamaBaseURL = "http://127.0.0.1:11434" + }, + }, + { + name: "anthropic model is ready", + patrolAvailable: true, + wantStatus: patrolReadinessReady, + wantReady: true, + wantCheckID: "tools", + wantCheck: patrolReadinessReady, + wantSummary: "ready to run", + configure: func(aiCfg *config.AIConfig) { + aiCfg.Enabled = true + aiCfg.Model = "anthropic:claude-3-5-sonnet-latest" + aiCfg.AnthropicAPIKey = "test-key" + }, + }, + { + name: "deepseek aliases warn instead of silently ready", + patrolAvailable: true, + wantStatus: patrolReadinessWarning, + wantReady: true, + wantCheckID: "tools", + wantCheck: patrolReadinessWarning, + wantSummary: "DeepSeek model capability varies", + configure: func(aiCfg *config.AIConfig) { + aiCfg.Enabled = true + aiCfg.Model = "deepseek:deepseek-v4-flush7pro" + aiCfg.DeepSeekAPIKey = "test-key" + }, + }, + { + name: "missing patrol service blocks before settings checks", + patrolAvailable: false, + wantStatus: patrolReadinessNotReady, + wantReady: false, + wantCheckID: "service", + wantCheck: patrolReadinessNotReady, + wantSummary: "Pulse Patrol service is not available", + configure: func(aiCfg *config.AIConfig) { + aiCfg.Enabled = true + aiCfg.Model = "anthropic:claude-3-5-sonnet-latest" + aiCfg.AnthropicAPIKey = "test-key" + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + aiCfg := config.NewDefaultAIConfig() + if tt.configure != nil { + tt.configure(aiCfg) + } + require.NoError(t, persistence.SaveAIConfig(*aiCfg)) + + handler := newTestAISettingsHandler(cfg, persistence, nil) + readiness := handler.buildPatrolReadiness(context.Background(), handler.GetAIService(context.Background()), tt.patrolAvailable) + + require.Equal(t, tt.wantStatus, readiness.Status) + require.Equal(t, tt.wantReady, readiness.Ready) + require.Contains(t, readiness.Summary, tt.wantSummary) + check := requirePatrolReadinessCheck(t, readiness, tt.wantCheckID) + require.Equal(t, tt.wantCheck, check.Status) + }) + } +} + +func TestAISettingsHandler_PatrolReadinessNotReadyTakesPrecedenceOverWarnings(t *testing.T) { + readiness := summarizePatrolReadiness("ollama", "ollama:llama3", []PatrolReadinessCheck{ + {ID: "tools", Status: patrolReadinessWarning, Message: "warning message"}, + {ID: "model", Status: patrolReadinessNotReady, Message: "not ready message"}, + }) + + require.Equal(t, patrolReadinessNotReady, readiness.Status) + require.False(t, readiness.Ready) + require.Equal(t, "not ready message", readiness.Summary) +} + +func requirePatrolReadinessCheck(t *testing.T, readiness PatrolReadinessResponse, id string) PatrolReadinessCheck { + t.Helper() + for _, check := range readiness.Checks { + if check.ID == id { + return check + } + } + t.Fatalf("readiness check %q not found in %+v", id, readiness.Checks) + return PatrolReadinessCheck{} +} + func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) { t.Parallel() diff --git a/internal/api/ai_patrol_handlers_test.go b/internal/api/ai_patrol_handlers_test.go index 39832a66c..756196398 100644 --- a/internal/api/ai_patrol_handlers_test.go +++ b/internal/api/ai_patrol_handlers_test.go @@ -12,6 +12,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/ai" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) const ( @@ -64,6 +65,15 @@ func TestHandleGetPatrolStatus_NoPatrolService(t *testing.T) { if !resp.Healthy { t.Error("expected Healthy to be true when patrol not initialized (no issues)") } + if resp.Readiness == nil { + t.Fatal("expected readiness payload when patrol service is unavailable") + } + if resp.Readiness.Status != patrolReadinessNotReady { + t.Fatalf("readiness status = %q, want %q", resp.Readiness.Status, patrolReadinessNotReady) + } + if check := requirePatrolReadinessCheck(t, *resp.Readiness, "service"); check.Message != "Pulse Patrol service is not available." { + t.Fatalf("service readiness message = %q", check.Message) + } } func TestHandleGetPatrolStatus_NoAIService(t *testing.T) { @@ -101,6 +111,59 @@ func TestHandleGetPatrolStatus_NoAIService(t *testing.T) { if resp.LicenseStatus == "" { t.Error("expected LicenseStatus to be set when AI service missing") } + if resp.Readiness == nil { + t.Fatal("expected readiness payload when AI service is missing") + } + if resp.Readiness.Status != patrolReadinessNotReady { + t.Fatalf("readiness status = %q, want %q", resp.Readiness.Status, patrolReadinessNotReady) + } + if check := requirePatrolReadinessCheck(t, *resp.Readiness, "service"); check.Message != "Pulse AI runtime service is not available." { + t.Fatalf("service readiness message = %q", check.Message) + } +} + +func TestHandleGetPatrolStatus_NormalIncludesReadiness(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + aiCfg := config.NewDefaultAIConfig() + aiCfg.Enabled = true + aiCfg.Model = "ollama:llama3" + aiCfg.OllamaBaseURL = "http://127.0.0.1:11434" + if err := persistence.SaveAIConfig(*aiCfg); err != nil { + t.Fatalf("save AI config: %v", err) + } + + handler := newTestAISettingsHandler(cfg, persistence, nil) + handler.defaultAIService.SetReadState(unifiedresources.NewRegistry(nil)) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/status", nil) + rec := httptest.NewRecorder() + handler.HandleGetPatrolStatus(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp PatrolStatusResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Readiness == nil { + t.Fatal("expected readiness payload when patrol service is available") + } + if resp.Readiness.Status != patrolReadinessWarning { + t.Fatalf("readiness status = %q, want %q", resp.Readiness.Status, patrolReadinessWarning) + } + if !resp.Readiness.Ready { + t.Fatal("warning readiness should still allow Patrol to run") + } + if resp.Readiness.Provider != "ollama" || resp.Readiness.Model != "ollama:llama3" { + t.Fatalf("unexpected readiness provider/model: %+v", resp.Readiness) + } + requirePatrolReadinessCheck(t, *resp.Readiness, "tools") } func TestHandleGetPatrolFindings_MethodNotAllowed(t *testing.T) { diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 8c68f2ede..fb5f8ee60 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -9805,6 +9805,22 @@ func TestContract_PatrolStatusResponseJSONSnapshot(t *testing.T) { LicenseRequired: true, LicenseStatus: "none", UpgradeURL: "https://pulserelay.pro/upgrade?feature=ai_autofix", + Readiness: &PatrolReadinessResponse{ + Status: patrolReadinessNotReady, + Ready: false, + Summary: "The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.", + Provider: "ollama", + Model: "ollama:deepseek-r1:7b-llama-distill-q4_K_M", + Checks: []PatrolReadinessCheck{ + { + ID: "tools", + Status: patrolReadinessNotReady, + Label: "Patrol tools", + Message: "The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.", + Action: "open_provider_settings", + }, + }, + }, } payload.Summary.Critical = 1 payload.Summary.Warning = 2 @@ -9836,7 +9852,8 @@ func TestContract_PatrolStatusResponseJSONSnapshot(t *testing.T) { "license_required":true, "license_status":"none", "upgrade_url":"https://pulserelay.pro/upgrade?feature=ai_autofix", - "summary":{"critical":1,"warning":2,"watch":0,"info":4} + "summary":{"critical":1,"warning":2,"watch":0,"info":4}, + "readiness":{"status":"not_ready","ready":false,"summary":"The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.","provider":"ollama","model":"ollama:deepseek-r1:7b-llama-distill-q4_K_M","checks":[{"id":"tools","status":"not_ready","label":"Patrol tools","message":"The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.","action":"open_provider_settings"}]} }` assertJSONSnapshot(t, got, want) diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index e14057162..801c42b09 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -3627,7 +3627,7 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 183, + "line": 186, "heading_line": 106, } ],