Files
gastown/internal/tmux/tmux_test.go
Mike Lady 92042d679c feat: Add Cursor, Auggie, and Sourcegraph AMP agent presets (#247)
* feat: add Cursor Agent as compatible agent for Gas Town

Add AgentCursor preset with ProcessNames field for multi-agent detection:
- AgentCursor preset: cursor-agent -p -f (headless + force mode)
- ProcessNames field on AgentPresetInfo for agent detection
- IsAgentRunning(session, processNames) in tmux package
- GetProcessNames(agentName) helper function

Closes: ga-vwr

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: centralize agent preset list in config.go

Replace hardcoded ["claude", "gemini", "codex"] arrays with calls to
config.ListAgentPresets() to dynamically include all registered agents.

This fixes cursor agent not appearing in `gt config agent list` and
ensures new agent presets are automatically included everywhere.

Also updated doc comments to include "cursor" in example lists.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add comprehensive agent client tests

Add tests for agent detection and command generation:

- TestIsAgentRunning: validates process name detection for all agents
  (claude/node, gemini, codex, cursor-agent)
- TestIsAgentRunning_NonexistentSession: edge case handling
- TestIsClaudeRunning: backwards compatibility wrapper
- TestListAgentPresetsMatchesConstants: ensures ListAgentPresets()
  returns all AgentPreset constants
- TestAgentCommandGeneration: validates full command line generation
  for all supported agents

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add Auggie agent, fix Cursor interactive mode

Add Auggie CLI as supported agent:
- Command: auggie
- Args: --allow-indexing
- Supports session resume via --resume flag

Fix Cursor agent configuration:
- Remove -p flag (requires prompt, breaks interactive mode)
- Clear SessionIDEnv (cursor uses --resume with chatId directly)
- Keep -f flag for force/YOLO mode

Updated all test cases for both agents.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(agents): add Sourcegraph AMP as agent preset

Add AgentAmp constant and builtinPresets entry for Sourcegraph AMP CLI.

Configuration:
- Command: amp
- Args: --dangerously-allow-all --no-ide
- ResumeStyle: subcommand (amp threads continue <threadId>)
- ProcessNames: amp

Closes: ga-guq

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: lint error in cleanBeadsRuntimeFiles

Change function to not return error (was always nil).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: beads v0.46.0 compatibility and test fixes

- Add custom types config (agent,role,rig,convoy,event) after bd init calls
- Fix tmux_test.go to use variadic IsAgentRunning signature

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: update agent documentation for new presets

- README.md: Update agent examples to show cursor/auggie, add built-in presets list
- docs/reference.md: Add cursor, auggie, amp to built-in agents list
- CHANGELOG.md: Add entry for new agent presets under [Unreleased]

Addresses PR #247 review feedback.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:35:06 -08:00

428 lines
10 KiB
Go

package tmux
import (
"os/exec"
"strings"
"testing"
)
func hasTmux() bool {
_, err := exec.LookPath("tmux")
return err == nil
}
func TestListSessionsNoServer(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessions, err := tm.ListSessions()
// Should not error even if no server running
if err != nil {
t.Fatalf("ListSessions: %v", err)
}
// Result may be nil or empty slice
_ = sessions
}
func TestHasSessionNoServer(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
has, err := tm.HasSession("nonexistent-session-xyz")
if err != nil {
t.Fatalf("HasSession: %v", err)
}
if has {
t.Error("expected session to not exist")
}
}
func TestSessionLifecycle(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-session-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session
if err := tm.NewSession(sessionName, ""); err != nil {
t.Fatalf("NewSession: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// Verify exists
has, err := tm.HasSession(sessionName)
if err != nil {
t.Fatalf("HasSession: %v", err)
}
if !has {
t.Error("expected session to exist after creation")
}
// List should include it
sessions, err := tm.ListSessions()
if err != nil {
t.Fatalf("ListSessions: %v", err)
}
found := false
for _, s := range sessions {
if s == sessionName {
found = true
break
}
}
if !found {
t.Error("session not found in list")
}
// Kill session
if err := tm.KillSession(sessionName); err != nil {
t.Fatalf("KillSession: %v", err)
}
// Verify gone
has, err = tm.HasSession(sessionName)
if err != nil {
t.Fatalf("HasSession after kill: %v", err)
}
if has {
t.Error("expected session to not exist after kill")
}
}
func TestDuplicateSession(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-dup-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session
if err := tm.NewSession(sessionName, ""); err != nil {
t.Fatalf("NewSession: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// Try to create duplicate
err := tm.NewSession(sessionName, "")
if err != ErrSessionExists {
t.Errorf("expected ErrSessionExists, got %v", err)
}
}
func TestSendKeysAndCapture(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-keys-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session
if err := tm.NewSession(sessionName, ""); err != nil {
t.Fatalf("NewSession: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// Send echo command
if err := tm.SendKeys(sessionName, "echo HELLO_TEST_MARKER"); err != nil {
t.Fatalf("SendKeys: %v", err)
}
// Give it a moment to execute
// In real tests you'd wait for output, but for basic test we just capture
output, err := tm.CapturePane(sessionName, 50)
if err != nil {
t.Fatalf("CapturePane: %v", err)
}
// Should contain our marker (might not if shell is slow, but usually works)
if !strings.Contains(output, "echo HELLO_TEST_MARKER") {
t.Logf("captured output: %s", output)
// Don't fail, just note - timing issues possible
}
}
func TestGetSessionInfo(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-info-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session
if err := tm.NewSession(sessionName, ""); err != nil {
t.Fatalf("NewSession: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
info, err := tm.GetSessionInfo(sessionName)
if err != nil {
t.Fatalf("GetSessionInfo: %v", err)
}
if info.Name != sessionName {
t.Errorf("Name = %q, want %q", info.Name, sessionName)
}
if info.Windows < 1 {
t.Errorf("Windows = %d, want >= 1", info.Windows)
}
}
func TestWrapError(t *testing.T) {
tm := NewTmux()
tests := []struct {
stderr string
want error
}{
{"no server running on /tmp/tmux-...", ErrNoServer},
{"error connecting to /tmp/tmux-...", ErrNoServer},
{"duplicate session: test", ErrSessionExists},
{"session not found: test", ErrSessionNotFound},
{"can't find session: test", ErrSessionNotFound},
}
for _, tt := range tests {
err := tm.wrapError(nil, tt.stderr, []string{"test"})
if err != tt.want {
t.Errorf("wrapError(%q) = %v, want %v", tt.stderr, err, tt.want)
}
}
}
func TestEnsureSessionFresh_NoExistingSession(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-fresh-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// EnsureSessionFresh should create a new session
if err := tm.EnsureSessionFresh(sessionName, ""); err != nil {
t.Fatalf("EnsureSessionFresh: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// Verify session exists
has, err := tm.HasSession(sessionName)
if err != nil {
t.Fatalf("HasSession: %v", err)
}
if !has {
t.Error("expected session to exist after EnsureSessionFresh")
}
}
func TestEnsureSessionFresh_ZombieSession(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-zombie-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create a zombie session (session exists but no Claude/node running)
// A normal tmux session with bash/zsh is a "zombie" for our purposes
if err := tm.NewSession(sessionName, ""); err != nil {
t.Fatalf("NewSession: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// Verify it's a zombie (not running Claude/node)
if tm.IsClaudeRunning(sessionName) {
t.Skip("session unexpectedly has Claude running - can't test zombie case")
}
// Verify generic agent check also treats it as not running (shell session)
if tm.IsAgentRunning(sessionName) {
t.Fatalf("expected IsAgentRunning(%q) to be false for a fresh shell session", sessionName)
}
// EnsureSessionFresh should kill the zombie and create fresh session
// This should NOT error with "session already exists"
if err := tm.EnsureSessionFresh(sessionName, ""); err != nil {
t.Fatalf("EnsureSessionFresh on zombie: %v", err)
}
// Session should still exist
has, err := tm.HasSession(sessionName)
if err != nil {
t.Fatalf("HasSession: %v", err)
}
if !has {
t.Error("expected session to exist after EnsureSessionFresh on zombie")
}
}
func TestEnsureSessionFresh_IdempotentOnZombie(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-idem-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Call EnsureSessionFresh multiple times - should work each time
for i := 0; i < 3; i++ {
if err := tm.EnsureSessionFresh(sessionName, ""); err != nil {
t.Fatalf("EnsureSessionFresh attempt %d: %v", i+1, err)
}
}
defer func() { _ = tm.KillSession(sessionName) }()
// Session should exist
has, err := tm.HasSession(sessionName)
if err != nil {
t.Fatalf("HasSession: %v", err)
}
if !has {
t.Error("expected session to exist after multiple EnsureSessionFresh calls")
}
}
func TestIsAgentRunning(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-agent-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session (will run default shell)
if err := tm.NewSession(sessionName, ""); err != nil {
t.Fatalf("NewSession: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// Get the current pane command (should be bash/zsh/etc)
cmd, err := tm.GetPaneCommand(sessionName)
if err != nil {
t.Fatalf("GetPaneCommand: %v", err)
}
tests := []struct {
name string
processNames []string
wantRunning bool
}{
{
name: "empty process list",
processNames: []string{},
wantRunning: false,
},
{
name: "matching shell process",
processNames: []string{cmd}, // Current shell
wantRunning: true,
},
{
name: "claude agent (node) - not running",
processNames: []string{"node"},
wantRunning: cmd == "node", // Only true if shell happens to be node
},
{
name: "gemini agent - not running",
processNames: []string{"gemini"},
wantRunning: cmd == "gemini",
},
{
name: "cursor agent - not running",
processNames: []string{"cursor-agent"},
wantRunning: cmd == "cursor-agent",
},
{
name: "multiple process names with match",
processNames: []string{"nonexistent", cmd, "also-nonexistent"},
wantRunning: true,
},
{
name: "multiple process names without match",
processNames: []string{"nonexistent1", "nonexistent2"},
wantRunning: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tm.IsAgentRunning(sessionName, tt.processNames...)
if got != tt.wantRunning {
t.Errorf("IsAgentRunning(%q, %v) = %v, want %v (current cmd: %q)",
sessionName, tt.processNames, got, tt.wantRunning, cmd)
}
})
}
}
func TestIsAgentRunning_NonexistentSession(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
// IsAgentRunning on nonexistent session should return false, not error
got := tm.IsAgentRunning("nonexistent-session-xyz", "node", "gemini", "cursor-agent")
if got {
t.Error("IsAgentRunning on nonexistent session should return false")
}
}
func TestIsClaudeRunning(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-claude-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session (will run default shell, not Claude)
if err := tm.NewSession(sessionName, ""); err != nil {
t.Fatalf("NewSession: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// IsClaudeRunning should be false (shell is running, not node)
cmd, _ := tm.GetPaneCommand(sessionName)
wantRunning := cmd == "node"
if got := tm.IsClaudeRunning(sessionName); got != wantRunning {
t.Errorf("IsClaudeRunning() = %v, want %v (pane cmd: %q)", got, wantRunning, cmd)
}
}