Vectrum
Sign In
344 lines · 10.8 KB · Go
package tui

import (
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	tea "github.com/charmbracelet/bubbletea"

	"harness/internal/llm"
)

func someModels() []llm.Model {
	return []llm.Model{
		{ID: "claude-opus-5", DisplayName: "Claude Opus 5"},
		{ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5"},
		{ID: "claude-haiku-4-5-20251001", DisplayName: "Claude Haiku 4.5"},
	}
}

// /model opens on a wait, because the list comes from the endpoint rather
// than from a table compiled into the harness.
func TestModelPickerAsksTheEndpoint(t *testing.T) {
	m := newTestModel(t)
	m.submit("/model")

	if m.phase != phaseModel {
		t.Fatalf("phase = %v, want the picker open", m.phase)
	}
	if !m.modelsLoading {
		t.Error("the picker is not waiting on anything")
	}
	if !strings.Contains(m.modelView(), "anthropic") {
		t.Errorf("the wait does not say where it is asking:\n%s", m.modelView())
	}

	m.Update(modelsMsg{models: someModels()})

	if m.modelsLoading {
		t.Error("still loading after the answer arrived")
	}
	view := m.modelView()
	for _, want := range []string{"Claude Opus 5", "claude-sonnet-5", "Select model"} {
		if !strings.Contains(view, want) {
			t.Errorf("%q missing from the picker:\n%s", want, view)
		}
	}
}

// The cursor starts on what is in force, and that row is marked, so the
// picker opens saying where you already are.
func TestModelPickerStartsOnTheModelInForce(t *testing.T) {
	m := newTestModel(t)
	m.opts.Client.SetModel("claude-sonnet-5")
	m.submit("/model")
	m.Update(modelsMsg{models: someModels()})

	if got := m.models[m.modelIdx].ID; got != "claude-sonnet-5" {
		t.Errorf("cursor on %q, want the model in force", got)
	}
	if !strings.Contains(m.modelView(), "✓") {
		t.Errorf("the model in force is not marked:\n%s", m.modelView())
	}
}

// Prices we know are shown beside the id, including for the dated ids the API
// hands out; a model we have no price for simply says less.
func TestModelRowsShowTheIDAndWhatItCosts(t *testing.T) {
	m := newTestModel(t)
	m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
	m.submit("/model")
	m.Update(modelsMsg{models: append(someModels(), llm.Model{ID: "mystery-1", DisplayName: "Mystery"})})

	view := m.modelView()
	if !strings.Contains(view, "claude-haiku-4-5-20251001") {
		t.Errorf("the id is missing:\n%s", view)
	}
	if !strings.Contains(view, "$1") || !strings.Contains(view, "$5") {
		t.Errorf("the price of a known model is missing:\n%s", view)
	}
	for _, line := range strings.Split(view, "\n") {
		if strings.Contains(line, "Mystery") && strings.Contains(line, "$") {
			t.Errorf("a model with no known price was given one: %q", line)
		}
	}

	// A model the endpoint gave no name for is shown once, not twice: there
	// is nothing to say beyond its id.
	m2 := newTestModel(t)
	m2.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
	m2.submit("/model")
	m2.Update(modelsMsg{models: []llm.Model{{ID: "gateway/mystery"}}})
	for _, line := range strings.Split(m2.modelView(), "\n") {
		if strings.Count(line, "gateway/mystery") > 1 {
			t.Errorf("the id is printed twice: %q", line)
		}
	}
}

func TestModelPickerAppliesOnEnter(t *testing.T) {
	m := newTestModel(t)
	m.submit("/model")
	m.Update(modelsMsg{models: someModels()})

	m.Update(key("down"))
	if m.opts.Client.Model() != "claude-opus-5" {
		t.Error("moving the cursor switched the model; it should only move")
	}

	m.Update(key("enter"))

	if got := m.opts.Client.Model(); got != "claude-sonnet-5" {
		t.Errorf("model = %q, want claude-sonnet-5", got)
	}
	if m.phase != phaseIdle {
		t.Errorf("phase = %v after applying, want the prompt back", m.phase)
	}
	if out := m.tr.render(); !strings.Contains(out, "claude-sonnet-5") {
		t.Errorf("the switch was not reported: %q", out)
	}
	if !strings.Contains(m.status(), "Sonnet 5") {
		t.Errorf("the status bar still names the old model: %q", m.status())
	}
}

func TestModelPickerNumbersJumpAndEscapeCancels(t *testing.T) {
	m := newTestModel(t)
	m.submit("/model")
	m.Update(modelsMsg{models: someModels()})

	m.Update(key("3"))
	if got := m.models[m.modelIdx].ID; got != "claude-haiku-4-5-20251001" {
		t.Errorf("3 selected %q", got)
	}
	m.Update(key("esc"))

	if got := m.opts.Client.Model(); got != "claude-opus-5" {
		t.Errorf("model = %q, want it left alone", got)
	}
	if m.phase != phaseIdle {
		t.Errorf("phase = %v, want the prompt back", m.phase)
	}
}

// An endpoint that cannot be asked says so, and says where to go instead,
// rather than leaving an empty picker on screen.
func TestModelPickerReportsAFailedFetch(t *testing.T) {
	m := newTestModel(t)
	m.submit("/model")

	m.Update(modelsMsg{err: errors.New("404 not_found_error: unknown route")})

	if m.phase != phaseIdle {
		t.Errorf("phase = %v, want the picker gone", m.phase)
	}
	out := m.tr.render()
	for _, want := range []string{"404", "-model"} {
		if !strings.Contains(out, want) {
			t.Errorf("%q missing from the report: %q", want, out)
		}
	}
}

func TestModelPickerReportsAnEmptyList(t *testing.T) {
	m := newTestModel(t)
	m.submit("/model")

	m.Update(modelsMsg{models: nil})

	if m.phase != phaseIdle {
		t.Errorf("phase = %v, want the picker gone", m.phase)
	}
	if !strings.Contains(m.tr.render(), "no models") {
		t.Errorf("an empty list was not explained: %q", m.tr.render())
	}
}

// Escaping the wait means the answer is no longer wanted: it must not reopen
// the picker over whatever the user did next.
func TestModelPickerIgnoresAnAnswerItNoLongerWants(t *testing.T) {
	m := newTestModel(t)
	m.submit("/model")
	m.Update(key("esc"))

	m.Update(modelsMsg{models: someModels()})

	if m.phase != phaseIdle {
		t.Errorf("phase = %v, want the picker to have stayed closed", m.phase)
	}
	if len(m.models) != 0 {
		t.Error("a discarded answer was kept")
	}
}

// Typing the id is still the fast path, and it does not need the endpoint to
// answer anything.
func TestTypedModelSkipsThePicker(t *testing.T) {
	m := newTestModel(t)
	m.submit("/model claude-haiku-4-5")

	if m.phase != phaseIdle {
		t.Errorf("phase = %v, want no picker", m.phase)
	}
	if got := m.opts.Client.Model(); got != "claude-haiku-4-5" {
		t.Errorf("model = %q", got)
	}
}

// A long list scrolls inside the room the terminal has rather than pushing
// the transcript off the screen.
func TestModelPickerFitsTheTerminal(t *testing.T) {
	var many []llm.Model
	for i := 0; i < 30; i++ {
		many = append(many, llm.Model{ID: fmt.Sprintf("claude-model-%d", i), DisplayName: fmt.Sprintf("Model %d", i)})
	}
	for _, h := range []int{12, 24, 40} {
		m := newTestModel(t)
		m.Update(tea.WindowSizeMsg{Width: 80, Height: h})
		m.submit("/model")
		m.Update(modelsMsg{models: many})

		if got := strings.Count(m.View(), "\n") + 1; got > h {
			t.Errorf("height %d: the screen is %d lines", h, got)
		}
		if !strings.Contains(m.modelView(), "more") {
			t.Errorf("height %d: a list of 30 showed no count of what did not fit", h)
		}

		// The cursor stays visible as it walks down the list.
		for i := 0; i < 29; i++ {
			m.Update(key("down"))
		}
		if !strings.Contains(m.modelView(), "Model 29") {
			t.Errorf("height %d: the list did not scroll to the cursor:\n%s", h, m.modelView())
		}
	}
}

// The fetch command is the part that talks to the endpoint; it turns both
// outcomes into a message the model can handle.
func TestFetchModelsCommandCarriesTheResult(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, `{"data":[{"type":"model","id":"claude-opus-5","display_name":"Claude Opus 5"}],"has_more":false}`)
	}))
	defer srv.Close()

	client := llm.New(llm.WithBaseURL(srv.URL), llm.WithAPIKey("k"))
	msg, ok := fetchModels(client)().(modelsMsg)
	if !ok {
		t.Fatal("the command did not produce a modelsMsg")
	}
	if msg.err != nil {
		t.Fatalf("err = %v", msg.err)
	}
	if len(msg.models) != 1 || msg.models[0].ID != "claude-opus-5" {
		t.Errorf("models = %+v", msg.models)
	}
}

