Vectrum
Sign In
1002 lines · 33.4 KB · Go
package tui

import (
	"context"
	"strings"
	"testing"
	"time"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"

	"harness/internal/agent"
	"harness/internal/approval"
	"harness/internal/llm"
	"harness/internal/metrics"
	"harness/internal/subagent"
	"harness/internal/tools"
)

// fakeAgents stands in for the supervisor: the TUI only ever asks it for a
// snapshot, takes finished reports off it, and tells it to pause, resume or
// kill by id.
type fakeAgents struct {
	rows    []subagent.Info
	reports []subagent.Info
	acted   []string
	// onAct fires inside Pause/Resume/Kill, standing in for the real
	// supervisor's Changed callback — which reaches the program through an
	// unbuffered channel the update loop is the only reader of.
	onAct func()
	// unclaimable are reports the supervisor has already handed to the model
	// another way, which the front end must not send again.
	unclaimable map[string]bool
}

func (f *fakeAgents) Snapshot() []subagent.Info    { return f.rows }
func (f *fakeAgents) TakeReports() []subagent.Info { r := f.reports; f.reports = nil; return r }
func (f *fakeAgents) Live() int {
	var n int
	for _, r := range f.rows {
		if r.Status.Live() {
			n++
		}
	}
	return n
}
func (f *fakeAgents) act(what string) error {
	f.acted = append(f.acted, what)
	if f.onAct != nil {
		f.onAct()
	}
	return nil
}

func (f *fakeAgents) Pause(id string) error                  { return f.act("pause " + id) }
func (f *fakeAgents) Resume(id string) error                 { return f.act("resume " + id) }
func (f *fakeAgents) Kill(id string) error                   { return f.act("kill " + id) }
func (f *fakeAgents) Subscribe(func(subagent.Event), func()) {}

// ClaimReport refuses a report the model has already been given another way.
func (f *fakeAgents) ClaimReport(id string) bool { return !f.unclaimable[id] }

func info(id, preset string, status subagent.Status) subagent.Info {
	return subagent.Info{
		ID: id, Preset: preset, Model: "claude-haiku-4-5", Task: "do the thing",
		Status: status, Turns: 3, Started: time.Now().Add(-20 * time.Second),
		Usage: llm.Usage{InputTokens: 1000, OutputTokens: 200},
	}
}

// newAgentModel is a model with a sidebar to drive.
func newAgentModel(t *testing.T, rows ...subagent.Info) (*model, *fakeAgents) {
	t.Helper()
	f := &fakeAgents{rows: rows}
	m := newModel(context.Background(), Options{
		Client: llm.New(llm.WithAPIKey("x")),
		Root:   "/tmp/repo",
		Policy: approval.New(nil),
		Agents: f,
	})
	m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
	m.Update(evAgents{})
	return m, f
}

func TestCtrlBOpensAndClosesTheSidebar(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))

	if strings.Contains(m.View(), "explore") {
		t.Fatal("the sidebar is showing before it was opened")
	}
	m.Update(key("ctrl+b"))
	if !strings.Contains(m.View(), "explore") {
		t.Fatalf("ctrl+b did not open the sidebar:\n%s", m.View())
	}
	m.Update(key("ctrl+b"))
	if strings.Contains(m.View(), "explore") {
		t.Error("ctrl+b did not close the sidebar again")
	}
}

// The row is what tells you whether an agent is working or stuck, so it has to
// carry the id, the preset, the model and the numbers.
func TestARowNamesTheAgentAndWhatItCosts(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
	m.Update(key("ctrl+b"))

	view := m.View()
	for _, want := range []string{"a1", "explore", "Haiku", "3 turns"} {
		if !strings.Contains(view, want) {
			t.Errorf("the sidebar never says %q:\n%s", want, view)
		}
	}
}

func TestEveryStatusIsLegibleInTheSidebar(t *testing.T) {
	rows := []subagent.Info{
		info("a1", "explore", subagent.Queued),
		info("a2", "plan", subagent.Running),
		info("a3", "general", subagent.Waiting),
		info("a4", "explore", subagent.Paused),
		info("a5", "plan", subagent.Done),
		info("a6", "general", subagent.Failed),
		info("a7", "explore", subagent.Killed),
	}
	m, _ := newAgentModel(t, rows...)
	m.Update(key("ctrl+b"))

	view := m.View()
	for _, r := range rows {
		if !strings.Contains(view, r.ID) {
			t.Errorf("%s (%s) is missing from the sidebar:\n%s", r.ID, r.Status, view)
		}
	}
	for _, word := range []string{"queued", "waiting", "paused", "done", "failed", "stopped"} {
		if !strings.Contains(view, word) {
			t.Errorf("no row reads as %q:\n%s", word, view)
		}
	}
}

