package herdr import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) func TestTmuxBackendStartsCapturesPromptsAndKillsClaude(t *testing.T) { if testing.Short() { t.Skip("requires tmux") } dir := t.TempDir() harness := filepath.Join(dir, "fake-claude") script := "#!/bin/sh\nprintf '❯ ready\\n'\nwhile IFS= read -r line; do printf 'GOT:%s\\n' \"$line\"; done\n" if err := os.WriteFile(harness, []byte(script), 0o755); err != nil { t.Fatal(err) } b := NewTmuxBackend(filepath.Join(t.TempDir(), "tmux.sock"), harness) if err := b.Check(context.Background()); err != nil { t.Fatal(err) } s, err := b.StartAgent(context.Background(), dir, dir, "", "claude", "tmux-backend-test") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = b.Kill(context.Background(), s) }) if s.Worktree != dir || s.Harness != "claude" || !strings.Contains(s.PaneID, ":") || !strings.Contains(s.PaneID, ".") { t.Fatalf("unexpected session: %+v", s) } if err := b.Prompt(context.Background(), s.PaneID, "hello from Orchestra", 0); err != nil { t.Fatal(err) } deadline := time.Now().Add(2 * time.Second) for { capture, err := b.PaneCapture(context.Background(), s, "recent") if err != nil { t.Fatal(err) } if strings.Contains(capture, "GOT:hello from Orchestra") { break } if time.Now().After(deadline) { t.Fatalf("prompt was not captured: %q", capture) } time.Sleep(20 * time.Millisecond) } for _, line := range []string{"/clear", "@HANDOFF.md"} { if err := b.SendText(context.Background(), s, line); err != nil { t.Fatal(err) } if err := b.SendKeys(context.Background(), s, []string{"ENTER"}); err != nil { t.Fatal(err) } } deadline = time.Now().Add(2 * time.Second) for { capture, err := b.PaneCapture(context.Background(), s, "recent") if err != nil { t.Fatal(err) } if strings.Contains(capture, "GOT:/clear") && strings.Contains(capture, "GOT:@HANDOFF.md") { break } if time.Now().After(deadline) { t.Fatalf("Claude rollover lines were not captured: %q", capture) } time.Sleep(20 * time.Millisecond) } if status, err := b.AgentStatus(context.Background(), s); err != nil || status != "idle" { t.Fatalf("status=%q err=%v", status, err) } if err := b.ReleaseAgent(context.Background(), s, "claude"); err != nil { t.Fatal(err) } if err := b.Kill(context.Background(), s); err != nil { t.Fatal(err) } if _, err := b.PaneCapture(context.Background(), s, "recent"); err == nil { t.Fatal("killed tmux session remained readable") } } func TestTmuxBackendRefusesUnverifiedHarnesses(t *testing.T) { b := NewTmuxBackend("test", "true") if _, err := b.StartAgent(context.Background(), "", t.TempDir(), "", "codex", "task"); err == nil { t.Fatal("tmux backend accepted Codex before its terminal behavior was implemented") } } func TestTmuxSessionNameKeepsCollisionResistantSuffix(t *testing.T) { a := tmuxSessionName(strings.Repeat("same-prefix", 10) + "-one") b := tmuxSessionName(strings.Repeat("same-prefix", 10) + "-two") if a == b || len(a) > 64 || len(b) > 64 { t.Fatalf("unsafe tmux session names %q %q", a, b) } } // The launch dump is Orchestra's scratch space, not the session's work. A // worktree that starts dirty pollutes the gate, the review diff, and the // agent's own reading of `git status`. func TestWriteLaunchContextLeavesTheWorktreeClean(t *testing.T) { dir := t.TempDir() for _, args := range [][]string{{"init"}, {"config", "user.email", "t@t"}, {"config", "user.name", "t"}, {"commit", "--allow-empty", "-m", "init"}} { if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil { t.Fatalf("git %v: %v: %s", args, err, out) } } if err := WriteLaunchContext(dir, "the instruction"); err != nil { t.Fatal(err) } b, err := os.ReadFile(filepath.Join(dir, LaunchContextFile)) if err != nil || string(b) != "the instruction" { t.Fatalf("launch context = %q, err %v", b, err) } out, err := exec.Command("git", "-C", dir, "status", "--short").CombinedOutput() if err != nil { t.Fatal(err) } if strings.TrimSpace(string(out)) != "" { t.Fatalf("worktree is dirty after a launch dump:\n%s", out) } } func TestLaunchTransportIsHarnessSpecific(t *testing.T) { b := &TmuxBackend{} if got := b.LaunchTransport("claude"); got != LaunchFileRef { t.Fatalf("claude transport=%q, want %q", got, LaunchFileRef) } for _, harness := range []string{"opencode", "codex", ""} { if got := b.LaunchTransport(harness); got != LaunchInline { t.Fatalf("%s transport=%q, want %q", harness, got, LaunchInline) } } } // The exact F15 signature: the TUI coalesced the launch text into a paste and // absorbed the Enter, leaving the instruction in the input editor. func TestPendingInputSeesAnUnsubmittedPaste(t *testing.T) { stalled := " b141e4f6 - none | 0/1.0m ctx\n❯ [Pasted text #1 +66 lines]\n────────────\n" if got := pendingInput(stalled); got != "[Pasted text #1 +66 lines]" { t.Fatalf("pendingInput=%q, want the unsubmitted paste", got) } if got := pendingInput("esc to interrupt\n❯ \n"); got != "" { t.Fatalf("pendingInput=%q, want empty for a cleared editor", got) } if got := pendingInput("❯ @.orchestra/launch.md is your complete Orchestra launch instruction."); got == "" { t.Fatal("a one-line reference still sitting in the editor must count as pending") } if got := pendingInput("no editor line here"); got != "" { t.Fatalf("pendingInput=%q, want empty when no editor line is visible", got) } } // fakeTmux scripts capture-pane so confirmation can be driven without waiting // on a real harness. clearAfter is how many captures still show the stalled // editor before it clears. func fakeTmux(t *testing.T, clearAfter int, failCapture bool) *TmuxBackend { t.Helper() dir := t.TempDir() bin := filepath.Join(dir, "tmux") fail := "0" if failCapture { fail = "1" } script := `#!/bin/sh state=` + filepath.Join(dir, "count") + ` cmd="" for a in "$@"; do case "$a" in capture-pane|has-session|display-message) cmd=$a; break;; esac done case "$cmd" in has-session) exit 0 ;; display-message) printf '0\tclaude\n'; exit 0 ;; capture-pane) if [ "` + fail + `" = "1" ]; then echo "no server running" >&2; exit 1; fi n=$(cat $state 2>/dev/null || echo 0); n=$((n+1)); echo $n > $state if [ "$n" -le "` + fmt.Sprint(clearAfter) + `" ]; then printf '\xe2\x9d\xaf [Pasted text #1 +66 lines]\n' else printf 'esc to interrupt\n\xe2\x9d\xaf \n' fi exit 0 ;; esac exit 0 ` if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { t.Fatal(err) } return &TmuxBackend{Binary: bin, LaunchConfirmTimeout: 2 * time.Second, LaunchConfirmPoll: 5 * time.Millisecond} } func TestConfirmLaunchAcceptsObservedActivity(t *testing.T) { b := fakeTmux(t, 1, false) evidence, err := b.ConfirmLaunch(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference) if err != nil { t.Fatalf("confirm: %v", err) } if !strings.Contains(evidence, "busy") { t.Fatalf("evidence=%q, want the observed activity named", evidence) } } // Prompt returning nil is not proof. An editor that still holds the text at // the deadline is a launch failure, and it must be the one class that releases // the lease instead of holding it as uncertain. func TestConfirmLaunchFailsWhileTheEditorStillHoldsThePrompt(t *testing.T) { b := fakeTmux(t, 1000, false) b.LaunchConfirmTimeout = 60 * time.Millisecond _, err := b.ConfirmLaunch(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference) if !errors.Is(err, ErrPromptNotSubmitted) { t.Fatalf("err=%v, want ErrPromptNotSubmitted", err) } if !strings.Contains(err.Error(), "Pasted text") { t.Fatalf("err=%v, want the stalled editor content quoted", err) } } // A confirmation that cannot read the pane must not become an acknowledgement. func TestConfirmLaunchSurfacesTransportErrors(t *testing.T) { b := fakeTmux(t, 0, true) if _, err := b.ConfirmLaunch(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference); err == nil { t.Fatal("a failed pane capture must not confirm a launch") } }