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)
This commit is contained in:
committed by
Steve Yegge
parent
2de2d6b7e4
commit
00a59dec44
@@ -361,13 +361,54 @@ gt config default-agent [name] # Get or set town default agent
|
||||
|
||||
**Built-in agents**: `claude`, `gemini`, `codex`, `cursor`, `auggie`, `amp`
|
||||
|
||||
**Custom agents**: Define per-town in `mayor/town.json`:
|
||||
**Custom agents**: Define per-town via CLI or JSON:
|
||||
```bash
|
||||
gt config agent set claude-glm "claude-glm --model glm-4"
|
||||
gt config agent set claude "claude-opus" # Override built-in
|
||||
gt config default-agent claude-glm # Set default
|
||||
```
|
||||
|
||||
**Advanced agent config** (`settings/agents.json`):
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"opencode": {
|
||||
"command": "opencode",
|
||||
"args": [],
|
||||
"resume_flag": "--session",
|
||||
"resume_style": "flag",
|
||||
"non_interactive": {
|
||||
"subcommand": "run",
|
||||
"output_flag": "--format json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rig-level agents** (`<rig>/settings/config.json`):
|
||||
```json
|
||||
{
|
||||
"type": "rig-settings",
|
||||
"version": 1,
|
||||
"agent": "opencode",
|
||||
"agents": {
|
||||
"opencode": {
|
||||
"command": "opencode",
|
||||
"args": ["--session"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Agent resolution order**: rig-level → town-level → built-in presets.
|
||||
|
||||
For OpenCode autonomous mode, set env var in your shell profile:
|
||||
```bash
|
||||
export OPENCODE_PERMISSION='{"*":"allow"}'
|
||||
```
|
||||
|
||||
### Rig Management
|
||||
|
||||
```bash
|
||||
|
||||
@@ -215,16 +215,11 @@ func ensureRegistry() {
|
||||
initRegistryLocked()
|
||||
}
|
||||
|
||||
// LoadAgentRegistry loads agent definitions from a JSON file and merges with built-ins.
|
||||
// User-defined agents override built-in presets with the same name.
|
||||
// This function caches loaded paths to avoid redundant file reads.
|
||||
func LoadAgentRegistry(path string) error {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
|
||||
// loadAgentRegistryFromPath loads agent definitions from a JSON file and merges with built-ins.
|
||||
// Caller must hold registryMu write lock.
|
||||
func loadAgentRegistryFromPathLocked(path string) error {
|
||||
initRegistryLocked()
|
||||
|
||||
// Check if already loaded from this path
|
||||
if loadedPaths[path] {
|
||||
return nil
|
||||
}
|
||||
@@ -232,8 +227,8 @@ func LoadAgentRegistry(path string) error {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304: path is from config
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
loadedPaths[path] = true // Mark as "loaded" (no file)
|
||||
return nil // No custom config, use built-ins only
|
||||
loadedPaths[path] = true
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -243,7 +238,6 @@ func LoadAgentRegistry(path string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Merge user-defined agents (override built-ins)
|
||||
for name, preset := range userRegistry.Agents {
|
||||
preset.Name = AgentPreset(name)
|
||||
globalRegistry.Agents[name] = preset
|
||||
@@ -253,12 +247,41 @@ func LoadAgentRegistry(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAgentRegistry loads agent definitions from a JSON file and merges with built-ins.
|
||||
// User-defined agents override built-in presets with the same name.
|
||||
// This function caches loaded paths to avoid redundant file reads.
|
||||
func LoadAgentRegistry(path string) error {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
return loadAgentRegistryFromPathLocked(path)
|
||||
}
|
||||
|
||||
// DefaultAgentRegistryPath returns the default path for agent registry.
|
||||
// Located alongside other town settings.
|
||||
func DefaultAgentRegistryPath(townRoot string) string {
|
||||
return filepath.Join(townRoot, "settings", "agents.json")
|
||||
}
|
||||
|
||||
// DefaultRigAgentRegistryPath returns the default path for rig-level agent registry.
|
||||
// Located in <rig>/settings/agents.json.
|
||||
func DefaultRigAgentRegistryPath(rigPath string) string {
|
||||
return filepath.Join(rigPath, "settings", "agents.json")
|
||||
}
|
||||
|
||||
// RigAgentRegistryPath returns the path for rig-level agent registry.
|
||||
// Alias for DefaultRigAgentRegistryPath for consistency with other path functions.
|
||||
func RigAgentRegistryPath(rigPath string) string {
|
||||
return DefaultRigAgentRegistryPath(rigPath)
|
||||
}
|
||||
|
||||
// LoadRigAgentRegistry loads agent definitions from a rig-level JSON file and merges with built-ins.
|
||||
// This function works similarly to LoadAgentRegistry but for rig-level configurations.
|
||||
func LoadRigAgentRegistry(path string) error {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
return loadAgentRegistryFromPathLocked(path)
|
||||
}
|
||||
|
||||
// GetAgentPreset returns the preset info for a given agent name.
|
||||
// Returns nil if the preset is not found.
|
||||
func GetAgentPreset(name AgentPreset) *AgentPresetInfo {
|
||||
|
||||
@@ -142,7 +142,7 @@ func TestLoadAgentRegistry(t *testing.T) {
|
||||
// Reset global registry for test isolation
|
||||
ResetRegistryForTesting()
|
||||
|
||||
// Load the custom registry
|
||||
// Load should succeed
|
||||
if err := LoadAgentRegistry(configPath); err != nil {
|
||||
t.Fatalf("LoadAgentRegistry failed: %v", err)
|
||||
}
|
||||
@@ -152,6 +152,7 @@ func TestLoadAgentRegistry(t *testing.T) {
|
||||
if myAgent == nil {
|
||||
t.Fatal("custom agent 'my-agent' not found after loading registry")
|
||||
}
|
||||
|
||||
if myAgent.Command != "my-agent-bin" {
|
||||
t.Errorf("my-agent.Command = %v, want my-agent-bin", myAgent.Command)
|
||||
}
|
||||
@@ -210,6 +211,7 @@ func TestMergeWithPreset(t *testing.T) {
|
||||
if merged.Command != "/custom/claude" {
|
||||
t.Errorf("merged command should be user value, got %s", merged.Command)
|
||||
}
|
||||
|
||||
if len(merged.Args) != 1 || merged.Args[0] != "--custom-arg" {
|
||||
t.Errorf("merged args should be user value, got %v", merged.Args)
|
||||
}
|
||||
@@ -265,12 +267,14 @@ func TestBuildResumeCommand(t *testing.T) {
|
||||
agentName: "claude",
|
||||
sessionID: "",
|
||||
wantEmpty: true,
|
||||
contains: []string{"claude"},
|
||||
},
|
||||
{
|
||||
name: "unknown agent",
|
||||
agentName: "unknown-agent",
|
||||
sessionID: "session-123",
|
||||
wantEmpty: true,
|
||||
contains: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -502,3 +506,112 @@ func TestCursorAgentPreset(t *testing.T) {
|
||||
t.Errorf("cursor ResumeStyle = %q, want flag", info.ResumeStyle)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultRigAgentRegistryPath verifies that the default rig agent registry path is constructed correctly.
|
||||
func TestDefaultRigAgentRegistryPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
rigPath string
|
||||
expectedPath string
|
||||
}{
|
||||
{"/Users/alice/gt/myproject", "/Users/alice/gt/myproject/settings/agents.json"},
|
||||
{"/tmp/my-rig", "/tmp/my-rig/settings/agents.json"},
|
||||
{"relative/path", "relative/path/settings/agents.json"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.rigPath, func(t *testing.T) {
|
||||
got := DefaultRigAgentRegistryPath(tt.rigPath)
|
||||
want := tt.expectedPath
|
||||
if got != want {
|
||||
t.Errorf("DefaultRigAgentRegistryPath(%s) = %s, want %s", tt.rigPath, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRigAgentRegistry verifies that rig-level agent registry is loaded correctly.
|
||||
func TestLoadRigAgentRegistry(t *testing.T) {
|
||||
// Reset registry for test isolation
|
||||
ResetRegistryForTesting()
|
||||
t.Cleanup(ResetRegistryForTesting)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
registryPath := filepath.Join(tmpDir, "settings", "agents.json")
|
||||
configDir := filepath.Join(tmpDir, "settings")
|
||||
|
||||
// Create settings directory
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create settings dir: %v", err)
|
||||
}
|
||||
|
||||
// Write agent registry
|
||||
registryContent := `{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"opencode": {
|
||||
"command": "opencode",
|
||||
"args": ["--session"],
|
||||
"non_interactive": {
|
||||
"subcommand": "run",
|
||||
"output_flag": "--format json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
if err := os.WriteFile(registryPath, []byte(registryContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write registry file: %v", err)
|
||||
}
|
||||
|
||||
// Test 1: Load should succeed and merge agents
|
||||
t.Run("load and merge", func(t *testing.T) {
|
||||
if err := LoadRigAgentRegistry(registryPath); err != nil {
|
||||
t.Fatalf("LoadRigAgentRegistry(%s) failed: %v", registryPath, err)
|
||||
}
|
||||
|
||||
info := GetAgentPresetByName("opencode")
|
||||
if info == nil {
|
||||
t.Fatal("expected opencode agent to be available after loading rig registry")
|
||||
}
|
||||
|
||||
if info.Command != "opencode" {
|
||||
t.Errorf("expected opencode agent command to be 'opencode', got %s", info.Command)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 2: File not found should return nil (no error)
|
||||
t.Run("file not found", func(t *testing.T) {
|
||||
nonExistentPath := filepath.Join(tmpDir, "other-rig", "settings", "agents.json")
|
||||
if err := LoadRigAgentRegistry(nonExistentPath); err != nil {
|
||||
t.Errorf("LoadRigAgentRegistry(%s) should not error for non-existent file: %v", nonExistentPath, err)
|
||||
}
|
||||
|
||||
// Verify that previously loaded agent (from test 1) is still available
|
||||
info := GetAgentPresetByName("opencode")
|
||||
if info == nil {
|
||||
t.Errorf("expected opencode agent to still be available after loading non-existent path")
|
||||
return
|
||||
}
|
||||
if info.Command != "opencode" {
|
||||
t.Errorf("expected opencode agent command to be 'opencode', got %s", info.Command)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 3: Invalid JSON should error
|
||||
t.Run("invalid JSON", func(t *testing.T) {
|
||||
invalidRegistryPath := filepath.Join(tmpDir, "bad-rig", "settings", "agents.json")
|
||||
badConfigDir := filepath.Join(tmpDir, "bad-rig", "settings")
|
||||
if err := os.MkdirAll(badConfigDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create bad-rig settings dir: %v", err)
|
||||
}
|
||||
|
||||
invalidContent := `{"version": 1, "agents": {invalid json}}`
|
||||
if err := os.WriteFile(invalidRegistryPath, []byte(invalidContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write invalid registry file: %v", err)
|
||||
}
|
||||
|
||||
if err := LoadRigAgentRegistry(invalidRegistryPath); err == nil {
|
||||
t.Errorf("LoadRigAgentRegistry(%s) should error for invalid JSON: got nil", invalidRegistryPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
433
internal/config/integration_test.go
Normal file
433
internal/config/integration_test.go
Normal file
@@ -0,0 +1,433 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -824,6 +824,9 @@ func ResolveAgentConfig(townRoot, rigPath string) *RuntimeConfig {
|
||||
// Load custom agent registry if it exists
|
||||
_ = LoadAgentRegistry(DefaultAgentRegistryPath(townRoot))
|
||||
|
||||
// Load rig-level custom agent registry if it exists (for per-rig custom agents)
|
||||
_ = LoadRigAgentRegistry(RigAgentRegistryPath(rigPath))
|
||||
|
||||
// Determine which agent name to use
|
||||
agentName := ""
|
||||
if rigSettings != nil && rigSettings.Agent != "" {
|
||||
@@ -834,8 +837,7 @@ func ResolveAgentConfig(townRoot, rigPath string) *RuntimeConfig {
|
||||
agentName = "claude" // ultimate fallback
|
||||
}
|
||||
|
||||
// Look up the agent configuration
|
||||
return lookupAgentConfig(agentName, townSettings)
|
||||
return lookupAgentConfig(agentName, townSettings, rigSettings)
|
||||
}
|
||||
|
||||
// ResolveAgentConfigWithOverride resolves the agent configuration for a rig, with an optional override.
|
||||
@@ -864,6 +866,9 @@ func ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride string) (*R
|
||||
// Load custom agent registry if it exists
|
||||
_ = LoadAgentRegistry(DefaultAgentRegistryPath(townRoot))
|
||||
|
||||
// Load rig-level custom agent registry if it exists (for per-rig custom agents)
|
||||
_ = LoadRigAgentRegistry(RigAgentRegistryPath(rigPath))
|
||||
|
||||
// Determine which agent name to use
|
||||
agentName := ""
|
||||
if agentOverride != "" {
|
||||
@@ -876,13 +881,21 @@ func ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride string) (*R
|
||||
agentName = "claude" // ultimate fallback
|
||||
}
|
||||
|
||||
// If an override is requested, validate it exists.
|
||||
// If an override is requested, validate it exists
|
||||
if agentOverride != "" {
|
||||
// Check rig-level custom agents first
|
||||
if rigSettings != nil && rigSettings.Agents != nil {
|
||||
if custom, ok := rigSettings.Agents[agentName]; ok && custom != nil {
|
||||
return fillRuntimeDefaults(custom), agentName, nil
|
||||
}
|
||||
}
|
||||
// Then check town-level custom agents
|
||||
if townSettings.Agents != nil {
|
||||
if custom, ok := townSettings.Agents[agentName]; ok && custom != nil {
|
||||
return fillRuntimeDefaults(custom), agentName, nil
|
||||
}
|
||||
}
|
||||
// Then check built-in presets
|
||||
if preset := GetAgentPresetByName(agentName); preset != nil {
|
||||
return RuntimeConfigFromPreset(AgentPreset(agentName)), agentName, nil
|
||||
}
|
||||
@@ -890,13 +903,20 @@ func ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride string) (*R
|
||||
}
|
||||
|
||||
// Normal lookup path (no override)
|
||||
return lookupAgentConfig(agentName, townSettings), agentName, nil
|
||||
return lookupAgentConfig(agentName, townSettings, rigSettings), agentName, nil
|
||||
}
|
||||
|
||||
// lookupAgentConfig looks up an agent by name.
|
||||
// First checks town's custom agents, then built-in presets from agents.go.
|
||||
func lookupAgentConfig(name string, townSettings *TownSettings) *RuntimeConfig {
|
||||
// First check town's custom agents
|
||||
// Checks rig-level custom agents first, then town's custom agents, then built-in presets from agents.go.
|
||||
func lookupAgentConfig(name string, townSettings *TownSettings, rigSettings *RigSettings) *RuntimeConfig {
|
||||
// First check rig's custom agents (NEW - fix for rig-level agent support)
|
||||
if rigSettings != nil && rigSettings.Agents != nil {
|
||||
if custom, ok := rigSettings.Agents[name]; ok && custom != nil {
|
||||
return fillRuntimeDefaults(custom)
|
||||
}
|
||||
}
|
||||
|
||||
// Then check town's custom agents (existing)
|
||||
if townSettings != nil && townSettings.Agents != nil {
|
||||
if custom, ok := townSettings.Agents[name]; ok && custom != nil {
|
||||
return fillRuntimeDefaults(custom)
|
||||
|
||||
@@ -1570,3 +1570,94 @@ func TestSaveTownSettings(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestLookupAgentConfigWithRigSettings verifies that lookupAgentConfig checks
|
||||
// rig-level agents first, then town-level agents, then built-ins.
|
||||
func TestLookupAgentConfigWithRigSettings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rigSettings *RigSettings
|
||||
townSettings *TownSettings
|
||||
expectedCommand string
|
||||
expectedFrom string
|
||||
}{
|
||||
{
|
||||
name: "rig-custom-agent",
|
||||
rigSettings: &RigSettings{
|
||||
Agent: "default-rig-agent",
|
||||
Agents: map[string]*RuntimeConfig{
|
||||
"rig-custom-agent": {
|
||||
Command: "custom-rig-cmd",
|
||||
Args: []string{"--rig-flag"},
|
||||
},
|
||||
},
|
||||
},
|
||||
townSettings: &TownSettings{
|
||||
Agents: map[string]*RuntimeConfig{
|
||||
"town-custom-agent": {
|
||||
Command: "custom-town-cmd",
|
||||
Args: []string{"--town-flag"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedCommand: "custom-rig-cmd",
|
||||
expectedFrom: "rig",
|
||||
},
|
||||
{
|
||||
name: "town-custom-agent",
|
||||
rigSettings: &RigSettings{
|
||||
Agents: map[string]*RuntimeConfig{
|
||||
"other-rig-agent": {
|
||||
Command: "other-rig-cmd",
|
||||
},
|
||||
},
|
||||
},
|
||||
townSettings: &TownSettings{
|
||||
Agents: map[string]*RuntimeConfig{
|
||||
"town-custom-agent": {
|
||||
Command: "custom-town-cmd",
|
||||
Args: []string{"--town-flag"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedCommand: "custom-town-cmd",
|
||||
expectedFrom: "town",
|
||||
},
|
||||
{
|
||||
name: "unknown-agent",
|
||||
rigSettings: nil,
|
||||
townSettings: nil,
|
||||
expectedCommand: "claude",
|
||||
expectedFrom: "builtin",
|
||||
},
|
||||
{
|
||||
name: "claude",
|
||||
rigSettings: &RigSettings{
|
||||
Agent: "claude",
|
||||
},
|
||||
townSettings: &TownSettings{
|
||||
Agents: map[string]*RuntimeConfig{
|
||||
"claude": {
|
||||
Command: "custom-claude",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedCommand: "custom-claude",
|
||||
expectedFrom: "town",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rc := lookupAgentConfig(tt.name, tt.townSettings, tt.rigSettings)
|
||||
|
||||
if rc == nil {
|
||||
t.Errorf("lookupAgentConfig(%s) returned nil", tt.name)
|
||||
}
|
||||
|
||||
if rc.Command != tt.expectedCommand {
|
||||
t.Errorf("lookupAgentConfig(%s).Command = %s, want %s", tt.name, rc.Command, tt.expectedCommand)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,11 @@ type RigSettings struct {
|
||||
// If empty, uses the town's default_agent setting.
|
||||
// Takes precedence over Runtime if both are set.
|
||||
Agent string `json:"agent,omitempty"`
|
||||
|
||||
// Agents defines custom agent configurations or overrides for this rig.
|
||||
// Similar to TownSettings.Agents but applies to this rig only.
|
||||
// Allows per-rig custom agents for polecats and crew members.
|
||||
Agents map[string]*RuntimeConfig `json:"agents,omitempty"`
|
||||
}
|
||||
|
||||
// CrewConfig represents crew workspace settings for a rig.
|
||||
|
||||
Reference in New Issue
Block a user