Vectrum
Sign In
589 lines · 18.4 KB · Go
package herdr

import (
	"context"
	"errors"
	"strconv"
	"strings"
	"sync"
	"testing"

	"codey/internal/hooks"
)

// pane is the environment of a process running inside a herdr pane.
var pane = map[string]string{
	"HERDR_ENV":      "1",
	"HERDR_PANE_ID":  "w1:p1",
	"HERDR_BIN_PATH": "/usr/local/bin/herdr",
}

func env(m map[string]string) func(string) string {
	return func(k string) string { return m[k] }
}

// fake replaces the CLI with a recorder. The returned slice holds the argument
// lists herdr would have been run with.
type fake struct {
	mu    sync.Mutex
	calls [][]string
	err   error
}

func (f *fake) run(_ context.Context, _ string, args []string) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.calls = append(f.calls, args)
	return f.err
}

// states is the sequence of --state values reported, which is what herdr's
// sidebar would have shown, in order.
func (f *fake) states() []string {
	f.mu.Lock()
	defer f.mu.Unlock()
	var out []string
	for _, args := range f.calls {
		if v, ok := flagOf(args, "--state"); ok {
			out = append(out, v)
		}
	}
	return out
}

func flagOf(args []string, name string) (string, bool) {
	for i, a := range args {
		if a == name && i+1 < len(args) {
			return args[i+1], true
		}
	}
	return "", false
}

// reporterIn builds a detected reporter with the CLI stubbed out.
func reporterIn(t *testing.T, m map[string]string) (*Reporter, *fake) {
	t.Helper()
	r := detect(env(m))
	if r == nil {
		t.Fatal("Detect found no pane in an environment that describes one")
	}
	f := &fake{}
	r.run = f.run
	return r, f
}

func fire(t *testing.T, r *Reporter, events ...hooks.Payload) {
	t.Helper()
	for _, p := range events {
		if err := r.Handle(context.Background(), p); err != nil {
			t.Fatalf("Handle(%s): %v", p.Event, err)
		}
	}
}

func TestDetectRequiresTheWholeEnvironment(t *testing.T) {
	cases := []struct {
		name string
		env  map[string]string
		want bool
	}{
		{"inside a pane", pane, true},
		{"nothing set", map[string]string{}, false},
		{"not in herdr", map[string]string{"HERDR_PANE_ID": "w1:p1", "HERDR_BIN_PATH": "/bin/herdr"}, false},
		// A HERDR_ENV exported into a shell that outlived its pane is exactly
		// the case where reporting would go nowhere, forever.
		{"no pane id", map[string]string{"HERDR_ENV": "1", "HERDR_BIN_PATH": "/bin/herdr"}, false},
		{"no binary", map[string]string{"HERDR_ENV": "1", "HERDR_PANE_ID": "w1:p1"}, false},
		{"herdr env is not 1", map[string]string{"HERDR_ENV": "0", "HERDR_PANE_ID": "w1:p1", "HERDR_BIN_PATH": "/bin/herdr"}, false},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := detect(env(c.env)) != nil; got != c.want {
				t.Errorf("detected %v, want %v", got, c.want)
			}
		})
	}
}

func TestOutsideHerdrThereIsNothingToCall(t *testing.T) {
	r := detect(env(map[string]string{}))
	// The nil reporter is handed straight to hooks.Add, so every one of these
	// is a call that really happens.
	if r.IsNil() != true {
		t.Error("a nil reporter does not say it is nil")
	}
	if err := r.Handle(context.Background(), hooks.Payload{Event: hooks.RunStart}); err != nil {
		t.Errorf("Handle on a nil reporter: %v", err)
	}
	if err := r.Close(); err != nil {
		t.Errorf("Close on a nil reporter: %v", err)
	}
	if r.Pane() != "" {
		t.Error("a nil reporter claimed a pane")
	}
}

func TestALifecycleBecomesTheStatesHerdrShows(t *testing.T) {
	r, f := reporterIn(t, pane)

	fire(t, r,
		hooks.Payload{Event: hooks.SessionStart, Session: "s1"},
		hooks.Payload{Event: hooks.RunStart, Session: "s1"},
		hooks.Payload{Event: hooks.TurnStart, Session: "s1", Turn: 1},
		hooks.Payload{Event: hooks.ToolStart, Session: "s1", Tool: "bash"},
		hooks.Payload{Event: hooks.Blocked, Session: "s1", Tool: "bash", Reason: hooks.ReasonApproval, Message: "bash: rm -rf build"},
		hooks.Payload{Event: hooks.Unblocked, Session: "s1", Tool: "bash"},
		hooks.Payload{Event: hooks.ToolResult, Session: "s1", Tool: "bash"},
		hooks.Payload{Event: hooks.RunEnd, Session: "s1"},
	)

	want := []string{StateIdle, StateWorking, StateBlocked, StateWorking, StateIdle}
	got := f.states()
	if strings.Join(got, ",") != strings.Join(want, ",") {
		t.Errorf("reported %v, want %v", got, want)
	}
}

// Every tool call and every turn would otherwise spawn a process to say what
// herdr already knows.
func TestAnUnchangedStateIsNotReportedAgain(t *testing.T) {
	r, f := reporterIn(t, pane)

	fire(t, r,
		hooks.Payload{Event: hooks.RunStart, Session: "s1"},
		hooks.Payload{Event: hooks.TurnStart, Session: "s1"},
		hooks.Payload{Event: hooks.ToolStart, Session: "s1", Tool: "read_file"},
		hooks.Payload{Event: hooks.ToolResult, Session: "s1", Tool: "read_file"},
		hooks.Payload{Event: hooks.ToolStart, Session: "s1", Tool: "grep"},
	)

	if got := f.states(); len(got) != 1 || got[0] != StateWorking {
		t.Errorf("reported %v, want one working and no repeats", got)
	}
}

func TestTheReportIsTheCommandHerdrDocuments(t *testing.T) {
	r, f := reporterIn(t, pane)
	fire(t, r, hooks.Payload{Event: hooks.SessionStart, Session: "20260919T120000Z"})

	args := f.calls[0]
	if len(args) < 3 || args[0] != "pane" || args[1] != "report-agent" || args[2] != "w1:p1" {
		t.Fatalf("the command starts %v, want pane report-agent on this pane", args[:min(3, len(args))])
	}
	for flag, want := range map[string]string{
		"--source":           Source,
		"--agent":            Agent,
		"--state":            StateIdle,
		"--agent-session-id": "20260919T120000Z",
		"--seq":              "1",
	} {
		got, ok := flagOf(args, flag)
		if !ok {
			t.Errorf("%s was not passed", flag)
			continue
		}
		if got != want {
			t.Errorf("%s is %q, want %q", flag, got, want)
		}
	}
}

// Herdr ignores a stale sequence number from the same source, which is what
// keeps a slow report for an old state from landing on top of a new one.
func TestSequenceNumbersStrictlyIncrease(t *testing.T) {
	r, f := reporterIn(t, pane)

	fire(t, r,
		hooks.Payload{Event: hooks.SessionStart, Session: "s1"},
		hooks.Payload{Event: hooks.RunStart, Session: "s1"},
		hooks.Payload{Event: hooks.RunEnd, Session: "s1"},
		hooks.Payload{Event: hooks.RunStart, Session: "s1"},
	)

	last := int64(0)
	for _, args := range f.calls {
		v, ok := flagOf(args, "--seq")
		if !ok {
			t.Fatalf("a report went out without a sequence number: %v", args)
		}
		n, err := strconv.ParseInt(v, 10, 64)
		if err != nil {
			t.Fatalf("sequence number %q: %v", v, err)
		}
		if n <= last {
			t.Errorf("sequence went %d after %d, want strictly increasing", n, last)
		}
		last = n
	}
}

func TestABlockSaysWhatItIsWaitingFor(t *testing.T) {
	cases := []struct {
		name string
		p    hooks.Payload
		want string
	}{
		{
			name: "approval carries the command",
			p:    hooks.Payload{Event: hooks.Blocked, Session: "s1", Reason: hooks.ReasonApproval, Message: "bash: rm -rf build"},
			want: "bash: rm -rf build",
		},
		{
			name: "a question carries its headers",
			p:    hooks.Payload{Event: hooks.Blocked, Session: "s1", Reason: hooks.ReasonQuestion, Message: "asked about Storage"},
			want: "asked about Storage",
		},
		{
			name: "an approval with nothing to say still says which kind",
			p:    hooks.Payload{Event: hooks.Blocked, Session: "s1", Reason: hooks.ReasonApproval},
			want: "waiting for approval",
		},
		{
			name: "a question with nothing to say still says which kind",
			p:    hooks.Payload{Event: hooks.Blocked, Session: "s1", Reason: hooks.ReasonQuestion},
			want: "waiting for an answer",
		},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			r, f := reporterIn(t, pane)
			fire(t, r, c.p)
			got, ok := flagOf(f.calls[0], "--message")
			if !ok {
				t.Fatal("a blocked report went out with no message")
			}
			if got != c.want {
				t.Errorf("message is %q, want %q", got, c.want)
			}
		})
	}
}

