mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-20 01:01:20 +00:00
Hydrate Assistant handoff resource scope
This commit is contained in:
parent
25f9172e2e
commit
aeede4fb2f
11 changed files with 327 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}`
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue