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:
Subhrajit Makur
2026-01-08 01:23:29 +05:30
committed by Steve Yegge
parent 2de2d6b7e4
commit 00a59dec44
7 changed files with 746 additions and 20 deletions

View File

@@ -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)
}
})
}