mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
221 lines
7.2 KiB
Go
221 lines
7.2 KiB
Go
package ai
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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"
|
|
)
|
|
|
|
func TestRunAIAnalysis_TriageQuietStillCallsLLM(t *testing.T) {
|
|
svc := NewService(config.NewConfigPersistence(t.TempDir()), nil)
|
|
svc.cfg = &config.AIConfig{Enabled: true, PatrolModel: "mock:model"}
|
|
|
|
calls := 0
|
|
svc.SetChatService(&mockChatService{
|
|
executor: tools.NewPulseToolExecutor(tools.ExecutorConfig{}),
|
|
executePatrolStreamFunc: func(ctx context.Context, req PatrolExecuteRequest, callback ChatStreamCallback) (*PatrolStreamResponse, error) {
|
|
calls++
|
|
if !strings.Contains(req.Prompt, "# Deterministic Triage Results") {
|
|
t.Fatalf("expected quiet triage context in model prompt, got:\n%s", req.Prompt)
|
|
}
|
|
return &PatrolStreamResponse{
|
|
Content: "No issues found after reviewing the provided infrastructure context.",
|
|
InputTokens: 10,
|
|
OutputTokens: 6,
|
|
}, nil
|
|
},
|
|
})
|
|
|
|
ps := NewPatrolService(svc, nil)
|
|
state := models.StateSnapshot{}
|
|
|
|
triage := ps.runDeterministicTriageState(context.Background(), patrolRuntimeStateForTest(ps, state), nil, nil)
|
|
if triage == nil {
|
|
t.Fatal("expected triage result")
|
|
}
|
|
if !triage.IsQuiet {
|
|
t.Fatalf("expected quiet triage state, got IsQuiet=%v", triage.IsQuiet)
|
|
}
|
|
|
|
res, err := ps.runAIAnalysisState(context.Background(), patrolRuntimeStateForTest(ps, state), nil, "")
|
|
if err != nil {
|
|
t.Fatalf("runAIAnalysis() unexpected error for quiet triage: %v", err)
|
|
}
|
|
if res == nil {
|
|
t.Fatal("expected analysis result")
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("expected quiet patrol to call the model once, got %d", calls)
|
|
}
|
|
if res.TriageSkippedLLM {
|
|
t.Fatal("quiet patrol should not mark LLM as skipped")
|
|
}
|
|
if !strings.Contains(res.Response, "No issues found") {
|
|
t.Fatalf("expected model response, got: %q", res.Response)
|
|
}
|
|
}
|
|
|
|
func TestBuildTriageSeedContext_FlaggedOnly(t *testing.T) {
|
|
ps := NewPatrolService(nil, nil)
|
|
state := triageIntegrationState(10)
|
|
flaggedIDs := map[string]bool{
|
|
"qemu/102": true,
|
|
"qemu/107": true,
|
|
}
|
|
triage := triageIntegrationResult(state, flaggedIDs)
|
|
|
|
seed, _ := ps.buildTriageSeedContextState(triage, patrolRuntimeStateForTest(ps, state), nil, nil)
|
|
if !strings.Contains(seed, "# Deterministic Triage Results") {
|
|
t.Fatalf("expected triage briefing header, got:\n%s", seed)
|
|
}
|
|
|
|
for _, expected := range []string{"vm-02", "vm-07"} {
|
|
if !strings.Contains(seed, expected) {
|
|
t.Fatalf("expected flagged resource %q in triage seed, got:\n%s", expected, seed)
|
|
}
|
|
}
|
|
for _, unexpected := range []string{"vm-00", "vm-03", "vm-09"} {
|
|
if strings.Contains(seed, unexpected) {
|
|
t.Fatalf("did not expect unflagged resource %q in triage seed, got:\n%s", unexpected, seed)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildTriageSeedContext_SmallOutput(t *testing.T) {
|
|
ps := NewPatrolService(nil, nil)
|
|
state := triageIntegrationState(40)
|
|
flaggedIDs := map[string]bool{
|
|
"qemu/102": true,
|
|
"qemu/107": true,
|
|
}
|
|
triage := triageIntegrationResult(state, flaggedIDs)
|
|
|
|
triageSeed, _ := ps.buildTriageSeedContextState(triage, patrolRuntimeStateForTest(ps, state), nil, nil)
|
|
fullSeed, _ := ps.buildSeedContextState(patrolRuntimeStateForTest(ps, state), nil, nil)
|
|
|
|
if len(fullSeed) == 0 {
|
|
t.Fatal("expected non-empty full seed context")
|
|
}
|
|
if len(triageSeed) == 0 {
|
|
t.Fatal("expected non-empty triage seed context")
|
|
}
|
|
if len(triageSeed) >= len(fullSeed) {
|
|
t.Fatalf("expected triage seed smaller than full seed, got triage=%d full=%d", len(triageSeed), len(fullSeed))
|
|
}
|
|
if len(triageSeed)*2 > len(fullSeed) {
|
|
t.Fatalf("expected triage seed to be significantly smaller, got triage=%d full=%d", len(triageSeed), len(fullSeed))
|
|
}
|
|
}
|
|
|
|
func TestComputeTriageMaxTurns(t *testing.T) {
|
|
if got := computeTriageMaxTurns(0, nil); got != 8 {
|
|
t.Fatalf("0 flags: expected 8 turns, got %d", got)
|
|
}
|
|
if got := computeTriageMaxTurns(1, nil); got != 8 {
|
|
t.Fatalf("1 flag: expected 8 turns, got %d", got)
|
|
}
|
|
if got := computeTriageMaxTurns(3, nil); got != 14 {
|
|
t.Fatalf("3 flags: expected 14 turns, got %d", got)
|
|
}
|
|
if got := computeTriageMaxTurns(10, nil); got != 35 {
|
|
t.Fatalf("10 flags: expected 35 turns, got %d", got)
|
|
}
|
|
if got := computeTriageMaxTurns(15, nil); got != 40 {
|
|
t.Fatalf("15 flags: expected 40 turns (cap), got %d", got)
|
|
}
|
|
|
|
quickScope := &PatrolScope{Depth: PatrolDepthQuick}
|
|
if got := computeTriageMaxTurns(15, quickScope); got != 20 {
|
|
t.Fatalf("quick scope expected 20-turn cap, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestGetPatrolSystemPromptForTriage(t *testing.T) {
|
|
ps := NewPatrolService(&Service{
|
|
cfg: &config.AIConfig{PatrolAutoFix: false},
|
|
}, nil)
|
|
|
|
prompt := ps.getPatrolSystemPromptForTriage()
|
|
if !strings.Contains(prompt, "Pulse has assembled deterministic evidence before this turn") {
|
|
t.Fatalf("expected triage preamble in prompt, got:\n%s", prompt)
|
|
}
|
|
if !strings.Contains(prompt, "prioritized context, not as a final diagnosis") {
|
|
t.Fatalf("expected triage prompt to preserve model-owned assessment boundary, got:\n%s", prompt)
|
|
}
|
|
if strings.Contains(prompt, "Triage already verified") || strings.Contains(prompt, "Focus your turns exclusively") {
|
|
t.Fatalf("triage prompt must not present deterministic pre-pass as a Pulse-authored judgment boundary, got:\n%s", prompt)
|
|
}
|
|
if !strings.Contains(prompt, "## Investigation Tools") || !strings.Contains(prompt, "pulse_query") {
|
|
t.Fatalf("expected tool descriptions from base prompt, got:\n%s", prompt)
|
|
}
|
|
if strings.Contains(prompt, "Your job is to find issues that simple threshold-based alerts CANNOT catch") {
|
|
t.Fatalf("expected standard opening to be replaced in triage prompt, got:\n%s", prompt)
|
|
}
|
|
}
|
|
|
|
func triageIntegrationState(vmCount int) models.StateSnapshot {
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{
|
|
ID: "node/pve1",
|
|
Name: "pve1",
|
|
Status: "online",
|
|
CPU: 0.15, // CPU is 0-1 scale (15%)
|
|
Memory: models.Memory{Usage: 20.0}, // already percent (20%)
|
|
Disk: models.Disk{Usage: 30.0}, // already percent (30%)
|
|
},
|
|
},
|
|
VMs: make([]models.VM, 0, vmCount),
|
|
}
|
|
|
|
for i := 0; i < vmCount; i++ {
|
|
state.VMs = append(state.VMs, models.VM{
|
|
ID: fmt.Sprintf("qemu/%d", 100+i),
|
|
Name: fmt.Sprintf("vm-%02d", i),
|
|
Node: "pve1",
|
|
Status: "running",
|
|
Template: false,
|
|
CPU: 0.10, // CPU is 0-1 scale (10%)
|
|
Memory: models.Memory{Usage: 42.0}, // already percent (42%)
|
|
Disk: models.Disk{Usage: 36.0}, // already percent (36%)
|
|
})
|
|
}
|
|
|
|
return state
|
|
}
|
|
|
|
func triageIntegrationResult(state models.StateSnapshot, flaggedIDs map[string]bool) *TriageResult {
|
|
flags := make([]TriageFlag, 0, len(flaggedIDs))
|
|
for _, vm := range state.VMs {
|
|
if !flaggedIDs[vm.ID] {
|
|
continue
|
|
}
|
|
flags = append(flags, TriageFlag{
|
|
ResourceID: vm.ID,
|
|
ResourceName: vm.Name,
|
|
ResourceType: "vm",
|
|
Category: "performance",
|
|
Severity: "warning",
|
|
Reason: "Memory at 92% (threshold: 88%)",
|
|
Metric: "memory",
|
|
Value: 92,
|
|
Threshold: 88,
|
|
})
|
|
}
|
|
|
|
return &TriageResult{
|
|
Flags: flags,
|
|
Summary: TriageSummary{
|
|
TotalNodes: len(state.Nodes),
|
|
TotalGuests: len(state.VMs),
|
|
RunningGuests: len(state.VMs),
|
|
FlaggedCount: len(flags),
|
|
},
|
|
FlaggedIDs: flaggedIDs,
|
|
}
|
|
}
|