// Selecting an agent shows its own transcript in the main pane: the one place
// the turns it took are visible at all.
func TestOpeningAnAgentShowsItsOwnThread(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running), info("a2", "plan", subagent.Running))
	m.tr.addUser("what my own thread says")
	m.Update(evSub{ev: subagent.Event{ID: "a1", Preset: "explore",
		Agent: agent.Event{Kind: agent.EventText, Text: "what the first agent said"}}})
	m.Update(evSub{ev: subagent.Event{ID: "a2", Preset: "plan",
		Agent: agent.Event{Kind: agent.EventText, Text: "what the second agent said"}}})

	m.Update(key("ctrl+b"))
	m.Update(key("enter"))
	view := m.View()
	if !strings.Contains(view, "what the first agent said") {
		t.Fatalf("a1's thread is not on screen:\n%s", view)
	}
	if strings.Contains(view, "what my own thread says") {
		t.Error("the main thread is still on screen underneath the agent's")
	}

	m.Update(key("down"))
	m.Update(key("enter"))
	view = m.View()
	if !strings.Contains(view, "what the second agent said") {
		t.Fatalf("moving to a2 did not show its thread:\n%s", view)
	}
	if strings.Contains(view, "what the first agent said") {
		t.Error("a1's thread is still showing while a2 is selected")
	}
}

func TestEscLeavesTheThreadThenClosesTheSidebar(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
	m.tr.addUser("my own thread")
	m.Update(evSub{ev: subagent.Event{ID: "a1", Preset: "explore",
		Agent: agent.Event{Kind: agent.EventText, Text: "the agent's thread"}}})
	m.Update(key("ctrl+b"))
	m.Update(key("enter"))

	m.Update(key("esc"))
	if m.viewing != "" {
		t.Errorf("still viewing %q after esc", m.viewing)
	}
	if !strings.Contains(m.View(), "explore") {
		t.Error("esc closed the whole sidebar rather than leaving the thread")
	}
	if !strings.Contains(m.View(), "my own thread") {
		t.Error("esc did not put your own thread back")
	}

	m.Update(key("esc"))
	if strings.Contains(m.View(), "explore") {
		t.Error("a second esc did not close the sidebar")
	}
}

func TestPauseResumeAndKillActOnTheSelectedAgent(t *testing.T) {
	m, f := newAgentModel(t,
		info("a1", "explore", subagent.Running),
		info("a2", "plan", subagent.Paused),
	)
	m.Update(key("ctrl+b"))

	press(t, m, key("p")) // a1 is running, so p pauses it
	press(t, m, key("down"))
	press(t, m, key("p")) // a2 is paused, so p resumes it
	press(t, m, key("k"))

	if strings.Join(f.acted, ", ") != "pause a1, resume a2, kill a2" {
		t.Errorf("the sidebar asked for %v", f.acted)
	}
}

// Pausing or killing must happen off the update loop. The supervisor tells
// the front end that something changed by sending it a message, and the
// channel that carries it is read by the update loop itself — so a supervisor
// call made from inside Update deadlocks the whole program, keyboard included.
func TestControllingAnAgentHappensOffTheUpdateLoop(t *testing.T) {
	m, f := newAgentModel(t, info("a1", "explore", subagent.Running))
	m.Update(key("ctrl+b"))

	var inUpdate, calledInUpdate bool
	f.onAct = func() {
		if inUpdate {
			calledInUpdate = true
		}
	}

	inUpdate = true
	_, cmd := m.Update(key("p"))
	inUpdate = false

	if calledInUpdate {
		t.Fatal("the supervisor was called from inside Update, which deadlocks the program")
	}
	if cmd == nil {
		t.Fatal("pressing p produced no command, so nothing will ever pause the agent")
	}
	drain(t, m, cmd)
	if len(f.acted) != 1 || f.acted[0] != "pause a1" {
		t.Errorf("after the command ran, the supervisor saw %v", f.acted)
	}
}

// press feeds a key and then runs whatever command came back, the way the
// program's scheduler would.
func press(t *testing.T, m *model, msg tea.Msg) {
	t.Helper()
	_, cmd := m.Update(msg)
	drain(t, m, cmd)
}

// While the sidebar has the keys, what you type must not land in the prompt —
// otherwise "k" both kills an agent and types a letter.
func TestKeysGoToTheSidebarNotThePromptWhileItIsOpen(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
	m.Update(key("ctrl+b"))
	typeIn(m, "pk")

	if got := m.input.Value(); got != "" {
		t.Errorf("the prompt swallowed %q while the sidebar was open", got)
	}
	m.Update(key("esc"))
	typeIn(m, "hello")
	if got := m.input.Value(); got != "hello" {
		t.Errorf("after esc the prompt has %q, want %q", got, "hello")
	}
}