// The message is a line in a sidebar. A prompt pasted into a commit message
// must not arrive as ten lines of someone else's terminal.
func TestAMessageIsMadeSafeForASidebar(t *testing.T) {
	r, f := reporterIn(t, pane)
	long := strings.Repeat("a", 400)
	fire(t, r, hooks.Payload{
		Event:   hooks.Blocked,
		Session: "s1",
		Reason:  hooks.ReasonApproval,
		Message: "bash: echo \x1b[31mred\x07\n\nsecond line\t" + long,
	})

	got, _ := flagOf(f.calls[0], "--message")
	if strings.ContainsAny(got, "\n\r\t\x1b\x07") {
		t.Errorf("control characters survived into %q", got)
	}
	if len([]rune(got)) > maxMessage+1 {
		t.Errorf("message is %d characters, want it capped near %d", len([]rune(got)), maxMessage)
	}
}

func TestAStateThatStopsBeingBlockedDropsTheMessage(t *testing.T) {
	r, f := reporterIn(t, pane)
	fire(t, r,
		hooks.Payload{Event: hooks.Blocked, Session: "s1", Reason: hooks.ReasonApproval, Message: "bash: make"},
		hooks.Payload{Event: hooks.Unblocked, Session: "s1"},
	)

	if _, ok := flagOf(f.calls[1], "--message"); ok {
		t.Error("the working report still carried the block's message")
	}
}

// One pane, several tabs: what a person scanning a sidebar needs to find is
// the agent that stopped and wants them, and after that the one still going.
func TestTabsAreReducedToOneStatePerPane(t *testing.T) {
	cases := []struct {
		name string
		fire []hooks.Payload
		want string
	}{
		{
			name: "one working tab makes the pane working",
			fire: []hooks.Payload{
				{Event: hooks.SessionStart, Session: "s1"},
				{Event: hooks.SessionStart, Session: "s2"},
				{Event: hooks.RunStart, Session: "s2"},
			},
			want: StateWorking,
		},
		{
			name: "a blocked tab beats a working one",
			fire: []hooks.Payload{
				{Event: hooks.SessionStart, Session: "s1"},
				{Event: hooks.SessionStart, Session: "s2"},
				{Event: hooks.RunStart, Session: "s1"},
				{Event: hooks.Blocked, Session: "s2", Reason: hooks.ReasonQuestion},
			},
			want: StateBlocked,
		},
		{
			name: "the pane goes idle only when every tab has",
			fire: []hooks.Payload{
				{Event: hooks.SessionStart, Session: "s1"},
				{Event: hooks.SessionStart, Session: "s2"},
				{Event: hooks.RunStart, Session: "s1"},
				{Event: hooks.RunStart, Session: "s2"},
				{Event: hooks.RunEnd, Session: "s1"},
				{Event: hooks.RunEnd, Session: "s2"},
			},
			want: StateIdle,
		},
		{
			name: "a tab that closed stops holding the pane busy",
			fire: []hooks.Payload{
				{Event: hooks.SessionStart, Session: "s1"},
				{Event: hooks.SessionStart, Session: "s2"},
				{Event: hooks.RunStart, Session: "s2"},
				{Event: hooks.SessionEnd, Session: "s2"},
			},
			want: StateIdle,
		},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			r, f := reporterIn(t, pane)
			fire(t, r, c.fire...)
			states := f.states()
			if len(states) == 0 {
				t.Fatal("nothing was reported at all")
			}
			if got := states[len(states)-1]; got != c.want {
				t.Errorf("the pane ended up %q, want %q (sequence %v)", got, c.want, states)
			}
		})
	}
}

// The session reference is what herdr would resume the pane with, so it has to
// be the first session opened rather than whichever tab moved last.
func TestTheReportedSessionIsTheOneCodeyResumes(t *testing.T) {
	r, f := reporterIn(t, pane)
	fire(t, r,
		hooks.Payload{Event: hooks.SessionStart, Session: "first"},
		hooks.Payload{Event: hooks.SessionStart, Session: "second"},
		hooks.Payload{Event: hooks.RunStart, Session: "second"},
	)

	for _, args := range f.calls {
		if got, _ := flagOf(args, "--agent-session-id"); got != "first" {
			t.Errorf("reported session %q, want the first one opened", got)
		}
	}
}

