Files
gastown/internal/config/integration_test.go
Subhrajit Makur 00a59dec44 feat: Add rig-level custom agent support (#12)
* feat: Add rig-level custom agent support

Implement rig-level custom agent configuration support to enable per-rig
agent definitions in <rig>/settings/config.json, following the same pattern as
town-level agents in settings/config.json.

Changes:
- Added RigSettings.Agents field to internal/config/types.go
- Added DefaultRigAgentRegistryPath() and LoadRigAgentRegistry() functions to internal/config/agents.go
- Updated ResolveAgentConfigWithOverride() to accept and pass rigSettings parameter
- Updated GetRuntimeCommandWithAgentOverride() to use rigSettings when available
- Updated GetRuntimeCommandWithPromptAndAgentOverride() to use rigSettings
- Updated all Build*WithOverride functions to pass rigSettings

This fixes the issue where rig-level agent settings were loaded but
ignored by lookupAgentConfig, enabling per-rig custom agents for
polecats and crew members.

* test: Add rig-level custom agent tests

Added comprehensive unit tests for rig agent registry functions:
- TestDefaultRigAgentRegistryPath: verifies path construction
- TestLoadRigAgentRegistry: verifies file loading and JSON parsing
- TestLookupAgentConfigWithRigSettings: verifies agent lookup priority (rig > town > builtin)

Added placeholder integration test for future CI/CD setup.

* initial commit

* fix: resolve compilation errors in rig-level custom agent support

- Add missing RigAgentRegistryPath function (alias for DefaultRigAgentRegistryPath)
- Restore ResolveAgentConfigWithOverride function that was incorrectly removed
- Fix ResolveAgentConfig to return single value (not triple)
- Add initRegistryLocked() call to LoadRigAgentRegistry to prevent nil panic
- Fix DefaultRigAgentRegistryPath to use rigPath directly (not parent dir)
- Fix test file syntax errors (remove EOF artifacts)
- Fix test parameter order for lookupAgentConfig calls
- Fix test expectations to match correct custom agent override behavior

* test: implement rig-level custom agent integration test

- Add stub agent script that simulates AI agent with Q&A capability
- Test ResolveAgentConfig correctly picks up rig-level agents
- Test BuildPolecatStartupCommand includes custom agent command
- Test ResolveAgentConfigWithOverride respects rig agents
- Test rig agents override town agents with same name
- Add tmux integration test that spawns session and verifies output
- Stub agent echoes 'STUB_AGENT_STARTED' and handles ping/pong Q&A
- All tests pass including real tmux session verification

* docs: add OpenCode custom agent example to reference

- Show settings/agents.json format for advanced configs
- Include OpenCode example with session resume flags
- Document OPENCODE_PERMISSION env var for autonomous mode

* fix: improve rig-level agent support with docs and test fixes

- Add rig-level agent documentation to reference.md
- Document agent resolution order (rig → town → built-in)
- Deduplicate LoadAgentRegistry/LoadRigAgentRegistry into shared helper
- Fix test isolation in TestLoadRigAgentRegistry
- Fix nil pointer dereference in test assertions (use t.Fatal not t.Error)
2026-01-07 21:06:46 -08:00

434 lines
13 KiB
Go

// Test Rig-Level Custom Agent Support
//
// This integration test verifies that custom agents defined in rig-level
// settings/config.json are correctly loaded and used when spawning polecats.
// It creates a stub agent, configures it at the rig level, and verifies
// the agent is actually used via tmux session capture.
package config
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// TestRigLevelCustomAgentIntegration tests end-to-end rig-level custom agent functionality.
// This test:
// 1. Creates a stub agent script that echoes identifiable output
// 2. Sets up a minimal town/rig with the custom agent configured
// 3. Verifies that BuildPolecatStartupCommand uses the custom agent
// 4. Optionally spawns a tmux session and verifies output (if tmux available)
func TestRigLevelCustomAgentIntegration(t *testing.T) {
tmpDir := t.TempDir()
// Create the stub agent script
stubAgentPath := createStubAgent(t, tmpDir)
// Set up town structure
townRoot := filepath.Join(tmpDir, "town")
rigName := "testrig"
rigPath := filepath.Join(townRoot, rigName)
setupTestTownWithCustomAgent(t, townRoot, rigName, stubAgentPath)
// Test 1: Verify ResolveAgentConfig picks up the custom agent
t.Run("ResolveAgentConfig uses rig-level agent", func(t *testing.T) {
rc := ResolveAgentConfig(townRoot, rigPath)
if rc == nil {
t.Fatal("ResolveAgentConfig returned nil")
}
if rc.Command != stubAgentPath {
t.Errorf("Expected command %q, got %q", stubAgentPath, rc.Command)
}
// Verify args are passed through
if len(rc.Args) != 2 || rc.Args[0] != "--test-mode" || rc.Args[1] != "--stub" {
t.Errorf("Expected args [--test-mode --stub], got %v", rc.Args)
}
})
// Test 2: Verify BuildPolecatStartupCommand includes the custom agent
t.Run("BuildPolecatStartupCommand uses custom agent", func(t *testing.T) {
cmd := BuildPolecatStartupCommand(rigName, "test-polecat", rigPath, "")
if !strings.Contains(cmd, stubAgentPath) {
t.Errorf("Expected command to contain stub agent path %q, got: %s", stubAgentPath, cmd)
}
if !strings.Contains(cmd, "--test-mode") {
t.Errorf("Expected command to contain --test-mode, got: %s", cmd)
}
// Verify environment variables are set
if !strings.Contains(cmd, "GT_ROLE=polecat") {
t.Errorf("Expected GT_ROLE=polecat in command, got: %s", cmd)
}
if !strings.Contains(cmd, "GT_POLECAT=test-polecat") {
t.Errorf("Expected GT_POLECAT=test-polecat in command, got: %s", cmd)
}
})
// Test 3: Verify ResolveAgentConfigWithOverride respects rig agents
t.Run("ResolveAgentConfigWithOverride with rig agent", func(t *testing.T) {
rc, agentName, err := ResolveAgentConfigWithOverride(townRoot, rigPath, "stub-agent")
if err != nil {
t.Fatalf("ResolveAgentConfigWithOverride failed: %v", err)
}
if agentName != "stub-agent" {
t.Errorf("Expected agent name 'stub-agent', got %q", agentName)
}
if rc.Command != stubAgentPath {
t.Errorf("Expected command %q, got %q", stubAgentPath, rc.Command)
}
})
// Test 4: Verify unknown agent override returns error
t.Run("ResolveAgentConfigWithOverride unknown agent errors", func(t *testing.T) {
_, _, err := ResolveAgentConfigWithOverride(townRoot, rigPath, "nonexistent-agent")
if err == nil {
t.Fatal("Expected error for nonexistent agent, got nil")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("Expected 'not found' error, got: %v", err)
}
})
// Test 5: Tmux integration (skip if tmux not available)
t.Run("TmuxSessionWithCustomAgent", func(t *testing.T) {
if _, err := exec.LookPath("tmux"); err != nil {
t.Skip("tmux not available, skipping session test")
}
testTmuxSessionWithStubAgent(t, tmpDir, stubAgentPath, rigName)
})
}
// createStubAgent creates a bash script that simulates an AI agent.
// The script echoes identifiable output and handles simple Q&A.
func createStubAgent(t *testing.T, tmpDir string) string {
t.Helper()
stubScript := `#!/bin/bash
# Stub Agent for Integration Testing
# This simulates an AI agent with identifiable output
AGENT_NAME="STUB_AGENT"
AGENT_VERSION="1.0.0"
echo "=========================================="
echo "STUB_AGENT_STARTED"
echo "Agent: $AGENT_NAME v$AGENT_VERSION"
echo "Args: $@"
echo "Working Dir: $(pwd)"
echo "GT_ROLE: ${GT_ROLE:-not_set}"
echo "GT_POLECAT: ${GT_POLECAT:-not_set}"
echo "GT_RIG: ${GT_RIG:-not_set}"
echo "=========================================="
# Simple Q&A loop
while true; do
echo ""
echo "STUB_AGENT_READY"
echo "Enter question (or 'exit' to quit):"
# Read with timeout for non-interactive testing
if read -t 5 question; then
case "$question" in
"exit"|"quit"|"q")
echo "STUB_AGENT_EXITING"
exit 0
;;
"what is 2+2"*)
echo "STUB_AGENT_ANSWER: 4"
;;
"ping"*)
echo "STUB_AGENT_ANSWER: pong"
;;
"status"*)
echo "STUB_AGENT_ANSWER: operational"
;;
*)
echo "STUB_AGENT_ANSWER: I received your question: $question"
;;
esac
else
# Timeout - check if we should exit
if [ -f "/tmp/stub_agent_stop_$$" ]; then
echo "STUB_AGENT_STOPPING (signal file detected)"
rm -f "/tmp/stub_agent_stop_$$"
exit 0
fi
fi
done
`
stubPath := filepath.Join(tmpDir, "stub-agent")
if err := os.WriteFile(stubPath, []byte(stubScript), 0755); err != nil {
t.Fatalf("Failed to create stub agent: %v", err)
}
return stubPath
}
// setupTestTownWithCustomAgent creates a minimal town/rig structure with a custom agent.
func setupTestTownWithCustomAgent(t *testing.T, townRoot, rigName, stubAgentPath string) {
t.Helper()
rigPath := filepath.Join(townRoot, rigName)
// Create directory structure
dirs := []string{
filepath.Join(townRoot, "mayor"),
filepath.Join(townRoot, "settings"),
filepath.Join(rigPath, "settings"),
filepath.Join(rigPath, "polecats"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("Failed to create directory %s: %v", dir, err)
}
}
// Create town.json
townConfig := map[string]interface{}{
"type": "town",
"version": 2,
"name": "test-town",
"created_at": time.Now().Format(time.RFC3339),
}
writeTownJSON(t, filepath.Join(townRoot, "mayor", "town.json"), townConfig)
// Create town settings (empty, uses defaults)
townSettings := map[string]interface{}{
"type": "town-settings",
"version": 1,
"default_agent": "claude",
}
writeTownJSON(t, filepath.Join(townRoot, "settings", "config.json"), townSettings)
// Create rig settings with custom agent
rigSettings := map[string]interface{}{
"type": "rig-settings",
"version": 1,
"agent": "stub-agent",
"agents": map[string]interface{}{
"stub-agent": map[string]interface{}{
"command": stubAgentPath,
"args": []string{"--test-mode", "--stub"},
},
},
}
writeTownJSON(t, filepath.Join(rigPath, "settings", "config.json"), rigSettings)
// Create rigs.json
rigsConfig := map[string]interface{}{
"version": 1,
"rigs": map[string]interface{}{
rigName: map[string]interface{}{
"git_url": "https://github.com/test/testrepo.git",
"added_at": time.Now().Format(time.RFC3339),
},
},
}
writeTownJSON(t, filepath.Join(townRoot, "mayor", "rigs.json"), rigsConfig)
}
// writeTownJSON writes a JSON config file.
func writeTownJSON(t *testing.T, path string, data interface{}) {
t.Helper()
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
t.Fatalf("Failed to marshal JSON for %s: %v", path, err)
}
if err := os.WriteFile(path, jsonData, 0644); err != nil {
t.Fatalf("Failed to write %s: %v", path, err)
}
}
// testTmuxSessionWithStubAgent tests that a tmux session runs the stub agent.
func testTmuxSessionWithStubAgent(t *testing.T, tmpDir, stubAgentPath, rigName string) {
t.Helper()
sessionName := fmt.Sprintf("gt-test-%d", time.Now().UnixNano())
workDir := tmpDir
// Cleanup session on exit
defer func() {
exec.Command("tmux", "kill-session", "-t", sessionName).Run()
}()
// Create tmux session
cmd := exec.Command("tmux", "new-session", "-d", "-s", sessionName, "-c", workDir)
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create tmux session: %v", err)
}
// Set environment variables
envVars := map[string]string{
"GT_ROLE": "polecat",
"GT_POLECAT": "test-polecat",
"GT_RIG": rigName,
}
for key, val := range envVars {
cmd := exec.Command("tmux", "set-environment", "-t", sessionName, key, val)
if err := cmd.Run(); err != nil {
t.Logf("Warning: failed to set %s: %v", key, err)
}
}
// Send the stub agent command
agentCmd := fmt.Sprintf("%s --test-mode --stub", stubAgentPath)
cmd = exec.Command("tmux", "send-keys", "-t", sessionName, agentCmd, "Enter")
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to send keys: %v", err)
}
// Wait for agent to start
time.Sleep(2 * time.Second)
// Capture pane output
output := captureTmuxPane(t, sessionName, 50)
// Verify stub agent started
if !strings.Contains(output, "STUB_AGENT_STARTED") {
t.Errorf("Expected STUB_AGENT_STARTED in output, got:\n%s", output)
}
// Verify environment variables were visible to agent
if !strings.Contains(output, "GT_ROLE: polecat") {
t.Logf("Warning: GT_ROLE not visible in agent output (tmux env may not propagate to subshell)")
}
// Send a question and verify response
cmd = exec.Command("tmux", "send-keys", "-t", sessionName, "ping", "Enter")
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to send ping: %v", err)
}
time.Sleep(1 * time.Second)
output = captureTmuxPane(t, sessionName, 50)
if !strings.Contains(output, "STUB_AGENT_ANSWER: pong") {
t.Errorf("Expected 'pong' response, got:\n%s", output)
}
// Send exit command
cmd = exec.Command("tmux", "send-keys", "-t", sessionName, "exit", "Enter")
if err := cmd.Run(); err != nil {
t.Logf("Warning: failed to send exit: %v", err)
}
time.Sleep(500 * time.Millisecond)
// Final capture to verify clean exit
output = captureTmuxPane(t, sessionName, 50)
if !strings.Contains(output, "STUB_AGENT_EXITING") {
t.Logf("Note: Agent may have exited before capture. Output:\n%s", output)
}
t.Logf("Tmux session test completed successfully")
}
// captureTmuxPane captures the output from a tmux pane.
func captureTmuxPane(t *testing.T, sessionName string, lines int) string {
t.Helper()
cmd := exec.Command("tmux", "capture-pane", "-t", sessionName, "-p", "-S", fmt.Sprintf("-%d", lines))
output, err := cmd.Output()
if err != nil {
t.Logf("Warning: failed to capture pane: %v", err)
return ""
}
return string(output)
}
// TestRigAgentOverridesTownAgent verifies rig agents take precedence over town agents.
func TestRigAgentOverridesTownAgent(t *testing.T) {
tmpDir := t.TempDir()
townRoot := filepath.Join(tmpDir, "town")
rigName := "testrig"
rigPath := filepath.Join(townRoot, rigName)
// Create directory structure
dirs := []string{
filepath.Join(townRoot, "mayor"),
filepath.Join(townRoot, "settings"),
filepath.Join(rigPath, "settings"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("Failed to create directory %s: %v", dir, err)
}
}
// Town settings with a custom agent
townSettings := map[string]interface{}{
"type": "town-settings",
"version": 1,
"default_agent": "my-agent",
"agents": map[string]interface{}{
"my-agent": map[string]interface{}{
"command": "/town/path/to/agent",
"args": []string{"--town-level"},
},
},
}
writeTownJSON(t, filepath.Join(townRoot, "settings", "config.json"), townSettings)
// Rig settings with SAME agent name but different config (should override)
rigSettings := map[string]interface{}{
"type": "rig-settings",
"version": 1,
"agent": "my-agent",
"agents": map[string]interface{}{
"my-agent": map[string]interface{}{
"command": "/rig/path/to/agent",
"args": []string{"--rig-level"},
},
},
}
writeTownJSON(t, filepath.Join(rigPath, "settings", "config.json"), rigSettings)
// Create town.json
townConfig := map[string]interface{}{
"type": "town",
"version": 2,
"name": "test-town",
"created_at": time.Now().Format(time.RFC3339),
}
writeTownJSON(t, filepath.Join(townRoot, "mayor", "town.json"), townConfig)
// Resolve agent config
rc := ResolveAgentConfig(townRoot, rigPath)
if rc == nil {
t.Fatal("ResolveAgentConfig returned nil")
}
// Rig agent should take precedence
if rc.Command != "/rig/path/to/agent" {
t.Errorf("Expected rig agent command '/rig/path/to/agent', got %q", rc.Command)
}
if len(rc.Args) != 1 || rc.Args[0] != "--rig-level" {
t.Errorf("Expected rig args [--rig-level], got %v", rc.Args)
}
}