// A subagent's events belong to its own transcript, not to yours.
func TestSubagentOutputNeverLandsInTheMainTranscript(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
	m.Update(evSub{ev: subagent.Event{ID: "a1", Preset: "explore",
		Agent: agent.Event{Kind: agent.EventToolStart, Tool: "grep", Input: []byte(`{"pattern":"x"}`)}}})

	if strings.Contains(m.tr.render(), "grep") {
		t.Errorf("the agent's tool call landed in the main transcript:\n%s", m.tr.render())
	}
}

// A finished agent is worth a line in your own transcript even when its report
// is not being delivered yet: an agent that finishes invisibly is one you go
// looking for.
func TestAFinishedAgentIsNoticedInTheMainTranscript(t *testing.T) {
	m, f := newAgentModel(t, info("a1", "explore", subagent.Running))
	done := info("a1", "explore", subagent.Done)
	done.Report = "found it in loop.go"
	f.rows = []subagent.Info{done}
	f.reports = []subagent.Info{done}
	typeIn(m, "half a sentence") // holds the report back

	m.Update(evAgents{})
	if !strings.Contains(m.tr.render(), "a1") {
		t.Errorf("nothing in the transcript says a1 finished:\n%s", m.tr.render())
	}
}

// A turn must not start under your fingers: it blurs the prompt and eats what
// you were typing.
func TestAReportIsHeldWhileYouAreTyping(t *testing.T) {
	m, f := newAgentModel(t, info("a1", "explore", subagent.Done))
	f.reports = []subagent.Info{info("a1", "explore", subagent.Done)}
	typeIn(m, "half a sentence")

	m.Update(evAgents{})
	if len(m.pendingReports) != 1 {
		t.Fatalf("the report was not held: %d pending", len(m.pendingReports))
	}
	if m.phase != phaseIdle {
		t.Errorf("a turn started while the prompt had text in it")
	}
	if got := m.input.Value(); got != "half a sentence" {
		t.Errorf("the prompt now reads %q", got)
	}
}

func TestReportsAreHeldWhileAnApprovalIsOnScreen(t *testing.T) {
	m, f := newAgentModel(t, info("a1", "explore", subagent.Done))
	f.reports = []subagent.Info{info("a1", "explore", subagent.Done)}
	pend(m, approval.Request{Tool: "bash", Summary: "run something"})

	m.Update(evAgents{})
	if len(m.pendingReports) != 1 {
		t.Fatalf("the report was not held while an approval was up")
	}
}

// Idle and empty-handed, the report goes into the conversation by itself and
// the agent is set going on it — no keystroke required.
func TestAReportStartsATurnWhenYouAreIdle(t *testing.T) {
	m, f := newAgentModel(t, info("a1", "explore", subagent.Done))
	m.opts.Agent = newQuietAgent(t)

	done := info("a1", "explore", subagent.Done)
	done.Report = "found it in loop.go"
	f.reports = []subagent.Info{done}

	m.Update(evAgents{})
	if len(m.pendingReports) != 0 {
		t.Fatalf("the report is still pending with nothing in the way")
	}
	if m.phase != phaseRunning {
		t.Errorf("phase = %v, want a turn to have started", m.phase)
	}

	var sent string
	for _, msg := range m.opts.Agent.State().History() {
		for _, b := range msg.Content {
			sent += b.Text
		}
	}
	if !strings.Contains(sent, "found it in loop.go") {
		t.Errorf("the conversation never got the report:\n%s", sent)
	}
}

// Whatever was typed before the report landed is still yours to send, and the
// report goes up ahead of it rather than being glued onto it.
func TestAHeldReportGoesUpWhenYouSendYourOwnMessage(t *testing.T) {
	m, f := newAgentModel(t, info("a1", "explore", subagent.Done))
	m.opts.Agent = newQuietAgent(t)
	done := info("a1", "explore", subagent.Done)
	done.Report = "found it in loop.go"
	f.reports = []subagent.Info{done}

	typeIn(m, "and now fix it")
	m.Update(evAgents{})
	m.Update(key("enter"))

	history := m.opts.Agent.State().History()
	if len(history) != 1 {
		t.Fatalf("history has %d messages, want the report as its own message", len(history))
	}
	if !strings.Contains(history[0].Content[0].Text, "found it in loop.go") {
		t.Errorf("the report did not go up ahead of the message:\n%+v", history[0])
	}
	if len(m.pendingReports) != 0 {
		t.Error("the report is still pending after a turn started")
	}
}

