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) } } } // F17, found live during burn-in run 3: a queued or already-accepted prompt // renders with the same "❯" prefix as an unsubmitted one, so only cursor // ownership can tell them apart. func TestInputStateReadsTheEditorOwningTheCursor(t *testing.T) { wrapped := "\u2500\u2500\u2500\u2500\n\u276f @.orchestra/launch.md is your complete Orchestra launch instruction. Read\n .orchestra/launch.md now and follow it.\n\u2500\u2500\u2500\u2500\n" b := paneTmux(t, wrapped, 2) state, err := b.inputState(context.Background(), Session{PaneID: "s:1.0"}) if err != nil { t.Fatal(err) } if !state.Active || !sameInput(state.Text, LaunchReference) { t.Fatalf("state=%+v, want the wrapped launch reference in the active editor", state) } // The same text, but the cursor sits in the empty editor below it: the // harness took the prompt and queued it. queued := " \u276f @.orchestra/launch.md is your complete Orchestra launch instruction. Read\n .orchestra/launch.md now and follow it.\n\u2500\u2500\u2500\u2500\n\u276f Press up to edit queued messages\n" b = paneTmux(t, queued, 3) state, err = b.inputState(context.Background(), Session{PaneID: "s:1.0"}) if err != nil { t.Fatal(err) } if !state.Active || sameInput(state.Text, LaunchReference) { t.Fatalf("state=%+v, want the queued marker rather than the launch text", state) } if !queuedInput(state.Text) { t.Fatalf("state=%+v, want queued input recognized", state) } } // Run 3 proved one Enter is not enough. A launch still owning the cursor must // be resubmitted, and the evidence must say how many submits it took. func TestConfirmInputResubmitsUntilTheEditorClears(t *testing.T) { b, keys := submitTmux(t, 2) evidence, err := b.ConfirmInput(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference) if err != nil { t.Fatalf("confirm: %v", err) } if !strings.Contains(evidence, "submit_attempts=2") { t.Fatalf("evidence=%q, want two submits recorded", evidence) } if got := enterCount(t, keys); got != 1 { t.Fatalf("resent Enter %d times, want 1", got) } } // The intervention is bounded independently of the observation deadline: a TUI // that already accepted the prompt must not receive a fortieth Enter. func TestConfirmInputBoundsResubmission(t *testing.T) { b, keys := submitTmux(t, 99) // Generous deadline on purpose: the cap must be what stops the resends. b.LaunchConfirmTimeout = 3 * time.Second if _, err := b.ConfirmInput(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference); !errors.Is(err, ErrPromptNotSubmitted) { t.Fatalf("err=%v, want ErrPromptNotSubmitted", err) } if got := enterCount(t, keys); got != launchResubmitLimit { t.Fatalf("resent Enter %d times, want the %d-resubmit cap", got, launchResubmitLimit) } } // Queued is submitted. Treating it as a stalled editor would kill a pane whose // harness had already accepted the instruction. func TestConfirmInputAcceptsQueuedInput(t *testing.T) { pane := "\u2500\u2500\u2500\u2500\n\u276f Press up to edit queued messages\n" b := paneTmux(t, pane, 1) evidence, err := b.ConfirmInput(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference) if err != nil { t.Fatalf("confirm: %v", err) } if !strings.Contains(evidence, "confirmation=queued") { t.Fatalf("evidence=%q, want queued input confirmed", evidence) } } // paneTmux serves one fixed pane with the cursor on the given row. func paneTmux(t *testing.T, pane string, cursor int) *TmuxBackend { t.Helper() dir := t.TempDir() paneFile := filepath.Join(dir, "pane") if err := os.WriteFile(paneFile, []byte(pane), 0o644); err != nil { t.Fatal(err) } bin := filepath.Join(dir, "tmux") script := `#!/bin/sh cmd="" for a in "$@"; do case "$a" in capture-pane|has-session|display-message|send-keys) cmd=$a; break;; esac done case "$cmd" in has-session) exit 0 ;; capture-pane) cat ` + paneFile + ` ; exit 0 ;; display-message) case "$*" in *cursor_y*) printf '` + fmt.Sprint(cursor) + `\n' ;; *) printf '0\tclaude\n' ;; esac exit 0 ;; esac exit 0 ` if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { t.Fatal(err) } return &TmuxBackend{Binary: bin, LaunchConfirmTimeout: time.Second, LaunchConfirmPoll: 5 * time.Millisecond} } // submitTmux holds the launch reference in the active editor until the given // number of submits has been received, then clears it. The first submit is the // one Prompt already sent, so submitOn=2 means one resend is required. func submitTmux(t *testing.T, submitOn int) (*TmuxBackend, string) { t.Helper() dir := t.TempDir() keys := filepath.Join(dir, "keys") bin := filepath.Join(dir, "tmux") script := `#!/bin/sh dir=` + dir + ` cmd="" for a in "$@"; do case "$a" in capture-pane|has-session|display-message|send-keys) cmd=$a; break;; esac done submits=$(cat $dir/submits 2>/dev/null || echo 1) case "$cmd" in has-session) exit 0 ;; send-keys) case "$*" in *Enter*) echo Enter >> ` + keys + ` echo $((submits+1)) > $dir/submits ;; esac exit 0 ;; capture-pane) if [ "$submits" -ge "` + fmt.Sprint(submitOn) + `" ]; then printf 'esc to interrupt\n\xe2\x9d\xaf \n' else printf '\xe2\x94\x80\x0a\xe2\x9d\xaf ` + LaunchReference + `\n' fi exit 0 ;; display-message) case "$*" in *cursor_y*) if [ "$submits" -ge "` + fmt.Sprint(submitOn) + `" ]; then printf '1\n'; else printf '1\n'; fi ;; *) printf '0\tclaude\n' ;; esac exit 0 ;; esac exit 0 ` if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { t.Fatal(err) } return &TmuxBackend{Binary: bin, LaunchConfirmTimeout: time.Second, LaunchConfirmPoll: 5 * time.Millisecond}, keys } func enterCount(t *testing.T, path string) int { t.Helper() b, err := os.ReadFile(path) if os.IsNotExist(err) { return 0 } if err != nil { t.Fatal(err) } return len(strings.Fields(string(b))) } // 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) case "$*" in *cursor_y*) printf '0\n' ;; *) printf '0\tclaude\n' ;; esac 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 TestConfirmInputAcceptsObservedActivity(t *testing.T) { b := fakeTmux(t, 1, false) evidence, err := b.ConfirmInput(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 TestConfirmInputFailsWhileTheEditorStillHoldsThePrompt(t *testing.T) { b := fakeTmux(t, 1000, false) b.LaunchConfirmTimeout = 60 * time.Millisecond _, err := b.ConfirmInput(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 TestConfirmInputSurfacesTransportErrors(t *testing.T) { b := fakeTmux(t, 0, true) if _, err := b.ConfirmInput(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference); err == nil { t.Fatal("a failed pane capture must not confirm a launch") } } // F16 and F20 are separate concerns and this is the line between them: // Orchestra confirms delivery of what it originates, but nothing typed at a // pane counts as the agent doing work. A capture whose only difference is the // input line must produce the same progress digest. func TestPaneProgressIgnoresInputLines(t *testing.T) { body := "────\n ran the checks, nothing to change\n────\n" idle := paneTmux(t, body+"❯ \n", 3) typed := paneTmux(t, body+"❯ go ahead and implement it\n", 3) a, err := idle.PaneProgress(context.Background(), Session{PaneID: "s:1.0"}) if err != nil { t.Fatal(err) } b, err := typed.PaneProgress(context.Background(), Session{PaneID: "s:1.0"}) if err != nil { t.Fatal(err) } if a != b { t.Fatalf("typing changed the progress digest:\n%q\n%q", a, b) } if !strings.Contains(a, "ran the checks") { t.Fatalf("progress digest dropped harness output: %q", a) } // Harness output still moves it. worked := paneTmux(t, "────\n edited scripts/orchestra_e2e_healthcheck.sh\n────\n❯ \n", 3) c, err := worked.PaneProgress(context.Background(), Session{PaneID: "s:1.0"}) if err != nil { t.Fatal(err) } if c == a { t.Fatal("real harness output left the progress digest unchanged") } } // TestMissingSessionIsRecognisedOnAnEmptyRunningServer guards F32. A running // but empty tmux server answers "no current target", not "no server running". // The runtime only stays alive past its last pane since it became its own unit // running `tmux -D`, so this reply had never been seen before. Treating it as // a real error made Kill fail for a pane that was already gone, which left the // quarantine set and pinned the worker's only session slot. func TestMissingSessionIsRecognisedOnAnEmptyRunningServer(t *testing.T) { for _, message := range []string{"no current target", "can't find session: x", "no server running"} { bin := filepath.Join(t.TempDir(), "tmux") script := "#!/bin/sh\necho \"" + message + "\" >&2\nexit 1\n" if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { t.Fatal(err) } b := &TmuxBackend{Binary: bin} if err := b.Kill(context.Background(), Session{PaneID: "s:1.0"}); err != nil { t.Fatalf("%q: killing an already-gone session failed: %v", message, err) } status, err := b.AgentStatus(context.Background(), Session{PaneID: "s:1.0"}) if err != nil || status != "exited" { t.Fatalf("%q: status=%q err=%v, want exited", message, status, err) } } } // TestEmptyEditorIsNotProofBeforeItEverHeldTheText guards F33. The submit // races the TUI's render: a poll 7ms after Enter found an empty editor, called // it confirmation=editor_cleared, and the launch text then sat unsent for the // whole lease. Run 5 died on exactly that. An editor that never held the text // has to keep being observed, and once the text appears the resubmit path can // do its job. func TestEmptyEditorIsNotProofBeforeItEverHeldTheText(t *testing.T) { dir := t.TempDir() bin := filepath.Join(dir, "tmux") calls := filepath.Join(dir, "calls") // capture-pane reports an empty editor on the first two polls, exactly as a // TUI that has not rendered the paste yet, then shows the submitted text. script := `#!/bin/sh cmd="" for a in "$@"; do case "$a" in capture-pane|has-session|display-message|send-keys) cmd=$a; break;; esac done case "$cmd" in has-session) exit 0 ;; send-keys) exit 0 ;; display-message) case "$*" in *cursor_y*) printf '1\n' ;; *) printf '0\tclaude\n' ;; esac exit 0 ;; capture-pane) n=$(cat ` + calls + ` 2>/dev/null || echo 0); n=$((n+1)); echo $n > ` + calls + ` if [ "$n" -le 2 ]; then printf '\xe2\x9d\xaf \n\n'; else printf '\xe2\x9d\xaf hello world\n\n'; fi exit 0 ;; esac exit 0 ` if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { t.Fatal(err) } b := &TmuxBackend{Binary: bin, LaunchConfirmTimeout: 300 * time.Millisecond, LaunchConfirmPoll: 5 * time.Millisecond} _, err := b.ConfirmInput(context.Background(), Session{PaneID: "s:1.0"}, "hello world") if err == nil { t.Fatal("an editor still holding the submitted text was reported as confirmed") } if !errors.Is(err, ErrPromptNotSubmitted) { t.Fatalf("err=%v, want ErrPromptNotSubmitted", err) } }