okami.mom/albedo

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

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

main
12.4 KiB372 linesraw
1//go:build unix
2
3// Prompt scenarios: `albedo -p` sends one prompt, blocks until its turn ends,
4// and prints only the final assistant text, so a script can pipe it.
5package e2e
6
7import (
8	"context"
9	"encoding/json"
10	"errors"
11	"strings"
12	"testing"
13	"time"
14
15	"albedo/cli/internal/config"
16	"albedo/cli/internal/daemon"
17)
18
19// A new prompt runs in a fresh session on the model named provider/model, and
20// stdout is exactly the reply; `albedo models` lists that same spelling.
21func TestPromptPrintsTheFinalReplyFromTheNamedModel(t *testing.T) {
22	profile := providerRoute(t, echoReply)
23	model := profile + "/fixture-model"
24	if listed := cli(t, "models"); !strings.Contains(listed, model+"\n") {
25		t.Fatalf("albedo models does not list %s:\n%s", model, listed)
26	}
27
28	stdout := cli(t, "-p", "print me", "--model", model)
29	if stdout != "echo: print me\n" {
30		t.Fatalf("albedo -p printed %q", stdout)
31	}
32	if got := len(suite.provider.requests(profile)); got != 1 {
33		t.Fatalf("provider %s saw %d requests, want 1", profile, got)
34	}
35}
36
37// --model on an existing session moves only that session: the turn reaches
38// the other provider, and new sessions still default to the active one.
39func TestPromptToASessionSwitchesItsModelWithoutChangingTheDefault(t *testing.T) {
40	other := "PromptSwitchTarget"
41	if err := suite.provider.addProfile(other, echoReply); err != nil {
42		t.Fatal(err)
43	}
44	profile := providerRoute(t, echoReply)
45	id := newSession(t, t.TempDir())
46
47	stdout := cli(t, "--prompt", "switch", "--session", id, "--model", other+"/fixture-model")
48	if stdout != "echo: switch\n" {
49		t.Fatalf("albedo -p -s printed %q", stdout)
50	}
51	if len(suite.provider.requests(other)) != 1 || len(suite.provider.requests(profile)) != 0 {
52		t.Fatalf("turn went to the wrong provider: %s=%d %s=%d", other,
53			len(suite.provider.requests(other)), profile, len(suite.provider.requests(profile)))
54	}
55	if session := daemonSession(t, id); session.Provider != other {
56		t.Fatalf("session provider is %q, want %q", session.Provider, other)
57	}
58	profiles, err := daemon.ProviderProfiles(context.Background(), conn(t))
59	if err != nil || profiles.Active != profile {
60		t.Fatalf("default provider became %q (%v), want %q", profiles.Active, err, profile)
61	}
62}
63
64// A turn that fails must end the command with the failure, not hang or print
65// an empty reply; a model no provider offers fails before any session exists.
66func TestPromptReportsAFailedTurnAndAnUnknownModel(t *testing.T) {
67	profile := t.Name()
68	if err := saveAndSelectProvider(context.Background(), conn(t), profile, config.Settings{
69		Extension: "openai",
70		BaseURL:   suite.provider.server.URL + "/t/missing",
71		APIKey:    "fixture-key",
72		Model:     "fixture-model",
73		Protocol:  "chat_completions",
74	}); err != nil {
75		t.Fatal(err)
76	}
77
78	stdout, stderr, err := runCLI("-p", "fail", "--model", profile+"/fixture-model")
79	if err == nil || stdout != "" || !strings.Contains(stderr, "404") {
80		t.Fatalf("failed turn: err=%v stdout=%q stderr=%q", err, stdout, stderr)
81	}
82
83	before := len(daemonSessions(t))
84	_, stderr, err = runCLI("-p", "never sent", "--model", "no-such-model")
85	if err == nil || !strings.Contains(stderr, "albedo models") {
86		t.Fatalf("unknown model: err=%v stderr=%q", err, stderr)
87	}
88	if after := len(daemonSessions(t)); after != before {
89		t.Fatalf("an unknown model still created a session (%d -> %d)", before, after)
90	}
91}
92
93// --timeout fails the command once it passes and stops the turn it started:
94// the session is idle long before the stalled provider would have answered.
95func TestPromptTimeoutStopsTheTurn(t *testing.T) {
96	entered, release := make(chan struct{}), make(chan struct{})
97	t.Cleanup(func() { close(release) })
98	profile := providerRoute(t, func(request map[string]any) string {
99		if lastUserText(request) == "stall" {
100			close(entered)
101			select {
102			case <-release:
103			case <-time.After(10 * time.Second):
104			}
105		}
106		return echoReply(request)
107	})
108	// The deadline tests turn cancellation, not session creation or kernel boot.
109	session := newSession(t, t.TempDir())
110	cli(t, "-p", "warm the timeout session", "--session", session)
111	waitIdle(t, session, profile, 1)
112
113	started := time.Now()
114	stdout, stderr, err := runCLI("-p", "stall", "--session", session, "--timeout", "1s")
115	if err == nil || stdout != "" || !strings.Contains(stderr, "timed out after 1s") {
116		t.Fatalf("timed-out prompt: err=%v stdout=%q stderr=%q", err, stdout, stderr)
117	}
118	select {
119	case <-entered:
120	default:
121		t.Fatal("the deadline expired before the stalled turn reached the provider")
122	}
123	_, id, _ := strings.Cut(strings.TrimSpace(stderr), "the session is ")
124	if id != session {
125		t.Fatalf("timeout reported session %q, want %q", id, session)
126	}
127	for {
128		status, err := daemon.NewChatClient(conn(t), id).GetStatus(context.Background())
129		if err != nil {
130			t.Fatalf("status of %q: %v", id, err)
131		}
132		if status.Idle {
133			break
134		}
135		if time.Since(started) > 6*time.Second {
136			t.Fatal("the timed-out turn is still running")
137		}
138		time.Sleep(100 * time.Millisecond)
139	}
140}
141
142// Cancellation while queued must leave the already running turn alone.
143func TestPromptTimeoutWhileQueuedDoesNotInterruptAnotherTurn(t *testing.T) {
144	entered, release := make(chan struct{}), make(chan struct{})
145	releaseClosed := false
146	defer func() {
147		if !releaseClosed {
148			close(release)
149		}
150	}()
151	profile := providerRoute(t, func(request map[string]any) string {
152		if lastUserText(request) == "other turn" {
153			close(entered)
154			<-release
155		}
156		return echoReply(request)
157	})
158	t.Parallel()
159	id := newSession(t, t.TempDir())
160	client := daemon.NewChatClient(conn(t), id)
161	if _, err := client.Send(t.Context(), "other turn", nil); err != nil {
162		t.Fatal(err)
163	}
164	select {
165	case <-entered:
166	case <-time.After(10 * time.Second):
167		t.Fatal("other turn never started")
168	}
169	stdout, stderr, err := runCLI("--prompt", "queued turn", "--session", id, "--timeout", "1s")
170	if err == nil || stdout != "" || !strings.Contains(stderr, "timed out after 1s") {
171		t.Fatalf("queued timeout: %v %q %q", err, stdout, stderr)
172	}
173	status, statusErr := client.GetStatus(t.Context())
174	if statusErr != nil || status.Idle {
175		t.Fatalf("queued cancellation stopped the other turn: %+v %v", status, statusErr)
176	}
177	if !strings.Contains(stderr, "queued submission cancelled") || strings.Contains(stderr, "stopped") {
178		t.Fatalf("incorrect cancellation report: %s", stderr)
179	}
180	_, identity, _ := strings.Cut(stderr, "operation ")
181	inputID, _, _ := strings.Cut(identity, " can be queried")
182	receipt, receiptErr := daemon.GetInput(t.Context(), conn(t), id, inputID)
183	if receiptErr != nil || receipt.Admission != "accepted" || receipt.Delivery == nil || *receipt.Delivery != "cancelled" || receipt.Turn != nil {
184		t.Fatalf("timed call did not cancel an admitted queued input: %+v, %v", receipt, receiptErr)
185	}
186	close(release)
187	releaseClosed = true
188	waitIdle(t, id, profile, 1)
189	if got := len(suite.provider.requests(profile)); got != 1 {
190		t.Fatalf("cancelled queued prompt still ran: %d requests", got)
191	}
192	missing, err := client.PrepareTurn("never submitted", nil, nil, false)
193	if err != nil {
194		t.Fatal(err)
195	}
196	_, err = client.CancelSubmission(t.Context(), missing.ID())
197	if failure, ok := errors.AsType[*daemon.APIError](err); !ok || failure.StatusCode != 404 {
198		t.Fatalf("missing cancellation: %v", err)
199	}
200}
201
202// Both identified submissions contribute to the same next turn. Targeted
203// cancellation must leave that shared turn running, including anonymous input.
204func TestSubmissionCancellationLeavesSharedRunningTurn(t *testing.T) {
205	firstEntered, firstRelease := make(chan struct{}), make(chan struct{})
206	sharedEntered, sharedRelease := make(chan struct{}), make(chan struct{})
207	firstReleased, sharedReleased := false, false
208	defer func() {
209		if !firstReleased {
210			close(firstRelease)
211		}
212		if !sharedReleased {
213			close(sharedRelease)
214		}
215	}()
216	profile := providerRoute(t, func(request map[string]any) string {
217		if lastUserText(request) == "blocking turn" {
218			close(firstEntered)
219			<-firstRelease
220		} else {
221			close(sharedEntered)
222			<-sharedRelease
223		}
224		return echoReply(request)
225	})
226	t.Parallel()
227	id := newSession(t, t.TempDir())
228	client := daemon.NewChatClient(conn(t), id)
229	if _, err := client.Send(t.Context(), "blocking turn", nil); err != nil {
230		t.Fatal(err)
231	}
232	select {
233	case <-firstEntered:
234	case <-time.After(10 * time.Second):
235		t.Fatal("first turn never started")
236	}
237	shared, err := client.Send(t.Context(), "identified contribution", nil)
238	if err != nil {
239		t.Fatal(err)
240	}
241	if _, err := client.Send(t.Context(), "anonymous contribution", nil); err != nil {
242		t.Fatal(err)
243	}
244	queued, err := client.Send(t.Context(), "cancel this queued contribution", nil)
245	if err != nil {
246		t.Fatal(err)
247	}
248	outcome, cancelErr := client.CancelSubmission(t.Context(), queued.OperationID)
249	if cancelErr != nil || outcome != "cancelled_queued" {
250		t.Fatalf("queued cancellation = %q %v", outcome, cancelErr)
251	}
252	outcome, cancelErr = client.CancelSubmission(t.Context(), queued.OperationID)
253	if cancelErr != nil || outcome != "not_pending" {
254		t.Fatalf("repeated queued cancellation = %q %v", outcome, cancelErr)
255	}
256	close(firstRelease)
257	firstReleased = true
258	select {
259	case <-sharedEntered:
260	case <-time.After(10 * time.Second):
261		t.Fatal("shared turn never started")
262	}
263	for range 2 {
264		outcome, err := client.CancelSubmission(t.Context(), shared.OperationID)
265		if err != nil || outcome != "shared_running" {
266			t.Fatalf("shared cancellation = %q, %v", outcome, err)
267		}
268	}
269	status, err := client.GetStatus(t.Context())
270	if err != nil || status.Idle {
271		t.Fatalf("shared turn stopped: %+v %v", status, err)
272	}
273	close(sharedRelease)
274	sharedReleased = true
275	waitIdle(t, id, profile, 2)
276	requests := suite.provider.requests(profile)
277	encoded, marshalErr := json.Marshal(requests[1])
278	if marshalErr != nil || strings.Contains(string(encoded), "cancel this queued contribution") || !strings.Contains(string(encoded), "identified contribution") || !strings.Contains(string(encoded), "anonymous contribution") {
279		t.Fatalf("queued cancellation damaged sibling inputs: %s %v", encoded, marshalErr)
280	}
281}
282
283// Cancellation races turn admission and completion. Once that target settles,
284// repeating cancellation cannot stop a later exclusively owned turn.
285func TestSubmissionCancellationRacesStartAndCompletion(t *testing.T) {
286	firstEntered, firstRelease := make(chan struct{}), make(chan struct{})
287	laterEntered, laterRelease := make(chan struct{}), make(chan struct{})
288	firstReleased, laterReleased := false, false
289	defer func() {
290		if !firstReleased {
291			close(firstRelease)
292		}
293		if !laterReleased {
294			close(laterRelease)
295		}
296	}()
297	profile := providerRoute(t, func(request map[string]any) string {
298		switch lastUserText(request) {
299		case "hold first turn":
300			close(firstEntered)
301			<-firstRelease
302		case "hold later turn":
303			close(laterEntered)
304			<-laterRelease
305		}
306		return echoReply(request)
307	})
308	t.Parallel()
309	id := newSession(t, t.TempDir())
310	client := daemon.NewChatClient(conn(t), id)
311	if _, err := client.Send(t.Context(), "hold first turn", nil); err != nil {
312		t.Fatal(err)
313	}
314	select {
315	case <-firstEntered:
316	case <-time.After(10 * time.Second):
317		t.Fatal("first turn never started")
318	}
319	target, err := client.Send(t.Context(), "short target turn", nil)
320	if err != nil {
321		t.Fatal(err)
322	}
323	submissionID := target.OperationID
324	type cancellation struct {
325		err     error
326		outcome string
327	}
328	result := make(chan cancellation, 1)
329	start := make(chan struct{})
330	go func() {
331		<-start
332		outcome, err := client.CancelSubmission(t.Context(), submissionID)
333		result <- cancellation{err: err, outcome: outcome}
334	}()
335	close(start)
336	close(firstRelease)
337	firstReleased = true
338	var cancelled cancellation
339	select {
340	case cancelled = <-result:
341	case <-time.After(10 * time.Second):
342		t.Fatal("racing cancellation did not return")
343	}
344	if cancelled.err != nil {
345		t.Fatal(cancelled.err)
346	}
347	switch cancelled.outcome {
348	case "cancelled_queued", "interrupt_requested", "not_pending":
349	default:
350		t.Fatalf("unexpected racing cancellation outcome %q", cancelled.outcome)
351	}
352	waitIdle(t, id, profile, 1)
353	if _, err := client.Send(t.Context(), "hold later turn", nil); err != nil {
354		t.Fatal(err)
355	}
356	select {
357	case <-laterEntered:
358	case <-time.After(10 * time.Second):
359		t.Fatal("later turn never started")
360	}
361	outcome, err := client.CancelSubmission(t.Context(), submissionID)
362	if err != nil || outcome != "not_pending" {
363		t.Fatalf("settled target cancellation = %q %v", outcome, err)
364	}
365	status, err := client.GetStatus(t.Context())
366	if err != nil || status.Idle {
367		t.Fatalf("old cancellation stopped a later turn: %+v %v", status, err)
368	}
369	close(laterRelease)
370	laterReleased = true
371	waitIdle(t, id, profile, 2)
372}