okami.mom/albedo

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

nekomimi.petRestore the webhooks shortcut and keep agent access visiblef51cc1a

main
11.1 KiB270 linesraw
1//go:build unix
2
3// Model and extension selection cross the composer, keyboard, and daemon
4// session. Stubbed command replies cannot catch a dropped request or reload.
5package e2e
6
7import (
8	"slices"
9	"strings"
10	"testing"
11
12	"albedo/cli/internal/daemon"
13	"albedo/cli/internal/tui"
14
15	tea "charm.land/bubbletea/v2"
16	"github.com/charmbracelet/x/ansi"
17)
18
19func TestTUIEffortSelectorCommitsThroughTheDaemon(t *testing.T) {
20	providerRoute(t, echoReply)
21	session := daemonSession(t, newSession(t, t.TempDir()))
22	if _, err := daemon.SelectModel(t.Context(), conn(t), session.ID, daemon.ModelSelectionRequest{Model: "o3", Effort: "high", ETag: session.ETag}); err != nil {
23		t.Fatal(err)
24	}
25	session = daemonSession(t, session.ID)
26	d := driveTUI(t, &session)
27
28	// Selecting a model goes through the real composer: enter dispatches the
29	// command, the app asks the daemon, and the answer lands in the model.
30	d.App.Chat.TextArea.SetValue("/model o3-mini")
31	dispatched, ok := d.Key(tea.KeyEnter).(tui.ChatExecuteCommandMsg)
32	if !ok || dispatched.Name != "/model" || dispatched.Args != "o3-mini" {
33		t.Fatalf("enter did not dispatch /model: %#v", dispatched)
34	}
35	d.Dispatch(dispatched)
36	if d.App.ActiveSession == nil || d.App.ActiveSession.Model != "o3-mini" || d.App.ActiveSession.Effort != "high" {
37		t.Fatalf("the daemon's model switch did not land:\n%s", d.View())
38	}
39
40	// /effort arrives as the message the command menu submits for it; the
41	// daemon, not a stub, decides which tiers the model offers and which one
42	// is current.
43	d.Dispatch(tui.ChatExecuteCommandMsg{Name: "/effort"})
44	opened := d.View()
45	for _, tier := range []string{"low", "medium", "high"} {
46		if !strings.Contains(opened, tier) {
47			t.Fatalf("the selector is missing tier %q:\n%s", tier, opened)
48		}
49	}
50	if !strings.Contains(opened, "Reasoning effort") || !strings.Contains(opened, "[high]") {
51		t.Fatalf("the daemon's current tier is not selected:\n%s", opened)
52	}
53	if strings.Contains(opened, "› ") {
54		t.Fatal("the composer must be hidden while choosing effort")
55	}
56
57	// Even a narrow selector stays inside the terminal and Escape restores the
58	// composer without changing the daemon.
59	d.Update(tea.WindowSizeMsg{Width: 28, Height: 14})
60	for line := range strings.SplitSeq(d.View(), "\n") {
61		if width := ansi.StringWidth(line); width > 28 {
62			t.Fatalf("effort selector rendered a %d-cell line at width 28: %q", width, line)
63		}
64	}
65	d.Key(tea.KeyEscape)
66	if view := d.View(); strings.Contains(view, "Reasoning effort") || !strings.Contains(view, "› ") {
67		t.Fatalf("Escape did not restore the composer:\n%s", view)
68	}
69	d.Update(tea.WindowSizeMsg{Width: 80, Height: 22})
70	d.Dispatch(tui.ChatExecuteCommandMsg{Name: "/effort"})
71
72	// The keyboard alone moves the selection and commits it.
73	d.Key(tea.KeyLeft)
74	if !strings.Contains(d.View(), "[medium]") {
75		t.Fatalf("left did not select medium:\n%s", d.View())
76	}
77	committed, ok := d.Key(tea.KeyEnter).(tui.ChatExecuteCommandMsg)
78	if !ok || committed.Name != "/effort" || committed.Args != "medium" {
79		t.Fatalf("enter did not commit the selected tier: %#v", committed)
80	}
81	d.Dispatch(committed)
82	settled := d.View()
83	if strings.Contains(settled, "Reasoning effort") {
84		t.Fatalf("the selector stayed open after committing:\n%s", settled)
85	}
86	if !strings.Contains(settled, "Effort saved.") || !strings.Contains(settled, "› ") {
87		t.Fatalf("the daemon's answer did not reach the chat:\n%s", settled)
88	}
89	if d.App.ActiveSession.Effort != "medium" {
90		t.Fatalf("the chat kept effort %q", d.App.ActiveSession.Effort)
91	}
92
93	// The truth at the end is the daemon's session record.
94	session = daemonSession(t, d.App.ActiveSession.ID)
95	if session.Model != "o3-mini" || session.Effort != "medium" {
96		t.Fatalf("session %s kept model %q effort %q, want o3-mini at medium", session.ID, session.Model, session.Effort)
97	}
98}
99
100// Every prompt retitles the session. The chat keeps the configuration it
101// observed before that, so a retitle must not make /effort a conflict.
102func TestTUIEffortAfterAPromptRetitlesTheSession(t *testing.T) {
103	profile := providerRoute(t, echoReply)
104	session := daemonSession(t, newSession(t, t.TempDir()))
105	if _, err := daemon.SelectModel(t.Context(), conn(t), session.ID, daemon.ModelSelectionRequest{Model: "o3", Effort: "low", ETag: session.ETag}); err != nil {
106		t.Fatal(err)
107	}
108	session = daemonSession(t, session.ID)
109	d := driveTUI(t, &session)
110	defer d.App.Chat.Close()
111	if _, err := daemon.NewChatClient(conn(t), session.ID).Send(t.Context(), "a prompt that names the session", nil); err != nil {
112		t.Fatal(err)
113	}
114	waitIdle(t, session.ID, profile, 1)
115	if retitled := daemonSession(t, session.ID); retitled.Title == session.Title {
116		t.Fatalf("the prompt kept the title %q", retitled.Title)
117	}
118
119	d.Dispatch(tui.ChatExecuteCommandMsg{Name: "/effort", Args: "high"})
120	if view := d.View(); !strings.Contains(view, "Effort saved.") {
121		t.Fatalf("/effort after a prompt failed:\n%s", view)
122	}
123	if session := daemonSession(t, session.ID); session.Effort != "high" {
124		t.Fatalf("session kept effort %q, want high", session.Effort)
125	}
126}
127
128// Text after a command that declares no arguments is a prompt that starts with
129// the command's name; the command would only reject it.
130func TestTUISendsTextAfterAnArgumentlessCommandAsAPrompt(t *testing.T) {
131	profile := providerRoute(t, echoReply)
132	t.Parallel()
133	session := daemonSession(t, newSession(t, t.TempDir()))
134	// A session lists its commands once its first turn has loaded them.
135	if _, err := daemon.NewChatClient(conn(t), session.ID).Send(t.Context(), "hello", nil); err != nil {
136		t.Fatal(err)
137	}
138	waitIdle(t, session.ID, profile, 1)
139	d := driveTUI(t, &session)
140	defer d.App.Chat.Close()
141	d.Dispatch(tui.CapabilityPageChangedMsg{})
142	jobs := slices.IndexFunc(d.App.Chat.CommandMenu.Catalog, func(command daemon.SessionCommand) bool { return command.Name == "/jobs" })
143	if jobs < 0 || len(d.App.Chat.CommandMenu.Catalog[jobs].Arguments) != 0 {
144		t.Fatalf("the daemon's catalog has no argumentless /jobs: %+v", d.App.Chat.CommandMenu.Catalog)
145	}
146	d.connected()
147
148	d.App.Chat.TextArea.SetValue("/jobs is broken")
149	var admitted tui.ChatTurnSentMsg
150	for _, message := range d.results(d.Update(tea.KeyPressMsg{Code: tea.KeyEnter})) {
151		if sent, ok := message.(tui.ChatTurnSentMsg); ok {
152			admitted = sent
153			d.Update(sent)
154		}
155	}
156	if admitted.Err != nil || !admitted.OK {
157		t.Fatalf("enter did not send the prompt: %+v\n%s", admitted, d.View())
158	}
159	waitIdle(t, session.ID, profile, 2)
160	if reply := strings.Join(eventText(durableHistorySnapshot(t, session.ID), "message"), "\n"); !strings.Contains(reply, "echo: /jobs is broken") {
161		t.Fatalf("the model did not receive the whole prompt; got:\n%s", reply)
162	}
163}
164
165func TestTUIWebhooksShortcutOpensSettingsWithoutACatalog(t *testing.T) {
166	providerRoute(t, echoReply)
167	session := daemonSession(t, newSession(t, t.TempDir()))
168	if _, err := daemon.SelectExtension(t.Context(), conn(t), session.ID, daemon.ExtensionSelectionRequest{Name: "webhooks", Scope: "session", Enabled: new(false), ETag: session.ETag}); err != nil {
169		t.Fatal(err)
170	}
171	session = daemonSession(t, session.ID)
172	d := driveTUI(t, &session)
173	defer d.App.Chat.Close()
174	d.connected()
175
176	d.Type("/web")
177	if view := d.View(); !strings.Contains(view, "/webhooks") {
178		t.Fatalf("the command menu has no webhooks settings shortcut:\n%s", view)
179	}
180	d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
181	if d.App.State != tui.AppStateWebhooksPage || !strings.Contains(d.App.WebhooksPage.Error, "webhooks are off") {
182		t.Fatalf("the disabled extension did not open its settings guidance:\n%s", d.View())
183	}
184	d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEscape})
185	if d.App.State != tui.AppStateChat {
186		t.Fatalf("Escape did not return to chat:\n%s", d.View())
187	}
188
189	enableExtension(t, conn(t), session.ID, "webhooks")
190	d.Type("/webhooks")
191	// Dismiss completion so Enter exercises local command recognition too.
192	d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEscape})
193	d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
194	if d.App.State != tui.AppStateWebhooksPage || d.App.WebhooksPage.Error != "" || d.App.WebhooksPage.Loading {
195		t.Fatalf("the typed shortcut did not load webhooks settings:\n%s", d.View())
196	}
197}
198
199func TestTUIExtensionPickerSwitchesCompactionStrategies(t *testing.T) {
200	profile := providerRoute(t, echoReply)
201	t.Parallel()
202	session := daemonSession(t, newSession(t, t.TempDir()))
203	d := driveTUI(t, &session)
204	defer d.App.Chat.Close()
205	for turn, name := range []string{"lcm", "rolling", "lcm"} {
206		d.Dispatch(tui.ChatExecuteCommandMsg{Name: "/extensions"})
207		picker := d.App.ExtensionPicker
208		index := -1
209		for i, item := range picker.Extensions {
210			if item.Name == name {
211				index = i
212				if item.Enabled {
213					t.Fatalf("strategy %s is already enabled before its switch", name)
214				}
215			}
216		}
217		if index < 0 {
218			t.Fatalf("strategy %s is missing from the actual extension picker", name)
219		}
220		for d.App.ExtensionPicker.Cursor < index {
221			d.Dispatch(tea.KeyPressMsg{Code: tea.KeyDown})
222		}
223		for d.App.ExtensionPicker.Cursor > index {
224			d.Dispatch(tea.KeyPressMsg{Code: tea.KeyUp})
225		}
226		d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift})
227		d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
228		if picker := d.App.ExtensionPicker; picker.Error != "" || picker.Confirming() || !strings.Contains(picker.Notice, "Reload completed.") {
229			t.Fatalf("strategy %s did not apply through the picker:\n%s", name, d.View())
230		}
231		configuration, err := daemon.GetSessionConfiguration(t.Context(), conn(t), session.ID)
232		if err != nil {
233			t.Fatal(err)
234		}
235		for _, strategy := range []string{"lcm", "rolling"} {
236			chosen := configuration.Value.Selection.Extensions[strategy]
237			if enabled := chosen != nil && *chosen; enabled != (strategy == name) {
238				t.Fatalf("single %s toggle left competing selection %+v", name, configuration.Value.Selection.Extensions)
239			}
240		}
241		client := daemon.NewChatClient(conn(t), session.ID)
242		if _, err := client.Send(t.Context(), "use the selected strategy", nil); err != nil {
243			t.Fatal(err)
244		}
245		waitIdle(t, session.ID, profile, turn+1)
246		prepared, err := daemon.GetContextSnapshot(t.Context(), conn(t), session.ID)
247		if err != nil || prepared.State != "ready" || prepared.Compaction.Strategy != name {
248			t.Fatalf("turn did not use selected strategy %s: %+v, %v", name, prepared, err)
249		}
250		if turn == 0 {
251			current := daemonSession(t, session.ID)
252			if _, err := daemon.RenameSession(t.Context(), conn(t), session.ID, "strategy review", current.ETag); err != nil {
253				t.Fatal(err)
254			}
255			// Another client edited this session while the picker was open.
256			// A stale confirmation refreshes the choices without replaying it.
257			d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift})
258			d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
259			picker := d.App.ExtensionPicker
260			if picker.Loading || picker.Saving || picker.Confirming() || picker.Error != "" || !strings.Contains(picker.Notice, "Review the refreshed choices") {
261				t.Fatalf("stale selection did not return to review:\n%s", d.View())
262			}
263			configuration, err := daemon.GetSessionConfiguration(t.Context(), conn(t), session.ID)
264			if err != nil || configuration.Value.Selection.Extensions[name] == nil || !*configuration.Value.Selection.Extensions[name] {
265				t.Fatalf("stale confirmation changed the saved strategy: %+v, %v", configuration, err)
266			}
267		}
268		d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEscape})
269	}
270}