package tui
import (
"context"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"harness/internal/approval"
"harness/internal/llm"
"harness/internal/skills"
)
// newTestModel builds a model far enough to drive Update/View without a
// terminal or a live agent.
func newTestModel(t *testing.T) *model {
t.Helper()
m := newModel(context.Background(), Options{
Client: llm.New(llm.WithAPIKey("x")),
Root: "/tmp/repo",
Policy: approval.New(nil),
})
m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
return m
}
func key(s string) tea.KeyMsg {
switch s {
case "enter":
return tea.KeyMsg{Type: tea.KeyEnter}
case "esc":
return tea.KeyMsg{Type: tea.KeyEsc}
case "ctrl+c":
return tea.KeyMsg{Type: tea.KeyCtrlC}
case "ctrl+r":
return tea.KeyMsg{Type: tea.KeyCtrlR}
case "shift+tab":
return tea.KeyMsg{Type: tea.KeyShiftTab}
case "tab":
return tea.KeyMsg{Type: tea.KeyTab}
case "up":
return tea.KeyMsg{Type: tea.KeyUp}
case "down":
return tea.KeyMsg{Type: tea.KeyDown}
}
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
}
// typeIn sends a string one keystroke at a time, the way a person produces
// it: the completion menu reacts to keys, not to the value being assigned.
func typeIn(m *model, s string) {
for _, r := range s {
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
}
// pend queues an approval the way the bridge would, and returns the channel
// the model is expected to answer on.
func pend(m *model, req approval.Request) chan approval.Decision {
reply := make(chan approval.Decision, 1)
m.Update(approvalMsg{req: req, reply: reply})
return reply
}
func TestApprovalKeysMapToDecisions(t *testing.T) {
cases := []struct {
key string
want approval.Decision
}{
{"y", approval.Once},
{"enter", approval.Once},
{"n", approval.Deny},
{"esc", approval.Deny},
{"t", approval.AlwaysThisTool},
{"a", approval.AlwaysThisCommand},
}
for _, tc := range cases {
t.Run(tc.key, func(t *testing.T) {
m := newTestModel(t)
reply := pend(m, approval.Request{Tool: "bash", Rememberable: true})
if m.phase != phaseApproving {
t.Fatalf("phase = %v, want phaseApproving", m.phase)
}
m.Update(key(tc.key))
select {
case got := <-reply:
if got != tc.want {
t.Errorf("decision = %v, want %v", got, tc.want)
}
default:
t.Fatal("no decision was sent")
}
if m.phase != phaseRunning {
t.Errorf("phase = %v after deciding, want phaseRunning", m.phase)
}
})
}
}
// "always this exact one" is refused for an opaque command, and crucially the
// agent stays blocked rather than being silently approved or denied.
func TestAlwaysThisCommandRefusedWhenNotRememberable(t *testing.T) {
m := newTestModel(t)
reply := pend(m, approval.Request{Tool: "bash", Rememberable: false})
m.Update(key("a"))
select {
case got := <-reply:
t.Fatalf("sent %v for an unrememberable command; want the prompt to stay open", got)
default:
}
if m.phase != phaseApproving {
t.Errorf("phase = %v, want the prompt still open", m.phase)
}
if !strings.Contains(m.tr.render(), "cannot be remembered") {
t.Error("no explanation shown for the refusal")
}
// It must still be answerable.
m.Update(key("y"))
if got := <-reply; got != approval.Once {
t.Errorf("decision = %v, want Once", got)
}
}
func TestUnknownKeyDuringApprovalDoesNothing(t *testing.T) {
m := newTestModel(t)
reply := pend(m, approval.Request{Tool: "bash", Rememberable: true})
m.Update(key("z"))
select {
case got := <-reply:
t.Fatalf("stray key sent decision %v", got)
default:
}
if m.phase != phaseApproving {
t.Error("stray key dismissed the prompt")
}
}
// Queued approvals are answered one at a time, in order.
func TestApprovalsAreQueued(t *testing.T) {
m := newTestModel(t)
first := pend(m, approval.Request{Tool: "bash", Rememberable: true})
second := pend(m, approval.Request{Tool: "edit_file", Rememberable: true})
m.Update(key("y"))
if got := <-first; got != approval.Once {
t.Errorf("first decision = %v", got)
}
if m.phase != phaseApproving {
t.Fatal("phase left approving while a second request was queued")
}
m.Update(key("n"))
if got := <-second; got != approval.Deny {
t.Errorf("second decision = %v", got)
}
if m.phase != phaseRunning {
t.Errorf("phase = %v after the queue drained", m.phase)
}
}
// A turn that ends while an approval is outstanding must release the agent
// rather than leave it blocked forever.
func TestTurnDoneDeniesOutstandingApprovals(t *testing.T) {
m := newTestModel(t)
reply := pend(m, approval.Request{Tool: "bash"})
m.opts.SessionDir = t.TempDir()
m.Update(turnDone{})
select {
case got := <-reply:
if got != approval.Deny {
t.Errorf("decision = %v, want Deny", got)
}
default:
t.Fatal("an outstanding approval was never answered")
}
if m.phase != phaseIdle {
t.Errorf("phase = %v, want phaseIdle", m.phase)
}
if len(m.pending) != 0 {
t.Error("pending approvals were not cleared")
}
}
func TestCtrlCDuringApprovalDeniesAndInterrupts(t *testing.T) {
m := newTestModel(t)
cancelled := false
m.cancel = func() { cancelled = true }
reply := pend(m, approval.Request{Tool: "bash"})
m.Update(key("ctrl+c"))
if got := <-reply; got != approval.Deny {
t.Errorf("decision = %v, want Deny", got)
}
if !cancelled {
t.Error("ctrl+c during an approval did not cancel the turn")
}
if !m.interrupting {
t.Error("interrupting flag not set")
}
}
func TestCtrlCInterruptsRunningTurnThenQuitsWhenIdle(t *testing.T) {
m := newTestModel(t)
cancelled := false
m.phase = phaseRunning
m.cancel = func() { cancelled = true }
if _, cmd := m.Update(key("ctrl+c")); cmd != nil && isQuit(cmd) {
t.Fatal("ctrl+c quit the program while a turn was running")
}
if !cancelled {
t.Error("running turn was not cancelled")
}
if m.quitting {
t.Error("model marked quitting during a running turn")
}
m.phase = phaseIdle
if _, cmd := m.Update(key("ctrl+c")); cmd == nil || !isQuit(cmd) {
t.Error("ctrl+c while idle did not quit")
}
}
func isQuit(cmd tea.Cmd) bool {
if cmd == nil {
return false
}
_, ok := cmd().(tea.QuitMsg)
return ok
}
func TestSlashCommands(t *testing.T) {
m := newTestModel(t)
m.submit("/help")
if !strings.Contains(m.tr.render(), "/allowed") {
t.Error("/help did not list commands")
}
m.submit("/allowed")
if !strings.Contains(m.tr.render(), "nothing is permanently allowed") {
t.Error("/allowed did not report an empty policy")
}
m.submit("/nope")
if !strings.Contains(m.tr.render(), "unknown command") {
t.Error("an unknown command was not reported")
}
m.submit("/clear")
if len(m.tr.entries) != 0 {
t.Errorf("/clear left %d entries", len(m.tr.entries))
}
if _, cmd := m.submit("/exit"); !isQuit(cmd) {
t.Error("/exit did not quit")
}
}
// Input is refused while the agent is working: accepting it would imply it had
// been queued, and nothing would ever send it.
func TestSlashSkillsListsThem(t *testing.T) {
m := newTestModel(t)
m.opts.Skills = []skills.Skill{
{Name: "commit-helper", Description: "Use when writing commit messages", Source: "personal"},
}
m.submit("/skills")
out := m.tr.render()
if !strings.Contains(out, "commit-helper") || !strings.Contains(out, "Use when writing commit messages") {
t.Errorf("/skills did not list the skill:\n%s", out)
}
}
func TestSlashSkillsWithNoneSaysSo(t *testing.T) {
m := newTestModel(t)
m.submit("/skills")
if !strings.Contains(m.tr.render(), "no skills") {
t.Errorf("/skills with none found said nothing useful:\n%s", m.tr.render())
}
}
func TestKnownSkillIsSentAsAUserTurn(t *testing.T) {
m := newTestModel(t)
m.opts.Skills = []skills.Skill{{Name: "commit-helper", Description: "d"}}
m.submit("/commit-helper fix the flaky test")
if m.phase != phaseRunning {
t.Errorf("phase = %v, want phaseRunning — the line was not sent", m.phase)
}
if !strings.Contains(m.tr.render(), "/commit-helper fix the flaky test") {
t.Errorf("the user turn is not in the transcript:\n%s", m.tr.render())
}
}
func TestUnknownSlashCommandStillErrors(t *testing.T) {
m := newTestModel(t)
m.opts.Skills = []skills.Skill{{Name: "commit-helper", Description: "d"}}
m.submit("/commit-helpr")
if m.phase == phaseRunning {
t.Error("a typo was sent to the model instead of being caught")
}
if !strings.Contains(m.tr.render(), "unknown command") {
t.Errorf("no error for the typo:\n%s", m.tr.render())
}
}
func TestHelpMentionsSkills(t *testing.T) {
m := newTestModel(t)
m.submit("/help")
if !strings.Contains(m.tr.render(), "/skills") {
t.Error("/help does not mention /skills")
}
}
func TestEnterIgnoredWhileRunning(t *testing.T) {
m := newTestModel(t)
m.phase = phaseRunning
m.input.SetValue("another thing")
m.Update(key("enter"))
if m.input.Value() != "another thing" {
t.Error("input was consumed while a turn was running")
}
}
func TestUsageAccumulatesAcrossTurns(t *testing.T) {
m := newTestModel(t)
m.Update(evUsage{usage: llm.Usage{InputTokens: 10, OutputTokens: 3, CacheReadInputTokens: 100}})
m.Update(evUsage{usage: llm.Usage{InputTokens: 5, OutputTokens: 2}})
if m.usage.InputTokens != 15 || m.usage.OutputTokens != 5 || m.usage.CacheReadInputTokens != 100 {
t.Errorf("usage = %+v, want in 15 out 5 cached 100", m.usage)
}
if s := m.status(); !strings.Contains(s, "15") {
t.Errorf("status line does not show usage: %q", s)
}
}
// The cost is the last field on the status line and the easiest one to lose to
// a width miscount; at a normal width it must survive intact.
func TestStatusLineKeepsCostAtNormalWidth(t *testing.T) {
m := newTestModel(t)
m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
m.Update(evUsage{usage: llm.Usage{InputTokens: 2400, OutputTokens: 190, CacheReadInputTokens: 16000}})
status := m.status()
if _, known := m.usage.Cost(m.opts.Client.Model); !known {
t.Skip("no pricing for the test model")
}
if strings.HasSuffix(strings.TrimSpace(status), "…") {
t.Errorf("status line was truncated at width 120: %q", status)
}
if !strings.Contains(status, "$") {
t.Errorf("cost missing from the status line: %q", status)
}
}
// Approving a call records the decision on that call, so the transcript reads
// in the order things actually happened.
func TestApprovalNoteAttachesToTheCall(t *testing.T) {
m := newTestModel(t)
m.tr.startTool("bash", "command=go build ./...")
reply := pend(m, approval.Request{Tool: "bash", Rememberable: true})
m.Update(key("y"))
<-reply
m.tr.finishTool("bash", "build ok", false)
out := m.tr.render()
note, result := strings.Index(out, "approved once"), strings.Index(out, "build ok")
if note < 0 || result < 0 || note > result {
t.Errorf("approval did not render with the call it gated:\n%s", out)
}
}
func TestToggleVerboseRedrawsTranscript(t *testing.T) {
m := newTestModel(t)
m.tr.startTool("bash", "command=go test")
m.tr.finishTool("bash", strings.Repeat("noise\n", 30), false)
if !strings.Contains(m.tr.render(), "more lines") {
t.Fatal("output was not collapsed to begin with")
}
m.Update(key("ctrl+r"))
if strings.Contains(m.tr.render(), "more lines") {
t.Error("ctrl+r did not expand tool output")
}
}
// The view must render at any size without panicking; a resize to something
// tiny is the classic crash.
func TestViewRendersAtAwkwardSizes(t *testing.T) {
for _, size := range [][2]int{{80, 24}, {20, 6}, {200, 60}, {1, 1}} {
m := newTestModel(t)
m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]})
m.tr.addUser("hello")
m.tr.startTool("edit_file", "path=main.go")
m.tr.finishTool("edit_file", "--- a\n+++ b\n@@ -1 +1 @@\n-x\n+y\n", false)
m.sync()
if got := m.View(); got == "" {
t.Errorf("empty view at %dx%d", size[0], size[1])
}
pend(m, approval.Request{Tool: "bash", Summary: "run a shell command", Detail: "rm -rf /", Rememberable: false})
if got := m.View(); !strings.Contains(got, "bash") {
t.Errorf("approval not shown at %dx%d", size[0], size[1])
}
}
}
func TestViewShowsApprovalOptionsConditionally(t *testing.T) {
m := newTestModel(t)
pend(m, approval.Request{Tool: "bash", Summary: "run it", Detail: "ls", Rememberable: true})
if got := m.approvalView(); !strings.Contains(got, "always this exact one") {
t.Error("rememberable request did not offer the exact-command option")
}
m2 := newTestModel(t)
pend(m2, approval.Request{Tool: "bash", Summary: "run it", Detail: "eval $(x)", Rememberable: false})
if got := m2.approvalView(); strings.Contains(got, "always this exact one") {
t.Error("opaque command offered an option that would be a lie")
}
}
// /effort is the one command that changes how the next turn runs, so it has to
// take, be visible in the chrome, and reach the agent.
func TestEffortCommandSetsTheLevel(t *testing.T) {
m := newTestModel(t)
m.submit("/effort xhigh")
if m.opts.Effort != "xhigh" {
t.Errorf("effort = %q, want xhigh", m.opts.Effort)
}
if !strings.Contains(m.activity(), "xhigh") {
t.Errorf("the effort pill still reads %q", m.activity())
}
if !strings.Contains(m.tr.render(), "xhigh") {
t.Error("the change was not reported in the transcript")
}
}
func TestEffortCommandRejectsAnUnknownLevel(t *testing.T) {
m := newTestModel(t)
m.opts.Effort = "high"
m.submit("/effort ludicrous")
if m.opts.Effort != "high" {
t.Errorf("effort = %q, want the previous level kept", m.opts.Effort)
}
out := m.tr.render()
if !strings.Contains(out, "ludicrous") || !strings.Contains(out, "xhigh") {
t.Errorf("want the bad level named and the good ones listed, got %q", out)
}
if m.phase != phaseIdle {
t.Error("a rejected effort still started a turn")
}
}
func TestBareEffortReportsTheLevelsAndTheCurrentOne(t *testing.T) {
m := newTestModel(t)
m.opts.Effort = "medium"
m.submit("/effort")
out := m.tr.render()
for _, level := range llm.EffortLevels {
if !strings.Contains(out, level) {
t.Errorf("level %q missing from %q", level, out)
}
}
if !strings.Contains(out, "● medium") {
t.Errorf("the level in force is not marked: %q", out)
}
if m.phase != phaseIdle {
t.Error("asking about effort started a turn")
}
}