From aeede4fb2f2d14bb8e95ce72f3aad5fa467e9f66 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 6 May 2026 17:59:55 +0100 Subject: [PATCH] Hydrate Assistant handoff resource scope --- .../v6/internal/subsystems/agent-lifecycle.md | 5 +- .../v6/internal/subsystems/ai-runtime.md | 10 ++- .../v6/internal/subsystems/api-contracts.md | 9 ++- .../internal/subsystems/storage-recovery.md | 6 +- internal/ai/chat/service.go | 40 ++++++++++ .../chat/service_execute_additional_test.go | 62 +++++++++++++++ internal/ai/chat/types.go | 27 +++++-- internal/ai/tools/tools_query.go | 79 +++++++++++++++++++ internal/api/ai_handler.go | 79 +++++++++++++++++-- internal/api/ai_handler_test.go | 8 ++ internal/api/contract_test.go | 30 ++++++- 11 files changed, 327 insertions(+), 28 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 17bf3cf9a..1381e3211 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -358,8 +358,9 @@ profile and assignment columns, but embedded table framing must route through agent auto-approval policy stay canonical in the agent/runtime owners, not in Patrol investigation-record prompt text. Model-only Assistant handoff context for a Patrol finding, including same-session metadata retained for - follow-up turns, is also not agent lifecycle state and must not be used as - enrollment evidence, command-websocket identity, or installer authority. + follow-up turns and any resolved-resource scope hydrated from that finding, + is also not agent lifecycle state and must not be used as enrollment + evidence, command-websocket identity, or installer authority. The same isolation rule applies to CSRF token-store behavior in `internal/api/csrf_store.go`: lifecycle-adjacent browser flows may rely on the shared API/security layer to keep parallel replacement-token retries diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 86a6b0ed3..a667a7542 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -178,9 +178,13 @@ runtime cost control, and shared AI transport surfaces. preserving the user's authored prompt as the persisted conversation message; the model-only handoff may persist as session metadata so same-session follow-up turns keep the Patrol finding context without - mutating saved user messages. 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. + 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. 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 1641b33ef..f3b887798 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -750,9 +750,12 @@ the canonical monitored-system blocked payload. out of the persisted prompt and inside governed approval/remediation context; the backend may pass that summary as model-only handoff context for the current turn and retain it as same-session model context for - follow-up turns, and frontend handoff briefings must derive from the same - shared investigation payload rather than inventing a second - finding-context transport shape. + 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. 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/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index b7ad55227..d8ea50cd2 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -413,8 +413,10 @@ bypass the API fail-closed execution gate. only as adjacent investigation context, not as a recovery support verdict or restore execution contract. If that guidance is passed as model-only Assistant handoff context instead of persisted prompt text, the boundary is - unchanged: it still cannot become backup freshness, restore eligibility, or - storage-local recovery authority. + unchanged. If the same handoff seeds Assistant resolved-resource scope, that + scope remains AI/runtime action-validation context only and still cannot + become backup freshness, restore eligibility, or storage-local recovery + authority. That same adjacent `internal/api/` boundary still carries Patrol-run execution identity. Storage and recovery may observe shared Patrol transport through `internal/api/chat_service_adapter.go`, but they must not diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index 4b5cd1180..90876cdc6 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -507,6 +507,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) // Per-request autonomous mode override (used by investigation to avoid // mutating shared service state from concurrent goroutines). @@ -799,6 +800,45 @@ func injectHandoffContextIntoLatestUserMessage(messages []Message, handoffContex } } +func (s *Service) hydrateHandoffResources(sessionID string, handoffResources []HandoffResource, sessions *SessionStore, provider tools.UnifiedResourceProvider) { + if len(handoffResources) == 0 || sessions == nil || provider == nil { + return + } + + resolvedCtx := sessions.GetResolvedContext(sessionID) + seen := make(map[string]struct{}, len(handoffResources)) + for _, resource := range handoffResources { + key := strings.ToLower(strings.TrimSpace(resource.Type) + "\x00" + strings.TrimSpace(resource.ID) + "\x00" + strings.TrimSpace(resource.Name)) + if key == "\x00\x00" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + + reg, ok := tools.CanonicalHandoffResourceRegistration(provider, resource.ID, resource.Name, resource.Type, resource.Node) + if !ok { + log.Debug(). + Str("session_id", sessionID). + Str("resource_id", resource.ID). + Str("resource_name", resource.Name). + Str("resource_type", resource.Type). + Msg("[ChatService] Skipped unresolved handoff resource") + continue + } + resolvedCtx.AddResolvedResource(reg) + if resolved, ok := resolvedCtx.GetResolvedResourceByAlias(reg.Name); ok { + resolvedCtx.MarkExplicitAccess(resolved.GetResourceID()) + log.Debug(). + Str("session_id", sessionID). + Str("resource_id", resolved.GetResourceID()). + Str("resource_name", reg.Name). + Msg("[ChatService] Hydrated handoff resource into resolved context") + } + } +} + // PatrolRequest represents a patrol execution request within the chat service type PatrolRequest struct { Prompt string `json:"prompt"` diff --git a/internal/ai/chat/service_execute_additional_test.go b/internal/ai/chat/service_execute_additional_test.go index b95300a15..7b047e9c2 100644 --- a/internal/ai/chat/service_execute_additional_test.go +++ b/internal/ai/chat/service_execute_additional_test.go @@ -256,6 +256,68 @@ func TestService_ExecuteStream_ReusesModelHandoffContextAcrossFollowUps(t *testi } } +func TestService_ExecuteStream_HandoffResourceHydratesResolvedContext(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewSessionStore(tmpDir) + if err != nil { + t.Fatalf("failed to create session store: %v", err) + } + + state := models.StateSnapshot{ + VMs: []models.VM{ + {ID: "vm:node1:101", VMID: 101, Name: "web-server", Node: "node1", Status: "running"}, + }, + } + registry := unifiedresources.NewRegistry(nil) + registry.IngestSnapshot(state) + vmResources := registry.ListByType(unifiedresources.ResourceTypeVM) + if len(vmResources) != 1 { + t.Fatalf("expected one canonical VM resource, got %d", len(vmResources)) + } + vmResource := vmResources[0] + unifiedProvider := unifiedresources.NewUnifiedAIAdapter(registry) + + executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{UnifiedResourceProvider: unifiedProvider}) + provider := &stubServiceProvider{} + loop := NewAgenticLoop(provider, executor, "system") + + svc := &Service{ + cfg: &config.AIConfig{ChatModel: "openai:test"}, + sessions: store, + executor: executor, + agenticLoop: loop, + provider: provider, + unifiedResourceProvider: unifiedProvider, + started: true, + } + + req := ExecuteRequest{ + SessionID: "sess-handoff-resource", + Prompt: "What should I do next?", + HandoffResources: []HandoffResource{{ + ID: vmResource.ID, + Name: "web-server", + Type: "vm", + Node: "node1", + }}, + } + if err := svc.ExecuteStream(context.Background(), req, func(StreamEvent) {}); err != nil { + t.Fatalf("ExecuteStream failed: %v", err) + } + + resolved := store.GetResolvedContext("sess-handoff-resource") + info, found := resolved.GetResolvedResourceByAlias("web-server") + if !found { + t.Fatalf("expected handoff resource to be registered by alias") + } + if !resolved.WasRecentlyAccessed(info.GetResourceID(), time.Minute) { + t.Fatalf("expected handoff resource to be marked as explicitly accessed") + } + if _, err := resolved.ValidateResourceForAction(info.GetResourceID(), "restart"); err != nil { + t.Fatalf("expected handoff VM to allow governed restart action: %v", err) + } +} + func latestProviderUserContent(t *testing.T, messages []providers.Message) string { t.Helper() diff --git a/internal/ai/chat/types.go b/internal/ai/chat/types.go index ffcee9d10..9f9e2fabc 100644 --- a/internal/ai/chat/types.go +++ b/internal/ai/chat/types.go @@ -90,16 +90,27 @@ type StructuredMention struct { Node string `json:"node,omitempty"` // Proxmox node or parent host } +// HandoffResource represents an explicit product-originated resource handoff. +// Unlike model-only handoff text, this is used only to seed session-scoped +// resource validation from canonical unified-resource registrations. +type HandoffResource struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Node string `json:"node,omitempty"` +} + // ExecuteRequest represents a chat execution request type ExecuteRequest struct { - Prompt string `json:"prompt"` - SessionID string `json:"session_id,omitempty"` - Model string `json:"model,omitempty"` - Mentions []StructuredMention `json:"mentions,omitempty"` - FindingID string `json:"finding_id,omitempty"` // Pre-populate finding context for "Discuss" flow - HandoffContext string `json:"handoff_context,omitempty"` // Model-only context for scoped handoffs; not persisted as user-authored text. - MaxTurns int `json:"max_turns,omitempty"` // Override max agentic turns (0 = use default) - AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Per-request autonomous override (nil = use service default) + Prompt string `json:"prompt"` + SessionID string `json:"session_id,omitempty"` + Model string `json:"model,omitempty"` + Mentions []StructuredMention `json:"mentions,omitempty"` + FindingID string `json:"finding_id,omitempty"` // Pre-populate finding context for "Discuss" flow + HandoffContext string `json:"handoff_context,omitempty"` // Model-only context for scoped handoffs; not persisted as user-authored text. + HandoffResources []HandoffResource `json:"handoff_resources,omitempty"` // Product-originated resources to seed governed session validation. + MaxTurns int `json:"max_turns,omitempty"` // Override max agentic turns (0 = use default) + AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Per-request autonomous override (nil = use service default) } // QuestionAnswer represents a user's answer to a question diff --git a/internal/ai/tools/tools_query.go b/internal/ai/tools/tools_query.go index 215fc427e..055de0a10 100644 --- a/internal/ai/tools/tools_query.go +++ b/internal/ai/tools/tools_query.go @@ -2544,6 +2544,85 @@ func canonicalStorageRegistration(resource unifiedresources.Resource) (ResourceR }, true } +// CanonicalHandoffResourceRegistration resolves a product-originated resource +// handoff into the same governed registration shape used by explicit resource +// reads. It intentionally fails closed when the resource cannot be found in the +// unified provider, because invented registrations would bypass capability and +// executor policy. +func CanonicalHandoffResourceRegistration(provider UnifiedResourceProvider, resourceID, resourceName, resourceType, node string) (ResourceRegistration, bool) { + if provider == nil { + return ResourceRegistration{}, false + } + + refs := appendUniqueStrings(nil, resourceID, resourceName) + normalizedType := strings.TrimSpace(strings.ToLower(resourceType)) + switch normalizedType { + case "node": + normalizedType = "agent" + case "physical-disk": + normalizedType = "physical_disk" + } + + switch normalizedType { + case "agent", "docker-host": + refs = appendUniqueStrings(refs, node) + if resource, ok := findCanonicalResourceByReference(provider.GetByType(unifiedresources.ResourceTypeAgent), refs...); ok { + return canonicalAgentRegistration(resource) + } + case "vm": + if resource, ok := findCanonicalGuestResourceByReferences(provider, "vm", refs...); ok { + return canonicalGuestRegistration("vm", resource) + } + case "system-container", "lxc": + if resource, ok := findCanonicalGuestResourceByReferences(provider, "system-container", refs...); ok { + return canonicalGuestRegistration("system-container", resource) + } + case "app-container": + if resource, ok := findCanonicalAppContainerResourceByReferences(provider, refs...); ok { + return resolvedAppContainerRegistration(resource) + } + case "storage": + if resource, ok := findCanonicalResourceByReference(canonicalStoragePoolResources(provider), refs...); ok { + return canonicalStorageRegistration(resource) + } + } + + return ResourceRegistration{}, false +} + +func findCanonicalResourceByReference(resources []unifiedresources.Resource, references ...string) (unifiedresources.Resource, bool) { + for _, ref := range references { + ref = strings.TrimSpace(ref) + if ref == "" { + continue + } + for _, resource := range resources { + if matchesCanonicalResourceReference(resource, ref, resourceDisplayName(resource), resource.Name) { + return resource, true + } + } + } + return unifiedresources.Resource{}, false +} + +func findCanonicalGuestResourceByReferences(provider UnifiedResourceProvider, kind string, references ...string) (unifiedresources.Resource, bool) { + for _, ref := range references { + if resource, ok := findCanonicalGuestResource(provider, kind, ref); ok { + return resource, true + } + } + return unifiedresources.Resource{}, false +} + +func findCanonicalAppContainerResourceByReferences(provider UnifiedResourceProvider, references ...string) (unifiedresources.Resource, bool) { + for _, ref := range references { + if resource, _, ok := findCanonicalAppContainerResource(provider, ref); ok { + return resource, true + } + } + return unifiedresources.Resource{}, false +} + func findCanonicalResourceByID(resources []unifiedresources.Resource, resourceID string) (unifiedresources.Resource, bool) { resourceID = strings.TrimSpace(resourceID) if resourceID == "" { diff --git a/internal/api/ai_handler.go b/internal/api/ai_handler.go index db490ae54..0ae6d4fab 100644 --- a/internal/api/ai_handler.go +++ b/internal/api/ai_handler.go @@ -794,7 +794,8 @@ func buildUnifiedFindingChatContext(f *unified.UnifiedFinding) string { appendChatContextLine(&b, "Title", f.Title) appendChatContextLine(&b, "Severity", string(f.Severity)) appendChatContextLine(&b, "Category", string(f.Category)) - appendChatContextLine(&b, "Resource", fmt.Sprintf("%s (%s)", f.ResourceName, f.ResourceType)) + appendChatContextLine(&b, "Resource", formatChatResource(f.ResourceName, f.ResourceType)) + appendChatContextLine(&b, "Resource ID", f.ResourceID) appendChatContextLine(&b, "Description", f.Description) appendChatContextLine(&b, "Recommendation", f.Recommendation) appendChatContextLine(&b, "Evidence", f.Evidence) @@ -821,6 +822,9 @@ func appendInvestigationRecordChatContext(b *strings.Builder, rec *aicontracts.I appendChatContextLine(b, "Status", string(rec.Status)) appendChatContextLine(b, "Outcome", string(rec.Outcome)) appendChatContextLine(b, "Confidence", string(rec.Confidence)) + appendChatContextLine(b, "Subject Resource", formatChatResource(rec.Subject.ResourceName, rec.Subject.ResourceType)) + appendChatContextLine(b, "Subject Resource ID", rec.Subject.ResourceID) + appendChatContextLine(b, "Subject Node", rec.Subject.Node) appendChatContextLine(b, "Conclusion", rec.Conclusion) appendChatContextLine(b, "Recommended Action", rec.RecommendedAction) appendChatContextLine(b, "Trigger", rec.Trigger.Title) @@ -859,6 +863,62 @@ func appendInvestigationRecordChatContext(b *strings.Builder, rec *aicontracts.I } } +func buildUnifiedFindingHandoffResources(f *unified.UnifiedFinding) []chat.HandoffResource { + if f == nil { + return nil + } + + resources := make([]chat.HandoffResource, 0, 2) + add := func(resource chat.HandoffResource) { + 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 == "" { + return + } + key := strings.ToLower(resource.Type + "\x00" + resource.ID + "\x00" + resource.Name + "\x00" + resource.Node) + for _, existing := range resources { + existingKey := strings.ToLower(strings.TrimSpace(existing.Type) + "\x00" + strings.TrimSpace(existing.ID) + "\x00" + strings.TrimSpace(existing.Name) + "\x00" + strings.TrimSpace(existing.Node)) + if existingKey == key { + return + } + } + resources = append(resources, resource) + } + + add(chat.HandoffResource{ + ID: f.ResourceID, + Name: f.ResourceName, + Type: f.ResourceType, + Node: f.Node, + }) + if f.InvestigationRecord != nil { + add(chat.HandoffResource{ + ID: f.InvestigationRecord.Subject.ResourceID, + Name: f.InvestigationRecord.Subject.ResourceName, + Type: f.InvestigationRecord.Subject.ResourceType, + Node: f.InvestigationRecord.Subject.Node, + }) + } + return resources +} + +func formatChatResource(name, resourceType string) string { + name = strings.TrimSpace(name) + resourceType = strings.TrimSpace(resourceType) + switch { + case name != "" && resourceType != "": + return fmt.Sprintf("%s (%s)", name, resourceType) + case name != "": + return name + case resourceType != "": + return resourceType + default: + return "" + } +} + func appendInvestigationRecordEvidenceContext(b *strings.Builder, evidence []aicontracts.InvestigationRecordEvidence) { if len(evidence) == 0 { return @@ -1078,11 +1138,13 @@ func (h *AIHandler) HandleChat(w http.ResponseWriter, r *http.Request) { // chat service injects this into the current model turn without persisting it // as the user's authored prompt, so conversation history stays readable. handoffContext := "" + var handoffResources []chat.HandoffResource if req.FindingID != "" { store := h.GetUnifiedStoreForOrg(GetOrgID(ctx)) if store != nil { if f := store.Get(req.FindingID); f != nil { handoffContext = buildUnifiedFindingChatContext(f) + handoffResources = buildUnifiedFindingHandoffResources(f) } } } @@ -1090,13 +1152,14 @@ func (h *AIHandler) HandleChat(w http.ResponseWriter, r *http.Request) { // Stream from AI chat service serviceSentDone := false err := svc.ExecuteStream(ctx, chat.ExecuteRequest{ - Prompt: req.Prompt, - SessionID: req.SessionID, - Model: req.Model, - Mentions: chatMentions, - FindingID: req.FindingID, - HandoffContext: handoffContext, - AutonomousMode: req.AutonomousMode, + Prompt: req.Prompt, + SessionID: req.SessionID, + Model: req.Model, + Mentions: chatMentions, + FindingID: req.FindingID, + HandoffContext: handoffContext, + HandoffResources: handoffResources, + AutonomousMode: req.AutonomousMode, }, func(event chat.StreamEvent) { if event.Type == "done" { serviceSentDone = true diff --git a/internal/api/ai_handler_test.go b/internal/api/ai_handler_test.go index ad27b2562..d5dfd2204 100644 --- a/internal/api/ai_handler_test.go +++ b/internal/api/ai_handler_test.go @@ -627,6 +627,8 @@ func TestHandleChat_IncludesInvestigationRecordContext(t *testing.T) { assert.Equal(t, "What happened?", reqArg.Prompt) assert.Contains(t, reqArg.HandoffContext, "[Finding Context]") assert.Contains(t, reqArg.HandoffContext, "[Investigation Record]") + assert.Contains(t, reqArg.HandoffContext, "Resource ID: vm-100") + assert.Contains(t, reqArg.HandoffContext, "Subject Resource ID: vm-100") assert.Contains(t, reqArg.HandoffContext, "Conclusion: Backup job saturated CPU.") assert.Contains(t, reqArg.HandoffContext, "Recommended Action: Approve a controlled service restart after backup completion.") assert.Contains(t, reqArg.HandoffContext, "Evidence 1: metrics: CPU stayed above 95% for 10 minutes") @@ -634,6 +636,12 @@ func TestHandleChat_IncludesInvestigationRecordContext(t *testing.T) { assert.Contains(t, reqArg.HandoffContext, "Proposed Fix Commands: 1 command recorded for approval context") assert.NotContains(t, reqArg.HandoffContext, "User message: What happened?") assert.NotContains(t, reqArg.HandoffContext, "systemctl restart workload.service") + assert.Equal(t, []chat.HandoffResource{{ + ID: "vm-100", + Name: "web-server", + Type: "vm", + Node: "pve-1", + }}, reqArg.HandoffResources) }) body := `{"prompt":"What happened?","finding_id":"finding-123"}` diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index c546b4429..a2538c215 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -129,12 +129,22 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { if err != nil { t.Fatalf("read chat session store: %v", err) } + chatTypesSource, err := os.ReadFile(filepath.Clean("../ai/chat/types.go")) + if err != nil { + t.Fatalf("read chat types: %v", err) + } + toolsQuerySource, err := os.ReadFile(filepath.Clean("../ai/tools/tools_query.go")) + if err != nil { + t.Fatalf("read AI tools query runtime: %v", err) + } handlerText := string(handlerSource) for _, required := range []string{ "handoffContext = buildUnifiedFindingChatContext(f)", - "Prompt: req.Prompt", - "HandoffContext: handoffContext", + "handoffResources = buildUnifiedFindingHandoffResources(f)", + "Prompt:", + "HandoffContext:", + "HandoffResources: handoffResources", } { if !strings.Contains(handlerText, required) { t.Fatalf("ai_handler.go must preserve model-only finding handoff contract: missing %q", required) @@ -149,6 +159,7 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { "handoffContext := strings.TrimSpace(req.HandoffContext)", "sessions.SetModelHandoffContext(session.ID, handoffContext)", "sessions.GetModelHandoffContext(session.ID)", + "s.hydrateHandoffResources(session.ID, req.HandoffResources, sessions, unifiedResourceProvider)", "injectHandoffContextIntoLatestUserMessage(messages, handoffContext)", "User message: ", } { @@ -167,6 +178,21 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { t.Fatalf("chat session store must persist model-only handoff metadata outside messages: missing %q", required) } } + + chatTypesText := string(chatTypesSource) + for _, required := range []string{ + "type HandoffResource struct", + "HandoffResources []HandoffResource", + } { + if !strings.Contains(chatTypesText, required) { + t.Fatalf("chat request must carry structured handoff resource scope outside messages: missing %q", required) + } + } + + toolsQueryText := string(toolsQuerySource) + if !strings.Contains(toolsQueryText, "func CanonicalHandoffResourceRegistration(provider UnifiedResourceProvider") { + t.Fatal("AI tools runtime must own canonical handoff resource registration") + } } func TestContract_ProxmoxSetupScriptUsesPrivilegeSeparatedTokenACLs(t *testing.T) {