Files
rcourtman 7996848b64 Submit advertised lifecycle actions through pulse_control, not prose
A paying operator asked the Assistant to reboot five Proxmox VMs matching
a name pattern (GitHub #1782, support mail 2026-08-26 and 2026-08-29). The
model resolved the VMs and then ended with a report that invented a
prerequisite: a QEMU guest agent on 6.3.2, a "discovery binding" on stable
6.4.0. It never planned the action. Three defects made the governed path
fail whenever the model did try it, and nothing refused the prose ending
when it did not:

- pulse_control handed the session-scoped id (vm:<node>:<vmid>) to the
  action lifecycle, whose registry keys on canonical unified ids, so a
  Proxmox guest plan could never resolve.
- pulse_control gated the action on the legacy per-executor action list,
  which never carried the canonical "reboot" capability Proxmox guests
  advertise, so "reboot" was refused as not permitted before planning.
- A reference absent from the session context was refused with "resource
  discovery is required" even when the unified inventory resolved it.

pulse_control now binds its target to the canonical unified resource
(session alias first, then a unique inventory match, refusing ambiguity
with candidate ids and naming the pulse_query recovery on a miss), passes
the canonical id to the planner, and answers "not available" only from
the resource's current advertised capabilities. The FSM ordering block
and the shared operating instructions state that a recoverable block is
not a limitation to report, and the instructions require the governed
action tool for advertised capabilities. The agentic loop adds a bounded
advertised-action gate: when the operator asked for a lifecycle action,
pulse_control was offered but never submitted, and a session-resolved
resource advertises the action, a tool-free final answer is refused once
with the exact per-target calls.

Covered by tools and loop unit tests (the #1782 transcript against a
scripted provider fails on the previous code with the two exact errors
above), a prompt-contract test, and the live eval scenario
ProxmoxBulkLifecycleActionScenario.
2026-09-02 00:19:08 +01:00

236 lines
6.4 KiB
Go

package eval
import (
"flag"
"os"
"testing"
)
var runLiveEval = flag.Bool("live", false, "Run live eval against Pulse API (requires running Pulse)")
func TestMain(m *testing.M) {
flag.Parse()
os.Exit(m.Run())
}
// TestQuickSmokeTest runs a minimal smoke test against the live API
// Run with: go test -v ./internal/ai/eval -run TestQuickSmokeTest -live
func TestQuickSmokeTest(t *testing.T) {
if !*runLiveEval {
t.Skip("Skipping live eval test. Use -live flag to run against live Pulse API")
}
runner := NewRunner(DefaultConfig())
scenario := QuickSmokeTest()
result := runner.RunScenario(scenario)
runner.PrintSummary(result)
if !result.Passed {
t.Fatalf("Scenario '%s' failed", scenario.Name)
}
}
// TestReadOnlyInfrastructure runs the full read-only infrastructure scenario
// Run with: go test -v ./internal/ai/eval -run TestReadOnlyInfrastructure -live
func TestReadOnlyInfrastructure(t *testing.T) {
if !*runLiveEval {
t.Skip("Skipping live eval test. Use -live flag to run against live Pulse API")
}
runner := NewRunner(DefaultConfig())
scenario := ReadOnlyInfrastructureScenario()
result := runner.RunScenario(scenario)
runner.PrintSummary(result)
if !result.Passed {
t.Fatalf("Scenario '%s' failed", scenario.Name)
}
}
// TestRoutingValidation runs the routing validation scenario
// Run with: go test -v ./internal/ai/eval -run TestRoutingValidation -live
func TestRoutingValidation(t *testing.T) {
if !*runLiveEval {
t.Skip("Skipping live eval test. Use -live flag to run against live Pulse API")
}
runner := NewRunner(DefaultConfig())
scenario := RoutingValidationScenario()
result := runner.RunScenario(scenario)
runner.PrintSummary(result)
if !result.Passed {
t.Fatalf("Scenario '%s' failed", scenario.Name)
}
}
// TestLogTailing runs the log tailing scenario
// Run with: go test -v ./internal/ai/eval -run TestLogTailing -live
func TestLogTailing(t *testing.T) {
if !*runLiveEval {
t.Skip("Skipping live eval test. Use -live flag to run against live Pulse API")
}
runner := NewRunner(DefaultConfig())
scenario := LogTailingScenario()
result := runner.RunScenario(scenario)
runner.PrintSummary(result)
if !result.Passed {
t.Fatalf("Scenario '%s' failed", scenario.Name)
}
}
// TestDiscovery runs the infrastructure discovery scenario
// Run with: go test -v ./internal/ai/eval -run TestDiscovery -live
func TestDiscovery(t *testing.T) {
if !*runLiveEval {
t.Skip("Skipping live eval test. Use -live flag to run against live Pulse API")
}
runner := NewRunner(DefaultConfig())
scenario := DiscoveryScenario()
result := runner.RunScenario(scenario)
runner.PrintSummary(result)
if !result.Passed {
t.Fatalf("Scenario '%s' failed", scenario.Name)
}
}
// TestAllScenarios runs all defined scenarios
// Run with: go test -v ./internal/ai/eval -run TestAllScenarios -live
func TestAllScenarios(t *testing.T) {
if !*runLiveEval {
t.Skip("Skipping live eval test. Use -live flag to run against live Pulse API")
}
runner := NewRunner(DefaultConfig())
scenarios := []Scenario{
QuickSmokeTest(),
ReadOnlyInfrastructureScenario(),
RoutingValidationScenario(),
LogTailingScenario(),
DiscoveryScenario(),
ProxmoxBulkLifecycleActionScenario(),
}
allPassed := true
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
result := runner.RunScenario(scenario)
runner.PrintSummary(result)
if !result.Passed {
allPassed = false
t.Errorf("Scenario '%s' failed", scenario.Name)
}
})
}
if !allPassed {
t.Fatal("One or more scenarios failed")
}
}
// TestPatrolBasic runs the basic patrol eval scenario
// Run with: go test -v ./internal/ai/eval -run TestPatrolBasic -live
func TestPatrolBasic(t *testing.T) {
if !*runLiveEval {
t.Skip("Skipping live eval test. Use -live flag to run against live Pulse API")
}
runner := NewRunner(DefaultConfig())
result := runner.RunPatrolScenario(PatrolBasicScenario())
runner.PrintPatrolSummary(result)
if !result.Success {
t.Fatalf("Patrol scenario '%s' failed", "Patrol Basic Run")
}
}
// TestPatrolInvestigation runs the patrol investigation quality scenario
// Run with: go test -v ./internal/ai/eval -run TestPatrolInvestigation -live
func TestPatrolInvestigation(t *testing.T) {
if !*runLiveEval {
t.Skip("Skipping live eval test. Use -live flag to run against live Pulse API")
}
runner := NewRunner(DefaultConfig())
result := runner.RunPatrolScenario(PatrolInvestigationScenario())
runner.PrintPatrolSummary(result)
if !result.Success {
t.Fatalf("Patrol scenario '%s' failed", "Patrol Investigation Quality")
}
}
// TestPatrolFindingQuality runs the patrol finding quality scenario
// Run with: go test -v ./internal/ai/eval -run TestPatrolFindingQuality -live
func TestPatrolFindingQuality(t *testing.T) {
if !*runLiveEval {
t.Skip("Skipping live eval test. Use -live flag to run against live Pulse API")
}
runner := NewRunner(DefaultConfig())
result := runner.RunPatrolScenario(PatrolFindingQualityScenario())
runner.PrintPatrolSummary(result)
if !result.Success {
t.Fatalf("Patrol scenario '%s' failed", "Patrol Finding Quality")
}
}
// TestAllPatrolScenarios runs all patrol eval scenarios
// Run with: go test -v ./internal/ai/eval -run TestAllPatrolScenarios -live
func TestAllPatrolScenarios(t *testing.T) {
if !*runLiveEval {
t.Skip("Skipping live eval test. Use -live flag to run against live Pulse API")
}
runner := NewRunner(DefaultConfig())
allPassed := true
for _, scenario := range AllPatrolScenarios() {
t.Run(scenario.Name, func(t *testing.T) {
result := runner.RunPatrolScenario(scenario)
runner.PrintPatrolSummary(result)
if !result.Success {
allPassed = false
t.Errorf("Patrol scenario '%s' failed", scenario.Name)
}
})
}
if !allPassed {
t.Fatal("One or more patrol scenarios failed")
}
}
// TestProxmoxBulkLifecycleAction runs the issue #1782 regression: a bulk VM
// reboot request must end in governed pulse_control plans, not a report.
// Run with: go test -v ./internal/ai/eval -run TestProxmoxBulkLifecycleAction -live
// Set EVAL_LIFECYCLE_PATTERN and EVAL_LIFECYCLE_TARGET_COUNT for the estate.
func TestProxmoxBulkLifecycleAction(t *testing.T) {
if !*runLiveEval {
t.Skip("Skipping live eval test. Use -live flag to run against live Pulse API")
}
runner := NewRunner(DefaultConfig())
scenario := ProxmoxBulkLifecycleActionScenario()
result := runner.RunScenario(scenario)
runner.PrintSummary(result)
if !result.Passed {
t.Fatalf("Scenario '%s' failed", scenario.Name)
}
}