From f38f3a5409bd05aaef8f42270047b69930ff48dc Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 6 May 2026 18:15:58 +0100 Subject: [PATCH] Persist Assistant handoff resource references --- .../v6/internal/subsystems/ai-runtime.md | 11 ++- .../v6/internal/subsystems/api-contracts.md | 9 +- internal/ai/chat/service.go | 18 +++- .../chat/service_execute_additional_test.go | 34 ++++++- internal/ai/chat/session.go | 88 +++++++++++++++++-- internal/ai/chat/session_additional_test.go | 65 ++++++++++++++ internal/api/contract_test.go | 8 +- 7 files changed, 216 insertions(+), 17 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index a667a7542..6d4ce83e0 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -181,10 +181,13 @@ runtime cost control, and shared AI transport surfaces. mutating saved user messages. 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. Proposed-fix command text must - stay out of both the persisted chat message and the model-only handoff - context, and command payloads remain approval-context data, not - conversational copy. + and explicit-access checks stay governed. Structured handoff resource + references may persist as session model-context metadata for follow-up turns, + but they remain references only; each turn must rehydrate them from the + current canonical unified-resource model before action validation can use + them. Proposed-fix command text must stay out of both the persisted chat + message and the model-only handoff context, and command payloads remain + approval-context data, not conversational copy. The Assistant drawer may also render an attached context briefing for that handoff, but the briefing is runtime context visibility only: it must not mutate chat control settings, execute tools, or reveal raw command payloads. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index f3b887798..b8627952b 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -753,9 +753,12 @@ the canonical monitored-system blocked payload. follow-up turns. The backend may also carry structured handoff resource references from the same finding into chat execution, but those references must hydrate through canonical unified-resource registration before they - affect session-scoped action validation. Frontend handoff briefings must - derive from the same shared investigation payload rather than inventing a - second finding-context transport shape. + affect session-scoped action validation. If stored for follow-up turns, they + remain model-context references rather than saved user text or action + authority, and the runtime must re-resolve them against current canonical + resources before use. Frontend handoff briefings must derive from the same + shared investigation payload rather than inventing a second finding-context + transport shape. 7. Keep Patrol summary payload consumers aligned on one assessment hierarchy: transport-driven Patrol summary surfaces may show supporting counts and outcomes, but the canonical assessment and verification states must remain singular and not be repeated as a second compact verdict strip 8. Keep Patrol verification and activity facts unified on one transport-backed secondary status area: when frontend consumers combine Patrol status payloads (`runtime_state`, `last_patrol_at`, `last_activity_at`, `trigger_status`) with run-history transport, the latest run result, activity mix, scoped-trigger state, and circuit-breaker context must read as one supporting explanation beneath the primary assessment instead of being re-expanded into a separate full-width status strip plus duplicate summary layers and the main Patrol page composition boundary, so once that governed diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index 90876cdc6..6d907d456 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -449,11 +449,27 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac log.Debug().Str("session_id", session.ID).Msg("[ChatService] Session ensured") handoffContext := strings.TrimSpace(req.HandoffContext) + handoffResources := normalizeHandoffResources(req.HandoffResources) if handoffContext != "" { if err := sessions.SetModelHandoffContext(session.ID, handoffContext); err != nil { log.Warn().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to persist model handoff context") } + if err := sessions.SetModelHandoffResources(session.ID, handoffResources); err != nil { + log.Warn().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to persist model handoff resources") + } } else { + if len(handoffResources) > 0 { + if err := sessions.SetModelHandoffResources(session.ID, handoffResources); err != nil { + log.Warn().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to persist model handoff resources") + } + } else { + storedHandoffResources, err := sessions.GetModelHandoffResources(session.ID) + if err != nil { + log.Warn().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to load model handoff resources") + } else { + handoffResources = storedHandoffResources + } + } storedHandoffContext, err := sessions.GetModelHandoffContext(session.ID) if err != nil { log.Warn().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to load model handoff context") @@ -507,7 +523,7 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac executor = baseExecutor.Clone() executor.SetControlLevel(effectiveControlLevel) } - s.hydrateHandoffResources(session.ID, req.HandoffResources, sessions, unifiedResourceProvider) + s.hydrateHandoffResources(session.ID, handoffResources, sessions, unifiedResourceProvider) // Per-request autonomous mode override (used by investigation to avoid // mutating shared service state from concurrent goroutines). diff --git a/internal/ai/chat/service_execute_additional_test.go b/internal/ai/chat/service_execute_additional_test.go index 7b047e9c2..f3f8f424d 100644 --- a/internal/ai/chat/service_execute_additional_test.go +++ b/internal/ai/chat/service_execute_additional_test.go @@ -292,8 +292,9 @@ func TestService_ExecuteStream_HandoffResourceHydratesResolvedContext(t *testing } req := ExecuteRequest{ - SessionID: "sess-handoff-resource", - Prompt: "What should I do next?", + SessionID: "sess-handoff-resource", + Prompt: "What should I do next?", + HandoffContext: "[Finding Context]\nID: finding-123", HandoffResources: []HandoffResource{{ ID: vmResource.ID, Name: "web-server", @@ -316,6 +317,35 @@ func TestService_ExecuteStream_HandoffResourceHydratesResolvedContext(t *testing if _, err := resolved.ValidateResourceForAction(info.GetResourceID(), "restart"); err != nil { t.Fatalf("expected handoff VM to allow governed restart action: %v", err) } + + reloadedStore, err := NewSessionStore(tmpDir) + if err != nil { + t.Fatalf("failed to reload session store: %v", err) + } + reloadedSvc := &Service{ + cfg: &config.AIConfig{ChatModel: "openai:test"}, + sessions: reloadedStore, + executor: executor, + agenticLoop: loop, + provider: provider, + unifiedResourceProvider: unifiedProvider, + started: true, + } + followUpReq := ExecuteRequest{ + SessionID: "sess-handoff-resource", + Prompt: "Can you restart it?", + } + if err := reloadedSvc.ExecuteStream(context.Background(), followUpReq, func(StreamEvent) {}); err != nil { + t.Fatalf("follow-up ExecuteStream failed: %v", err) + } + reloadedResolved := reloadedStore.GetResolvedContext("sess-handoff-resource") + reloadedInfo, found := reloadedResolved.GetResolvedResourceByAlias("web-server") + if !found { + t.Fatalf("expected stored handoff resource to rehydrate by alias after session reload") + } + if _, err := reloadedResolved.ValidateResourceForAction(reloadedInfo.GetResourceID(), "restart"); err != nil { + t.Fatalf("expected rehydrated handoff VM to allow governed restart action: %v", err) + } } func latestProviderUserContent(t *testing.T, messages []providers.Message) string { diff --git a/internal/ai/chat/session.go b/internal/ai/chat/session.go index 6d07cae8b..b2a08f106 100644 --- a/internal/ai/chat/session.go +++ b/internal/ai/chat/session.go @@ -51,8 +51,37 @@ type sessionData struct { } type sessionModelContext struct { - HandoffContext string `json:"handoff_context,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + HandoffContext string `json:"handoff_context,omitempty"` + HandoffResources []HandoffResource `json:"handoff_resources,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +func normalizeHandoffResources(resources []HandoffResource) []HandoffResource { + if len(resources) == 0 { + return nil + } + + normalized := make([]HandoffResource, 0, len(resources)) + seen := make(map[string]struct{}, len(resources)) + for _, resource := range resources { + resource.ID = strings.TrimSpace(resource.ID) + resource.Name = strings.TrimSpace(resource.Name) + resource.Type = strings.TrimSpace(resource.Type) + resource.Node = strings.TrimSpace(resource.Node) + if resource.ID == "" && resource.Name == "" { + continue + } + key := strings.ToLower(resource.Type + "\x00" + resource.ID + "\x00" + resource.Name + "\x00" + resource.Node) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, resource) + } + if len(normalized) == 0 { + return nil + } + return normalized } const maxSessionIDLength = 128 @@ -320,9 +349,38 @@ func (s *SessionStore) SetModelHandoffContext(id, handoffContext string) error { } now := time.Now() - data.ModelContext = &sessionModelContext{ - HandoffContext: handoffContext, - UpdatedAt: now, + if data.ModelContext == nil { + data.ModelContext = &sessionModelContext{} + } + data.ModelContext.HandoffContext = handoffContext + data.ModelContext.UpdatedAt = now + data.UpdatedAt = now + + return s.writeSession(*data) +} + +// SetModelHandoffResources stores product-originated resource references for +// future turns. These references are not authority by themselves; chat execution +// re-resolves them through the canonical unified-resource model before use. +func (s *SessionStore) SetModelHandoffResources(id string, handoffResources []HandoffResource) error { + resources := normalizeHandoffResources(handoffResources) + + s.mu.Lock() + defer s.mu.Unlock() + + data, err := s.readSession(id) + if err != nil { + return err + } + + now := time.Now() + if data.ModelContext == nil { + data.ModelContext = &sessionModelContext{} + } + data.ModelContext.HandoffResources = resources + data.ModelContext.UpdatedAt = now + if strings.TrimSpace(data.ModelContext.HandoffContext) == "" && len(data.ModelContext.HandoffResources) == 0 { + data.ModelContext = nil } data.UpdatedAt = now @@ -344,12 +402,30 @@ func (s *SessionStore) GetModelHandoffContext(id string) (string, error) { return strings.TrimSpace(data.ModelContext.HandoffContext), nil } +// GetModelHandoffResources returns stored handoff resource references for a +// session. Callers must rehydrate them through canonical resource registration +// before using them for action validation. +func (s *SessionStore) GetModelHandoffResources(id string) ([]HandoffResource, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + data, err := s.readSession(id) + if err != nil { + return nil, err + } + if data.ModelContext == nil { + return nil, nil + } + return normalizeHandoffResources(data.ModelContext.HandoffResources), nil +} + func (s *SessionStore) clearModelHandoffContextLocked(id string) error { data, err := s.readSession(id) if err != nil { return err } - if data.ModelContext == nil || strings.TrimSpace(data.ModelContext.HandoffContext) == "" { + if data.ModelContext == nil || + (strings.TrimSpace(data.ModelContext.HandoffContext) == "" && len(normalizeHandoffResources(data.ModelContext.HandoffResources)) == 0) { return nil } diff --git a/internal/ai/chat/session_additional_test.go b/internal/ai/chat/session_additional_test.go index 5f2c109b3..d5d55f6b8 100644 --- a/internal/ai/chat/session_additional_test.go +++ b/internal/ai/chat/session_additional_test.go @@ -72,6 +72,57 @@ func TestSessionStore_ModelHandoffContextLifecycle(t *testing.T) { t.Fatalf("handoff context = %q, want trimmed %q", got, strings.TrimSpace(handoffContext)) } + handoffResources := []HandoffResource{ + {ID: " vm-100 ", Name: " web-server ", Type: " vm ", Node: " pve-1 "}, + {ID: "vm-100", Name: "web-server", Type: "vm", Node: "pve-1"}, + } + if err := store.SetModelHandoffResources(session.ID, handoffResources); err != nil { + t.Fatalf("SetModelHandoffResources failed: %v", err) + } + gotResources, err := store.GetModelHandoffResources(session.ID) + if err != nil { + t.Fatalf("GetModelHandoffResources failed: %v", err) + } + if len(gotResources) != 1 { + t.Fatalf("handoff resources = %#v, want one normalized resource", gotResources) + } + if gotResources[0] != (HandoffResource{ID: "vm-100", Name: "web-server", Type: "vm", Node: "pve-1"}) { + t.Fatalf("handoff resource = %#v, want normalized VM", gotResources[0]) + } + + reloadedStore, err := NewSessionStore(filepath.Dir(store.dataDir)) + if err != nil { + t.Fatalf("failed to reload session store: %v", err) + } + reloadedResources, err := reloadedStore.GetModelHandoffResources(session.ID) + if err != nil { + t.Fatalf("GetModelHandoffResources after reload failed: %v", err) + } + if len(reloadedResources) != 1 || reloadedResources[0].ID != "vm-100" { + t.Fatalf("reloaded handoff resources = %#v, want persisted VM reference", reloadedResources) + } + + if err := store.SetModelHandoffResources(session.ID, nil); err != nil { + t.Fatalf("SetModelHandoffResources clear failed: %v", err) + } + gotResources, err = store.GetModelHandoffResources(session.ID) + if err != nil { + t.Fatalf("GetModelHandoffResources after resource clear failed: %v", err) + } + if len(gotResources) != 0 { + t.Fatalf("handoff resources after resource clear = %#v, want empty", gotResources) + } + got, err = store.GetModelHandoffContext(session.ID) + if err != nil { + t.Fatalf("GetModelHandoffContext after resource clear failed: %v", err) + } + if got != strings.TrimSpace(handoffContext) { + t.Fatalf("handoff context after resource clear = %q, want retained context", got) + } + if err := store.SetModelHandoffResources(session.ID, handoffResources); err != nil { + t.Fatalf("SetModelHandoffResources restore failed: %v", err) + } + if err := store.AddMessage(session.ID, Message{Role: "user", Content: "What happened?"}); err != nil { t.Fatalf("AddMessage failed: %v", err) } @@ -91,6 +142,13 @@ func TestSessionStore_ModelHandoffContextLifecycle(t *testing.T) { if got == "" { t.Fatalf("expected keep-pinned context clear to retain model handoff") } + gotResources, err = store.GetModelHandoffResources(session.ID) + if err != nil { + t.Fatalf("GetModelHandoffResources after keep-pinned clear failed: %v", err) + } + if len(gotResources) != 1 { + t.Fatalf("expected keep-pinned context clear to retain handoff resources, got %#v", gotResources) + } store.ClearSessionState(session.ID, false) got, err = store.GetModelHandoffContext(session.ID) @@ -100,6 +158,13 @@ func TestSessionStore_ModelHandoffContextLifecycle(t *testing.T) { if got != "" { t.Fatalf("handoff context after full clear = %q, want empty", got) } + gotResources, err = store.GetModelHandoffResources(session.ID) + if err != nil { + t.Fatalf("GetModelHandoffResources after full clear failed: %v", err) + } + if len(gotResources) != 0 { + t.Fatalf("handoff resources after full clear = %#v, want empty", gotResources) + } } func TestSessionStore_ResolvedContextLifecycle(t *testing.T) { diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index a2538c215..9c9c356bc 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -157,9 +157,12 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { chatServiceText := string(chatServiceSource) for _, required := range []string{ "handoffContext := strings.TrimSpace(req.HandoffContext)", + "handoffResources := normalizeHandoffResources(req.HandoffResources)", "sessions.SetModelHandoffContext(session.ID, handoffContext)", "sessions.GetModelHandoffContext(session.ID)", - "s.hydrateHandoffResources(session.ID, req.HandoffResources, sessions, unifiedResourceProvider)", + "sessions.SetModelHandoffResources(session.ID, handoffResources)", + "sessions.GetModelHandoffResources(session.ID)", + "s.hydrateHandoffResources(session.ID, handoffResources, sessions, unifiedResourceProvider)", "injectHandoffContextIntoLatestUserMessage(messages, handoffContext)", "User message: ", } { @@ -173,6 +176,9 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { "ModelContext *sessionModelContext", "SetModelHandoffContext", "GetModelHandoffContext", + "SetModelHandoffResources", + "GetModelHandoffResources", + "HandoffResources []HandoffResource", } { if !strings.Contains(chatSessionText, required) { t.Fatalf("chat session store must persist model-only handoff metadata outside messages: missing %q", required)