okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffauffeat(api)!: migrate daemon and clients to protocol 361c43ca

main
7.6 KiB197 linesraw
1// The picker's keyboard state machine (search versus browsing, per-row effort
2// picks, cap toggles) needs catalog shapes the e2e fake provider cannot serve,
3// so these tests check what enter sends for each state.
4package tui
5
6import (
7	"strings"
8	"testing"
9
10	"albedo/cli/internal/config"
11	"albedo/cli/internal/daemon"
12
13	tea "charm.land/bubbletea/v2"
14	"github.com/charmbracelet/x/ansi"
15)
16
17var pickerProfiles = config.Profiles{
18	Active: "work",
19	Providers: map[string]config.Settings{
20		"work":  {Extension: "openai", BaseURL: "https://work.example/v1", Model: "gpt-5", Protocol: "responses"},
21		"codex": {Extension: "codex", Model: "gpt-5-codex", Protocol: "responses"},
22	},
23}
24
25var reasoning = []string{"low", "medium", "high"}
26
27func loadedPicker(t *testing.T) ModelPickerModel {
28	t.Helper()
29	m := NewModelPickerModel(nil, pickerProfiles, "gpt-5", "work", "high")
30	m.SetSize(120, 30)
31	m, _ = m.Update(modelCatalogLoadedMsg{Generation: m.Generation, Profile: "work", Models: []daemon.Model{
32		{ID: "gpt-4.1", Context: 1_047_576},
33		{ID: "gpt-5", Efforts: reasoning, Context: 400_000, Output: 128_000, Input: []string{"text", "image"}},
34		{ID: "o4-mini", Efforts: []string{"low", "medium"}},
35	}})
36	m, _ = m.Update(modelCatalogLoadedMsg{Generation: m.Generation, Profile: "codex", Models: []daemon.Model{
37		{ID: "gpt-5-codex", Efforts: []string{"minimal", "low", "medium", "high"}},
38	}})
39	return m
40}
41
42func pickerKey(m ModelPickerModel, keys ...tea.KeyPressMsg) ModelPickerModel {
43	for _, k := range keys {
44		m, _ = m.Update(k)
45	}
46	return m
47}
48
49func typed(s string) tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyExtended, Text: s} }
50
51func chosen(t *testing.T, m ModelPickerModel) ModelPickerSelectMsg {
52	t.Helper()
53	_, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
54	if cmd == nil {
55		t.Fatal("enter chose nothing")
56	}
57	msg, ok := cmd().(ModelPickerSelectMsg)
58	if !ok {
59		t.Fatalf("enter sent %T", cmd())
60	}
61	return msg
62}
63
64func rowIDs(m ModelPickerModel) []string {
65	ids := make([]string, len(m.rows))
66	for i, r := range m.rows {
67		ids[i] = r.profile + "/" + r.model.ID
68		if r.typed {
69			ids[i] = "typed:" + ids[i]
70		}
71	}
72	return ids
73}
74
75func TestModelPickerArrowsStepEffortAndEnterSendsIt(t *testing.T) {
76	m := loadedPicker(t)
77	if got := chosen(t, m); got.Effort != "high" || got.Model != "gpt-5" || got.Provider != "work" {
78		t.Fatalf("unchanged choice %+v, want the session's high effort", got)
79	}
80	m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyLeft}, tea.KeyPressMsg{Code: tea.KeyLeft}, tea.KeyPressMsg{Code: tea.KeyLeft})
81	if got := chosen(t, m); got.Effort != "low" {
82		t.Fatalf("effort %q after stepping down past the end, want low", got.Effort)
83	}
84	m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyRight})
85	if got := chosen(t, m); got.Effort != "medium" {
86		t.Fatalf("effort %q, want medium", got.Effort)
87	}
88	// Another row lacking the session's level shows the daemon's default,
89	// and the first row keeps its pick.
90	m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyDown}, tea.KeyPressMsg{Code: tea.KeyDown})
91	if got := chosen(t, m); got.Model != "o4-mini" || got.Effort != "medium" {
92		t.Fatalf("choice %+v, want o4-mini at medium", got)
93	}
94	m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyUp}, tea.KeyPressMsg{Code: tea.KeyUp})
95	if got := chosen(t, m); got.Effort != "medium" {
96		t.Fatalf("first row forgot its pick: %+v", got)
97	}
98	// A model without levels sends none.
99	m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyDown}, tea.KeyPressMsg{Code: tea.KeyRight})
100	if got := chosen(t, m); got.Model != "gpt-4.1" || got.Effort != "" {
101		t.Fatalf("choice %+v, want gpt-4.1 without effort", got)
102	}
103}
104
105func TestModelPickerArrowsEditASearchUntilYouBrowse(t *testing.T) {
106	m := loadedPicker(t)
107	m = pickerKey(m, typed("codex"))
108	if got := strings.Join(rowIDs(m), " "); got != "codex/gpt-5-codex typed:work/codex typed:codex/codex" {
109		t.Fatalf("search rows %q", got)
110	}
111	m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyLeft}, typed("x"))
112	if m.search.Value() != "codexx" {
113		t.Fatalf("left arrow did not reach the search: %q", m.search.Value())
114	}
115	m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyBackspace}, tea.KeyPressMsg{Code: tea.KeyUp}, tea.KeyPressMsg{Code: tea.KeyLeft})
116	if got := chosen(t, m); got.Model != "gpt-5-codex" || got.Effort != "medium" {
117		t.Fatalf("after browsing, left should lower high to medium: %+v", got)
118	}
119}
120
121func TestModelPickerOffersATypedIDToEveryProfileMissingIt(t *testing.T) {
122	m := pickerKey(loadedPicker(t), typed("gpt-5-codex"))
123	got := strings.Join(rowIDs(m), " ")
124	if got != "codex/gpt-5-codex typed:work/gpt-5-codex" {
125		t.Fatalf("rows %q", got)
126	}
127	m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyDown})
128	if got := chosen(t, m); got.Model != "gpt-5-codex" || got.Provider != "work" || got.Effort != "" {
129		t.Fatalf("typed choice %+v", got)
130	}
131}
132
133func TestModelPickerViewFitsEveryWidth(t *testing.T) {
134	for _, size := range [][2]int{{30, 10}, {60, 20}, {80, 24}, {100, 30}, {160, 40}} {
135		m := loadedPicker(t)
136		m.SetSize(size[0], size[1])
137		view := m.View()
138		lines := strings.Split(view, "\n")
139		if len(lines) > size[1] {
140			t.Fatalf("%v: %d lines", size, len(lines))
141		}
142		for _, line := range lines {
143			if w := ansi.StringWidth(line); w > size[0] {
144				t.Fatalf("%v: line is %d cells wide: %q", size, w, ansi.Strip(line))
145			}
146		}
147	}
148}
149
150func TestModelPickerTabRaisesAModelsContextCap(t *testing.T) {
151	m := NewModelPickerModel(nil, pickerProfiles, "gpt-5-codex", "codex", "")
152	m.SetSize(140, 30)
153	m, _ = m.Update(modelCatalogLoadedMsg{Generation: m.Generation, Profile: "codex", Models: []daemon.Model{
154		{ID: "gpt-6-astra", Context: 272_000, MaxContext: 872_000, Efforts: reasoning},
155		{ID: "gpt-6-sol", CapKey: "codex/gpt-6-sol", Context: 272_000, MaxContext: 872_000, Raised: true},
156		{ID: "gpt-5.5", Context: 272_000},
157	}})
158	m = pickerKey(m, typed("astra"))
159	if view := ansi.Strip(m.View()); !strings.Contains(view, "tab raises to 872k") || !strings.Contains(view, "272k tokens") {
160		t.Fatalf("astra should offer to raise its cap:\n%s", view)
161	}
162	m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyTab})
163	if view := ansi.Strip(m.View()); !strings.Contains(view, "872k tokens") || !strings.Contains(view, "tab restores 272k") {
164		t.Fatalf("a raised cap should show the larger window:\n%s", view)
165	}
166	if got := chosen(t, m).RaiseCap; got == nil || !*got {
167		t.Fatalf("enter should raise astra's cap, got %v", got)
168	}
169	// Flipping back leaves the saved cap alone.
170	m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyTab})
171	if got := chosen(t, m).RaiseCap; got != nil {
172		t.Fatalf("an unchanged cap should send nothing, got %v", *got)
173	}
174
175	// A saved raise shows as raised, and tab restores the default.
176	sol := pickerKey(m, tea.KeyPressMsg{Code: tea.KeyBackspace}, tea.KeyPressMsg{Code: tea.KeyBackspace},
177		tea.KeyPressMsg{Code: tea.KeyBackspace}, tea.KeyPressMsg{Code: tea.KeyBackspace},
178		tea.KeyPressMsg{Code: tea.KeyBackspace}, typed("6-sol"))
179	if view := ansi.Strip(sol.View()); !strings.Contains(view, "tab restores 272k") {
180		t.Fatalf("sol's saved raise should show:\n%s", view)
181	}
182	sol = pickerKey(sol, tea.KeyPressMsg{Code: tea.KeyTab})
183	if got := chosen(t, sol).RaiseCap; got == nil || *got {
184		t.Fatalf("tab should restore sol's default window, got %v", got)
185	}
186
187	// Nothing to raise, nothing to toggle.
188	older := pickerKey(sol, tea.KeyPressMsg{Code: tea.KeyBackspace}, tea.KeyPressMsg{Code: tea.KeyBackspace},
189		tea.KeyPressMsg{Code: tea.KeyBackspace}, tea.KeyPressMsg{Code: tea.KeyBackspace},
190		tea.KeyPressMsg{Code: tea.KeyBackspace}, typed("5.5"), tea.KeyPressMsg{Code: tea.KeyTab})
191	if view := ansi.Strip(older.View()); strings.Contains(view, "tab raises") || strings.Contains(view, "tab cap") {
192		t.Fatalf("gpt-5.5 has no cap to raise:\n%s", view)
193	}
194	if got := chosen(t, older).RaiseCap; got != nil {
195		t.Fatalf("gpt-5.5 should send no cap, got %v", *got)
196	}
197}