// Cost is accumulated at the model that earned it. Pricing the whole session
// at whatever it ended on would rewrite history every time you switch.
func TestCostIsPricedAtTheModelInForceWhenItWasSpent(t *testing.T) {
	m := newTestModel(t)
	m.opts.Client.SetModel("claude-opus-5") // $5 per Mtok in
	m.Update(evUsage{usage: llm.Usage{InputTokens: 1_000_000}})

	m.opts.Client.SetModel("claude-haiku-4-5") // $1 per Mtok in
	m.Update(evUsage{usage: llm.Usage{InputTokens: 1_000_000}})

	if got := m.cost; got != 6 {
		t.Errorf("cost = %v, want 6: five dollars of Opus and one of Haiku", got)
	}
	if !strings.Contains(m.status(), "$6.0000") {
		t.Errorf("the status bar disagrees: %q", m.status())
	}
}

// A turn ends with the session's totals, which on a resumed session include
// usage this process never saw. That remainder is priced once.
func TestTurnTotalsPriceOnlyWhatWasNotPricedAlready(t *testing.T) {
	m := newTestModel(t)
	m.opts.Client.SetModel("claude-opus-5")
	m.Update(evUsage{usage: llm.Usage{InputTokens: 1_000_000}})

	total := llm.Usage{InputTokens: 3_000_000}
	m.Update(turnDone{usage: total, turns: 1})
	if got := m.cost; got != 15 {
		t.Errorf("cost = %v, want 15: the session's three million at five dollars each", got)
	}

	// The same totals arriving again are the same tokens, not new ones.
	m.Update(turnDone{usage: total, turns: 2})
	if got := m.cost; got != 15 {
		t.Errorf("cost = %v after a second turn that spent nothing, want 15", got)
	}
}

