okami.mom/albedo

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

nekomimi.pettui: ctrl-click a path in the transcript to open it in an editorcc6ab84

main
24.3 KiB755 linesraw
1//go:build unix
2
3// Package e2e drives a real daemon through the CLI and TUI with a scripted
4// model provider. Scenarios isolate provider routes and workspaces; most run
5// in parallel. The suite uses Unix signals for daemon teardown and PID checks.
6package e2e
7
8import (
9	"bytes"
10	"context"
11	"encoding/json"
12	"errors"
13	"fmt"
14	"io"
15	"net/http"
16	"net/http/httptest"
17	"os"
18	"os/exec"
19	"path/filepath"
20	"runtime"
21	"slices"
22	"strconv"
23	"strings"
24	"sync"
25	"syscall"
26	"testing"
27	"time"
28
29	"albedo/cli/internal/config"
30	"albedo/cli/internal/daemon"
31	"albedo/cli/internal/localdaemon"
32)
33
34// suite is the process-wide fixture: one daemon, one provider server, one
35// hermetic home. A restart scenario must update daemonPID.
36var suite struct {
37	contract  *wireContract
38	conn      *daemon.Connection
39	provider  *fakeProvider
40	root      string // the albedo repository
41	cli       string // the freshly built CLI binary
42	home      string // hermetic ALBEDO_HOME
43	env       []string
44	daemonPID int
45	// remoteHome is the loopback ssh's remote HOME.
46	remoteHome string
47}
48
49func TestMain(m *testing.M) {
50	started := time.Now()
51	shutdown, err := bootSuite()
52	if err != nil {
53		fmt.Fprintln(os.Stderr, "go e2e setup failed:", err)
54		if shutdown != nil {
55			// The daemon may already be running; take it down either way.
56			report, ok := shutdown()
57			fmt.Fprintf(os.Stderr, "go e2e teardown after failed setup: %s\n", report)
58			if !ok {
59				fmt.Fprintln(os.Stderr, "go e2e teardown failed; a daemon may be leaked")
60			}
61		}
62		os.Exit(1)
63	}
64	code := m.Run()
65	report, ok := shutdown()
66	suite.contract.mu.Lock()
67	if failures := suite.contract.failures; len(failures) != 0 {
68		for _, failure := range failures {
69			fmt.Fprintln(os.Stderr, "OpenAPI:", failure)
70		}
71		code = 1
72	}
73	suite.contract.mu.Unlock()
74	fmt.Printf("go e2e suite: %s, %s\n", time.Since(started).Round(time.Millisecond), report)
75	if !ok {
76		code = 1
77	}
78	os.Exit(code)
79}
80
81func bootSuite() (func() (string, bool), error) {
82	root, err := repoRoot()
83	if err != nil {
84		return nil, err
85	}
86	suite.contract, err = loadWireContract(root)
87	if err != nil {
88		return nil, fmt.Errorf("loading OpenAPI contract: %w", err)
89	}
90	temp, err := os.MkdirTemp("", "albedo-go-e2e-")
91	if err != nil {
92		return nil, err
93	}
94	teardown := func() (string, bool) { return teardownSuite(temp) }
95
96	home := filepath.Join(temp, "home")
97	userHome := filepath.Join(temp, "user-home")
98	scratch := filepath.Join(temp, "tmp")
99	for _, dir := range []string{home, userHome, scratch} {
100		if mkdirErr := os.MkdirAll(dir, 0o700); mkdirErr != nil {
101			return teardown, mkdirErr
102		}
103	}
104
105	// Build the exact CLI the scenarios drive, so the suite never depends on
106	// (or races with) a stale cli/bin/albedo.
107	suite.cli = filepath.Join(temp, "bin", "albedo")
108	build := exec.Command("go", "build", "-o", suite.cli, "./cmd/albedo")
109	build.Dir = filepath.Join(root, "cli")
110	if out, buildErr := build.CombinedOutput(); buildErr != nil {
111		return teardown, fmt.Errorf("building ./cmd/albedo: %w\n%s", buildErr, out)
112	}
113
114	launcher, err := snapshotDaemon(root, filepath.Join(temp, "daemon"))
115	if err != nil {
116		return teardown, err
117	}
118
119	sshBin, err := loopbackSSH(temp)
120	if err != nil {
121		return teardown, err
122	}
123	suite.root, suite.home = root, home
124	suite.env = hermeticEnv(root, home, userHome, scratch, launcher, sshBin)
125	suite.provider = newFakeProvider()
126	// Both explicit and background catalogue refreshes stay within the
127	// fixture. An empty catalogue leaves native model inference observable.
128	extensionConfig, marshalErr := json.Marshal(map[string]any{"models": map[string]any{"refreshHours": 0, "url": suite.provider.server.URL + "/models-dev"}, "cacheTtl": map[string]any{"url": nil}})
129	if marshalErr != nil {
130		return teardown, marshalErr
131	}
132	if writeErr := os.WriteFile(filepath.Join(home, "extensions.json"), extensionConfig, 0o600); writeErr != nil {
133		return teardown, writeErr
134	}
135	if writeErr := os.WriteFile(filepath.Join(home, "models.json"), []byte(`{}`), 0o600); writeErr != nil {
136		return teardown, writeErr
137	}
138
139	if _, stderr, startupErr := runCLI("sessions"); startupErr != nil {
140		return teardown, bootFailure(startupErr, stderr)
141	}
142	snap, err := awaitDaemon(home)
143	if err != nil {
144		return teardown, err
145	}
146	suite.conn, err = daemon.Attach(context.Background(), snap, func(ctx context.Context) (daemon.ConnectionSnapshot, error) {
147		return localdaemon.Rediscover(ctx, home)
148	})
149	if err != nil {
150		return teardown, err
151	}
152	suite.daemonPID = snap.Pid
153	client := suite.conn.HTTPClient()
154	client.Transport = contractTransport{upstream: client.Transport, contract: suite.contract}
155	return teardown, nil
156}
157
158// teardownSuite shuts the daemon down, waits for its process to exit, kills it
159// if it must, and removes the hermetic tree. A surviving daemon fails the run,
160// because it would poison the next one.
161func teardownSuite(temp string) (string, bool) {
162	var report []string
163	ok := true
164	switch snap, err := readDaemonSnapshot(suite.home); {
165	case err != nil:
166		report = append(report, "daemon record unreadable: "+err.Error())
167		if suite.daemonPID != 0 {
168			ok = false
169			if !awaitExit(suite.daemonPID, 500*time.Millisecond) {
170				_ = syscall.Kill(suite.daemonPID, syscall.SIGKILL)
171			}
172		}
173	case suite.conn == nil:
174		// Setup never reached the daemon; the parent watcher takes it down
175		// when this process exits.
176		report = append(report, fmt.Sprintf("daemon %d recorded but never connected", snap.Pid))
177	default:
178		ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
179		defer cancel()
180		if err := daemon.StopDaemon(ctx, suite.conn); err != nil {
181			report = append(report, fmt.Sprintf("shutdown request failed: %v", err))
182		}
183		if awaitExit(snap.Pid, 15*time.Second) {
184			report = append(report, fmt.Sprintf("daemon %d exited cleanly", snap.Pid))
185		} else {
186			_ = syscall.Kill(snap.Pid, syscall.SIGKILL)
187			ok = false
188			report = append(report, fmt.Sprintf("daemon %d ignored shutdown and was killed", snap.Pid))
189		}
190	}
191	if suite.provider != nil {
192		suite.provider.close()
193	}
194	_ = os.RemoveAll(temp)
195	return strings.Join(report, "; "), ok
196}
197
198// awaitExit polls until the process is gone.
199func awaitExit(pid int, within time.Duration) bool {
200	deadline := time.Now().Add(within)
201	for time.Now().Before(deadline) {
202		if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) {
203			return true
204		}
205		time.Sleep(100 * time.Millisecond)
206	}
207	return false
208}
209
210// bootFailure wraps a setup error with the daemon log, the only record of a
211// daemon that died before it could publish daemon.json.
212func bootFailure(err error, cliStderr string) error {
213	log, _ := os.ReadFile(filepath.Join(suite.home, "daemon.log"))
214	return fmt.Errorf("%w\ncli: %s\ndaemon.log: %s", err, cliStderr, log)
215}
216
217// awaitDaemon waits for the booting daemon to publish daemon.json.
218func awaitDaemon(home string) (daemon.ConnectionSnapshot, error) {
219	deadline := time.Now().Add(2 * time.Minute)
220	for time.Now().Before(deadline) {
221		if snap, err := readDaemonSnapshot(home); err == nil {
222			return snap, nil
223		}
224		time.Sleep(100 * time.Millisecond)
225	}
226	return daemon.ConnectionSnapshot{}, bootFailure(errors.New("daemon did not publish daemon.json"), "")
227}
228
229func readDaemonSnapshot(home string) (daemon.ConnectionSnapshot, error) {
230	data, err := os.ReadFile(filepath.Join(home, "daemon.json"))
231	if err != nil {
232		return daemon.ConnectionSnapshot{}, err
233	}
234	var snap daemon.ConnectionSnapshot
235	if err := json.Unmarshal(data, &snap); err != nil {
236		return daemon.ConnectionSnapshot{}, err
237	}
238	if snap.Port < 1 {
239		return daemon.ConnectionSnapshot{}, fmt.Errorf("daemon.json has no port: %s", data)
240	}
241	return snap, nil
242}
243
244func repoRoot() (string, error) {
245	_, file, _, ok := runtime.Caller(0)
246	if !ok {
247		return "", errors.New("cannot locate the e2e source file")
248	}
249	for dir := filepath.Dir(file); ; dir = filepath.Dir(dir) {
250		if _, err := os.Stat(filepath.Join(dir, "gleam.toml")); err == nil {
251			return dir, nil
252		}
253		if parent := filepath.Dir(dir); parent == dir {
254			return "", fmt.Errorf("no albedo repository above %s", filepath.Dir(file))
255		}
256	}
257}
258
259// snapshotDaemon returns the launcher the daemon boots from instead of
260// `gleam run`: test.sh's snapshot when it passes ALBEDO_TEST_DAEMON, else a
261// fresh one in out (see test/snapshot-daemon.sh).
262func snapshotDaemon(root, out string) (string, error) {
263	if launcher := os.Getenv("ALBEDO_TEST_DAEMON"); launcher != "" {
264		return launcher, nil
265	}
266	launcher, err := exec.Command(filepath.Join(root, "test", "snapshot-daemon.sh"), out).Output()
267	if err != nil {
268		if exit, ok := errors.AsType[*exec.ExitError](err); ok {
269			return "", fmt.Errorf("snapshotting the daemon: %w\n%s", err, exit.Stderr)
270		}
271		return "", fmt.Errorf("snapshotting the daemon: %w", err)
272	}
273	return strings.TrimSpace(string(launcher)), nil
274}
275
276// hermeticEnv builds the environment for every CLI invocation and the daemon
277// it spawns: no real home, no inherited albedo state, temporary files inside
278// the suite's tree, the snapshot's launcher, and the parent watcher.
279func hermeticEnv(root, home, userHome, scratch, daemonExe, sshBin string) []string {
280	var env []string
281	for _, kv := range os.Environ() {
282		name, value, _ := strings.Cut(kv, "=")
283		if name == "HOME" || name == "TMPDIR" || name == "ERL_FLAGS" || strings.HasPrefix(name, "ALBEDO_") {
284			continue
285		}
286		if name == "PATH" {
287			kv = "PATH=" + sshBin + string(os.PathListSeparator) + value
288		}
289		env = append(env, kv)
290	}
291	return append(env,
292		"HOME="+userHome,
293		"TMPDIR="+scratch,
294		"ALBEDO_HOME="+home,
295		"ALBEDO_PARENT_PID="+strconv.Itoa(os.Getpid()),
296		"ALBEDO_ROOT="+root,
297		"ALBEDO_DAEMON="+daemonExe,
298		"ALBEDO_NO_BROWSER=1",
299		// Two schedulers keep a booting test VM from pinning every core.
300		"ERL_FLAGS=+S 2:2 +SDcpu 2:2 +sbwt none +sbwtdcpu none +sbwtdio none",
301	)
302}
303
304// loopbackSSH puts an ssh on the daemon's PATH that runs every command here,
305// under its own "remote" HOME (suite.remoteHome), for scenarios with a remote
306// workspace; the real ssh, and its masters, are never reached. Host `nohost`
307// cannot be reached.
308func loopbackSSH(temp string) (string, error) {
309	bin, remoteBin := filepath.Join(temp, "ssh-bin"), filepath.Join(temp, "remote-bin")
310	suite.remoteHome = filepath.Join(temp, "remote-home")
311	for _, dir := range []string{bin, remoteBin, suite.remoteHome} {
312		if err := os.MkdirAll(dir, 0o700); err != nil {
313			return "", err
314		}
315	}
316	python, err := exec.LookPath("python3")
317	if err != nil {
318		return "", err
319	}
320	remotePath := "'" + strings.ReplaceAll(remoteBin+string(os.PathListSeparator)+os.Getenv("PATH"), "'", "'\"'\"'") + "'"
321	ssh := `#!/bin/sh
322while [ $# -gt 0 ]; do
323  case "$1" in
324    -o|-O|-l|-p|-i|-F|-J|-S) shift 2 ;;
325    -*) shift ;;
326    *) break ;;
327  esac
328done
329target=$1
330shift
331case "$target" in
332  stage-ready|stage-failure)
333    [ $# -eq 0 ] && exit 0
334    gate="` + suite.remoteHome + `/$target"
335    case "$*" in
336      *"tar -C"*)
337        touch "$gate.staging"
338        while [ ! -f "$gate.release-stage" ]; do sleep 0.02; done
339        if [ "$target" = stage-failure ]; then cat >/dev/null; echo "fixture staging failed" >&2; exit 1; fi
340        ;;
341      *"uname -s"*)
342        touch "$gate.connecting"
343        while [ ! -f "$gate.release-connect" ]; do sleep 0.02; done
344        printf 'Linux\nx86_64\n` + suite.remoteHome + `\n1\n3.11.17\nmissing\n'
345        exit 0 ;;
346    esac ;;
347  nohost) echo "ssh: connect to host nohost port 22: Connection refused" >&2; exit 255 ;;
348  lockedhost) echo "lockedhost: Permission denied (publickey)." >&2; exit 255 ;;
349esac
350exec env -i HOME=` + suite.remoteHome + ` PATH=` + remotePath + ` SHELL=` + remoteBin + `/loginsh /bin/sh -c "$*"
351`
352	// a login shell that sources no profile, which would put the system
353	// python ahead of the linked one
354	loginsh := "#!/bin/sh\n[ \"$1\" = -l ] && shift\nexec /bin/sh \"$@\"\n"
355	if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte(ssh), 0o755); err != nil {
356		return "", err
357	}
358	if err := os.WriteFile(filepath.Join(remoteBin, "loginsh"), []byte(loginsh), 0o755); err != nil {
359		return "", err
360	}
361	return bin, os.Symlink(python, filepath.Join(remoteBin, "python3"))
362}
363
364// runCLI runs the built binary the way a user would and returns its output.
365func runCLI(args ...string) (string, string, error) {
366	ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
367	defer cancel()
368	cmd := exec.CommandContext(ctx, suite.cli, args...)
369	cmd.Dir = suite.root
370	cmd.Env = suite.env
371	var stdout, stderr bytes.Buffer
372	cmd.Stdout, cmd.Stderr = &stdout, &stderr
373	err := cmd.Run()
374	return stdout.String(), stderr.String(), err
375}
376
377// fakeProvider scripts the model network. Each scenario registers its own
378// route, so one shared httptest server isolates scenarios instead of a daemon
379// per test.
380type fakeProvider struct {
381	server  *httptest.Server
382	byName  map[string]*fakeRoute // by provider profile name
383	byRoute map[string]*fakeRoute // by path segment under /t/
384	seq     int
385
386	mu sync.Mutex
387}
388
389// fakeRoute is one scripted profile: a reply function and every request the
390// daemon sent to it.
391type fakeRoute struct {
392	reply    func(request map[string]any) string
393	requests []map[string]any // {authorization, model, body}
394	mu       sync.Mutex
395}
396
397func newFakeProvider() *fakeProvider {
398	p := &fakeProvider{
399		byName:  make(map[string]*fakeRoute),
400		byRoute: make(map[string]*fakeRoute),
401	}
402	mux := http.NewServeMux()
403	mux.HandleFunc("/t/", p.serve)
404	mux.HandleFunc("/models-dev", func(w http.ResponseWriter, r *http.Request) {
405		_ = json.NewEncoder(w).Encode(map[string]any{})
406	})
407	p.server = httptest.NewServer(mux)
408	return p
409}
410
411func (p *fakeProvider) close() { p.server.Close() }
412
413// addProfile registers a scripted route and saves it through the daemon,
414// which owns config.json and makes the saved profile active; earlier profiles
415// stay configured so their sessions keep working.
416func (p *fakeProvider) addProfile(name string, reply func(map[string]any) string) error {
417	p.mu.Lock()
418	p.seq++
419	segment := strconv.Itoa(p.seq)
420	route := &fakeRoute{reply: reply}
421	p.byName[name] = route
422	p.byRoute[segment] = route
423	p.mu.Unlock()
424	return saveAndSelectProvider(context.Background(), suite.conn, name, config.Settings{
425		Extension: "openai",
426		BaseURL:   p.server.URL + "/t/" + segment,
427		APIKey:    "fixture-key",
428		Model:     "fixture-model",
429		Protocol:  "chat_completions",
430	})
431}
432
433// requests returns what the daemon sent to the named profile.
434func (p *fakeProvider) requests(name string) []map[string]any {
435	p.mu.Lock()
436	route := p.byName[name]
437	p.mu.Unlock()
438	if route == nil {
439		return nil
440	}
441	route.mu.Lock()
442	defer route.mu.Unlock()
443	return slices.Clone(route.requests)
444}
445
446func (p *fakeProvider) routeFor(path string) *fakeRoute {
447	segment, _, _ := strings.Cut(strings.TrimPrefix(path, "/t/"), "/")
448	p.mu.Lock()
449	defer p.mu.Unlock()
450	return p.byRoute[segment]
451}
452
453func (p *fakeProvider) serve(w http.ResponseWriter, r *http.Request) {
454	route := p.routeFor(r.URL.Path)
455	if route == nil {
456		http.NotFound(w, r)
457		return
458	}
459	switch r.Method {
460	case http.MethodGet:
461		// The models catalog endpoint; scenarios script none.
462		w.Header().Set("Content-Type", "application/json")
463		_, _ = w.Write([]byte("{}"))
464	case http.MethodPost:
465		route.serveChat(w, r)
466	default:
467		http.NotFound(w, r)
468	}
469}
470
471// serveChat records the request and streams it a scripted reply in the chat
472// completions SSE wire format the daemon parses.
473func (r *fakeRoute) serveChat(w http.ResponseWriter, req *http.Request) {
474	body, err := io.ReadAll(io.LimitReader(req.Body, 4<<20))
475	if err != nil {
476		http.Error(w, err.Error(), http.StatusBadRequest)
477		return
478	}
479	var decoded map[string]any
480	_ = json.Unmarshal(body, &decoded) // record even a malformed body
481
482	r.mu.Lock()
483	r.requests = append(r.requests, map[string]any{
484		"authorization": req.Header.Get("Authorization"),
485		"model":         decoded["model"],
486		"body":          decoded,
487	})
488	reply := r.reply
489	r.mu.Unlock()
490
491	text := "fixture reply"
492	if reply != nil {
493		text = reply(decoded)
494	}
495
496	w.Header().Set("Content-Type", "text/event-stream")
497	flusher, _ := w.(http.Flusher)
498	emit := func(delta map[string]any, finish string) {
499		choice := map[string]any{"index": 0, "delta": delta, "finish_reason": nil}
500		if finish != "" {
501			choice["finish_reason"] = finish
502		}
503		chunk, _ := json.Marshal(map[string]any{"id": "fixture", "choices": []any{choice}})
504		fmt.Fprintf(w, "data: %s\n\n", chunk)
505		if flusher != nil {
506			flusher.Flush()
507		}
508	}
509	if code, ok := strings.CutPrefix(text, pythonCallMark); ok {
510		args, _ := json.Marshal(map[string]string{"code": code})
511		emit(map[string]any{"tool_calls": []any{map[string]any{
512			"index": 0, "id": "fixture-call", "type": "function",
513			"function": map[string]any{"name": "python", "arguments": string(args)},
514		}}}, "")
515		emit(map[string]any{}, "tool_calls")
516		fmt.Fprint(w, "data: [DONE]\n\n")
517		return
518	}
519	for _, chunk := range chunks(text, 20) {
520		emit(map[string]any{"content": chunk}, "")
521	}
522	emit(map[string]any{}, "stop")
523	fmt.Fprint(w, "data: [DONE]\n\n")
524}
525
526// --- scenario helpers; new scenarios in this package build on these ---
527
528// conn returns the shared daemon connection and fails if the daemon under it
529// is no longer the one this suite booted.
530func conn(t *testing.T) *daemon.Connection {
531	t.Helper()
532	snap, err := readDaemonSnapshot(suite.home)
533	if err != nil {
534		t.Fatalf("daemon record: %v", err)
535	}
536	if snap.Pid != suite.daemonPID {
537		t.Fatalf("daemon %d replaced the suite daemon %d", snap.Pid, suite.daemonPID)
538	}
539	return suite.conn
540}
541
542// cli runs the built binary and fails the test when it exits non-zero.
543func cli(t *testing.T, args ...string) string {
544	t.Helper()
545	stdout, stderr, err := runCLI(args...)
546	if err != nil {
547		t.Fatalf("albedo %s: %v\nstdout: %s\nstderr: %s", strings.Join(args, " "), err, stdout, stderr)
548	}
549	return stdout
550}
551
552// newSession creates a session on the test's own provider route, so tests
553// running side by side never depend on which profile is active.
554func newSession(t *testing.T, workspace string) string {
555	t.Helper()
556	created, err := daemon.CreateSession(context.Background(), conn(t), daemon.CreateSessionRequest{Workspace: workspace, Provider: t.Name()})
557	if err != nil || created.ID == "" {
558		t.Fatalf("creating a session on %s: %v", t.Name(), err)
559	}
560	return created.ID
561}
562
563// cliSession creates a session through the CLI, exactly as a user would, on
564// whichever profile is active.
565func cliSession(t *testing.T, workspace string) string {
566	t.Helper()
567	var created struct {
568		Session string `json:"session"`
569	}
570	if err := json.Unmarshal([]byte(cli(t, "new", workspace)), &created); err != nil || created.Session == "" {
571		t.Fatalf("albedo new did not report a session: %v", err)
572	}
573	return created.Session
574}
575
576// providerRoute registers and selects a profile named after the test. Call it
577// before t.Parallel(): Go finishes these selections and all serial tests before
578// resuming parallel bodies. Those bodies must not depend on the active profile
579// or change shared settings. Tests that use defaults, log in, change global
580// extensions, or restart the daemon stay serial.
581func providerRoute(t *testing.T, reply func(map[string]any) string) string {
582	t.Helper()
583	if err := suite.provider.addProfile(t.Name(), reply); err != nil {
584		t.Fatalf("provider route: %v", err)
585	}
586	return t.Name()
587}
588
589// waitIdle waits for the turn to reach the provider before accepting idle:
590// a newly submitted turn can be queued while its kernel boots.
591func waitIdle(t *testing.T, session, profile string, wantRequests int) {
592	t.Helper()
593	connection := conn(t)
594	deadline := time.Now().Add(60 * time.Second)
595	for {
596		status, err := daemon.NewChatClient(connection, session).GetStatus(context.Background())
597		if err != nil {
598			t.Fatalf("status of %s: %v", session, err)
599		}
600		seen := len(suite.provider.requests(profile))
601		if !status.Running && seen >= wantRequests {
602			return
603		}
604		if time.Now().After(deadline) {
605			t.Fatalf("session %s never settled after %d provider requests (got %d)",
606				session, wantRequests, seen)
607		}
608		time.Sleep(100 * time.Millisecond)
609	}
610}
611
612// durableHistorySnapshot reads the daemon's canonical durable history resource.
613func durableHistorySnapshot(t *testing.T, session string) []map[string]any {
614	t.Helper()
615	page, err := daemon.NewChatClient(conn(t), session).History(t.Context(), 0, 200)
616	if err != nil {
617		t.Fatal(err)
618	}
619	events := []map[string]any{}
620	for _, event := range page.Events {
621		events = append(events, map[string]any{"type": string(event.Type), "text": event.Text})
622	}
623	return events
624}
625
626// eventText collects the text of every event of one kind.
627func eventText(events []map[string]any, kind string) []string {
628	var texts []string
629	for _, event := range events {
630		if event["type"] == kind {
631			if text, _ := event["text"].(string); text != "" {
632				texts = append(texts, text)
633			}
634		}
635	}
636	return texts
637}
638
639// echoReply scripts a provider that echoes the prompt back as assistant text,
640// so an assertion on the transcript proves the prompt crossed the whole stack.
641func echoReply(request map[string]any) string {
642	if prompt := lastUserText(request); prompt != "" {
643		return "echo: " + prompt
644	}
645	return "fixture reply"
646}
647
648// pythonCallMark starts a scripted reply that is a python tool call; see
649// pythonCall.
650const pythonCallMark = "\x00python:"
651
652// pythonCall scripts a reply that calls the python tool with code instead of
653// answering in text.
654func pythonCall(code string) string { return pythonCallMark + code }
655
656// toolAnswered reports whether request carries a tool result, so a scripted
657// reply can answer once its call has run.
658func toolAnswered(request map[string]any) bool {
659	messages, _ := request["messages"].([]any)
660	return slices.ContainsFunc(messages, func(raw any) bool {
661		message, _ := raw.(map[string]any)
662		return message["role"] == "tool"
663	})
664}
665
666// lastUserText finds the prompt the daemon forwarded.
667func lastUserText(request map[string]any) string {
668	messages, _ := request["messages"].([]any)
669	for _, raw := range slices.Backward(messages) {
670		message, _ := raw.(map[string]any)
671		if message["role"] != "user" {
672			continue
673		}
674		switch content := message["content"].(type) {
675		case string:
676			return content
677		case []any:
678			var parts []string
679			for _, part := range content {
680				if item, ok := part.(map[string]any); ok {
681					if text, _ := item["text"].(string); text != "" {
682						parts = append(parts, text)
683					}
684				}
685			}
686			return strings.Join(parts, " ")
687		}
688	}
689	return ""
690}
691
692// chunks splits text into stream-sized pieces.
693func chunks(text string, size int) []string {
694	var parts []string
695	for len(text) > size {
696		parts = append(parts, text[:size])
697		text = text[size:]
698	}
699	return append(parts, text)
700}
701
702// A test profile edit observes its validator under the same lock as its write.
703// Concurrent test profiles remain independent without silently rebasing intent.
704var sharedSettingsMu sync.Mutex
705
706func saveAndSelectProvider(ctx context.Context, connection *daemon.Connection, name string, profile config.Settings) error {
707	sharedSettingsMu.Lock()
708	defer sharedSettingsMu.Unlock()
709	settings, err := daemon.GetSettings(ctx, connection)
710	if err != nil {
711		return err
712	}
713	return daemon.SaveAndSelectProvider(ctx, connection, name, profile, settings.ETags["providers"])
714}
715func updateMCP(ctx context.Context, connection *daemon.Connection, session string, request daemon.MCPUpdateRequest) (daemon.ReloadResult, error) {
716	sharedSettingsMu.Lock()
717	defer sharedSettingsMu.Unlock()
718	settings, err := daemon.GetSettings(ctx, connection)
719	if err != nil {
720		return daemon.ReloadResult{}, err
721	}
722	request.ETag = settings.ETags["mcp"]
723	request.StoredSecrets = settings.Credentials.MCP[request.Name]
724	return daemon.SaveMCP(ctx, connection, session, request)
725}
726func enableExtension(t *testing.T, connection *daemon.Connection, session, name string) {
727	t.Helper()
728	sharedSettingsMu.Lock()
729	defer sharedSettingsMu.Unlock()
730	settings, err := daemon.GetSettings(t.Context(), connection)
731	if err != nil {
732		t.Fatal(err)
733	}
734	if _, err := daemon.SelectExtension(t.Context(), connection, session, daemon.ExtensionSelectionRequest{Name: name, Scope: "global", Enabled: new(true), ETag: settings.ETags["extensions"]}); err != nil {
735		t.Fatal(err)
736	}
737	current, err := daemon.GetSession(t.Context(), connection, session)
738	if err != nil {
739		t.Fatal(err)
740	}
741	if _, err := daemon.SelectExtension(t.Context(), connection, session, daemon.ExtensionSelectionRequest{Name: name, Scope: "session", Enabled: new(true), ETag: current.ETag}); err != nil {
742		t.Fatal(err)
743	}
744	if _, err := daemon.ReloadSession(t.Context(), connection, session, daemon.ReloadRequest{Target: "both"}); err != nil {
745		t.Fatal(err)
746	}
747}
748func sessionCondition(t *testing.T, connection *daemon.Connection, id string) daemon.SessionCondition {
749	t.Helper()
750	current, err := daemon.GetSession(t.Context(), connection, id)
751	if err != nil {
752		t.Fatal(err)
753	}
754	return daemon.SessionCondition{ETag: current.ETag, FamilyRevision: current.FamilyRevision}
755}