diff --git a/docs/API.md b/docs/API.md index b03881a1f..b34815471 100644 --- a/docs/API.md +++ b/docs/API.md @@ -938,7 +938,7 @@ Request bodies: - `GET /api/ai/intelligence/patterns` - `GET /api/ai/intelligence/predictions` - `GET /api/ai/intelligence/correlations` -- `GET /api/ai/intelligence/changes` +- `GET /api/ai/intelligence/changes` (canonical unified-resource timeline first, patrol-local memory fallback) - `GET /api/ai/intelligence/baselines` - `GET /api/ai/intelligence/remediations` - `GET /api/ai/intelligence/anomalies` diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 122fb1774..429f9de6e 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -167,6 +167,10 @@ stay on canonical Patrol runtime wiring: adjacent fleet and install surfaces must not revive tenant snapshot-provider bridges through shared AI handler setup once Patrol can initialize from tenant `ReadState` and unified-resource providers directly. +That same boundary now also assumes the Patrol-backed recent-changes API +surface reads through the canonical intelligence facade first, so adjacent +fleet and install surfaces do not bypass the shared unified timeline through +the old detector-only handler path. That same canonical /api/auto-register response must stay on one completion truth: caller-supplied Proxmox credentials complete registration with a direct-use action, and the runtime no longer preserves a dead pending-secret diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index aa01502a7..74b2ac9eb 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -266,6 +266,10 @@ The shared AI resource and infrastructure prompt contexts should also surface the same canonical recent changes section before any patrol-local fallback so the model sees the same timeline entries that power the resource API and intelligence summary counts. +The `/api/ai/intelligence/changes` endpoint should also route through the +canonical unified-intelligence recent-change accessor before any +patrol-local detector fallback, so the API surface reads the same unified +timeline source that powers the summary payload. Those backend AI and Patrol change summaries should derive their canonical labels and provenance fragments from `internal/unifiedresources/change_presentation.go`, so the resource-model diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 0f7147da9..07c6493aa 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -78,6 +78,7 @@ Own canonical runtime payload shapes between backend and frontend. 7. Route canonical AI intelligence summary and resource-intelligence reads through `frontend-modern/src/api/ai.ts`, `frontend-modern/src/stores/aiIntelligence.ts`, `frontend-modern/src/pages/AIIntelligence.tsx`, `internal/api/ai_handlers.go`, and `internal/api/contract_test.go` together so the summary card, store state, and backend payload stay aligned on one governed surface, including the canonical recent-changes slice and the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` card, so canonical recent-change timelines stay rendered through one governed frontend card instead of separate page-local list loops and the shared `frontend-modern/src/utils/resourceChangePresentation.ts` formatter used by the summary page and resource drawer, so canonical change wording does not drift across surfaces + and the `/api/ai/intelligence/changes` route plus `internal/api/contract_test.go`, so the canonical recent-changes endpoint stays on the same intelligence facade and contract snapshot instead of bypassing the shared timeline source and the canonical policy-posture snapshot derived from unified resources, so sensitivity, routing, and redaction counts stay owned by the same AI summary contract instead of being reconstructed as a page-local governance rollup and the resource-intelligence payload carried by the drawer AI card, so the governed posture snapshot remains visible on the resource-detail surface without introducing a separate posture endpoint and the learned-correlation payload loaded into the shared AI intelligence store, so the Patrol intelligence page and the AI summary page consume the same governed correlation slice instead of each page fetching its own copy diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 49c0fc3d0..1d2196607 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -77,6 +77,10 @@ that powers the shared resource API. Patrol-owned intelligence summaries should keep their recent-change counts backed by the same canonical timeline when available instead of only counting Patrol-local detector history. +The Patrol-backed `/api/ai/intelligence/changes` endpoint should also read +through the canonical intelligence facade first and only fall back to the +local detector when the unified timeline is unavailable, so the API payload +stays aligned with the same governed recent-change source. Patrol-owned resource and global intelligence prompt contexts should also render the canonical recent changes section before any patrol-local change detector fallback so the prompt surface stays aligned with the shared diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index e8620bdf1..77418926f 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -109,6 +109,10 @@ handlers stay on canonical Patrol runtime wiring: recovery- and storage-adjacent API helpers must not revive tenant snapshot-provider bridges through `internal/api/ai_handlers.go` once Patrol can initialize from tenant `ReadState` and unified-resource providers directly. +That same shared dependency also assumes the Patrol-backed recent-changes +API surface reads through the canonical intelligence facade first, so +storage and recovery handlers do not bypass the shared unified timeline +through the older detector-only path. The same shared API runtime also exposes unified-resource action, lifecycle, and export audit reads, but storage and recovery must continue to treat that as adjacent governed API ownership rather than timeline-store ownership. The diff --git a/internal/ai/intelligence.go b/internal/ai/intelligence.go index 747ae2c69..8797771c5 100644 --- a/internal/ai/intelligence.go +++ b/internal/ai/intelligence.go @@ -238,8 +238,6 @@ func (i *Intelligence) GetSummary() *IntelligenceSummary { findings := i.findings patternsDetector := i.patterns remediations := i.remediations - changes := i.changes - resourceTimelineStore := i.resourceTimelineStore unifiedResourceProvider := i.unifiedResourceProvider i.mu.RUnlock() @@ -258,26 +256,9 @@ func (i *Intelligence) GetSummary() *IntelligenceSummary { } // Aggregate recent activity - usedCanonicalRecentChanges := false - if resourceTimelineStore != nil { - if recent, err := resourceTimelineStore.GetRecentChanges("", time.Now().Add(-24*time.Hour), 100); err == nil { - summary.RecentChangesCount = len(recent) - if len(recent) > 0 { - summary.RecentChanges = append([]unifiedresources.ResourceChange{}, recent[:min(len(recent), 5)]...) - } - usedCanonicalRecentChanges = true - } - } - if !usedCanonicalRecentChanges && changes != nil { - recent := changes.GetRecentChanges(100, time.Now().Add(-24*time.Hour)) + if recent := i.GetRecentChanges(time.Now().Add(-24*time.Hour), 100); len(recent) > 0 { summary.RecentChangesCount = len(recent) - if len(recent) > 0 { - converted := make([]unifiedresources.ResourceChange, 0, len(recent)) - for _, change := range recent { - converted = append(converted, memory.ResourceChangeFromMemoryChange(change)) - } - summary.RecentChanges = append([]unifiedresources.ResourceChange{}, converted[:min(len(converted), 5)]...) - } + summary.RecentChanges = append([]unifiedresources.ResourceChange{}, recent[:min(len(recent), 5)]...) } if remediations != nil { @@ -387,6 +368,73 @@ func (i *Intelligence) GetResourceIntelligence(resourceID string) *ResourceIntel return intel } +// HasRecentChangesSource reports whether the intelligence layer has any source +// available for recent infrastructure changes. +func (i *Intelligence) HasRecentChangesSource() bool { + i.mu.RLock() + defer i.mu.RUnlock() + return i.resourceTimelineStore != nil || i.changes != nil +} + +// DescribeResource returns the canonical display name and type for a resource +// when the unified resource provider is available. +func (i *Intelligence) DescribeResource(resourceID string) (string, string) { + resourceID = strings.TrimSpace(resourceID) + if resourceID == "" { + return "", "" + } + + i.mu.RLock() + unifiedResourceProvider := i.unifiedResourceProvider + i.mu.RUnlock() + if unifiedResourceProvider == nil { + return "", "" + } + + for _, resource := range normalizeUnifiedResourceContextSlice(unifiedResourceProvider.GetAll()) { + if strings.TrimSpace(resource.ID) != resourceID { + continue + } + return unifiedresources.ResourceDisplayName(resource), string(resource.Type) + } + + return "", "" +} + +// GetRecentChanges returns the most recent infrastructure changes across the +// canonical unified-resource timeline, with patrol-local memory as fallback. +func (i *Intelligence) GetRecentChanges(since time.Time, limit int) []unifiedresources.ResourceChange { + if limit <= 0 { + return nil + } + + i.mu.RLock() + resourceTimelineStore := i.resourceTimelineStore + changesDetector := i.changes + i.mu.RUnlock() + + if resourceTimelineStore != nil { + if recent, err := resourceTimelineStore.GetRecentChanges("", since, limit); err == nil && len(recent) > 0 { + return recent + } + } + + if changesDetector == nil { + return nil + } + + recent := changesDetector.GetRecentChanges(limit, since) + if len(recent) == 0 { + return nil + } + + converted := make([]unifiedresources.ResourceChange, 0, len(recent)) + for _, change := range recent { + converted = append(converted, memory.ResourceChangeFromMemoryChange(change)) + } + return converted +} + // FormatContext builds a comprehensive context string for AI prompts func (i *Intelligence) FormatContext(resourceID string) string { i.mu.RLock() diff --git a/internal/ai/intelligence_test.go b/internal/ai/intelligence_test.go index 00b163500..7dabeff4c 100644 --- a/internal/ai/intelligence_test.go +++ b/internal/ai/intelligence_test.go @@ -345,6 +345,73 @@ func TestIntelligence_GetResourceIntelligence_FallsBackToChangeDetector(t *testi } } +func TestIntelligence_GetRecentChanges_UsesCanonicalTimeline(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + canonicalStore := ur.NewMemoryStore() + if err := canonicalStore.RecordChange(ur.ResourceChange{ + ID: "change-1", + ObservedAt: time.Now().Add(-time.Minute), + ResourceID: "vm-1", + Kind: ur.ChangeRestart, + SourceType: ur.SourcePlatformEvent, + Reason: "guest restarted", + }); err != nil { + t.Fatalf("record canonical change: %v", err) + } + intel.SetResourceTimelineStore(canonicalStore, "org-1") + + recent := intel.GetRecentChanges(time.Now().Add(-time.Hour), 100) + if len(recent) != 1 { + t.Fatalf("expected 1 canonical recent change, got %d", len(recent)) + } + if recent[0].Kind != ur.ChangeRestart { + t.Fatalf("expected canonical change kind %q, got %q", ur.ChangeRestart, recent[0].Kind) + } + if recent[0].Reason != "guest restarted" { + t.Fatalf("expected canonical reason, got %#v", recent[0].Reason) + } +} + +func TestIntelligence_GetRecentChanges_FallsBackToMemoryDetector(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10}) + changes.DetectChanges([]memory.ResourceSnapshot{ + {ID: "vm-fallback", Name: "resource-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()}, + }) + intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil) + + recent := intel.GetRecentChanges(time.Now().Add(-time.Hour), 100) + if len(recent) != 1 { + t.Fatalf("expected 1 fallback recent change, got %d", len(recent)) + } + if recent[0].SourceType != ur.SourceHeuristic { + t.Fatalf("expected heuristic fallback source, got %s", recent[0].SourceType) + } +} + +func TestIntelligence_DescribeResource_UsesUnifiedProvider(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + intel.SetUnifiedResourceProvider(&mockUnifiedResourceProvider{ + getAllFunc: func() []ur.Resource { + return []ur.Resource{ + { + ID: "vm-1", + Name: "canonical-vm", + Type: ur.ResourceTypeVM, + }, + } + }, + }) + + name, resourceType := intel.DescribeResource("vm-1") + if name != "canonical-vm" { + t.Fatalf("expected canonical display name, got %q", name) + } + if resourceType != string(ur.ResourceTypeVM) { + t.Fatalf("expected resource type vm, got %q", resourceType) + } +} + func TestIntelligence_FormatContext_WithKnowledge(t *testing.T) { intel := NewIntelligence(IntelligenceConfig{}) diff --git a/internal/api/ai_handlers_intelligence_additional_test.go b/internal/api/ai_handlers_intelligence_additional_test.go index 46e39a2c9..bd413574a 100644 --- a/internal/api/ai_handlers_intelligence_additional_test.go +++ b/internal/api/ai_handlers_intelligence_additional_test.go @@ -171,6 +171,27 @@ func TestHandleGetIntelligence_WithResourceID(t *testing.T) { } } +func TestHandleGetRecentChanges_NoSource(t *testing.T) { + t.Parallel() + handler := &AISettingsHandler{defaultAIService: newEnabledAIService(t)} + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/changes", nil) + rec := httptest.NewRecorder() + + handler.HandleGetRecentChanges(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["message"] != "Recent changes not initialized" { + t.Fatalf("unexpected message: %#v", payload["message"]) + } +} + func TestHandleGetIntelligence_ResourceIDAtLimit(t *testing.T) { t.Parallel() svc := newEnabledAIService(t) diff --git a/internal/api/ai_intelligence_handlers.go b/internal/api/ai_intelligence_handlers.go index 2c227f13b..e4f6560e9 100644 --- a/internal/api/ai_intelligence_handlers.go +++ b/internal/api/ai_intelligence_handlers.go @@ -375,11 +375,11 @@ func (h *AISettingsHandler) HandleGetRecentChanges(w http.ResponseWriter, r *htt return } - detector := patrol.GetChangeDetector() - if detector == nil { + intel := patrol.GetIntelligence() + if intel == nil || !intel.HasRecentChangesSource() { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "changes": []interface{}{}, - "message": "Change detector not initialized", + "message": "Recent changes not initialized", }); err != nil { log.Error().Err(err).Msg("Failed to write changes response") } @@ -396,20 +396,24 @@ func (h *AISettingsHandler) HandleGetRecentChanges(w http.ResponseWriter, r *htt } since := time.Now().Add(-time.Duration(hours) * time.Hour) - changes := detector.GetRecentChanges(100, since) + changes := intel.GetRecentChanges(since, 100) var result []map[string]interface{} for _, change := range changes { + resourceName, resourceType := intel.DescribeResource(change.ResourceID) + if strings.TrimSpace(resourceName) == "" { + resourceName = change.ResourceID + } result = append(result, map[string]interface{}{ "id": change.ID, "resource_id": change.ResourceID, - "resource_name": change.ResourceName, - "resource_type": change.ResourceType, - "change_type": change.ChangeType, - "before": change.Before, - "after": change.After, - "detected_at": change.DetectedAt, - "description": change.Description, + "resource_name": resourceName, + "resource_type": resourceType, + "change_type": string(change.Kind), + "before": change.From, + "after": change.To, + "detected_at": change.ObservedAt, + "description": unifiedresources.FormatResourceChangeSummary(change), }) } diff --git a/internal/api/ai_intelligence_handlers_test.go b/internal/api/ai_intelligence_handlers_test.go index b8dc0483e..f93199175 100644 --- a/internal/api/ai_intelligence_handlers_test.go +++ b/internal/api/ai_intelligence_handlers_test.go @@ -4,9 +4,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" + "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) // createTestAIHandler creates a test AI handler with minimal setup @@ -274,6 +277,75 @@ func TestHandleGetRecentChanges_NoPatrolService(t *testing.T) { } } +func TestHandleGetRecentChanges_WithCanonicalTimeline(t *testing.T) { + t.Parallel() + svc := newEnabledAIService(t) + canonicalStore := unifiedresources.NewMemoryStore() + if err := canonicalStore.RecordChange(unifiedresources.ResourceChange{ + ID: "change-canonical", + ObservedAt: time.Now().Add(-25 * time.Minute), + ResourceID: "vm-canonical", + Kind: unifiedresources.ChangeRestart, + From: "running", + To: "restarting", + SourceType: unifiedresources.SourcePlatformEvent, + SourceAdapter: unifiedresources.AdapterProxmox, + Reason: "guest restarted after maintenance", + }); err != nil { + t.Fatalf("record canonical change: %v", err) + } + setUnexportedField(t, svc, "resourceExportStore", canonicalStore) + patrol := svc.GetPatrolService() + setUnexportedField(t, patrol, "aiService", svc) + patrol.SetUnifiedResourceProvider(stubUnifiedResourceProvider{ + resources: []unifiedresources.Resource{ + { + ID: "vm-canonical", + Name: "canonical-vm", + Type: unifiedresources.ResourceTypeVM, + }, + }, + }) + handler := &AISettingsHandler{defaultAIService: svc} + + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/changes?hours=1", nil) + rec := httptest.NewRecorder() + + handler.HandleGetRecentChanges(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + changes, ok := payload["changes"].([]interface{}) + if !ok { + t.Fatalf("expected changes array in response, got %T", payload["changes"]) + } + if len(changes) != 1 { + t.Fatalf("expected 1 recent change, got %d", len(changes)) + } + change, ok := changes[0].(map[string]interface{}) + if !ok { + t.Fatalf("expected object change, got %#v", changes[0]) + } + if change["resource_name"] != "canonical-vm" { + t.Fatalf("expected canonical resource name, got %#v", change["resource_name"]) + } + if change["resource_type"] != string(unifiedresources.ResourceTypeVM) { + t.Fatalf("expected resource type vm, got %#v", change["resource_type"]) + } + if change["change_type"] != string(unifiedresources.ChangeRestart) { + t.Fatalf("expected canonical change type, got %#v", change["change_type"]) + } + if desc, ok := change["description"].(string); !ok || !strings.Contains(desc, "Restart") { + t.Fatalf("expected canonical change description, got %#v", change["description"]) + } +} + // TestHandleGetRemediations tests the remediations endpoint func TestHandleGetRemediations_MethodNotAllowed(t *testing.T) { t.Parallel() diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index e9c867dd2..3626d10a2 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -460,6 +460,73 @@ func TestContract_IntelligenceSummaryIncludesRecentChanges(t *testing.T) { } } +func TestContract_RecentChangesEndpointUsesCanonicalTimeline(t *testing.T) { + svc := newEnabledAIService(t) + canonicalStore := unifiedresources.NewMemoryStore() + if err := canonicalStore.RecordChange(unifiedresources.ResourceChange{ + ID: "change-canonical", + ObservedAt: time.Now().Add(-25 * time.Minute), + ResourceID: "vm-canonical", + Kind: unifiedresources.ChangeRestart, + From: "running", + To: "restarting", + SourceType: unifiedresources.SourcePlatformEvent, + SourceAdapter: unifiedresources.AdapterProxmox, + Reason: "guest restarted after maintenance", + }); err != nil { + t.Fatalf("record canonical change: %v", err) + } + svc.SetUnifiedResourceProvider(&stubUnifiedResourceProvider{ + resources: []unifiedresources.Resource{ + { + ID: "vm-canonical", + Name: "canonical-vm", + Type: unifiedresources.ResourceTypeVM, + }, + }, + }) + setUnexportedField(t, svc, "resourceExportStore", canonicalStore) + setUnexportedField(t, svc.GetPatrolService(), "aiService", svc) + + handlers := &AISettingsHandler{defaultAIService: svc} + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/changes?hours=1", nil) + rec := httptest.NewRecorder() + + handlers.HandleGetRecentChanges(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + + var payload map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + changes, ok := payload["changes"].([]interface{}) + if !ok { + t.Fatalf("expected changes array in response, got %T", payload["changes"]) + } + if len(changes) != 1 { + t.Fatalf("expected 1 recent change, got %d", len(changes)) + } + change, ok := changes[0].(map[string]interface{}) + if !ok { + t.Fatalf("expected object change, got %#v", changes[0]) + } + if change["resource_name"] != "canonical-vm" { + t.Fatalf("expected canonical resource name, got %#v", change["resource_name"]) + } + if change["resource_type"] != string(unifiedresources.ResourceTypeVM) { + t.Fatalf("expected resource type vm, got %#v", change["resource_type"]) + } + if change["change_type"] != string(unifiedresources.ChangeRestart) { + t.Fatalf("expected canonical change type, got %#v", change["change_type"]) + } + if desc, ok := change["description"].(string); !ok || !strings.Contains(desc, "Restart") { + t.Fatalf("expected canonical change description, got %#v", change["description"]) + } +} + func TestContract_ResolveAuthEnvPathUsesCanonicalRuntimeDataDir(t *testing.T) { envDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", envDir) diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 850b447c7..52b15b653 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -596,6 +596,9 @@ func TestIntelligenceRecentChangesUseCanonicalSummaryFormatter(t *testing.T) { } source := string(data) requiredSnippets := []string{ + "func (i *Intelligence) GetRecentChanges(since time.Time, limit int) []unifiedresources.ResourceChange", + "func (i *Intelligence) DescribeResource(resourceID string) (string, string)", + "func (i *Intelligence) HasRecentChangesSource() bool", "unifiedresources.FormatResourceRecentChangesContext(recent, includeResourcePrefix, \"##\")", } for _, snippet := range requiredSnippets { @@ -633,6 +636,27 @@ func TestMemoryChangeConversionHelpersAreSharedAcrossAIConsumers(t *testing.T) { } } +func TestAIRecentChangesHandlerUsesCanonicalIntelligencePath(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "api", "ai_intelligence_handlers.go")) + if err != nil { + t.Fatalf("failed to read ai_intelligence_handlers.go: %v", err) + } + source := string(data) + requiredSnippets := []string{ + "intel := patrol.GetIntelligence()", + "intel.HasRecentChangesSource()", + "intel.GetRecentChanges(since, 100)", + "intel.DescribeResource(change.ResourceID)", + "unifiedresources.FormatResourceChangeSummary(change)", + "Recent changes not initialized", + } + for _, snippet := range requiredSnippets { + if !strings.Contains(source, snippet) { + t.Fatalf("internal/api/ai_intelligence_handlers.go must pin canonical recent-changes snippet %q", snippet) + } + } +} + func TestResourcePresentationsUseSharedDurationHelper(t *testing.T) { requiredFiles := []string{ "change_presentation.go",