okami.mom/albedo

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

Aly Raffauffix(cli): resume child sessions by full ID4434577

main
11.4 KiB312 linesraw
1//go:build unix
2
3// CLI scenarios: the built binary, driven as a user would, against the suite's
4// one real daemon. Each scenario registers its own provider route and works in
5// its own workspace; assertions read what a user observes -- command output,
6// the session listing, the transcript -- plus what the provider recorded.
7package e2e
8
9import (
10	"context"
11	"encoding/json"
12	"path/filepath"
13	"slices"
14	"strings"
15	"testing"
16	"time"
17
18	"albedo/cli/internal/daemon"
19)
20
21func listedSessionPrefix(t *testing.T, id string) string {
22	t.Helper()
23	for line := range strings.SplitSeq(cli(t, "sessions"), "\n") {
24		fields := strings.Fields(line)
25		if len(fields) > 0 && len(fields[0]) >= 8 && strings.HasPrefix(id, fields[0]) {
26			return fields[0]
27		}
28	}
29	t.Fatalf("session listing omitted %s", id)
30	return ""
31}
32
33// A session created by `albedo new` must be listed by `sessions --json` with
34// its workspace, model, and active provider, and `resume` must resolve both
35// the full id and a prefix back to it. This only holds when new, sessions,
36// resume, and the daemon's session registry agree.
37func TestNewSessionIsListedAndResumes(t *testing.T) {
38	profile := providerRoute(t, echoReply)
39	workspace := t.TempDir()
40	created := cliSession(t, workspace)
41
42	listing := cli(t, "sessions", "--json")
43	var sessions []daemon.Session
44	if err := json.Unmarshal([]byte(listing), &sessions); err != nil {
45		t.Fatalf("sessions --json: %v\n%s", err, listing)
46	}
47	var listed *daemon.Session
48	for i := range sessions {
49		if sessions[i].ID == created {
50			listed = &sessions[i]
51		}
52	}
53	if listed == nil {
54		t.Fatalf("session %s is missing from `albedo sessions --json`:\n%s", created, listing)
55	}
56	if !samePath(listed.Workspace, workspace) {
57		t.Errorf("session workspace is %q, created in %q", listed.Workspace, workspace)
58	}
59	if listed.Provider != profile {
60		t.Errorf("session provider is %q, active profile is %q", listed.Provider, profile)
61	}
62	if listed.Model != "fixture-model" {
63		t.Errorf("session model is %q, want the configured fixture-model", listed.Model)
64	}
65
66	var resumed struct {
67		Session string `json:"session"`
68	}
69	if err := json.Unmarshal([]byte(cli(t, "resume", created)), &resumed); err != nil || resumed.Session != created {
70		t.Fatalf("albedo resume by id: session=%q err=%v", resumed.Session, err)
71	}
72	if err := json.Unmarshal([]byte(cli(t, "resume", listedSessionPrefix(t, created))), &resumed); err != nil || resumed.Session != created {
73		t.Fatalf("albedo resume by prefix: session=%q err=%v", resumed.Session, err)
74	}
75}
76
77// A prompt sent with `albedo send` must come back as assistant text: the
78// provider configuration reaches the daemon's upstream client, the turn runs,
79// and the committed transcript records the reply. The provider's recorded
80// request is the proof the daemon actually called the model.
81func TestSendTurnReachesProviderAndTranscript(t *testing.T) {
82	profile := providerRoute(t, echoReply)
83	t.Parallel()
84	workspace := t.TempDir()
85	id := newSession(t, workspace)
86
87	prompt := "e2e turn through the cli"
88	var sent daemon.SendResult
89	if err := json.Unmarshal([]byte(cli(t, "send", id, prompt)), &sent); err != nil || !sent.OK {
90		t.Fatalf("albedo send: ok=%v err=%v", sent.OK, err)
91	}
92
93	waitIdle(t, id, profile, 1)
94	reply := strings.Join(eventText(durableHistorySnapshot(t, id), "message"), "\n")
95	if !strings.Contains(reply, "echo: "+prompt) {
96		t.Fatalf("assistant reply is missing from the transcript; got:\n%s", reply)
97	}
98
99	requests := suite.provider.requests(profile)
100	if len(requests) == 0 {
101		t.Fatalf("provider profile %s received no request", profile)
102	}
103	request := requests[0]
104	if request["model"] != "fixture-model" {
105		t.Errorf("provider saw model %v, config specifies fixture-model", request["model"])
106	}
107	if request["authorization"] != "Bearer fixture-key" {
108		t.Errorf("provider saw authorization %v", request["authorization"])
109	}
110	body, _ := request["body"].(map[string]any)
111	if forwarded := lastUserText(body); !strings.Contains(forwarded, prompt) {
112		t.Errorf("provider request does not carry the prompt %q: %q", prompt, forwarded)
113	}
114}
115
116// A turn driven through daemon.ChatClient -- the path the TUI takes -- must
117// stream the assistant's reply as message events and settle to idle. This is
118// the real client: agent-scoped routes, SSE replay, and status polling against
119// the live daemon.
120func TestChatClientTurnStreamsAndSettles(t *testing.T) {
121	profile := providerRoute(t, echoReply)
122	t.Parallel()
123	workspace := t.TempDir()
124	id := newSession(t, workspace)
125
126	client := daemon.NewChatClient(conn(t), id)
127	ctx := t.Context()
128
129	events := make(chan daemon.StreamEvent, 128)
130	streamErr := make(chan error, 1)
131	go func() {
132		streamErr <- client.Stream(ctx, 0, func(event daemon.StreamEvent) error {
133			select {
134			case events <- event:
135				return nil
136			case <-ctx.Done():
137				return ctx.Err()
138			}
139		})
140	}()
141
142	prompt := "e2e turn through the chat client"
143	if _, err := client.Send(ctx, prompt, nil); err != nil {
144		t.Fatalf("chat client send: %v", err)
145	}
146
147	var reply string
148	deadline := time.After(30 * time.Second)
149	for reply == "" {
150		select {
151		case event := <-events:
152			if event.Type == daemon.EventMessage {
153				reply = event.Text
154			}
155		case err := <-streamErr:
156			t.Fatalf("stream ended before the reply: %v", err)
157		case <-deadline:
158			t.Fatalf("no assistant message streamed within 30s")
159		}
160	}
161	if !strings.Contains(reply, "echo: "+prompt) {
162		t.Fatalf("streamed reply %q does not carry the scripted echo", reply)
163	}
164
165	waitIdle(t, id, profile, 1)
166	status, err := client.GetStatus(context.Background())
167	if err != nil {
168		t.Fatalf("chat client status: %v", err)
169	}
170	if status.Running || !status.Idle {
171		t.Fatalf("session is not idle after the turn: %+v", status)
172	}
173	if requests := suite.provider.requests(profile); len(requests) == 0 {
174		t.Fatalf("provider profile %s received no request", profile)
175	}
176}
177
178// `albedo sessions` prints shortened IDs, so send and stop must accept them the
179// way resume does. A printed prefix is unique only among the sessions that exist
180// when it is listed, and UUIDv7 IDs created together share long prefixes, so
181// this runs serially, before parallel tests create sessions.
182func TestSendAndStopAcceptAShortenedSessionID(t *testing.T) {
183	profile := providerRoute(t, echoReply)
184	id := newSession(t, t.TempDir())
185	prefix := listedSessionPrefix(t, id)
186
187	var sent daemon.SendResult
188	if err := json.Unmarshal([]byte(cli(t, "send", prefix, "short id")), &sent); err != nil || !sent.OK {
189		t.Fatalf("albedo send by prefix: ok=%v err=%v", sent.OK, err)
190	}
191	waitIdle(t, id, profile, 1)
192	cli(t, "stop", prefix)
193
194	if reply := cli(t, "--prompt", "by prefix", "--session", prefix); !strings.Contains(reply, "echo: by prefix") {
195		t.Fatalf("albedo --prompt --session by prefix did not reach the session: %s", reply)
196	}
197}
198
199// Another agent drives a session with `sessions send` and `sessions read`: the
200// newest turns come back as text or JSON, and a count of one leaves the older
201// turn out. It addresses the session by a listed prefix, so it runs serially.
202func TestSessionsSendAndRead(t *testing.T) {
203	profile := providerRoute(t, echoReply)
204	id := newSession(t, t.TempDir())
205	prefix := listedSessionPrefix(t, id)
206
207	for i, prompt := range []string{"first question", "second question"} {
208		var sent daemon.SendResult
209		if err := json.Unmarshal([]byte(cli(t, "sessions", "send", prefix, prompt)), &sent); err != nil || !sent.OK {
210			t.Fatalf("albedo sessions send %q: ok=%v err=%v", prompt, sent.OK, err)
211		}
212		waitIdle(t, id, profile, i+1)
213	}
214
215	latest := cli(t, "sessions", "read", prefix)
216	if !strings.Contains(latest, "[user] second question") || !strings.Contains(latest, "[assistant] echo: second question") {
217		t.Fatalf("read of the newest turn is missing it:\n%s", latest)
218	}
219	if strings.Contains(latest, "first question") {
220		t.Fatalf("read of one turn includes an older one:\n%s", latest)
221	}
222	both := cli(t, "sessions", "read", prefix, "2")
223	if !strings.Contains(both, "[user] first question") || !strings.Contains(both, "[assistant] echo: second question") {
224		t.Fatalf("read of two turns is missing one:\n%s", both)
225	}
226
227	var parsed struct {
228		Session string `json:"session"`
229		Running bool   `json:"running"`
230		Events  []struct {
231			Type string `json:"type"`
232			Text string `json:"text"`
233		} `json:"events"`
234	}
235	if err := json.Unmarshal([]byte(cli(t, "sessions", "read", id, "1", "--json")), &parsed); err != nil || parsed.Session != id || parsed.Running || len(parsed.Events) == 0 {
236		t.Fatalf("read --json: %+v err=%v", parsed, err)
237	}
238
239	if _, stderr, err := runCLI("sessions", "read", id, "0"); err == nil || !strings.Contains(stderr, "positive number") {
240		t.Fatalf("read with zero turns: err=%v stderr=%s", err, stderr)
241	}
242}
243
244// Sending to a session that does not exist must fail loudly rather than queue
245// a prompt into nothing.
246func TestSendToUnknownSessionFails(t *testing.T) {
247	t.Parallel()
248	stdout, stderr, err := runCLI("send", "e2e-no-such-session", "hello")
249	if err == nil {
250		t.Fatalf("albedo send to an unknown session exited 0: %s", stdout)
251	}
252	if !strings.Contains(stderr, "no session matches") {
253		t.Fatalf("expected a no-match diagnostic, got stderr: %s", stderr)
254	}
255}
256
257// samePath compares two paths, tolerating macOS /var -> /private/var symlinks.
258func samePath(a, b string) bool {
259	if filepath.Clean(a) == filepath.Clean(b) {
260		return true
261	}
262	resolvedA, errA := filepath.EvalSymlinks(a)
263	resolvedB, errB := filepath.EvalSymlinks(b)
264	return errA == nil && errB == nil && resolvedA == resolvedB
265}
266
267// Session deletion requires approval before it reaches the daemon.
268func TestCLISessionDeletionRequiresConfirmation(t *testing.T) {
269	providerRoute(t, echoReply)
270	t.Parallel()
271	id := newSession(t, t.TempDir())
272	stdout, stderr, err := runCLI("storage", "prune", "--session", id)
273	if err == nil || !strings.Contains(stdout, "Permanently delete session "+id) || !strings.Contains(stderr, "no files have been removed") {
274		t.Fatalf("unconfirmed deletion: %v %q %q", err, stdout, stderr)
275	}
276	if daemonSession(t, id).ID != id {
277		t.Fatal("unconfirmed session deletion mutated daemon")
278	}
279	cli(t, "storage", "prune", "--session", id, "--yes")
280	if sessions := daemonSessions(t); slices.ContainsFunc(sessions, func(session daemon.Session) bool { return session.ID == id }) {
281		t.Fatal("confirmed session was not deleted")
282	}
283}
284
285// Full IDs address children even though the default session listing contains roots.
286func TestCLICommandsReachAChildByItsFullID(t *testing.T) {
287	profile := providerRoute(t, echoReply)
288	t.Parallel()
289	parent := newSession(t, t.TempDir())
290	child, err := daemon.CreateSession(t.Context(), conn(t), daemon.CreateSessionRequest{
291		Kind: "child", ParentID: parent, Address: "cli-child", Name: "cli-child", Task: "child first turn",
292	})
293	if err != nil {
294		t.Fatal(err)
295	}
296	waitIdle(t, child.ID, profile, 1)
297	if slices.ContainsFunc(daemonSessions(t), func(session daemon.Session) bool { return session.ID == child.ID }) {
298		t.Fatal("child unexpectedly appears in the root listing")
299	}
300	var resumed struct {
301		Session string `json:"session"`
302	}
303	if err := json.Unmarshal([]byte(cli(t, "resume", child.ID)), &resumed); err != nil || resumed.Session != child.ID {
304		t.Fatalf("albedo resume child by full ID: session=%q err=%v", resumed.Session, err)
305	}
306	cli(t, "send", child.ID, "child second turn")
307	waitIdle(t, child.ID, profile, 2)
308	if text := cli(t, "sessions", "read", child.ID, "2"); !strings.Contains(text, "[user] child first turn") || !strings.Contains(text, "[assistant] echo: <mail") || !strings.Contains(text, "echo: child second turn") {
309		t.Fatalf("full child ID did not reach its transcript:\n%s", text)
310	}
311	cli(t, "stop", child.ID)
312}