// newQuietAgent is a real agent whose turns are never actually run: the tests
// above stop at the command, so nothing reaches the network.
func newQuietAgent(t *testing.T) *agent.Agent {
	t.Helper()
	log := metrics.NewWriter(nil, "test")
	state := agent.NewState()
	reg := tools.NewRegistry(log)
	return agent.New(llm.New(llm.WithAPIKey("x")), reg, state, log, agent.Options{})
}

// A sub-cent agent showing "$0.00" reads as free. Below a cent the row says
// nothing, and the line in the transcript gives the exact figure.
func TestSubCentCostsAreNotShownAsZero(t *testing.T) {
	cheap := info("a1", "explore", subagent.Done)
	cheap.Usage = llm.Usage{InputTokens: 1000, OutputTokens: 40}
	dear := info("a2", "plan", subagent.Done)
	dear.Model = "claude-opus-5"
	dear.Usage = llm.Usage{InputTokens: 100000, OutputTokens: 4000}

	m, _ := newAgentModel(t, cheap, dear)
	m.Update(key("ctrl+b"))
	view := m.View()

	if strings.Contains(view, "$0.00") {
		t.Errorf("a sub-cent agent is shown as costing nothing:\n%s", view)
	}
	if !strings.Contains(view, "$0.6") {
		t.Errorf("the expensive agent's cost is missing:\n%s", view)
	}
}

// Thirty columns is not enough for "claude-haiku-4-5" and a duration and a
// turn count, so the row carries the family and the thread's header carries
// the rest.
func TestShortModelKeepsTheFamily(t *testing.T) {
	cases := map[string]string{
		"claude-haiku-4-5":           "Haiku",
		"claude-opus-5":              "Opus",
		"claude-haiku-4-5-20251001":  "Haiku",
		"some-gateway/mistral-large": "some-gateway/mistral-large",
		"":                           "",
	}
	for id, want := range cases {
		if got := shortModel(id); got != want {
			t.Errorf("shortModel(%q) = %q, want %q", id, got, want)
		}
	}
}

// A minute in, "124s" stops being a number anyone reads as a duration.
func TestCompactDurationTurnsMinutesIntoMinutes(t *testing.T) {
	cases := []struct {
		d    time.Duration
		want string
	}{
		{3 * time.Second, "3s"},
		{59 * time.Second, "59s"},
		{time.Minute + 4*time.Second, "1m04s"},
		{124 * time.Second, "2m04s"},
		{2 * time.Hour, "120m00s"},
	}
	for _, c := range cases {
		if got := compactDuration(c.d); got != c.want {
			t.Errorf("compactDuration(%v) = %q, want %q", c.d, got, c.want)
		}
	}
}

// The pane is the full height of the screen: two short rows leave the status
// line running under it with nothing beside it.
func TestTheSidebarIsAsTallAsTheScreen(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
	m.Update(tea.WindowSizeMsg{Width: 78, Height: 16})
	m.Update(key("ctrl+b"))

	for i, line := range strings.Split(m.View(), "\n") {
		if !strings.Contains(line, "│") {
			t.Errorf("line %d has no sidebar beside it: %q", i, line)
		}
	}
}

// Nothing the model draws may be wider than the terminal it is drawn in: a
// composite that overflows wraps, and a wrapped full-screen layout tears.
func TestTheWholeScreenFitsTheTerminalWithTheSidebarOpen(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
	for _, size := range []tea.WindowSizeMsg{{Width: 78, Height: 14}, {Width: 100, Height: 30}, {Width: 62, Height: 20}} {
		m.Update(size)
		m.Update(evAgents{})
		for _, open := range []bool{true, false} {
			if m.bar.open != open {
				m.Update(key("ctrl+b"))
			}
			for i, line := range strings.Split(m.View(), "\n") {
				if w := lipgloss.Width(line); w > size.Width {
					t.Errorf("at %dx%d (sidebar open=%v) line %d is %d columns wide",
						size.Width, size.Height, open, i, w)
				}
			}
		}
	}
}

// The sidebar owns the keyboard while it is open, with two exceptions: the
// keys that stop things. Swallowing ctrl+c would mean opening the pane to
// watch an agent left you unable to interrupt your own turn.
func TestInterruptAndExitStillWorkWithTheSidebarOpen(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
	m.phase = phaseRunning
	var interrupted bool
	m.cancel = func() { interrupted = true }
	m.Update(key("ctrl+b"))

	m.Update(key("ctrl+c"))
	if !interrupted {
		t.Error("ctrl+c did not interrupt the turn while the sidebar was open")
	}

	m.phase = phaseIdle
	m.Update(key("ctrl+d"))
	if !m.quitting {
		t.Error("ctrl+d did not exit while the sidebar was open")
	}
}