// A subagent's comings and goings are not the pane's state: its parent is
// inside a tool call the whole time it runs, and already says so.
func TestSubagentEventsAreNotThePanesState(t *testing.T) {
	r, f := reporterIn(t, pane)
	fire(t, r,
		hooks.Payload{Event: hooks.RunStart, Session: "s1"},
		hooks.Payload{Event: hooks.RunEnd, Session: "s1", Agent: "a1"},
	)

	if got := f.states(); len(got) != 1 || got[0] != StateWorking {
		t.Errorf("reported %v, want the subagent's run end to have changed nothing", got)
	}
}

// Paused is not blocked: nobody is being waited on, and a held agent must not
// sit in the list of ones that need attention.
func TestAPausedAgentIsIdleRatherThanBlocked(t *testing.T) {
	r, f := reporterIn(t, pane)
	fire(t, r,
		hooks.Payload{Event: hooks.RunStart, Session: "s1"},
		hooks.Payload{Event: hooks.Paused, Session: "s1", Turn: 2},
		hooks.Payload{Event: hooks.Resumed, Session: "s1", Turn: 2},
	)

	want := []string{StateWorking, StateIdle, StateWorking}
	if got := f.states(); strings.Join(got, ",") != strings.Join(want, ",") {
		t.Errorf("reported %v, want %v", got, want)
	}
}

func TestCloseReleasesTheClaimOnce(t *testing.T) {
	r, f := reporterIn(t, pane)
	fire(t, r, hooks.Payload{Event: hooks.SessionStart, Session: "s1"})

	if err := r.Close(); err != nil {
		t.Fatalf("Close: %v", err)
	}
	if err := r.Close(); err != nil {
		t.Fatalf("second Close: %v", err)
	}

	var releases [][]string
	for _, args := range f.calls {
		if len(args) > 1 && args[1] == "release-agent" {
			releases = append(releases, args)
		}
	}
	if len(releases) != 1 {
		t.Fatalf("released %d times, want once", len(releases))
	}
	got := releases[0]
	if got[2] != "w1:p1" {
		t.Errorf("released pane %q, want the one reported to", got[2])
	}
	if src, _ := flagOf(got, "--source"); src != Source {
		t.Errorf("released source %q, want the one that claimed the pane", src)
	}
}

// Releasing a claim never made would tell herdr about an integration it has
// never heard from.
func TestCloseWithoutAReportReleasesNothing(t *testing.T) {
	r, f := reporterIn(t, pane)
	if err := r.Close(); err != nil {
		t.Fatalf("Close: %v", err)
	}
	if len(f.calls) != 0 {
		t.Errorf("a reporter that never reported still ran %v", f.calls)
	}
}

func TestEventsAfterCloseAreDropped(t *testing.T) {
	r, f := reporterIn(t, pane)
	fire(t, r, hooks.Payload{Event: hooks.SessionStart, Session: "s1"})
	if err := r.Close(); err != nil {
		t.Fatalf("Close: %v", err)
	}
	before := len(f.calls)

	fire(t, r, hooks.Payload{Event: hooks.RunStart, Session: "s1"})

	if len(f.calls) != before {
		t.Error("the pane was claimed back by an event fired after the release")
	}
}

func TestAFailingCLIIsReportedWithContext(t *testing.T) {
	r, f := reporterIn(t, pane)
	f.err = errors.New("no such pane")

	err := r.Handle(context.Background(), hooks.Payload{Event: hooks.RunStart, Session: "s1"})
	if err == nil {
		t.Fatal("a failing herdr call was reported as success")
	}
	if !strings.Contains(err.Error(), "report-agent") {
		t.Errorf("the error does not say which call failed: %v", err)
	}
}

// An interrupted turn still has a state worth reporting, and the report that
// says so must not be cancelled by the interrupt that caused it.
func TestReportsSurviveACancelledTurn(t *testing.T) {
	r, f := reporterIn(t, pane)
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	if err := r.Handle(ctx, hooks.Payload{Event: hooks.RunEnd, Session: "s1"}); err != nil {
		t.Fatalf("Handle: %v", err)
	}
	if len(f.calls) != 1 {
		t.Error("the state report was dropped because the turn had been cancelled")
	}
}

