okami.mom/albedo

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

nekomimi.pettest(e2e): run session-scoped Go scenarios in parallel5b3f8be

main
9.0 KiB245 linesraw
1//go:build unix
2
3// Expired receipts stop recovery without losing the original intent. The real
4// daemon admits each request before the proxy discards its acknowledgement.
5package e2e
6
7import (
8	"errors"
9	"fmt"
10	"net/http"
11	"slices"
12	"strings"
13	"sync"
14	"testing"
15	"time"
16
17	"albedo/cli/internal/daemon"
18	"albedo/cli/internal/tui"
19	tea "charm.land/bubbletea/v2"
20)
21
22func operationRequests(proxy *acknowledgementProxy) []string {
23	proxy.mu.Lock()
24	defer proxy.mu.Unlock()
25	return slices.DeleteFunc(slices.Clone(proxy.requests), func(request string) bool {
26		if strings.Contains(request, "/visits/") {
27			return true
28		}
29		if strings.HasPrefix(request, "PUT /sessions/") {
30			return false
31		}
32		if strings.HasPrefix(request, "GET /sessions/") {
33			return !slices.Contains(proxy.requests, "PUT "+strings.TrimPrefix(request, "GET "))
34		}
35		return true
36	})
37}
38
39func TestTUIExpiredSubmissionsRemainUnresolvedAcrossNavigation(t *testing.T) {
40	entered, release := make(chan struct{}), make(chan struct{})
41	released := false
42	defer func() {
43		if !released {
44			close(release)
45		}
46	}()
47	var enteredOnce sync.Once
48	profile := providerRoute(t, func(request map[string]any) string {
49		if lastUserText(request) == "hold expired submissions" {
50			enteredOnce.Do(func() { close(entered) })
51			<-release
52		}
53		return echoReply(request)
54	})
55	t.Parallel()
56	session := daemonSession(t, newSession(t, t.TempDir()))
57	other := daemonSession(t, newSession(t, t.TempDir()))
58	if _, err := daemon.NewChatClient(conn(t), session.ID).Send(t.Context(), "hold expired submissions", nil); err != nil {
59		t.Fatal(err)
60	}
61	select {
62	case <-entered:
63	case <-time.After(10 * time.Second):
64		t.Fatal("active turn never started")
65	}
66	proxy := cutAcknowledgement(t, "/sessions/"+session.ID+"/inputs/", expiredAcknowledgement)
67	driver := driveTUIWithConnection(t, &session, proxy.connection)
68	t.Cleanup(func() { driver.App.Chat.Close() })
69	driver.Update(tea.WindowSizeMsg{Width: 120, Height: 100})
70	driver.connected()
71	var handles []*daemon.OperationHandle
72	for index := range tui.MaxPendingUsers + 1 {
73		prompt := fmt.Sprintf("unresolved user %d", index)
74		driver.App.Chat.TextArea.SetValue(prompt)
75		if index == 0 {
76			driver.pasteImage(&daemon.ImageAttachment{Data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", ImageMetadata: daemon.ImageMetadata{MimeType: daemon.ImagePNG, Width: 1, Height: 1, Bytes: 69}})
77		}
78		var sent tui.ChatTurnSentMsg
79		for _, message := range driver.results(driver.Update(tea.KeyPressMsg{Code: tea.KeyEnter})) {
80			if result, ok := message.(tui.ChatTurnSentMsg); ok {
81				sent = result
82			}
83			if cmd := driver.Update(message); cmd != nil {
84				if _, ok := message.(tui.ChatTurnSentMsg); ok {
85					t.Fatalf("expired submission scheduled recovery: %T", message)
86				}
87			}
88		}
89		uncertainty, ok := errors.AsType[*daemon.UncertainOutcomeError](sent.Err)
90		if !ok || uncertainty.Handle != sent.Handle || !daemon.IsOperationExpired(sent.Err) || sent.Handle == nil {
91			t.Fatalf("expired outcome lost original handle: %+v", sent)
92		}
93		handles = append(handles, sent.Handle)
94	}
95	driver.App.Chat.TextArea.SetValue(".")
96	for _, message := range driver.results(driver.Update(tea.KeyPressMsg{Code: tea.KeyEnter})) {
97		if sent, ok := message.(tui.ChatTurnSentMsg); ok {
98			if sent.Handle == nil || !daemon.IsOperationExpired(sent.Err) {
99				t.Fatalf("expired continuation: %+v", sent)
100			}
101			handles = append(handles, sent.Handle)
102		}
103		if cmd := driver.Update(message); cmd != nil {
104			if _, ok := message.(tui.ChatTurnSentMsg); ok {
105				t.Fatal("expired continuation scheduled recovery")
106			}
107		}
108	}
109	if len(handles) != tui.MaxPendingUsers+2 {
110		t.Fatal("expired turns consumed pending capacity")
111	}
112	before := operationRequests(proxy)
113	for _, handle := range handles {
114		if cmd := driver.Update(tui.ChatOperationPollMsg{SessionID: session.ID, Generation: driver.App.Chat.Generation, Handle: handle}); cmd != nil {
115			t.Fatal("expired operation resumed polling")
116		}
117	}
118	driver.Update(tui.FolderOpenSessionMsg{Session: other})
119	for _, message := range driver.results(driver.Update(tui.FolderOpenSessionMsg{Session: session})) {
120		driver.Update(message)
121	}
122	driver.Update(tea.WindowSizeMsg{Width: 120, Height: 100})
123	view := driver.View()
124	for _, handle := range handles {
125		if !strings.Contains(view, handle.ID()) {
126			t.Fatalf("navigation lost unresolved identity %s:\n%s", handle.ID(), view)
127		}
128	}
129	if after := operationRequests(proxy); !slices.Equal(before, after) {
130		t.Fatalf("navigation resumed recovery: before=%v after=%v", before, after)
131	}
132	if len(before) != 2*len(handles) {
133		t.Fatalf("expiry retried HTTP admission: %v", before)
134	}
135	// A durable event can still reconcile an outcome whose receipt has expired.
136	close(release)
137	released = true
138	waitIdle(t, session.ID, profile, 2)
139	history, err := daemon.NewChatClient(conn(t), session.ID).History(t.Context(), 0, 120)
140	if err != nil {
141		t.Fatal(err)
142	}
143	matched := false
144	for _, event := range history.Events {
145		if event.Type == daemon.EventUser && event.OperationID == handles[0].ID() {
146			matched = true
147			if len(event.Images) != 1 || event.Images[0].Width != 1 || event.Images[0].Height != 1 {
148				t.Fatalf("expired image metadata lost: %+v", event)
149			}
150			driver.Update(tui.ChatStreamEventMsg{SessionID: session.ID, Generation: driver.App.Chat.Generation, Event: event})
151		}
152	}
153	if !matched {
154		for _, event := range history.Events {
155			t.Logf("durable event %s input=%s images=%d", event.Type, event.OperationID, len(event.Images))
156		}
157		t.Fatal("durable history did not retain the expired input identity")
158	}
159	if strings.Contains(driver.View(), handles[0].ID()) {
160		t.Fatalf("durable user echo did not reconcile expired row:\n%s", driver.View())
161	}
162
163}
164
165func TestTUICreationExpiryStopsRecoveryWithoutSwitchingSession(t *testing.T) {
166	providerRoute(t, echoReply)
167	// Default-changing tests finish before parallel bodies. These creations
168	// send no turns and do not assert which provider the daemon selects.
169	t.Parallel()
170	initial := daemonSession(t, newSession(t, t.TempDir()))
171	other := daemonSession(t, newSession(t, t.TempDir()))
172	proxy := cutAcknowledgement(t, "/sessions", unresolvedAcknowledgement)
173	driver := driveTUIWithConnection(t, &initial, proxy.connection)
174	t.Cleanup(func() { driver.App.Chat.Close() })
175	uncertain := driver.Send(tui.FolderNewSessionMsg{Workspace: t.TempDir()})
176	driver.Update(tui.FolderOpenSessionMsg{Session: other})
177	recovery := driver.Update(uncertain)
178	if recovery == nil {
179		t.Fatal("uncertain creation lost recovery")
180	}
181	proxy.allowReceipt.Store(true)
182	proxy.expireReceipt.Store(true)
183	var tick tea.Msg
184	for _, message := range driver.results(recovery) {
185		tick = message
186		response := driver.Send(message)
187		if cmd := driver.Update(response); cmd != nil {
188			t.Fatal("expired creation scheduled further recovery")
189		}
190	}
191	if driver.App.ActiveSession.ID != other.ID || !strings.Contains(driver.View(), "expired") {
192		t.Fatalf("creation expiry switched session or lost notice:\n%s", driver.View())
193	}
194	before := operationRequests(proxy)
195	operationID := strings.TrimPrefix(before[len(before)-1], "GET /sessions/")
196	if !strings.Contains(driver.View(), operationID) {
197		t.Fatal("expired creation notice lost its operation identity")
198	}
199	if tick == nil || driver.Update(tick) != nil {
200		t.Fatal("scheduled creation tick restarted expired lookup")
201	}
202	if after := operationRequests(proxy); !slices.Equal(before, after) {
203		t.Fatal("creation expiry resumed HTTP recovery")
204	}
205}
206
207func TestTUIRejectedSubmissionRestoresPromptAndImage(t *testing.T) {
208	providerRoute(t, echoReply)
209	t.Parallel()
210	driver := newTUIDriver(t)
211	t.Cleanup(func() { driver.App.Chat.Close() })
212	driver.connected()
213	image := daemon.ImageAttachment{Data: "invalid", ImageMetadata: daemon.ImageMetadata{MimeType: daemon.ImagePNG, Width: 1, Height: 1, Bytes: 1}}
214	driver.App.Chat.TextArea.SetValue("restore this rejected image ")
215	driver.pasteImage(&image)
216	send := func() tui.ChatTurnSentMsg {
217		var sent tui.ChatTurnSentMsg
218		for _, message := range driver.results(driver.Update(tea.KeyPressMsg{Code: tea.KeyEnter})) {
219			if result, ok := message.(tui.ChatTurnSentMsg); ok {
220				sent = result
221			}
222			driver.Update(message)
223		}
224		return sent
225	}
226	sent := send()
227	api, ok := errors.AsType[*daemon.APIError](sent.Err)
228	if !ok || api.StatusCode != http.StatusBadRequest || sent.Handle == nil {
229		t.Fatalf("real daemon did not reject image: %+v", sent)
230	}
231	if sent.Prompt != "restore this rejected image [Image #1]" || driver.App.Chat.TextArea.Value() != sent.Prompt {
232		t.Fatalf("rejection lost the prompt: %q", driver.App.Chat.TextArea.Value())
233	}
234	// Sending the restored prompt again carries the restored image.
235	if again := send(); len(again.Images) != 1 || again.Images[0] != image {
236		t.Fatalf("rejection lost the attachment: %+v", again.Images)
237	}
238	// A following valid submission demonstrates that the rejected row left no pending intent.
239	driver.App.Chat.TextArea.SetValue("valid after rejection")
240	for _, message := range driver.results(driver.Update(tea.KeyPressMsg{Code: tea.KeyEnter})) {
241		if result, ok := message.(tui.ChatTurnSentMsg); ok && result.Err != nil {
242			t.Fatal(result.Err)
243		}
244	}
245}