okami.mom/albedo

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

Aly Raffauffeat(api)!: migrate daemon and clients to protocol 361c43ca

main
6.4 KiB206 linesraw
1// A controlled peer holds snapshots across stream updates and delivers replies
2// late. Real provider timing cannot reliably create these response orderings.
3package tui
4
5import (
6	"albedo/cli/internal/testwire"
7	"context"
8	"encoding/json"
9	"fmt"
10	"net"
11	"net/http"
12	"net/http/httptest"
13	"strings"
14	"sync/atomic"
15	"testing"
16	"time"
17
18	"albedo/cli/internal/daemon"
19	tea "charm.land/bubbletea/v2"
20	"github.com/charmbracelet/x/ansi"
21)
22
23func TestAgentsRefreshDiscardsDirtyAndPreviousSubscriptionReplies(t *testing.T) {
24	ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
25	defer cancel()
26	feeds := []chan string{make(chan string, 4), make(chan string, 4), make(chan string, 4)}
27	failureRelease, dirtyRelease := make(chan struct{}), make(chan struct{})
28	var streams, snapshots, seeds atomic.Int32
29	changed := make(chan struct{}, 16)
30	notify := func() {
31		select {
32		case changed <- struct{}{}:
33		default:
34		}
35	}
36	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37		switch {
38		case r.URL.Path == "/sessions" && r.Header.Get("Accept") == "text/event-stream":
39			number := streams.Add(1)
40			notify()
41			if number > int32(len(feeds)) {
42				t.Error("unexpected extra subscription")
43				return
44			}
45			w.Header().Set("Content-Type", "text/event-stream")
46			testwire.InitialStream(w, "agents")
47			w.(http.Flusher).Flush()
48			for {
49				select {
50				case batch := <-feeds[number-1]:
51					_, _ = fmt.Fprintf(w, "data: %s\n\n", batch)
52					w.(http.Flusher).Flush()
53					if strings.Contains(batch, "overflow") {
54						return
55					}
56				case <-r.Context().Done():
57					return
58				case <-ctx.Done():
59					return
60				}
61			}
62		case r.URL.Path == "/sessions":
63			number := snapshots.Add(1)
64			notify()
65			if streams.Load() == 0 {
66				t.Error("snapshot preceded subscription readiness")
67			}
68			name := "LIVE_TREE"
69			switch number {
70			case 1:
71				select {
72				case <-failureRelease:
73				case <-ctx.Done():
74					return
75				}
76				w.WriteHeader(http.StatusServiceUnavailable)
77				_, _ = fmt.Fprint(w, `{"detail":"snapshot refused"}`)
78				return
79			case 2:
80				select {
81				case <-dirtyRelease:
82				case <-ctx.Done():
83					return
84				}
85				name = "DIRTY_TREE"
86			case 4:
87				name = "OLD_SUBSCRIPTION_TREE"
88			case 5:
89				name = "FINAL_TREE"
90			}
91			session := protocolSession("lead", generationA, 0)
92			session["name"] = name
93			_ = json.NewEncoder(w).Encode(map[string]any{"items": []any{session}, "next": nil, "family": map[string]any{"root_id": "lead", "revision": "family-a"}})
94		case r.URL.Path == "/sessions/lead":
95			session := protocolSession("lead", generationA, 0)
96			if r.URL.Query().Has("tail") {
97				preview := "OLD_SUBSCRIPTION_SEED"
98				if seeds.Add(1) > 1 {
99					preview = "FINAL_SEED"
100				}
101				session["preview"].(map[string]any)["transcript_count"] = 1
102				session["history"].(map[string]any)["items"] = []any{protocolEntry("answer", "assistant", preview, 1)}
103			}
104			_ = json.NewEncoder(w).Encode(session)
105		default:
106			t.Errorf("unexpected request: %s", r.URL.Path)
107			w.WriteHeader(http.StatusNotFound)
108		}
109	}))
110	defer server.Close()
111	defer cancel()
112	connection := daemon.NewConnection(daemon.ConnectionSnapshot{Port: server.Listener.Addr().(*net.TCPAddr).Port}, nil)
113	defer connection.HTTPClient().CloseIdleConnections()
114	model := NewAgentsViewModel(connection, "lead")
115	defer func() { model.Close() }()
116	model.SetSize(120, 30)
117	inbox := make(chan tea.Msg, 16)
118	var run func(tea.Cmd)
119	run = func(cmd tea.Cmd) {
120		if cmd == nil {
121			return
122		}
123		go func() {
124			message := cmd()
125			if batch, ok := message.(tea.BatchMsg); ok {
126				for _, child := range batch {
127					run(child)
128				}
129				return
130			}
131			switch message.(type) {
132			case agentsSnapshotMsg, agentsEventsMsg, agentsStreamClosedMsg, agentsSnapshotRetryMsg, agentsSeedMsg, agentsSeedErrMsg:
133				select {
134				case inbox <- message:
135				case <-ctx.Done():
136				}
137			}
138		}()
139	}
140	update := func(message tea.Msg) { var cmd tea.Cmd; model, cmd = model.Update(message); run(cmd) }
141	view := func() string { return ansi.Strip(model.View()) }
142	pump := func(done func() bool) {
143		t.Helper()
144		for !done() {
145			select {
146			case message := <-inbox:
147				update(message)
148			case <-changed:
149			case <-ctx.Done():
150				t.Fatalf("refresh did not settle:\n%s", view())
151			}
152		}
153	}
154	take := func(matches func(tea.Msg) bool) tea.Msg {
155		t.Helper()
156		for {
157			select {
158			case message := <-inbox:
159				if matches(message) {
160					return message
161				}
162				update(message)
163			case <-ctx.Done():
164				t.Fatalf("expected response never arrived:\n%s", view())
165				return nil
166			}
167		}
168	}
169	run(model.Init())
170	pump(func() bool { return snapshots.Load() == 1 })
171	feeds[0] <- `{"events":[{"type":"invalidate","data":{"urls":["/sessions"],"session_ids":["lead"],"scope_dirty":true}}]}`
172	// The event must be consumed before releasing the failed snapshot.
173	takeEvent := take(func(message tea.Msg) bool { batch, ok := message.(agentsEventsMsg); return ok && len(batch.Events) > 0 })
174	update(takeEvent)
175	close(failureRelease)
176	pump(func() bool { return strings.Contains(view(), "snapshot refused") })
177	pump(func() bool { return snapshots.Load() == 2 })
178	feeds[0] <- `{"events":[{"type":"invalidate","data":{"urls":["/sessions"],"session_ids":["lead"],"scope_dirty":true}}]}`
179	update(take(func(message tea.Msg) bool { batch, ok := message.(agentsEventsMsg); return ok && len(batch.Events) > 0 }))
180	close(dirtyRelease)
181	dirty := take(func(message tea.Msg) bool {
182		snapshot, ok := message.(agentsSnapshotMsg)
183		return ok && snapshot.Err == nil
184	})
185	update(dirty)
186	if strings.Contains(view(), "DIRTY_TREE") {
187		t.Fatal("dirty snapshot replaced the live graph")
188	}
189	pump(func() bool { return strings.Contains(view(), "LIVE_TREE") })
190	oldSeed := take(func(message tea.Msg) bool { _, ok := message.(agentsSeedMsg); return ok })
191	feeds[0] <- `{"events":[{"type":"overflow","data":{}}]}`
192	oldSnapshot := take(func(message tea.Msg) bool {
193		snapshot, ok := message.(agentsSnapshotMsg)
194		return ok && snapshot.Err == nil
195	})
196	feeds[1] <- `{"events":[{"type":"overflow","data":{}}]}`
197	pump(func() bool { return strings.Contains(view(), "FINAL_SEED") })
198	update(oldSnapshot)
199	update(oldSeed)
200	if strings.Contains(view(), "OLD_SUBSCRIPTION") || !strings.Contains(view(), "FINAL_TREE") || !strings.Contains(view(), "FINAL_SEED") {
201		t.Fatalf("late success restored stale membership or history:\n%s", view())
202	}
203	if streams.Load() != 3 || snapshots.Load() != 5 {
204		t.Fatalf("recovery made unexpected requests: streams=%d snapshots=%d", streams.Load(), snapshots.Load())
205	}
206}