// A subagent cannot ask a question, but it can ask for approval, and spawning
// does not block — so the prompt can arrive long after the session's own agent
// went quiet. A pane that called that idle would leave a person waiting on an
// agent that is waiting on them.
func TestASubagentsPromptStillBlocksThePane(t *testing.T) {
	r, f := reporterIn(t, pane)
	fire(t, r,
		hooks.Payload{Event: hooks.SessionStart, Session: "s1"},
		hooks.Payload{Event: hooks.RunStart, Session: "s1"},
		hooks.Payload{Event: hooks.RunEnd, Session: "s1"},
		hooks.Payload{Event: hooks.Blocked, Session: "s1", Agent: "a1", Tool: "bash",
			Reason: hooks.ReasonApproval, Message: "bash: rm -rf build"},
	)

	states := f.states()
	if got := states[len(states)-1]; got != StateBlocked {
		t.Errorf("the pane is %q, want it blocked on the subagent's prompt", got)
	}
	if msg, _ := flagOf(f.calls[len(f.calls)-1], "--message"); msg != "bash: rm -rf build" {
		t.Errorf("message is %q, want the subagent's command", msg)
	}
}

// Answering a prompt puts the pane back where it was, which is not always
// "working": a subagent's approval can arrive when nothing else is running.
func TestAnAnsweredPromptRestoresWhatTheAgentWasDoing(t *testing.T) {
	cases := []struct {
		name  string
		start []hooks.Payload
		want  string
	}{
		{
			name:  "answered mid-run",
			start: []hooks.Payload{{Event: hooks.RunStart, Session: "s1"}},
			want:  StateWorking,
		},
		{
			name: "answered after the run finished",
			start: []hooks.Payload{
				{Event: hooks.RunStart, Session: "s1"},
				{Event: hooks.RunEnd, Session: "s1"},
			},
			want: StateIdle,
		},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			r, f := reporterIn(t, pane)
			fire(t, r, c.start...)
			fire(t, r,
				hooks.Payload{Event: hooks.Blocked, Session: "s1", Agent: "a1", Reason: hooks.ReasonApproval},
				hooks.Payload{Event: hooks.Unblocked, Session: "s1", Agent: "a1"},
			)

			states := f.states()
			if got := states[len(states)-1]; got != c.want {
				t.Errorf("the pane ended up %q, want %q (sequence %v)", got, c.want, states)
			}
		})
	}
}

// Two agents in one session can be waiting at once. The first answer given
// must not clear the second question.
func TestOverlappingPromptsStayBlockedUntilTheLastIsAnswered(t *testing.T) {
	r, f := reporterIn(t, pane)
	fire(t, r,
		hooks.Payload{Event: hooks.RunStart, Session: "s1"},
		hooks.Payload{Event: hooks.Blocked, Session: "s1", Agent: "a1", Reason: hooks.ReasonApproval, Message: "bash: make"},
		hooks.Payload{Event: hooks.Blocked, Session: "s1", Agent: "a2", Reason: hooks.ReasonApproval, Message: "bash: make test"},
		hooks.Payload{Event: hooks.Unblocked, Session: "s1", Agent: "a2"},
	)

	states := f.states()
	if got := states[len(states)-1]; got != StateBlocked {
		t.Fatalf("the pane is %q after one of two prompts was answered, want blocked (sequence %v)", got, states)
	}

	fire(t, r, hooks.Payload{Event: hooks.Unblocked, Session: "s1", Agent: "a1"})
	states = f.states()
	if got := states[len(states)-1]; got != StateWorking {
		t.Errorf("the pane is %q once both prompts were answered, want working", got)
	}
}

// A subagent working is its parent working, and the parent says so itself.
func TestASubagentsOwnLifecycleIsNotThePanesState(t *testing.T) {
	r, f := reporterIn(t, pane)
	fire(t, r,
		hooks.Payload{Event: hooks.SessionStart, Session: "s1"},
		hooks.Payload{Event: hooks.RunStart, Session: "s1", Agent: "a1"},
		hooks.Payload{Event: hooks.TurnStart, Session: "s1", Agent: "a1"},
		hooks.Payload{Event: hooks.SessionEnd, Session: "s1", Agent: "a1"},
	)

	if got := f.states(); len(got) != 1 || got[0] != StateIdle {
		t.Errorf("reported %v, want only the session's own idle", got)
	}
}