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)
}
}