// click presses the left button where the model says something is drawn.
func click(x, y int) tea.MouseMsg {
	return tea.MouseMsg{X: x, Y: y, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft}
}

func TestClickingTheAgentsPillTogglesTheSidebar(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
	m.View() // the pill's position is recorded as it is drawn

	if m.pill.x1 == 0 {
		t.Fatal("the agents pill was never drawn, so it cannot be clicked")
	}
	m.Update(click(m.pill.x0, m.pill.y0))
	if !m.bar.open {
		t.Error("clicking the pill did not open the sidebar")
	}
	m.View()
	m.Update(click(m.pill.x0, m.pill.y0))
	if m.bar.open {
		t.Error("clicking the pill again did not close it")
	}
}

// One click selects a row; clicking the row already under the cursor opens it,
// which is what anyone who double-clicks gets.
func TestClickingARowSelectsItAndClickingAgainOpensIt(t *testing.T) {
	m, _ := newAgentModel(t,
		info("a1", "explore", subagent.Running),
		info("a2", "plan", subagent.Running),
	)
	m.Update(key("ctrl+b"))
	m.View()

	if len(m.bar.hits) != 2 {
		t.Fatalf("the sidebar recorded %d clickable rows, want 2", len(m.bar.hits))
	}
	second := m.bar.hits[1]
	x := m.width - sidebarWidth + 2

	m.Update(click(x, second.y0))
	if cur, _ := m.bar.selected(); cur.ID != "a2" {
		t.Fatalf("clicking a2's row selected %q", cur.ID)
	}
	if m.viewing != "" {
		t.Error("one click opened the thread; it should only select")
	}

	m.View()
	m.Update(click(x, second.y0))
	if m.viewing != "a2" {
		t.Errorf("clicking the selected row did not open it; viewing = %q", m.viewing)
	}
}

// A click in the transcript is not a click in the sidebar, however far right
// it lands while the pane is closed.
func TestClicksOutsideTheSidebarDoNothing(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
	m.View()
	m.Update(click(m.width-2, 3))
	if m.bar.open || m.viewing != "" {
		t.Error("a click in the transcript opened the sidebar")
	}
}

func TestMouseCommandTogglesReporting(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
	m.mouseOn = true

	m.submit("/mouse")
	if m.mouseOn {
		t.Error("/mouse did not turn reporting off")
	}
	if !strings.Contains(m.tr.render(), "mouse off") {
		t.Errorf("nothing said what happened:\n%s", m.tr.render())
	}
	m.submit("/mouse")
	if !m.mouseOn {
		t.Error("/mouse did not turn reporting back on")
	}
}

// The rows' click targets have to agree with where the rows were actually
// drawn. Recorded coordinates that are internally consistent but a line off
// the screen would make every click select the row above.
func TestRowClickTargetsMatchWhereTheRowsAreDrawn(t *testing.T) {
	m, _ := newAgentModel(t,
		info("a1", "explore", subagent.Running),
		info("a2", "plan", subagent.Running),
		info("a3", "general", subagent.Done),
	)
	m.Update(key("ctrl+b"))
	lines := strings.Split(m.View(), "\n")

	if len(m.bar.hits) != 3 {
		t.Fatalf("recorded %d click targets, want 3", len(m.bar.hits))
	}
	for _, h := range m.bar.hits {
		if h.y0 >= len(lines) {
			t.Fatalf("row %s is recorded at line %d of a %d-line screen", h.id, h.y0, len(lines))
		}
		if !strings.Contains(lines[h.y0], h.id) {
			t.Errorf("row %s is recorded at line %d, which reads %q", h.id, h.y0, lines[h.y0])
		}
	}
}

// The same for the pill: it is on the line it says it is.
func TestThePillIsRecordedOnTheLineItIsDrawnOn(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
	lines := strings.Split(m.View(), "\n")
	if m.pill.y0 >= len(lines) || !strings.Contains(lines[m.pill.y0], "agents") {
		t.Errorf("the pill is recorded at line %d, which reads %q", m.pill.y0, lines[m.pill.y0])
	}
}

