package tui import ( "context" "encoding/json" "sync" "testing" "time" tea "github.com/charmbracelet/bubbletea" "harness/internal/agent" "harness/internal/approval" ) // recorder stands in for tea.Program: it captures what the bridge sends // without needing a terminal. type recorder struct { mu sync.Mutex msgs []tea.Msg // onSend runs for each message, so a test can answer an approval from the // "UI side" the way the Update loop would. onSend func(tea.Msg) } func (r *recorder) Send(m tea.Msg) { r.mu.Lock() r.msgs = append(r.msgs, m) f := r.onSend r.mu.Unlock() if f != nil { f(m) } } func (r *recorder) all() []tea.Msg { r.mu.Lock() defer r.mu.Unlock() return append([]tea.Msg(nil), r.msgs...) } func TestObserveTranslatesEvents(t *testing.T) { r := &recorder{} b := &bridge{p: r} b.Observe(agent.Event{Kind: agent.EventTurnStart, Turn: 2}) b.Observe(agent.Event{Kind: agent.EventTextDelta, Text: "hi"}) b.Observe(agent.Event{Kind: agent.EventToolStart, Tool: "grep", Input: json.RawMessage(`{"pattern":"x"}`)}) b.Observe(agent.Event{Kind: agent.EventToolResult, Tool: "grep", Result: "x.go:1", IsError: false}) b.Observe(agent.Event{Kind: agent.EventCompact}) msgs := r.all() if len(msgs) != 5 { t.Fatalf("got %d messages, want 5", len(msgs)) } if m, ok := msgs[0].(evTurnStart); !ok || m.turn != 2 { t.Errorf("msgs[0] = %#v, want evTurnStart{2}", msgs[0]) } if m, ok := msgs[2].(evToolStart); !ok || m.input != "pattern=x" { t.Errorf("msgs[2] = %#v, want summarised tool input", msgs[2]) } if _, ok := msgs[4].(evCompact); !ok { t.Errorf("msgs[4] = %#v, want evCompact", msgs[4]) } } // The agent goroutine must block until the UI answers, and must get exactly // the decision the user picked. func TestPromptBlocksUntilAnswered(t *testing.T) { r := &recorder{} r.onSend = func(m tea.Msg) { if am, ok := m.(approvalMsg); ok { go func() { time.Sleep(10 * time.Millisecond) am.reply <- approval.AlwaysThisTool }() } } b := &bridge{p: r} start := time.Now() got, err := b.Prompt(context.Background(), approval.Request{Tool: "bash", Summary: "run a shell command"}) if err != nil { t.Fatalf("Prompt: %v", err) } if got != approval.AlwaysThisTool { t.Errorf("decision = %v, want AlwaysThisTool", got) } if time.Since(start) < 10*time.Millisecond { t.Error("Prompt returned before the UI answered") } } // A cancelled turn must not strand the agent on a channel no one will read. // This is the deadlock the whole design has to avoid. func TestPromptUnblocksOnCancel(t *testing.T) { r := &recorder{} // never answers b := &bridge{p: r} ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(10 * time.Millisecond) cancel() }() done := make(chan struct{}) var got approval.Decision var err error go func() { got, err = b.Prompt(ctx, approval.Request{Tool: "bash"}) close(done) }() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("Prompt did not return after its context was cancelled") } if got != approval.Deny { t.Errorf("decision = %v, want Deny on cancellation", got) } if err == nil { t.Error("want a non-nil error on cancellation") } } func TestPromptRefusesAnAlreadyCancelledContext(t *testing.T) { r := &recorder{} b := &bridge{p: r} ctx, cancel := context.WithCancel(context.Background()) cancel() got, err := b.Prompt(ctx, approval.Request{Tool: "bash"}) if got != approval.Deny || err == nil { t.Errorf("Prompt = (%v, %v), want (Deny, error)", got, err) } if len(r.all()) != 0 { t.Error("a cancelled prompt still bothered the UI") } } // The reply channel is buffered, so a UI that answers a request whose agent // has already given up must not block forever. func TestReplyChannelNeverBlocksTheUI(t *testing.T) { r := &recorder{} b := &bridge{p: r} ctx, cancel := context.WithCancel(context.Background()) var captured approvalMsg got := make(chan struct{}) r.onSend = func(m tea.Msg) { if am, ok := m.(approvalMsg); ok { captured = am close(got) } } go b.Prompt(ctx, approval.Request{Tool: "bash"}) <-got cancel() time.Sleep(20 * time.Millisecond) // let Prompt give up done := make(chan struct{}) go func() { captured.reply <- approval.Once // must not block close(done) }() select { case <-done: case <-time.After(time.Second): t.Fatal("answering an abandoned approval blocked the UI") } } // The bridge must satisfy the interfaces the agent and policy expect. If these // signatures drift, this stops compiling, which is the point. var ( _ = func(b *bridge) { var _ func(agent.Event) = b.Observe } _ = func(b *bridge) { var _ approval.Prompt = b.Prompt } )