// The context window is the other half of what a model is: /v1/models reports
// it, so the picker shows it rather than making you remember which model is
// the 200K one.
func TestModelRowsShowTheContextWindow(t *testing.T) {
	m := newTestModel(t)
	m.Update(tea.WindowSizeMsg{Width: 110, Height: 30})
	m.submit("/model")
	m.Update(modelsMsg{models: []llm.Model{
		{ID: "claude-opus-5", DisplayName: "Claude Opus 5", MaxInputTokens: 1_000_000},
		{ID: "claude-haiku-4-5", DisplayName: "Claude Haiku 4.5", MaxInputTokens: 200_000},
		{ID: "mystery", DisplayName: "Mystery"},
	}})

	view := m.modelView()
	for _, want := range []string{"1.0M in", "200.0k in"} {
		if !strings.Contains(view, want) {
			t.Errorf("%q missing from the picker:\n%s", want, view)
		}
	}
	for _, line := range strings.Split(view, "\n") {
		if strings.Contains(line, "Mystery") && strings.Contains(line, " in") {
			t.Errorf("a context window was invented for a model that reported none: %q", line)
		}
	}
}

// The welcome block names the model, so it has to follow a switch: it is the
// header of the session, not a record of how the session started.
func TestWelcomeBlockFollowsTheModel(t *testing.T) {
	m := newTestModel(t)
	if !strings.Contains(m.tr.entries[0].text, "Opus 5") {
		t.Fatalf("the welcome block does not name the model it started on:\n%s", m.tr.entries[0].text)
	}

	m.submit("/model claude-haiku-4-5")

	welcome := m.tr.entries[0].text
	if !strings.Contains(welcome, "Haiku 4.5") {
		t.Errorf("the welcome block still does not name the new model:\n%s", welcome)
	}
	if strings.Contains(welcome, "Opus 5") {
		t.Errorf("the welcome block names both models:\n%s", welcome)
	}
}