// A subagent's approval can arrive when nothing of yours is running, and
// answering it must leave the session where it found it. Dropping into
// "running" with no turn in flight strands the UI: enter is refused, esc only
// says "interrupting", and no further report is ever delivered.
func TestAnsweringASubagentsApprovalWhileIdleLeavesYouIdle(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))

	reply := pend(m, approval.Request{Tool: "bash", Summary: "run something", Agent: "a1"})
	if m.phase != phaseApproving {
		t.Fatalf("phase = %v while an approval is up", m.phase)
	}
	m.Update(key("y"))
	if got := <-reply; got != approval.Once {
		t.Fatalf("decision = %v", got)
	}
	if m.phase != phaseIdle {
		t.Errorf("phase = %v after answering; nothing of yours was running", m.phase)
	}

	// The prompt is usable again, which is the thing being stranded costs you.
	typeIn(m, "next")
	if m.input.Value() != "next" {
		t.Errorf("the prompt has %q", m.input.Value())
	}
}

// The same approval answered during a turn puts the turn back.
func TestAnsweringAnApprovalDuringATurnStaysRunning(t *testing.T) {
	m, _ := newAgentModel(t)
	m.phase = phaseRunning

	reply := pend(m, approval.Request{Tool: "bash", Summary: "run something"})
	m.Update(key("y"))
	<-reply
	if m.phase != phaseRunning {
		t.Errorf("phase = %v, want the turn to carry on", m.phase)
	}
}

// A turn ending cancels the approvals that belonged to it — but a subagent's
// approval belongs to the subagent, which is still running. Denying it because
// the parent finished takes the decision away from the user and tells the
// child it was refused.
func TestATurnEndingLeavesSubagentApprovalsAlone(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
	m.phase = phaseRunning

	mine := pend(m, approval.Request{Tool: "bash", Summary: "the parent's"})
	child := pend(m, approval.Request{Tool: "bash", Summary: "the child's", Agent: "a1"})

	m.Update(turnDone{})

	select {
	case got := <-mine:
		if got != approval.Deny {
			t.Errorf("the parent's own request ended as %v, want denied", got)
		}
	default:
		t.Error("the parent's request was left hanging after its turn ended")
	}
	select {
	case got := <-child:
		t.Fatalf("the child's request was answered %v by a turn it had nothing to do with", got)
	default:
	}
	if len(m.pending) != 1 || m.pending[0].req.Agent != "a1" {
		t.Fatalf("pending = %+v, want the child's request still waiting", m.pending)
	}
	if m.phase != phaseApproving {
		t.Errorf("phase = %v, want the box still up for the child", m.phase)
	}
}

// A report the agent already collected with agent_wait is worth a line in the
// transcript, and nothing more: sending it again would repeat it in the
// conversation and start a turn to acknowledge something it just read.
func TestAReportTheAgentAlreadyHasIsNotSentAgain(t *testing.T) {
	m, f := newAgentModel(t, info("a1", "explore", subagent.Done))
	m.opts.Agent = newQuietAgent(t)

	done := info("a1", "explore", subagent.Done)
	done.Report = "found it in loop.go"
	f.unclaimable = map[string]bool{"a1": true}
	f.reports = []subagent.Info{done}

	m.Update(evAgents{})

	if len(m.pendingReports) != 0 {
		t.Errorf("%d reports are queued for a conversation that already has them", len(m.pendingReports))
	}
	if m.phase != phaseIdle {
		t.Error("a turn was started to hand over a report the agent already read")
	}
	if len(m.opts.Agent.State().History()) != 0 {
		t.Error("the report went into the conversation twice")
	}
	if !strings.Contains(m.tr.render(), "a1") {
		t.Errorf("the transcript never mentions that a1 finished:\n%s", m.tr.render())
	}
}

// With several agents running, "bash wants to run …" is not a question anyone
// can answer: the box has to say who is asking.
func TestTheApprovalBoxNamesTheAgentAsking(t *testing.T) {
	m, _ := newAgentModel(t, info("a3", "explore", subagent.Running))
	pend(m, approval.Request{Tool: "bash", Summary: "run a shell command", Detail: "rm -rf build", Agent: "a3"})

	view := m.View()
	if !strings.Contains(view, "a3") {
		t.Errorf("the approval box does not say which agent is asking:\n%s", view)
	}
	if !strings.Contains(view, "explore") {
		t.Errorf("the approval box does not say what kind of agent it is:\n%s", view)
	}
}

// Your own agent's requests are not labelled: there is nobody else it could be.
func TestYourOwnApprovalIsNotLabelledWithAnAgent(t *testing.T) {
	m, _ := newAgentModel(t)
	m.phase = phaseRunning
	pend(m, approval.Request{Tool: "bash", Summary: "run a shell command"})

	if got := m.View(); strings.Contains(got, " · bash") {
		t.Errorf("the box labels the session's own request as if it came from an agent:\n%s", got)
	}
}

