okami.mom/albedo

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

Aly Raffauftest(stream): cover active output recovery and spill lifetimesb0282b1

main
5.1 KiB160 linesraw
1//go:build unix
2
3// A TUI opened mid-response must hydrate the prefix before consuming its suffix,
4// and replace provisional rows with exactly one canonical committed response.
5package e2e
6
7import (
8	"context"
9	"encoding/json"
10	"fmt"
11	"net/http"
12	"net/http/httptest"
13	"strings"
14	"sync"
15	"testing"
16	"time"
17
18	"albedo/cli/internal/config"
19	"albedo/cli/internal/daemon"
20	"albedo/cli/internal/tui"
21	tea "charm.land/bubbletea/v2"
22)
23
24func TestTUIActiveOutputAttach(t *testing.T) {
25	for _, size := range []int{32, 90000} {
26		t.Run(fmt.Sprint(size), func(t *testing.T) {
27			prefix := strings.Repeat("p", size) + " paused α🙂"
28			thinking := "thinking β"
29			suffix := " final suffix"
30			release := make(chan struct{})
31			var releaseOnce sync.Once
32			resume := func() { releaseOnce.Do(func() { close(release) }) }
33			t.Cleanup(resume)
34			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
35				w.Header().Set("Content-Type", "text/event-stream")
36				flush := w.(http.Flusher)
37				emit := func(field, text string) {
38					payload, _ := json.Marshal(map[string]any{"id": "fixture", "choices": []any{map[string]any{
39						"index": 0, "delta": map[string]string{field: text}, "finish_reason": nil,
40					}}})
41					fmt.Fprintf(w, "data: %s\n\n", payload)
42					flush.Flush()
43				}
44				emit("reasoning_content", thinking)
45				for _, part := range chunks(prefix, 1024) {
46					emit("content", part)
47				}
48				select {
49				case <-release:
50				case <-r.Context().Done():
51					return
52				}
53				emit("content", suffix)
54				fmt.Fprint(w, "data: {\"id\":\"fixture\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
55				flush.Flush()
56			}))
57			t.Cleanup(server.Close)
58			profile := fmt.Sprintf("active-output-%d", size)
59			if err := saveAndSelectProvider(t.Context(), conn(t), profile, config.Settings{
60				Extension: "openai", BaseURL: server.URL, APIKey: "fixture", Model: "fixture-model", Protocol: "chat_completions",
61			}); err != nil {
62				t.Fatal(err)
63			}
64			created, err := daemon.CreateSession(t.Context(), conn(t), daemon.CreateSessionRequest{Workspace: t.TempDir(), Provider: profile})
65			if err != nil {
66				t.Fatal(err)
67			}
68			session := daemonSession(t, created.ID)
69			ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
70			defer cancel()
71			observed := make(chan struct{})
72			ready := make(chan struct{})
73			var readyOnce sync.Once
74			observerErrors := make(chan error, 1)
75			go func() {
76				var text strings.Builder
77				observerErrors <- daemon.NewChatClient(conn(t), session.ID).Stream(ctx, 0, func(event daemon.StreamEvent) error {
78					readyOnce.Do(func() { close(ready) })
79					if event.Type == daemon.EventText {
80						text.WriteString(event.Text)
81						if text.String() == prefix {
82							close(observed)
83						}
84					}
85					return nil
86				})
87			}()
88			select {
89			case <-ready:
90			case err := <-observerErrors:
91				t.Fatalf("responsive subscriber did not attach: %v", err)
92			case <-ctx.Done():
93				t.Fatal("responsive subscriber did not attach")
94			}
95			if _, err := daemon.NewChatClient(conn(t), session.ID).Send(ctx, "fixture", nil); err != nil {
96				t.Fatal(err)
97			}
98			select {
99			case <-observed:
100			case err := <-observerErrors:
101				t.Fatalf("responsive subscriber ended: %v", err)
102			case <-ctx.Done():
103				t.Fatal("responsive subscriber did not see complete prefix")
104			}
105			driver := driveTUI(t, &session)
106			t.Cleanup(driver.App.Chat.Close)
107			inbox := make(chan tea.Msg, 32)
108			attachedAt := time.Now()
109			streamMessages(ctx, driver.App.Chat.Init(), inbox)
110			var restored, restoredThinking strings.Builder
111			resumed := false
112			committed := false
113			for {
114				select {
115				case msg := <-inbox:
116					cmd := driver.Update(msg)
117					if event, ok := msg.(tui.ChatStreamEventMsg); ok {
118						switch event.Event.Type {
119						case daemon.EventCommitted:
120							committed = true
121						case daemon.EventText:
122							if !resumed {
123								restored.WriteString(event.Event.Text)
124							}
125						case daemon.EventThinking:
126							if !resumed {
127								restoredThinking.WriteString(event.Event.Text)
128							}
129						}
130					}
131					if !resumed && restored.String() == prefix && restoredThinking.String() == thinking {
132						resumed = true
133						t.Logf("Init to hydrated TUI prefix (%d bytes text): %s", len(prefix), time.Since(attachedAt))
134						resume()
135					}
136					assistantCount := 0
137					for _, entry := range driver.App.Chat.History.Entries() {
138						if entry.Kind == tui.EntryAssistant && entry.Text == prefix+suffix && entry.Seq > 0 {
139							assistantCount++
140						}
141					}
142					if committed && assistantCount > 0 {
143						if assistantCount != 1 {
144							t.Fatalf("duplicate canonical responses: %d", assistantCount)
145						}
146						for _, entry := range driver.App.Chat.History.Entries() {
147							if entry.Kind == tui.EntryAssistant && entry.Seq == 0 {
148								t.Fatalf("provisional response survived canonical commit: %+v", entry)
149							}
150						}
151						return
152					}
153					streamMessages(ctx, cmd, inbox)
154				case <-ctx.Done():
155					t.Fatalf("attachment lost prefix or duplicated response: restored=%d want=%d thinking=%q resumed=%v\n%s", restored.Len(), len(prefix), restoredThinking.String(), resumed, driver.View())
156				}
157			}
158		})
159	}
160}