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