2417a39a1e
F41, live on run 5: the review agent produced nothing after 02:12 and the 02:46 renewal was granted anyway. The progress digest covered Claude Code's status footer, and one of its fields ticked inside the window. Reproduced the worker's stored progress_sha byte for byte from the live pane, so the branch taken was progress != ProgressSHA, not IsBusy and not an empty baseline. PaneProgress now cuts from the editor's lower rule and trims the spinner summary and version notice above it. AgentStatus still reads the raw capture, so the busy markers living in the footer are unaffected. Lease TTL moves to 5 minutes, from domain.LeaseTTL, with renewal at half of it. Reclaiming a stalled pane happens only at expiry, and 30 minutes per window made run 5's stall unbounded in practice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
533 lines
19 KiB
Go
533 lines
19 KiB
Go
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)
|
||
}
|
||
}
|
||
|
||
// F41, live on run 5: the review agent produced nothing after 02:12 and the
|
||
// 02:46 renewal was granted, because the digest covered the harness footer and
|
||
// one of its fields ticked. The layout below is the real pane, with the usage
|
||
// percentage and the version banner moved on.
|
||
func TestPaneProgressIgnoresHarnessFooter(t *testing.T) {
|
||
body := "────\n reviewed the diff, no blocking findings\n────\n❯ \n"
|
||
footer := func(pct, banner string) string {
|
||
return body +
|
||
" [Opus 5] 📁 06G4A4F0TFXKZHJE48N05XN1HG | 7d: " + pct + "\n" +
|
||
" cf474986 - Orchestra launch instructions | 57.9…\n" +
|
||
" ⏵⏵ auto mode on (shift+tab to cycle) · " + banner + "\n"
|
||
}
|
||
before := paneTmux(t, footer("51%", "current: 2.1.247 · latest: 2.1.248"), 3)
|
||
after := paneTmux(t, footer("52%", "✔ Update installed · Restart to update"), 3)
|
||
a, err := before.PaneProgress(context.Background(), Session{PaneID: "s:1.0"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
b, err := after.PaneProgress(context.Background(), Session{PaneID: "s:1.0"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if a != b {
|
||
t.Fatalf("footer churn changed the progress digest:\n%q\n%q", a, b)
|
||
}
|
||
if !strings.Contains(a, "reviewed the diff") {
|
||
t.Fatalf("progress digest dropped harness output: %q", a)
|
||
}
|
||
worked := paneTmux(t, "────\n reviewed the diff, wrote .orchestra/done\n────\n❯ \n"+
|
||
" [Opus 5] 📁 06G4A4F0TFXKZHJE48N05XN1HG | 7d: 51%\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 agent output left the progress digest unchanged")
|
||
}
|
||
}
|