// On a terminal too narrow for both panes the status line is not drawn, so the
// pill is nowhere — and a click at its remembered position must not toggle the
// pane shut from under a hint line that happens to be there instead.
func TestThePillIsForgottenWhenTheStatusLineIsNotDrawn(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
	m.View()
	was := m.pill

	m.Update(tea.WindowSizeMsg{Width: 50, Height: 20})
	m.Update(key("ctrl+b"))
	if !m.fullScreenSidebar() {
		t.Fatal("50 columns should not be showing both panes")
	}
	m.View()

	m.Update(click(was.x0, was.y0))
	if !m.bar.open {
		t.Error("a click where the pill used to be closed the pane")
	}
}

// A report held back because you were typing goes up as soon as the prompt is
// empty again, rather than waiting for something else to happen.
func TestAHeldReportGoesUpWhenThePromptIsClearedAgain(t *testing.T) {
	m, f := newAgentModel(t, info("a1", "explore", subagent.Done))
	m.opts.Agent = newQuietAgent(t)
	done := info("a1", "explore", subagent.Done)
	done.Report = "found it in loop.go"
	f.reports = []subagent.Info{done}

	typeIn(m, "ab")
	m.Update(evAgents{})
	if len(m.pendingReports) != 1 {
		t.Fatal("the report was not held while the prompt had text in it")
	}

	m.Update(key("backspace"))
	m.Update(key("backspace"))
	if len(m.pendingReports) != 0 {
		t.Errorf("clearing the prompt did not release the report")
	}
	if m.phase != phaseRunning {
		t.Errorf("phase = %v, want the turn to have started", m.phase)
	}
}

// The effort pill is a claim about what the next turn will ask for. On a model
// that does not take the level, the request goes out without it — so the pill
// has to say so rather than reporting a setting that is not in force.
func TestTheEffortPillSaysWhenTheModelIgnoresIt(t *testing.T) {
	m, _ := newAgentModel(t)
	m.opts.Effort = "high"

	m.opts.Client.SetModel("claude-opus-5")
	if got := m.View(); !strings.Contains(got, "● high") || strings.Contains(got, "not on") {
		t.Errorf("on a model that takes effort the pill should read plainly:\n%s", got)
	}

	m.opts.Client.SetModel("claude-haiku-4-5")
	view := m.View()
	if !strings.Contains(view, "high") {
		t.Fatalf("the pill vanished entirely:\n%s", view)
	}
	if !strings.Contains(view, "not on Haiku") {
		t.Errorf("the pill still claims a level the model rejects:\n%s", view)
	}
}

// The front end holds a report from the moment it is drained until the moment
// there is a turn to put it in, and agent_wait can hand the same report to the
// model in between. So delivery is decided when the message is written, by
// asking the supervisor — not by trusting a copy taken minutes earlier.
func TestAReportTheModelWasGivenMeanwhileIsNotInjected(t *testing.T) {
	m, f := newAgentModel(t, info("a1", "explore", subagent.Done))
	m.opts.Agent = newQuietAgent(t)

	done := info("a1", "explore", subagent.Done)
	done.Report = "found it in loop.go"
	f.reports = []subagent.Info{done}

	// It is held back while the prompt has text in it — which is where the
	// parent's own agent_wait call collects it.
	typeIn(m, "hold on")
	m.Update(evAgents{})
	if len(m.pendingReports) != 1 {
		t.Fatalf("the report was not held: %d pending", len(m.pendingReports))
	}
	f.unclaimable = map[string]bool{"a1": true}

	m.Update(key("backspace"))
	typeIn(m, "")
	for i := 0; i < 7; i++ {
		m.Update(key("backspace"))
	}

	if len(m.pendingReports) != 0 {
		t.Errorf("%d reports are still queued", len(m.pendingReports))
	}
	if n := len(m.opts.Agent.State().History()); n != 0 {
		t.Errorf("the conversation got %d messages for a report the model already had", n)
	}
	if m.phase != phaseIdle {
		t.Error("a turn was started to hand over a report the model already had")
	}
}

// The same path when nobody else has taken it: the report goes up.
func TestAnUnclaimedReportIsStillInjected(t *testing.T) {
	m, f := newAgentModel(t, info("a1", "explore", subagent.Done))
	m.opts.Agent = newQuietAgent(t)

	done := info("a1", "explore", subagent.Done)
	done.Report = "found it in loop.go"
	f.reports = []subagent.Info{done}

	m.Update(evAgents{})
	if len(m.pendingReports) != 0 {
		t.Errorf("the report was not delivered: %d pending", len(m.pendingReports))
	}
	history := m.opts.Agent.State().History()
	if len(history) != 1 || !strings.Contains(history[0].Content[0].Text, "found it in loop.go") {
		t.Errorf("the conversation did not get the report: %+v", history)
	}
}

