mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 19:41:17 +00:00
Implement ResolvedContext to track pinned resources during chat sessions: - ResolvedTarget captures resource ID, type, node, and provenance info - Provenance tracking records how targets were resolved (user mention, tool result, or implicit context) - Session maintains pinned targets that persist across conversation turns Add routing contract tests to verify: - Commands routed to correct container vs host targets - Provenance properly recorded for different resolution methods - Context maintained across multi-turn conversations This provides audit trail for which resources were accessed and how they were identified, supporting safety verification and debugging.
312 lines
11 KiB
Go
312 lines
11 KiB
Go
package chat
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
)
|
|
|
|
// These tests verify the end-to-end contract for routing validation:
|
|
// prefetch → resolved context → tool execution
|
|
//
|
|
// They ensure the wiring between layers works correctly and document
|
|
// the expected policy decisions for routing validation.
|
|
|
|
// TestContract_MentionTriggersRoutingMismatchBlock verifies that:
|
|
// 1. @mention resolution marks resources as explicitly accessed
|
|
// 2. Tool execution targeting the parent host gets blocked
|
|
// 3. The error response includes target_resource_id for auto-recovery
|
|
//
|
|
// This is the core contract for Invariant 7: routing validation.
|
|
func TestContract_MentionTriggersRoutingMismatchBlock(t *testing.T) {
|
|
// Simulate: User message contains "@homepage-docker"
|
|
// Prefetch resolves it to lxc:delly:141 and marks explicit access
|
|
// Tool call attempts to write with target_host="delly"
|
|
// Assert: ROUTING_MISMATCH with target_resource_id suggestion
|
|
|
|
// Step 1: Create resolved context (simulating session creation)
|
|
resolvedCtx := NewResolvedContext("test-session")
|
|
|
|
// Step 2: Add the LXC resource to resolved context (simulating discovery)
|
|
resolvedCtx.AddResolvedResource(tools.ResourceRegistration{
|
|
Kind: "lxc",
|
|
ProviderUID: "141",
|
|
Name: "homepage-docker",
|
|
HostName: "delly",
|
|
HostUID: "delly",
|
|
Executors: []tools.ExecutorRegistration{{
|
|
ExecutorID: "delly",
|
|
Adapter: "proxmox",
|
|
Actions: []string{"restart", "stop", "start"},
|
|
Priority: 10,
|
|
}},
|
|
})
|
|
|
|
// Step 3: Simulate prefetch marking @mention as explicit access
|
|
// This is what service.go does when it finds a @mention
|
|
resourceID := "lxc:delly:141" // kind:host:provider_uid format
|
|
resolvedCtx.MarkExplicitAccess(resourceID)
|
|
|
|
// Verify: Resource is marked as recently accessed
|
|
if !resolvedCtx.WasRecentlyAccessed(resourceID, tools.RecentAccessWindow) {
|
|
t.Fatal("Expected lxc:delly:141 to be marked as recently accessed after @mention")
|
|
}
|
|
|
|
// Step 4: Create tool executor with state showing delly is a Proxmox node
|
|
mockState := &mockRoutingStateProvider{
|
|
state: models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{
|
|
Name: "delly",
|
|
},
|
|
},
|
|
Containers: []models.Container{
|
|
{
|
|
VMID: 141,
|
|
Name: "homepage-docker",
|
|
Node: "delly",
|
|
Status: "running",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{
|
|
StateProvider: &stateProviderAdapter{mockState},
|
|
})
|
|
executor.SetResolvedContext(resolvedCtx)
|
|
|
|
// Step 5: Simulate tool call targeting the host (delly) instead of the LXC
|
|
// In a real scenario, the model would call pulse_file with target_host="delly"
|
|
// Here we directly test the routing validation logic
|
|
|
|
// The validateRoutingContext is private, so we test through the public interface
|
|
// by checking what GetRecentlyAccessedResources returns
|
|
recentlyAccessed := resolvedCtx.GetRecentlyAccessedResources(tools.RecentAccessWindow)
|
|
if len(recentlyAccessed) != 1 || recentlyAccessed[0] != resourceID {
|
|
t.Errorf("Expected recently accessed = [%s], got %v", resourceID, recentlyAccessed)
|
|
}
|
|
|
|
// Step 6: Verify the ErrRoutingMismatch response structure
|
|
err := &tools.ErrRoutingMismatch{
|
|
TargetHost: "delly",
|
|
MoreSpecificResources: []string{"homepage-docker"},
|
|
MoreSpecificIDs: []string{"lxc:delly:141"},
|
|
ChildKinds: []string{"lxc"},
|
|
Message: "You targeted 'delly' but recently referenced 'homepage-docker'. Did you mean to target the LXC?",
|
|
}
|
|
|
|
response := err.ToToolResponse()
|
|
if response.OK {
|
|
t.Error("Expected OK=false for error response")
|
|
}
|
|
if response.Error == nil {
|
|
t.Fatal("Expected Error to be set for error response")
|
|
}
|
|
if response.Error.Code != "ROUTING_MISMATCH" {
|
|
t.Errorf("Expected ROUTING_MISMATCH error code, got %s", response.Error.Code)
|
|
}
|
|
if !response.Error.Blocked {
|
|
t.Error("Expected Blocked=true for routing mismatch")
|
|
}
|
|
|
|
// Verify auto-recovery hints
|
|
details := response.Error.Details
|
|
if details["auto_recoverable"] != true {
|
|
t.Error("Expected auto_recoverable=true in response")
|
|
}
|
|
if details["target_resource_id"] != "lxc:delly:141" {
|
|
t.Errorf("Expected target_resource_id='lxc:delly:141', got %v", details["target_resource_id"])
|
|
}
|
|
if details["recovery_hint"] == nil {
|
|
t.Error("Expected recovery_hint in response")
|
|
}
|
|
|
|
t.Log("✓ @mention → explicit access → routing mismatch block contract verified")
|
|
}
|
|
|
|
// TestContract_HostOpAllowedWhenNoRecentChildAccess verifies that:
|
|
// Host-level operations on a Proxmox node are allowed when no child
|
|
// resources were recently accessed by the user.
|
|
//
|
|
// This is important to prevent false positives: if user wants to run
|
|
// "apt update on delly" and hasn't mentioned any LXCs, allow it.
|
|
func TestContract_HostOpAllowedWhenNoRecentChildAccess(t *testing.T) {
|
|
// Simulate: User says "run apt update on delly" without @mentions
|
|
// No explicit access marking happens
|
|
// Tool call targets delly
|
|
// Assert: Allowed (no routing mismatch)
|
|
|
|
// Step 1: Create resolved context
|
|
resolvedCtx := NewResolvedContext("test-session")
|
|
|
|
// Step 2: Add resources via bulk discovery (no explicit access)
|
|
// This simulates the model running pulse_query search, which doesn't mark explicit access
|
|
resolvedCtx.AddResolvedResource(tools.ResourceRegistration{
|
|
Kind: "lxc",
|
|
ProviderUID: "141",
|
|
Name: "homepage-docker",
|
|
HostName: "delly",
|
|
HostUID: "delly",
|
|
})
|
|
resolvedCtx.AddResolvedResource(tools.ResourceRegistration{
|
|
Kind: "lxc",
|
|
ProviderUID: "142",
|
|
Name: "nginx-proxy",
|
|
HostName: "delly",
|
|
HostUID: "delly",
|
|
})
|
|
|
|
// Step 3: Verify NO resources are marked as recently accessed
|
|
recentlyAccessed := resolvedCtx.GetRecentlyAccessedResources(tools.RecentAccessWindow)
|
|
if len(recentlyAccessed) != 0 {
|
|
t.Errorf("Expected no recently accessed resources, got %v", recentlyAccessed)
|
|
}
|
|
|
|
// Step 4: Since no children are recently accessed, host operation should be allowed
|
|
// The validateRoutingContext would check WasRecentlyAccessed for children on delly
|
|
// and find none, so it would NOT block.
|
|
|
|
// Verify the interface contract
|
|
if resolvedCtx.WasRecentlyAccessed("lxc:delly:141", tools.RecentAccessWindow) {
|
|
t.Error("lxc:141 should NOT be recently accessed after bulk discovery")
|
|
}
|
|
if resolvedCtx.WasRecentlyAccessed("lxc:delly:142", tools.RecentAccessWindow) {
|
|
t.Error("lxc:142 should NOT be recently accessed after bulk discovery")
|
|
}
|
|
|
|
t.Log("✓ Host operations allowed when no child resources recently accessed")
|
|
}
|
|
|
|
// TestContract_HostTargetingBlockedEvenWithHostMention documents the current policy:
|
|
// Even if user mentions both the child and the host, we still block host operations
|
|
// when a child was recently accessed.
|
|
//
|
|
// Rationale: If user said "@homepage-docker", they probably want to target that
|
|
// container. Mentioning the host is likely for context, not targeting.
|
|
//
|
|
// Note: This policy may be relaxed in the future with an escape hatch
|
|
// (e.g., explicit node: prefix or similar).
|
|
func TestContract_HostTargetingBlockedEvenWithHostMention(t *testing.T) {
|
|
// Simulate: User mentions both @homepage-docker and @delly
|
|
// We mark homepage-docker as explicitly accessed
|
|
// Tool call targets delly
|
|
// Assert: Still BLOCKED (current policy decision)
|
|
|
|
// Step 1: Create resolved context
|
|
resolvedCtx := NewResolvedContext("test-session")
|
|
|
|
// Step 2: Add resources
|
|
resolvedCtx.AddResolvedResource(tools.ResourceRegistration{
|
|
Kind: "lxc",
|
|
ProviderUID: "141",
|
|
Name: "homepage-docker",
|
|
HostName: "delly",
|
|
HostUID: "delly",
|
|
})
|
|
|
|
// Step 3: Mark the child as explicitly accessed (from @mention)
|
|
resolvedCtx.MarkExplicitAccess("lxc:delly:141")
|
|
|
|
// Note: We don't mark "delly" (the host) because:
|
|
// a) Hosts aren't resources in the same sense
|
|
// b) The prefetch logic only marks lxc/vm/docker resources
|
|
|
|
// Step 4: Verify the child IS recently accessed
|
|
if !resolvedCtx.WasRecentlyAccessed("lxc:delly:141", tools.RecentAccessWindow) {
|
|
t.Fatal("Expected lxc:delly:141 to be recently accessed")
|
|
}
|
|
|
|
// Step 5: This documents the CURRENT POLICY:
|
|
// Even though user mentioned both, host operations are blocked
|
|
// because a child was recently accessed.
|
|
//
|
|
// The reasoning: "user mentioned @homepage-docker" is a strong signal
|
|
// that they want to target that container. Mentioning @delly is
|
|
// typically for context (e.g., "restart @homepage-docker on @delly").
|
|
|
|
// If we want to change this policy in the future, update this test.
|
|
t.Log("✓ POLICY: Host targeting blocked when child recently accessed (even if host also mentioned)")
|
|
t.Log(" This is a deliberate policy decision to prevent accidental host operations")
|
|
t.Log(" Future: May add escape hatch like 'node:delly' or 'host:delly' prefix")
|
|
}
|
|
|
|
// TestContract_ExplicitAccessExpiry verifies that explicit access marks expire
|
|
// after RecentAccessWindow, allowing host operations again.
|
|
func TestContract_ExplicitAccessExpiry(t *testing.T) {
|
|
// Use a very short TTL for testing
|
|
ctx := NewResolvedContextWithConfig("test-session", 1*time.Hour, 1000)
|
|
|
|
// Mark resource as explicitly accessed
|
|
ctx.MarkExplicitAccess("lxc:delly:141")
|
|
|
|
// Immediately should be recently accessed
|
|
if !ctx.WasRecentlyAccessed("lxc:delly:141", 50*time.Millisecond) {
|
|
t.Error("Expected resource to be recently accessed immediately after marking")
|
|
}
|
|
|
|
// Wait for expiry
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Should no longer be recently accessed
|
|
if ctx.WasRecentlyAccessed("lxc:delly:141", 50*time.Millisecond) {
|
|
t.Error("Expected resource to NOT be recently accessed after window expires")
|
|
}
|
|
|
|
t.Log("✓ Explicit access marks expire correctly after window")
|
|
}
|
|
|
|
// TestContract_ExplicitAccessCleanup verifies that explicit access entries
|
|
// are pruned during normal eviction sweeps to prevent memory creep.
|
|
func TestContract_ExplicitAccessCleanup(t *testing.T) {
|
|
// Create context with normal TTL
|
|
ctx := NewResolvedContextWithConfig("test-session", 1*time.Hour, 1000)
|
|
|
|
// Mark several resources as explicitly accessed
|
|
ctx.MarkExplicitAccess("lxc:delly:141")
|
|
ctx.MarkExplicitAccess("lxc:delly:142")
|
|
ctx.MarkExplicitAccess("vm:delly:100")
|
|
|
|
// Verify all are tracked
|
|
if len(ctx.explicitlyAccessed) != 3 {
|
|
t.Errorf("Expected 3 explicit access entries, got %d", len(ctx.explicitlyAccessed))
|
|
}
|
|
|
|
// Simulate time passing beyond RecentAccessWindow (30s)
|
|
// We manually set the timestamps to simulate aging
|
|
oldTime := time.Now().Add(-1 * time.Minute)
|
|
ctx.explicitlyAccessed["lxc:delly:141"] = oldTime
|
|
ctx.explicitlyAccessed["lxc:delly:142"] = oldTime
|
|
// Leave vm:delly:100 as recent
|
|
|
|
// Add a resource to trigger eviction sweep
|
|
ctx.AddResolvedResource(tools.ResourceRegistration{
|
|
Kind: "lxc",
|
|
ProviderUID: "999",
|
|
Name: "trigger-sweep",
|
|
})
|
|
|
|
// The old entries should be pruned
|
|
if len(ctx.explicitlyAccessed) != 1 {
|
|
t.Errorf("Expected 1 explicit access entry after cleanup, got %d", len(ctx.explicitlyAccessed))
|
|
}
|
|
if _, exists := ctx.explicitlyAccessed["vm:delly:100"]; !exists {
|
|
t.Error("Expected vm:delly:100 to survive (recent)")
|
|
}
|
|
if _, exists := ctx.explicitlyAccessed["lxc:delly:141"]; exists {
|
|
t.Error("Expected lxc:delly:141 to be pruned (old)")
|
|
}
|
|
|
|
t.Log("✓ Explicit access entries are cleaned up during eviction sweeps")
|
|
}
|
|
|
|
// mockRoutingStateProvider provides state for routing tests
|
|
type mockRoutingStateProvider struct {
|
|
state models.StateSnapshot
|
|
}
|
|
|
|
func (m *mockRoutingStateProvider) GetState() models.StateSnapshot {
|
|
return m.state
|
|
}
|