Vectrum
Sign In
616 lines · 17.9 KB · Go
package tui

import (
	"context"
	"strings"
	"testing"

	tea "github.com/charmbracelet/bubbletea"

	"codey/internal/approval"
	"codey/internal/config"
	"codey/internal/llm"
	"codey/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 "backspace":
		return tea.KeyMsg{Type: tea.KeyBackspace}
	case "up":
		return tea.KeyMsg{Type: tea.KeyUp}
	case "down":
		return tea.KeyMsg{Type: tea.KeyDown}
	case "left":
		return tea.KeyMsg{Type: tea.KeyLeft}
	case "right":
		return tea.KeyMsg{Type: tea.KeyRight}
	}
	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)
			// The session's own agent asks during a turn; answering returns
			// to it. (A subagent can ask while you are idle — see
			// TestAnsweringASubagentsApprovalWhileIdleLeavesYouIdle.)
			m.phase = phaseRunning
			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)
	// These are the session's own agent asking, which only happens during a
	// turn. A subagent can ask while you are idle, and then answering puts
	// you back to idle rather than into a turn that is not running.
	m.phase = phaseRunning
	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)
	m.phase = phaseRunning
	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")
	}
}

// ---- the setup wizard, from the chat model's side ------------------------

// A first run opens the wizard before anything else is on screen. That is the
// whole point of the FirstRun flag: nobody should have to know /setup exists
// to be asked the questions once.
func TestFirstRunOpensTheWizard(t *testing.T) {
	m := newModel(context.Background(), Options{
		Client:   llm.New(llm.WithAPIKey("x")),
		Root:     "/tmp/repo",
		Policy:   approval.New(nil),
		FirstRun: true,
	})
	m.Init()
	m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})

	if m.wiz == nil {
		t.Fatal("a first run did not open the wizard")
	}
	if !strings.Contains(m.View(), "codey") {
		t.Error("the wizard is open but its screen is not what View returns")
	}
}

func TestASecondRunDoesNotOpenTheWizard(t *testing.T) {
	m := newTestModel(t)
	m.Init()
	if m.wiz != nil {
		t.Error("the wizard opened without a first run")
	}
}

// While the wizard is up it owns every key: a keystroke meant for it must not
// also reach the prompt underneath.
func TestWizardSwallowsKeysMeantForIt(t *testing.T) {
	m := newTestModel(t)
	m.Update(key("/setup"))
	m.submit("/setup")
	if m.wiz == nil {
		t.Fatal("/setup did not open the wizard")
	}

	before := m.input.Value()
	m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
	if m.input.Value() != before {
		t.Errorf("a key reached the prompt through the wizard: %q", m.input.Value())
	}
}

// Finishing hands the answers to OnConfig — the only path by which anything is
// saved or applied — and puts the chat back.
func TestFinishingTheWizardAppliesAndCloses(t *testing.T) {
	var got *config.Config
	m := newModel(context.Background(), Options{
		Client: llm.New(llm.WithAPIKey("x")),
		Root:   "/tmp/repo",
		Policy: approval.New(nil),
		SaveConfig: func(c config.Config) error {
			got = &c
			return nil
		},
	})
	m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
	m.submit("/setup")
	m.Update(modelsMsg{models: pricedModels()})

	for i := 0; i < 400 && m.wiz != nil; i++ {
		m.Update(key("enter"))
		m.Update(wizTick{})
	}
	if m.wiz != nil {
		t.Fatal("the wizard never closed")
	}
	if got == nil {
		t.Fatal("SaveConfig was never called")
	}
	if got.Model == "" || got.Effort == "" {
		t.Errorf("SaveConfig got an incomplete config: %+v", *got)
	}
	if m.opts.Effort != got.Effort {
		t.Errorf("the status bar still reports effort %q, want %q", m.opts.Effort, got.Effort)
	}
	if !strings.Contains(m.tr.render(), "setup:") {
		t.Error("the change was never reported in the transcript")
	}
	if m.opts.Client.Model() != got.Model {
		t.Errorf("the client is on %q, want the chosen %q", m.opts.Client.Model(), got.Model)
	}
}

// Cancelling must change nothing, and must say so rather than leaving the user
// wondering whether it took.
func TestCancellingTheWizardChangesNothing(t *testing.T) {
	called := false
	m := newModel(context.Background(), Options{
		Client:     llm.New(llm.WithAPIKey("x")),
		Root:       "/tmp/repo",
		Policy:     approval.New(nil),
		Config:     config.Config{Effort: "low"},
		SaveConfig: func(config.Config) error { called = true; return nil },
	})
	m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
	m.submit("/setup")
	m.Update(key("ctrl+c"))

	if m.wiz != nil {
		t.Fatal("ctrl+c did not close the wizard")
	}
	if called {
		t.Error("a cancelled wizard still applied its answers")
	}
	if m.quitting {
		t.Error("ctrl+c in the wizard quit the whole program")
	}
	if !strings.Contains(m.tr.render(), "cancelled") {
		t.Error("cancelling said nothing")
	}
}

// Changing the model mid-turn would mean the turn finishes on a different
// model than it started on, and the session log would be wrong about which.
func TestSetupIsRefusedWhileTheAgentIsWorking(t *testing.T) {
	m := newTestModel(t)
	m.phase = phaseRunning
	m.submit("/setup")

	if m.wiz != nil {
		t.Error("/setup opened the wizard mid-turn")
	}
	if !strings.Contains(m.tr.render(), "interrupt first") {
		t.Error("/setup was refused without saying why")
	}
}

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