// The thread header is where an agent's settings are legible — the row has no
// space for them — so it names the effort the agent is actually running at.
func TestTheThreadHeaderNamesTheEffort(t *testing.T) {
	low := info("a1", "explore", subagent.Running)
	low.Effort = "low"
	plain := info("a2", "plan", subagent.Running)

	m, _ := newAgentModel(t, low, plain)
	m.Update(key("ctrl+b"))
	m.Update(key("enter"))
	if got := m.agentHeader(80); !strings.Contains(got, "low") {
		t.Errorf("a1's header does not say what effort it runs at: %q", got)
	}

	m.Update(key("down"))
	if got := m.agentHeader(80); strings.Contains(got, "effort") {
		t.Errorf("a2's header invents an effort level: %q", got)
	}
}

// The pane owns the keyboard while it has focus, which is what makes k a kill
// rather than a letter — but the prompt has to be reachable without closing
// it. Clicking the prompt is how you get back to typing.
func TestClickingThePromptTakesFocusFromTheSidebar(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
	m.Update(key("ctrl+b"))

	typeIn(m, "no")
	if m.input.Value() != "" {
		t.Fatalf("the prompt took %q while the pane had focus", m.input.Value())
	}

	// A click anywhere in the main column — the prompt box included — is a
	// click on the conversation, and that is where typing should go.
	m.View()
	m.Update(click(4, m.height-4))

	if !m.bar.open {
		t.Error("clicking the prompt closed the sidebar; it should only move focus")
	}
	typeIn(m, "hello")
	if got := m.input.Value(); got != "hello" {
		t.Errorf("the prompt has %q after being clicked", got)
	}
}

// And clicking back into the pane gives it the keys again.
func TestClickingTheSidebarTakesFocusBack(t *testing.T) {
	m, f := newAgentModel(t, info("a1", "explore", subagent.Running))
	m.Update(key("ctrl+b"))
	m.View()
	m.Update(click(4, m.height-4)) // to the prompt

	m.View()
	x := m.width - sidebarWidth + 2
	m.Update(click(x, m.bar.hits[0].y0))

	press(t, m, key("p"))
	if len(f.acted) != 1 || f.acted[0] != "pause a1" {
		t.Errorf("p did not reach the pane after clicking it: %v", f.acted)
	}
	if m.input.Value() != "" {
		t.Errorf("the prompt took the keystroke too: %q", m.input.Value())
	}
}

// ctrl+b on a pane that is open but not focused takes you to it rather than
// closing it: the pane is right there, and closing what you are looking at is
// never what that key was for.
func TestCtrlBFocusesAnOpenPaneBeforeClosingIt(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))

	m.Update(key("ctrl+b")) // open, focused
	m.View()
	m.Update(click(4, m.height-4)) // focus the prompt
	if !m.bar.open || m.bar.focused {
		t.Fatalf("after clicking the prompt: open=%v focused=%v", m.bar.open, m.bar.focused)
	}

	m.Update(key("ctrl+b"))
	if !m.bar.open || !m.bar.focused {
		t.Errorf("ctrl+b on an unfocused pane gave open=%v focused=%v", m.bar.open, m.bar.focused)
	}
	m.Update(key("ctrl+b"))
	if m.bar.open {
		t.Error("ctrl+b on a focused pane did not close it")
	}
}

// Which pane has the keys has to be visible, or the next keystroke is a guess.
func TestTheScreenSaysWhichPaneHasTheKeys(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
	m.Update(key("ctrl+b"))
	focused := m.View()
	if !strings.Contains(focused, "esc back") {
		t.Errorf("a focused pane does not offer its keys:\n%s", focused)
	}

	m.View()
	m.Update(click(4, m.height-4))
	unfocused := m.View()
	if !strings.Contains(unfocused, "ctrl+b") {
		t.Errorf("an unfocused pane does not say how to get back to it:\n%s", unfocused)
	}
	if strings.Contains(unfocused, "p pause") {
		t.Errorf("an unfocused pane still offers keys it will not answer:\n%s", unfocused)
	}
}

// A turn ending puts the keyboard back in the prompt — unless the pane has it,
// in which case taking it away mid-keystroke would be the bug this focus
// separation exists to avoid.
func TestATurnEndingDoesNotStealFocusFromThePane(t *testing.T) {
	m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
	m.opts.SessionDir = t.TempDir()
	m.Update(key("ctrl+b"))
	m.phase = phaseRunning

	m.Update(turnDone{})
	if !m.bar.focused {
		t.Fatal("the pane lost focus when a turn ended")
	}
	typeIn(m, "no")
	if got := m.input.Value(); got != "" {
		t.Errorf("the prompt took %q after the turn ended", got)
	}
}