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