okami.mom/albedo

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

dawnsessions: a daemon restart ends session streams with eof instead of a terminal daemon_unavailable failure53cd699

main
8.8 KiB249 linesraw
1//go:build unix
2
3// A read through a ChatClient built before a real daemon restart refreshes
4// its connection before another turn without losing the first turn's transcript,
5// and an open session stream follows the session to the restarted daemon.
6// A fake daemon cannot exercise process lifetime or on-disk session persistence.
7package e2e
8
9import (
10	"context"
11	"fmt"
12	"strings"
13	"testing"
14	"time"
15
16	"albedo/cli/internal/daemon"
17	"albedo/cli/internal/tui"
18
19	tea "charm.land/bubbletea/v2"
20)
21
22// restartDaemon replaces the suite daemon under the same hermetic home and
23// records its new pid for later scenarios.
24func restartDaemon(t *testing.T) daemon.ConnectionSnapshot {
25	t.Helper()
26	old := suite.daemonPID
27
28	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
29	defer cancel()
30	if err := daemon.StopDaemon(ctx, conn(t)); err != nil {
31		t.Fatalf("shutdown request: %v", err)
32	}
33	if !awaitExit(old, 15*time.Second) {
34		t.Fatalf("daemon %d did not exit after shutdown", old)
35	}
36
37	if _, stderr, err := runCLI("sessions"); err != nil {
38		t.Fatalf("rebooting the daemon: %v\n%s", err, stderr)
39	}
40	deadline := time.Now().Add(2 * time.Minute)
41	for {
42		snap, err := readDaemonSnapshot(suite.home)
43		if err == nil && snap.Pid != old {
44			suite.daemonPID = snap.Pid
45			return snap
46		}
47		if time.Now().After(deadline) {
48			t.Fatalf("no daemon other than %d published daemon.json after the restart", old)
49		}
50		time.Sleep(100 * time.Millisecond)
51	}
52}
53
54// TestChatClientReadReconnectsAfterDaemonRestart recovers a read before
55// sending another turn through the same client and persisted session.
56func TestChatClientReadReconnectsAfterDaemonRestart(t *testing.T) {
57	profile := providerRoute(t, echoReply)
58	workspace := t.TempDir()
59	id := newSession(t, workspace)
60	configured := daemonSession(t, id)
61	if _, err := daemon.SelectModel(t.Context(), conn(t), id, daemon.ModelSelectionRequest{Model: "o3", Effort: "high", ETag: configured.ETag}); err != nil {
62		t.Fatalf("select a model with a default effort: %v", err)
63	}
64	configured = daemonSession(t, id)
65	if configured.Effort != "high" {
66		t.Fatalf("selected effort is %q, want high", configured.Effort)
67	}
68	if _, err := daemon.SelectEffort(t.Context(), conn(t), configured, ""); err != nil {
69		t.Fatalf("clear the effort preference: %v", err)
70	}
71	if cleared := daemonSession(t, id); cleared.Effort != "" {
72		t.Fatalf("cleared effort is %q", cleared.Effort)
73	}
74
75	// Reads and turns use the same client across the restart so they exercise
76	// connection recovery rather than a new attachment.
77	shared := conn(t)
78	client := daemon.NewChatClient(shared, id)
79
80	prompt1 := "turn before the restart"
81	if _, err := client.Send(context.Background(), prompt1, nil); err != nil {
82		t.Fatalf("first send: %v", err)
83	}
84	waitIdle(t, id, profile, 1)
85
86	oldSnap := shared.Snapshot()
87	snap := restartDaemon(t)
88	if reopened := daemonSession(t, id); reopened.Effort != "" {
89		t.Fatalf("restart replaced the cleared effort with %q", reopened.Effort)
90	}
91
92	page, err := client.History(context.Background(), 0, 120)
93	if err != nil || len(page.Events) == 0 {
94		t.Fatalf("read recovery after restart: %+v, %v", page, err)
95	}
96
97	prompt2 := "turn after the restart"
98	sent, err := client.Send(context.Background(), prompt2, nil)
99	if err != nil || sent == nil || !sent.OK {
100		t.Fatalf("second send through the same client after the restart: ok=%v err=%v", sent != nil && sent.OK, err)
101	}
102	waitIdle(t, id, profile, 2)
103	if continued := daemonSession(t, id); continued.Effort != "" {
104		t.Fatalf("resumed turn replaced the cleared effort with %q", continued.Effort)
105	}
106
107	// The shared connection must now describe the restarted daemon, not the
108	// one it was built on.
109	current := shared.Snapshot()
110	if current.Pid != snap.Pid {
111		t.Errorf("connection pid is %d, restarted daemon is %d", current.Pid, snap.Pid)
112	}
113	if current.Token != snap.Token {
114		t.Errorf("connection token is not the restarted daemon's")
115	}
116	if want := fmt.Sprintf("http://127.0.0.1:%d", snap.Port); shared.BaseURL() != want {
117		t.Errorf("connection base url is %s, want %s", shared.BaseURL(), want)
118	}
119	if current.Token == oldSnap.Token {
120		t.Errorf("the token never changed, so this run did not exercise a real restart")
121	}
122
123	// Both turns crossed the whole stack to the provider.
124	requests := suite.provider.requests(profile)
125	if len(requests) < 2 {
126		t.Fatalf("provider profile %s saw %d requests, want one per turn", profile, len(requests))
127	}
128	body, _ := requests[0]["body"].(map[string]any)
129	if forwarded := lastUserText(body); !strings.Contains(forwarded, prompt1) {
130		t.Errorf("first provider request does not carry the first prompt %q: %q", prompt1, forwarded)
131	}
132	body, _ = requests[len(requests)-1]["body"].(map[string]any)
133	if forwarded := lastUserText(body); !strings.Contains(forwarded, prompt2) {
134		t.Errorf("last provider request does not carry the post-restart prompt: %q", forwarded)
135	}
136
137	// The transcript survived the restart in the same session.
138	reply := strings.Join(eventText(durableHistorySnapshot(t, id), "message"), "\n")
139	if !strings.Contains(reply, "echo: "+prompt1) || !strings.Contains(reply, "echo: "+prompt2) {
140		t.Fatalf("transcript after the restart is missing a turn; got:\n%s", reply)
141	}
142}
143
144// A stopped turn survives a daemon restart as idle-but-interrupted. Reopening
145// it must accept ordinary input without a slash command changing its phase.
146func TestTUIReopensInterruptedSessionWithoutCompacting(t *testing.T) {
147	entered, release := make(chan struct{}), make(chan struct{})
148	defer close(release)
149	profile := providerRoute(t, func(request map[string]any) string {
150		if lastUserText(request) == "stop this turn" {
151			close(entered)
152			<-release
153		}
154		return echoReply(request)
155	})
156	id := newSession(t, t.TempDir())
157	client := daemon.NewChatClient(conn(t), id)
158	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
159	defer cancel()
160	if _, err := client.Send(ctx, "stop this turn", nil); err != nil {
161		t.Fatal(err)
162	}
163	select {
164	case <-entered:
165	case <-ctx.Done():
166		t.Fatal("the turn never reached the provider")
167	}
168	if interrupted, err := daemon.InterruptSession(ctx, conn(t), id); err != nil || !interrupted {
169		t.Fatalf("interrupt: %v, %v", interrupted, err)
170	}
171	waitIdle(t, id, profile, 1)
172	restartDaemon(t)
173	status, err := daemon.NewChatClient(conn(t), id).GetStatus(ctx)
174	if err != nil || !status.Idle || status.Phase == nil || *status.Phase != daemon.PhaseIdle {
175		t.Fatalf("reopened status: %+v, %v", status, err)
176	}
177
178	session := daemonSession(t, id)
179	d := driveTUI(t, &session)
180	defer d.App.Chat.Close()
181	page, err := client.History(ctx, 0, 120)
182	if err != nil {
183		t.Fatal(err)
184	}
185	for _, event := range page.Events {
186		d.Update(tui.ChatStreamEventMsg{
187			SessionID: id, Generation: d.App.Chat.Generation, Event: event,
188		})
189	}
190	if !strings.Contains(d.View(), "stop this turn") {
191		t.Fatal("the interrupted transcript did not load")
192	}
193	d.connected()
194	d.App.Chat.TextArea.SetValue("continue without compacting")
195	command := d.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
196	if command == nil {
197		t.Fatal("the composer refused the new prompt")
198	}
199	for _, msg := range d.results(command) {
200		if _, ok := msg.(tui.ChatTurnSentMsg); ok {
201			d.Update(msg)
202		}
203	}
204	waitIdle(t, id, profile, 2)
205	replies := strings.Join(eventText(durableHistorySnapshot(t, id), "message"), "\n")
206	if !strings.Contains(replies, "echo: continue without compacting") {
207		t.Fatalf("the reopened session did not answer: %q", replies)
208	}
209}
210
211// The daemon closes its sessions before it stops listening, so an open stream
212// sees its session stop first. That is a restart, not a failed session: the
213// chat keeps its stream and shows the next turn from the restarted daemon.
214func TestTUIStreamFollowsSessionAcrossDaemonRestart(t *testing.T) {
215	providerRoute(t, echoReply)
216	session := daemonSession(t, newSession(t, t.TempDir()))
217	driver := driveTUI(t, &session)
218	t.Cleanup(driver.App.Chat.Close)
219	ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
220	defer cancel()
221	inbox := make(chan tea.Msg, 32)
222	pump := func(until func(daemon.StreamEvent) bool) {
223		t.Helper()
224		for {
225			select {
226			case msg := <-inbox:
227				if result, ok := msg.(tui.ChatStreamResultMsg); ok {
228					t.Fatalf("the restart stopped the stream: %v", result.Err)
229				}
230				streamMessages(ctx, driver.Update(msg), inbox)
231				if event, ok := msg.(tui.ChatStreamEventMsg); ok && until(event.Event) {
232					return
233				}
234			case <-ctx.Done():
235				t.Fatalf("stream did not reach expected state:\n%s", driver.View())
236			}
237		}
238	}
239	streamMessages(ctx, driver.App.Chat.Init(), inbox)
240	pump(func(event daemon.StreamEvent) bool { return event.Type == daemon.EventReset })
241
242	restartDaemon(t)
243	if _, err := daemon.NewChatClient(conn(t), session.ID).Send(ctx, "turn after the restart", nil); err != nil {
244		t.Fatal(err)
245	}
246	pump(func(event daemon.StreamEvent) bool {
247		return event.Type == daemon.EventMessage && strings.Contains(event.Text, "echo: turn after the restart")
248	})
249}