* 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>
428 lines
10 KiB
Go
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)
|
|
}
|
|
}
|