okami.mom/albedo

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

dawntui: sessions and folders fuzzy filter, shared confirm and footers10e0f66

main
8.0 KiB229 linesraw
1//go:build unix
2
3// Pinning, renaming, archiving and deleting cross the session picker, the
4// prefs file and the daemon's PATCH and DELETE. A stubbed server cannot show
5// the daemon keeping the new title or dropping the deleted session.
6package e2e
7
8import (
9	"slices"
10	"strings"
11	"testing"
12	"time"
13
14	"albedo/cli/internal/daemon"
15	"albedo/cli/internal/tui"
16
17	tea "charm.land/bubbletea/v2"
18)
19
20func TestTUISessionPickerPinsRenamesArchivesAndDeletes(t *testing.T) {
21	providerRoute(t, echoReply)
22	t.Parallel()
23	d := newTUIDriver(t)
24	target := newSession(t, t.TempDir())
25	d.Dispatch(tui.ChatBackToSessionsMsg{})
26	// highlight puts the picker cursor on id; the actions themselves are keys.
27	highlight := func(id string) {
28		t.Helper()
29		i := slices.IndexFunc(d.App.SessionPicker.Filtered, func(it tui.PickerItem) bool { return it.ID == id })
30		if i < 0 {
31			t.Fatalf("the picker does not list %s:\n%s", id, d.View())
32		}
33		d.App.SessionPicker.Cursor = i
34		d.settle(d.results(d.App.SessionPicker.PreviewCmd()))
35	}
36	ctrl := func(code rune) { d.Dispatch(tea.KeyPressMsg{Code: code, Mod: tea.ModCtrl}) }
37
38	highlight(target)
39	ctrl('s')
40	saved, err := daemon.GetSession(t.Context(), conn(t), target)
41	if err != nil || !saved.Pinned || !strings.Contains(d.View(), "pinned 1") {
42		t.Fatalf("ctrl+s did not pin %s (pinned %v, %v):\n%s", target, saved.Pinned, err, d.View())
43	}
44
45	ctrl('r')
46	for _, r := range "renamed-session" {
47		d.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
48	}
49	d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
50	if title := daemonSession(t, target).Title; title != "renamed-session" || !strings.Contains(d.View(), "renamed-session") {
51		t.Fatalf("the daemon kept title %q:\n%s", title, d.View())
52	}
53
54	ctrl('a')
55	if saved := daemonSession(t, target); !saved.Archived {
56		t.Fatal("ctrl+a did not archive the selected session")
57	}
58	highlight("archive")
59	d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
60	highlight(target)
61	ctrl('d')
62	d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
63	if slices.ContainsFunc(daemonSessions(t), func(s daemon.Session) bool { return s.ID == target }) {
64		t.Fatalf("the daemon still lists deleted session %s", target)
65	}
66}
67
68// The session list carries no validators, so renaming, archiving and
69// deleting a row whose preview never loaded still have to reach the daemon,
70// and the picker has to show each change without being reopened.
71func TestTUIChangesSessionsStraightFromTheList(t *testing.T) {
72	providerRoute(t, echoReply)
73	t.Parallel()
74	d := newTUIDriver(t)
75	archived, renamed := newSession(t, t.TempDir()), newSession(t, t.TempDir())
76	d.Dispatch(tui.ChatBackToSessionsMsg{})
77	listed := func(id string) int {
78		return slices.IndexFunc(d.App.SessionPicker.Filtered, func(it tui.PickerItem) bool { return it.ID == id })
79	}
80	pick := func(id string) {
81		t.Helper()
82		i := listed(id)
83		if i < 0 {
84			t.Fatalf("the picker does not list %s:\n%s", id, d.View())
85		}
86		d.App.SessionPicker.Cursor = i
87	}
88	ctrl := func(code rune) { d.Dispatch(tea.KeyPressMsg{Code: code, Mod: tea.ModCtrl}) }
89
90	pick(archived)
91	ctrl('a')
92	if !daemonSession(t, archived).Archived || listed(archived) >= 0 {
93		t.Fatalf("ctrl+a did not archive the listed session:\n%s", d.View())
94	}
95
96	pick(renamed)
97	ctrl('r')
98	for _, r := range "listed-rename" {
99		d.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
100	}
101	d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
102	if title := daemonSession(t, renamed).Title; title != "listed-rename" || !strings.Contains(d.View(), "listed-rename") {
103		t.Fatalf("ctrl+r did not rename the listed session (daemon has %q):\n%s", title, d.View())
104	}
105
106	pick("archive")
107	d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
108	pick(archived)
109	ctrl('d')
110	d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
111	if slices.ContainsFunc(daemonSessions(t), func(s daemon.Session) bool { return s.ID == archived }) {
112		t.Fatalf("ctrl+d did not delete the archived session:\n%s", d.View())
113	}
114}
115
116// A long message reaches a preview as a content reference; the session list
117// and agents view read it in full instead of failing the preview.
118func TestSessionPreviewReadsLongMessagesInFull(t *testing.T) {
119	profile := providerRoute(t, echoReply)
120	t.Parallel()
121	id := newSession(t, t.TempDir())
122	long := strings.Repeat("long preview line ", 12000)
123	if _, err := daemon.NewChatClient(conn(t), id).Send(t.Context(), long, nil); err != nil {
124		t.Fatal(err)
125	}
126	waitIdle(t, id, profile, 1)
127	preview, err := daemon.GetSessionPreview(t.Context(), conn(t), id, 16)
128	if err != nil {
129		t.Fatalf("the preview failed: %v", err)
130	}
131	if !slices.ContainsFunc(preview.Items, func(item daemon.PreviewItem) bool { return item.Preview == "echo: "+long }) {
132		t.Fatalf("the long reply is missing from the preview: %d items", len(preview.Items))
133	}
134}
135
136func TestTUINavigationRetainsPendingTurnsAndContinuation(t *testing.T) {
137	entered, release := make(chan struct{}), make(chan struct{})
138	released := false
139	defer func() {
140		if !released {
141			close(release)
142		}
143	}()
144	profile := providerRoute(t, func(request map[string]any) string {
145		if lastUserText(request) == "active navigation turn" {
146			close(entered)
147			<-release
148		}
149		return echoReply(request)
150	})
151	t.Parallel()
152	session := daemonSession(t, newSession(t, t.TempDir()))
153	other := daemonSession(t, newSession(t, t.TempDir()))
154	if _, err := daemon.NewChatClient(conn(t), session.ID).Send(t.Context(), "active navigation turn", nil); err != nil {
155		t.Fatal(err)
156	}
157	select {
158	case <-entered:
159	case <-time.After(10 * time.Second):
160		t.Fatal("active turn did not start")
161	}
162	connection, err := daemon.Attach(t.Context(), conn(t).Snapshot(), nil)
163	if err != nil {
164		t.Fatal(err)
165	}
166	counter := &submissionCounterTransport{next: connection.HTTPClient().Transport, accepted: make(chan struct{})}
167	connection.HTTPClient().Transport = counter
168	t.Cleanup(connection.HTTPClient().CloseIdleConnections)
169	driver := driveTUIWithConnection(t, &session, connection)
170	t.Cleanup(func() { driver.App.Chat.Close() })
171	driver.connected()
172	submit := func(text string) tui.ChatTurnSentMsg {
173		t.Helper()
174		driver.App.Chat.TextArea.SetValue(text)
175		var sent tui.ChatTurnSentMsg
176		for _, message := range driver.results(driver.Update(tea.KeyPressMsg{Code: tea.KeyEnter})) {
177			if admission, ok := message.(tui.ChatTurnSentMsg); ok {
178				sent = admission
179			}
180			driver.Update(message)
181		}
182		if sent.Err != nil || sent.Handle == nil || !sent.OK {
183			t.Fatalf("durable admission: %+v", sent)
184		}
185		return sent
186	}
187	pending := submit("waiting after navigation")
188	continuation := submit(".")
189	driver.Update(tui.FolderOpenSessionMsg{Session: other})
190	seen := make(map[string]bool)
191	for _, message := range driver.results(driver.Update(tui.FolderOpenSessionMsg{Session: session})) {
192		if resolved, ok := message.(tui.ChatOperationResolvedMsg); ok {
193			seen[resolved.Handle.ID()] = true
194			if resolved.Err != nil || !resolved.Receipt.Pending() {
195				t.Fatalf("reattached intent: %+v", resolved)
196			}
197		}
198		driver.Update(message)
199	}
200	if !seen[pending.OperationID] || !seen[continuation.OperationID] || counter.posts.Load() != 2 {
201		t.Fatal("navigation lost an intent or resubmitted it")
202	}
203	if !strings.Contains(driver.View(), "waiting after navigation") {
204		t.Fatal("returning to the session lost its pending display")
205	}
206	close(release)
207	released = true
208	waitIdle(t, session.ID, profile, 2)
209	driver.Update(tui.FolderOpenSessionMsg{Session: other})
210	for _, message := range driver.results(driver.Update(tui.FolderOpenSessionMsg{Session: session})) {
211		driver.Update(message)
212	}
213	history, err := daemon.NewChatClient(conn(t), session.ID).History(t.Context(), 0, 120)
214	if err != nil {
215		t.Fatal(err)
216	}
217	matched := 0
218	for _, event := range history.Events {
219		if event.Type == daemon.EventUser && event.OperationID == pending.OperationID {
220			matched++
221		}
222		event.Replayed = true
223		driver.Update(tui.ChatStreamEventMsg{SessionID: session.ID, Generation: driver.App.Chat.Generation, Event: event})
224	}
225	receipt, err := daemon.ResolveOperation(t.Context(), connection, continuation.Handle)
226	if err != nil || receipt.Delivery == nil || *receipt.Delivery != "committed" || matched != 1 || counter.posts.Load() != 2 {
227		t.Fatalf("navigation delivery: matched=%d receipt=%+v err=%v posts=%d", matched, receipt, err, counter.posts.Load())
228	}
229}