Files
pulse/internal/ai/chat/service_tooling_test.go
T
rcourtman 6f37c8d0ef Align discovery prompt governance
- keep fallback Assistant governance aligned with discovery run support

- describe Patrol discovery as read-or-refresh evidence access
2026-05-15 23:11:59 +01:00

473 lines
16 KiB
Go

package chat
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
ur "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
type fakeStateProvider struct{}
func (f fakeStateProvider) ReadSnapshot() models.StateSnapshot {
return models.StateSnapshot{}
}
type fakeAgentServer struct{}
func (f fakeAgentServer) GetConnectedAgents() []agentexec.ConnectedAgent {
return []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "node-1"}}
}
func (f fakeAgentServer) ExecuteCommand(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) {
return &agentexec.CommandResultPayload{Stdout: "ok", ExitCode: 0}, nil
}
type fakeCanonicalReadState struct{}
func (f *fakeCanonicalReadState) VMs() []*ur.VMView { return nil }
func (f *fakeCanonicalReadState) Containers() []*ur.ContainerView { return nil }
func (f *fakeCanonicalReadState) Nodes() []*ur.NodeView { return nil }
func (f *fakeCanonicalReadState) Hosts() []*ur.HostView { return nil }
func (f *fakeCanonicalReadState) DockerHosts() []*ur.DockerHostView { return nil }
func (f *fakeCanonicalReadState) DockerContainers() []*ur.DockerContainerView { return nil }
func (f *fakeCanonicalReadState) StoragePools() []*ur.StoragePoolView { return nil }
func (f *fakeCanonicalReadState) PhysicalDisks() []*ur.PhysicalDiskView { return nil }
func (f *fakeCanonicalReadState) PBSInstances() []*ur.PBSInstanceView { return nil }
func (f *fakeCanonicalReadState) PMGInstances() []*ur.PMGInstanceView { return nil }
func (f *fakeCanonicalReadState) K8sClusters() []*ur.K8sClusterView { return nil }
func (f *fakeCanonicalReadState) K8sNodes() []*ur.K8sNodeView { return nil }
func (f *fakeCanonicalReadState) Pods() []*ur.PodView { return nil }
func (f *fakeCanonicalReadState) K8sDeployments() []*ur.K8sDeploymentView { return nil }
func (f *fakeCanonicalReadState) Workloads() []*ur.WorkloadView { return nil }
func (f *fakeCanonicalReadState) Infrastructure() []*ur.InfrastructureView { return nil }
type fakeRecoveryPointsProvider struct {
points []recovery.RecoveryPoint
}
func (f *fakeRecoveryPointsProvider) ListPoints(_ context.Context, opts recovery.ListPointsOptions) ([]recovery.RecoveryPoint, int, error) {
filtered := make([]recovery.RecoveryPoint, 0, len(f.points))
for _, point := range f.points {
if opts.Provider != "" && point.Provider != opts.Provider {
continue
}
if opts.Kind != "" && point.Kind != opts.Kind {
continue
}
filtered = append(filtered, point)
}
total := len(filtered)
if opts.Limit <= 0 {
return filtered, total, nil
}
page := opts.Page
if page <= 0 {
page = 1
}
start := (page - 1) * opts.Limit
if start >= total {
return []recovery.RecoveryPoint{}, total, nil
}
end := start + opts.Limit
if end > total {
end = total
}
return filtered[start:end], total, nil
}
func toolNameSet(list []providers.Tool) map[string]bool {
set := make(map[string]bool, len(list))
for _, tool := range list {
set[tool.Name] = true
}
return set
}
func TestToolsForExecutionMode_InteractiveExposesGovernedTools(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{
StateProvider: fakeStateProvider{},
AgentServer: fakeAgentServer{},
ReadState: &fakeCanonicalReadState{},
ControlLevel: tools.ControlLevelControlled,
})
svc := &Service{
executor: exec,
cfg: &config.AIConfig{
PatrolAnalyzeDocker: true,
PatrolAnalyzeStorage: true,
},
}
interactiveTools := svc.toolsForExecutionMode(false, false)
interactiveSet := toolNameSet(interactiveTools)
if !interactiveSet["pulse_kubernetes"] ||
!interactiveSet["pulse_storage"] ||
!interactiveSet["pulse_pmg"] ||
!interactiveSet["pulse_docker"] {
t.Fatalf("expected interactive chat to expose all governed tools to the selected model")
}
if !interactiveSet[pulseQuestionToolName] {
t.Fatalf("expected interactive chat to expose the clarification tool")
}
}
func TestToolsForExecutionMode_AutonomousNonPatrolExposesGovernedTools(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{
StateProvider: fakeStateProvider{},
AgentServer: fakeAgentServer{},
ControlLevel: tools.ControlLevelControlled,
})
svc := &Service{executor: exec}
toolsList := svc.toolsForExecutionMode(true, false)
set := toolNameSet(toolsList)
if !set["pulse_storage"] {
t.Fatalf("expected storage tool to be exposed without prompt keyword routing")
}
if !set["pulse_control"] || !set["pulse_file_edit"] || !set["pulse_docker"] {
t.Fatalf("expected autonomous non-patrol mode to expose governed write tools")
}
if set[pulseQuestionToolName] {
t.Fatalf("expected autonomous mode to exclude the interactive clarification tool")
}
}
func TestBuildSystemPrompt_DoesNotClaimGenericVMControl(t *testing.T) {
svc := &Service{}
prompt := svc.buildSystemPrompt()
if strings.Contains(prompt, "Run commands on hosts, containers, and VMs") {
t.Fatalf("expected system prompt to avoid blanket VM control claim, got %q", prompt)
}
if !strings.Contains(prompt, "Not every VM or container supports control") {
t.Fatalf("expected system prompt to call out read-only resource platforms, got %q", prompt)
}
if !strings.Contains(prompt, "control only resources that explicitly support shared Pulse actions") {
t.Fatalf("expected system prompt to describe capability-bound pulse_control usage, got %q", prompt)
}
if !strings.Contains(prompt, "## AVAILABLE TOOL GOVERNANCE") {
t.Fatalf("expected system prompt to include generated tool governance section, got %q", prompt)
}
if !strings.Contains(prompt, "pulse_kubernetes") {
t.Fatalf("expected system prompt to include the governed Kubernetes tool contract, got %q", prompt)
}
}
func TestBuildToolGovernancePromptSection_FallbackDiscoveryMatchesRunContract(t *testing.T) {
svc := &Service{}
prompt := svc.buildToolGovernancePromptSection()
if !strings.Contains(prompt, "pulse_discovery: mode=mixed") {
t.Fatalf("expected fallback governance to classify pulse_discovery as mixed, got %q", prompt)
}
if !strings.Contains(prompt, "run uses read-only evidence collection and updates the discovery cache") {
t.Fatalf("expected fallback governance to describe discovery refresh behavior, got %q", prompt)
}
}
func TestToolsForExecutionMode_RecoveryOnlyKeepsStorage(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{
RecoveryPointsProvider: &fakeRecoveryPointsProvider{points: []recovery.RecoveryPoint{{
ID: "pve-snapshot:before-upgrade",
Provider: recovery.ProviderProxmoxPVE,
Kind: recovery.KindSnapshot,
Mode: recovery.ModeLocal,
Outcome: recovery.OutcomeSuccess,
}}},
ControlLevel: tools.ControlLevelControlled,
})
svc := &Service{executor: exec}
toolsList := svc.toolsForExecutionMode(false, false)
set := toolNameSet(toolsList)
if !set["pulse_storage"] {
t.Fatalf("expected storage tool to be kept when recovery points are the only storage source")
}
}
func TestToolsForExecutionMode_PatrolScopeUsesConfigNotPrompt(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{
StateProvider: fakeStateProvider{},
AgentServer: fakeAgentServer{},
ControlLevel: tools.ControlLevelControlled,
})
svc := &Service{
executor: exec,
cfg: &config.AIConfig{
PatrolAnalyzeDocker: false,
PatrolAnalyzeStorage: false,
},
}
filtered := svc.toolsForExecutionMode(true, true)
set := toolNameSet(filtered)
if set["pulse_docker"] {
t.Fatalf("expected pulse_docker to follow disabled Patrol subsystem scope")
}
if set["pulse_storage"] {
t.Fatalf("expected pulse_storage to follow disabled Patrol subsystem scope")
}
if !set["pulse_query"] {
t.Fatalf("expected core read/query tools to remain available to the Patrol model")
}
}
func TestExecuteCommand_SuccessAndExitCode(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
exec.RegisterTool(tools.RegisteredTool{
Definition: tools.Tool{Name: "pulse_run_command"},
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.NewTextResult("Command failed (exit code 7): boom"), nil
},
})
svc := &Service{executor: exec}
output, code, err := svc.ExecuteCommand(context.Background(), "uptime", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if code != 7 {
t.Fatalf("expected exit code 7, got %d", code)
}
if !strings.Contains(output, "Command failed") {
t.Fatalf("expected command output, got: %s", output)
}
}
func TestExecuteCommand_ErrorAndApprovalPaths(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
exec.RegisterTool(tools.RegisteredTool{
Definition: tools.Tool{Name: "pulse_run_command"},
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.NewErrorResult(context.Canceled), nil
},
})
svc := &Service{executor: exec}
_, code, err := svc.ExecuteCommand(context.Background(), "uptime", "")
if err == nil || code != 1 {
t.Fatalf("expected error with exit code 1")
}
exec.RegisterTool(tools.RegisteredTool{
Definition: tools.Tool{Name: "pulse_run_command"},
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.NewTextResult("APPROVAL_REQUIRED: requires approval"), nil
},
})
_, _, err = svc.ExecuteCommand(context.Background(), "uptime", "")
if err == nil {
t.Fatalf("expected approval error")
}
}
func TestExecuteMCPTool_ErrorsAndSuccess(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
exec.RegisterTool(tools.RegisteredTool{
Definition: tools.Tool{Name: "test_tool"},
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.NewErrorResult(context.DeadlineExceeded), nil
},
})
svc := &Service{executor: exec}
_, err := svc.ExecuteMCPTool(context.Background(), "test_tool", map[string]interface{}{})
if err == nil {
t.Fatalf("expected tool error")
}
exec.RegisterTool(tools.RegisteredTool{
Definition: tools.Tool{Name: "test_tool"},
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.NewTextResult("POLICY_BLOCKED: nope"), nil
},
})
_, err = svc.ExecuteMCPTool(context.Background(), "test_tool", map[string]interface{}{})
if err == nil {
t.Fatalf("expected policy blocked error")
}
exec.RegisterTool(tools.RegisteredTool{
Definition: tools.Tool{Name: "test_tool"},
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.NewTextResult("ok"), nil
},
})
output, err := svc.ExecuteMCPTool(context.Background(), "test_tool", map[string]interface{}{})
if err != nil || output != "ok" {
t.Fatalf("expected success, got output=%q err=%v", output, err)
}
}
func TestExecuteMCPTool_PulseStorageSnapshotsToleratesMalformedRecoveryMetadata(t *testing.T) {
completedAt := time.Date(2026, 2, 24, 10, 30, 0, 0, time.UTC)
svc := &Service{
executor: tools.NewPulseToolExecutor(tools.ExecutorConfig{
StateProvider: fakeStateProvider{},
ReadState: &fakeCanonicalReadState{},
RecoveryPointsProvider: &fakeRecoveryPointsProvider{points: []recovery.RecoveryPoint{
{
ID: "pve-snapshot:snap-100-before-upgrade",
Provider: recovery.ProviderProxmoxPVE,
Kind: recovery.KindSnapshot,
Mode: recovery.ModeLocal,
Outcome: recovery.OutcomeSuccess,
SubjectRef: &recovery.ExternalRef{
Type: "vm",
Name: "100",
ID: "100",
Namespace: "pve1",
Class: "node1",
},
Display: &recovery.RecoveryPointDisplay{
SubjectLabel: "100",
ItemType: "vm",
ClusterLabel: "pve1",
NodeHostLabel: "node1",
EntityIDLabel: "100",
DetailsSummary: "before-upgrade",
},
CompletedAt: &completedAt,
},
}},
}),
}
output, err := svc.ExecuteMCPTool(context.Background(), "pulse_storage", map[string]interface{}{
"type": "snapshots",
"guest_id": "100",
"instance": "pve1",
})
if err != nil {
t.Fatalf("ExecuteMCPTool(pulse_storage snapshots): %v", err)
}
var resp tools.SnapshotsResponse
if err := json.Unmarshal([]byte(output), &resp); err != nil {
t.Fatalf("unmarshal snapshots response: %v\noutput=%s", err, output)
}
if resp.Total != 1 || resp.Filtered != 1 || len(resp.Snapshots) != 1 {
t.Fatalf("unexpected snapshots counts: total=%d filtered=%d len=%d", resp.Total, resp.Filtered, len(resp.Snapshots))
}
if resp.Snapshots[0].VMID != 100 || resp.Snapshots[0].Instance != "pve1" || resp.Snapshots[0].Node != "node1" {
t.Fatalf("unexpected snapshot identity: %#v", resp.Snapshots[0])
}
if resp.Snapshots[0].Type != "vm" || resp.Snapshots[0].SnapshotName != "before-upgrade" {
t.Fatalf("unexpected snapshot canonical fields: %#v", resp.Snapshots[0])
}
}
func TestExecuteMCPTool_PulseStorageBackupTasksToleratesMalformedRecoveryMetadata(t *testing.T) {
startedAt := time.Date(2026, 2, 24, 11, 0, 0, 0, time.UTC)
completedAt := time.Date(2026, 2, 24, 11, 15, 0, 0, time.UTC)
svc := &Service{
executor: tools.NewPulseToolExecutor(tools.ExecutorConfig{
StateProvider: fakeStateProvider{},
ReadState: &fakeCanonicalReadState{},
RecoveryPointsProvider: &fakeRecoveryPointsProvider{points: []recovery.RecoveryPoint{
{
ID: "pve-task:task-101-backup",
Provider: recovery.ProviderProxmoxPVE,
Kind: recovery.KindBackup,
Mode: recovery.ModeLocal,
Outcome: recovery.OutcomeSuccess,
SubjectRef: &recovery.ExternalRef{
Type: "vm",
Name: "101",
ID: "101",
Namespace: "pve1",
Class: "node1",
},
Display: &recovery.RecoveryPointDisplay{
SubjectLabel: "101",
ItemType: "vm",
ClusterLabel: "pve1",
NodeHostLabel: "node1",
EntityIDLabel: "101",
DetailsSummary: "completed successfully",
},
StartedAt: &startedAt,
CompletedAt: &completedAt,
},
}},
}),
}
output, err := svc.ExecuteMCPTool(context.Background(), "pulse_storage", map[string]interface{}{
"type": "backup_tasks",
"guest_id": "101",
"instance": "pve1",
"status": "OK",
})
if err != nil {
t.Fatalf("ExecuteMCPTool(pulse_storage backup_tasks): %v", err)
}
var resp tools.BackupTasksListResponse
if err := json.Unmarshal([]byte(output), &resp); err != nil {
t.Fatalf("unmarshal backup tasks response: %v\noutput=%s", err, output)
}
if resp.Total != 1 || resp.Filtered != 1 || len(resp.Tasks) != 1 {
t.Fatalf("unexpected backup task counts: total=%d filtered=%d len=%d", resp.Total, resp.Filtered, len(resp.Tasks))
}
if resp.Tasks[0].VMID != 101 || resp.Tasks[0].Instance != "pve1" || resp.Tasks[0].Node != "node1" {
t.Fatalf("unexpected backup task identity: %#v", resp.Tasks[0])
}
if resp.Tasks[0].Type != "vm" || resp.Tasks[0].Status != "OK" {
t.Fatalf("unexpected backup task canonical fields: %#v", resp.Tasks[0])
}
}
func TestService_StartInitializesActionAuditStore(t *testing.T) {
svc := NewService(Config{
AIConfig: &config.AIConfig{
ChatModel: "mock:model",
},
DataDir: t.TempDir(),
OrgID: "org-a",
})
svc.providerFactory = func(modelStr string) (providers.StreamingProvider, error) {
return &mockStreamingProvider{}, nil
}
if err := svc.Start(context.Background()); err != nil {
t.Fatalf("start service: %v", err)
}
if svc.actionAuditStore == nil {
t.Fatalf("expected action audit store to be initialized")
}
if svc.executor == nil {
t.Fatalf("expected executor to be initialized")
}
if svc.executor.GetActionAuditStore() == nil {
t.Fatalf("expected executor action audit store to be set")
}
if err := svc.Stop(context.Background()); err != nil {
t.Fatalf("stop service: %v", err)
}
if svc.actionAuditStore != nil {
t.Fatalf("expected action audit store to be cleared on stop")
}
}