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,
}
],