okami.mom/albedo

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

nekomimi.pettest(e2e): run session-scoped Go scenarios in parallel5b3f8be

main
10.3 KiB310 linesraw
1//go:build unix
2
3package e2e
4
5import (
6	"bufio"
7	"context"
8	"encoding/json"
9	"errors"
10	"fmt"
11	"net/http"
12	"net/http/httptest"
13	"net/url"
14	"os"
15	"path/filepath"
16	"reflect"
17	"strings"
18	"sync/atomic"
19	"testing"
20	"time"
21
22	"albedo/cli/internal/config"
23	"albedo/cli/internal/daemon"
24)
25
26// A raw protocol reader and the Go client attach to the same paused real
27// daemon turn. The reset snapshot must describe current activity and arrive
28// through the Go callback as live normalized progress. Finalized file changes
29// must also survive both the live result and durable history projection.
30func TestLateChatClientRestoresNormalizedToolProgress(t *testing.T) {
31	const code = "from pathlib import Path\nimport time\nPath('progress-ready').write_text('ready')\nwhile not Path('finish-progress').exists(): time.sleep(0.01)\nfiles.write('trace-display.txt', 'finished trace\\n')\nprint('finished')"
32	ready, release := make(chan struct{}), make(chan struct{})
33	var calls atomic.Int32
34	provider := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
35		if request.Method == http.MethodGet {
36			writer.Header().Set("Content-Type", "application/json")
37			_, _ = writer.Write([]byte(`{"data":[]}`))
38			return
39		}
40		call := calls.Add(1)
41		writer.Header().Set("Content-Type", "text/event-stream")
42		flusher, _ := writer.(http.Flusher)
43		writeChunk := func(delta map[string]any, finish string) {
44			choice := map[string]any{"index": 0, "delta": delta, "finish_reason": nil}
45			if finish != "" {
46				choice["finish_reason"] = finish
47			}
48			chunk, _ := json.Marshal(map[string]any{"id": "go-progress-e2e", "choices": []any{choice}})
49			_, _ = fmt.Fprintf(writer, "data: %s\n\n", chunk)
50			if flusher != nil {
51				flusher.Flush()
52			}
53		}
54		if call == 1 {
55			args, _ := json.Marshal(map[string]string{"code": code})
56			for offset := 0; offset < len(args); offset += 9 {
57				fragment := string(args[offset:min(len(args), offset+9)])
58				delta := map[string]any{"tool_calls": []any{map[string]any{
59					"index": 0, "function": map[string]any{"arguments": fragment},
60				}}}
61				if offset == 0 {
62					delta["tool_calls"] = []any{map[string]any{
63						"index": 0, "id": "native-go-progress", "type": "function",
64						"function": map[string]any{"name": "python", "arguments": fragment},
65					}}
66				}
67				writeChunk(delta, "")
68			}
69			close(ready)
70			select {
71			case <-release:
72			case <-request.Context().Done():
73				return
74			case <-time.After(30 * time.Second):
75				http.Error(writer, "test release timed out", http.StatusGatewayTimeout)
76				return
77			}
78			writeChunk(map[string]any{}, "tool_calls")
79		} else {
80			writeChunk(map[string]any{"content": "done"}, "")
81			writeChunk(map[string]any{}, "stop")
82		}
83		_, _ = fmt.Fprint(writer, "data: [DONE]\n\n")
84	}))
85	t.Cleanup(func() {
86		select {
87		case <-release:
88		default:
89			close(release)
90		}
91		provider.Close()
92	})
93
94	profile := t.Name()
95	if err := saveAndSelectProvider(context.Background(), conn(t), profile, config.Settings{
96		Extension: "openai", BaseURL: provider.URL, APIKey: "fixture-key",
97		Model: "fixture-model", Protocol: "chat_completions",
98	}); err != nil {
99		t.Fatal(err)
100	}
101	t.Parallel()
102	workspace := t.TempDir()
103	finishPath := filepath.Join(workspace, "finish-progress")
104	t.Cleanup(func() { _ = os.WriteFile(finishPath, []byte("finish"), 0o600) })
105	session := newSession(t, workspace)
106	client := daemon.NewChatClient(conn(t), session)
107	if _, err := client.Send(t.Context(), "pause for Go progress attachment", nil); err != nil {
108		t.Fatal(err)
109	}
110	select {
111	case <-ready:
112	case <-time.After(30 * time.Second):
113		t.Fatal("provider did not reach the tool argument barrier")
114	}
115
116	compareReset := func(phase string) []daemon.ToolProgress {
117		t.Helper()
118		// A flushed provider fragment may still be waiting for the actor.
119		// Wait for its full short preview before comparing two attachments.
120		wireProgress := waitCurrentProgress(t, conn(t), session, phase, code)
121		if len(wireProgress) != 1 || wireProgress[0].Phase != phase || wireProgress[0].Code == nil || wireProgress[0].Code.Text == "" {
122			t.Fatalf("raw reset did not expose the active call: %+v", wireProgress)
123		}
124		stop := errors.New("received live reset snapshot")
125		var restored *daemon.ToolProgress
126		err := daemon.NewChatClient(conn(t), session).Stream(t.Context(), 0, func(event daemon.StreamEvent) error {
127			if event.Type == daemon.EventToolProgress {
128				if event.Replayed {
129					t.Error("current progress was marked as replayed history")
130				}
131				restored = event.Progress
132				return stop
133			}
134			return nil
135		})
136		if !errors.Is(err, stop) {
137			t.Fatalf("Go stream did not deliver current progress: %v", err)
138		}
139		if restored == nil || !reflect.DeepEqual(*restored, wireProgress[0]) {
140			t.Fatalf("Go callback differs from independently decoded reset snapshot: raw=%+v callback=%+v", wireProgress[0], restored)
141		}
142		return wireProgress
143	}
144	generating := compareReset("generating")
145	if generating[0].Phase != "generating" {
146		t.Fatalf("initial progress phase = %q, want generating", generating[0].Phase)
147	}
148
149	close(release)
150	deadline := time.Now().Add(30 * time.Second)
151	for {
152		if _, err := os.Stat(filepath.Join(workspace, "progress-ready")); err == nil {
153			break
154		}
155		if time.Now().After(deadline) {
156			t.Fatal("Python tool did not reach its running barrier")
157		}
158		time.Sleep(25 * time.Millisecond)
159	}
160	running := compareReset("running")
161	if running[0].Phase != "running" || running[0].CallID != generating[0].CallID {
162		t.Fatalf("running reset lost call identity or phase: generating=%+v running=%+v", generating[0], running[0])
163	}
164	finishedTrace := errors.New("received finalized tool trace")
165	ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
166	defer cancel()
167	var liveTrace *daemon.ToolTrace
168	err := daemon.NewChatClient(conn(t), session).Stream(ctx, 0, func(event daemon.StreamEvent) error {
169		if event.Type == daemon.EventToolProgress && event.Progress != nil && event.Progress.Phase == "running" {
170			return os.WriteFile(finishPath, []byte("finish"), 0o600)
171		}
172		if event.Type == daemon.EventTool && event.ToolName == "python" {
173			if event.Replayed {
174				t.Error("live tool result was marked as replayed history")
175			}
176			liveTrace = event.ToolTrace
177			return finishedTrace
178		}
179		return nil
180	})
181	if !errors.Is(err, finishedTrace) {
182		t.Fatalf("finalized tool trace did not reach the Go callback: %v", err)
183	}
184	assertTrace := func(trace *daemon.ToolTrace) {
185		t.Helper()
186		if trace == nil {
187			t.Fatal("tool result lost its finalized trace")
188		}
189		for _, change := range trace.Changes {
190			if filepath.Base(change.Path) == "trace-display.txt" && change.Kind == "diff" && strings.Contains(change.Diff, "+finished trace") {
191				return
192			}
193		}
194		t.Fatalf("tool trace lost the actual file change: %+v", trace)
195	}
196	assertTrace(liveTrace)
197	deadline = time.Now().Add(30 * time.Second)
198	for {
199		status, err := client.GetStatus(t.Context())
200		if err != nil {
201			t.Fatal(err)
202		}
203		if calls.Load() >= 2 && !status.Running {
204			break
205		}
206		if time.Now().After(deadline) {
207			t.Fatalf("tool turn did not finish: provider calls=%d status=%+v", calls.Load(), status)
208		}
209		time.Sleep(25 * time.Millisecond)
210	}
211	history, err := client.History(t.Context(), 0, 120)
212	if err != nil {
213		t.Fatal(err)
214	}
215	var replayedTrace *daemon.ToolTrace
216	for _, event := range history.Events {
217		if event.Type == daemon.EventTool && event.ToolName == "python" {
218			replayedTrace = event.ToolTrace
219		}
220	}
221	assertTrace(replayedTrace)
222	if !reflect.DeepEqual(liveTrace, replayedTrace) {
223		t.Fatalf("durable history changed the finalized trace: live=%+v history=%+v", liveTrace, replayedTrace)
224	}
225}
226
227func readCurrentProgress(t *testing.T, connection *daemon.Connection, session string) []daemon.ToolProgress {
228	t.Helper()
229	ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
230	defer cancel()
231	endpoint := connection.BaseURL() + "/sessions/" + url.PathEscape(session)
232	request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
233	if err != nil {
234		t.Fatal(err)
235	}
236	request.Header.Set("Accept", "text/event-stream")
237	request.Header.Set("Authorization", "Bearer "+connection.Snapshot().Token)
238	response, err := connection.HTTPClient().Do(request)
239	if err != nil {
240		t.Fatal(err)
241	}
242	defer response.Body.Close()
243	if response.StatusCode != http.StatusOK {
244		t.Fatalf("raw session stream: %s", response.Status)
245	}
246	scanner := bufio.NewScanner(response.Body)
247	scanner.Buffer(make([]byte, 4096), 10*1024*1024)
248	for scanner.Scan() {
249		line := scanner.Text()
250		if !strings.HasPrefix(line, "data: ") {
251			continue
252		}
253		var batch struct {
254			Events   []map[string]any `json:"events"`
255			Snapshot struct {
256				CurrentProgress []struct {
257					CallID     string  `json:"call_id"`
258					ToolCallID *string `json:"tool_call_id"`
259					Name       string  `json:"name"`
260					Phase      string  `json:"phase"`
261					Preview    *struct {
262						Text   string `json:"text"`
263						Offset int    `json:"offset_scalars"`
264					} `json:"preview"`
265				} `json:"current_progress"`
266			} `json:"snapshot"`
267		}
268		if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &batch); err != nil {
269			t.Fatal(err)
270		}
271		if len(batch.Events) == 0 || batch.Events[0]["type"] != "reset" {
272			t.Fatalf("late raw attachment did not begin with a reset: %+v", batch.Events)
273		}
274		if batch.Snapshot.CurrentProgress == nil {
275			t.Fatal("reset snapshot omitted current_progress")
276		}
277		result := []daemon.ToolProgress{}
278		for _, item := range batch.Snapshot.CurrentProgress {
279			progress := daemon.ToolProgress{CallID: item.CallID, Name: item.Name, Phase: item.Phase}
280			if item.ToolCallID != nil {
281				progress.ToolCallID = *item.ToolCallID
282			}
283			if item.Preview != nil {
284				progress.Code = &daemon.ToolCodePreview{Text: item.Preview.Text, Offset: item.Preview.Offset}
285			}
286			result = append(result, progress)
287		}
288		return result
289	}
290	if err := scanner.Err(); err != nil {
291		t.Fatal(err)
292	}
293	t.Fatal("raw session stream ended before a reset batch")
294	return nil
295}
296
297func waitCurrentProgress(t *testing.T, connection *daemon.Connection, session, phase, code string) []daemon.ToolProgress {
298	t.Helper()
299	deadline := time.Now().Add(15 * time.Second)
300	for {
301		progress := readCurrentProgress(t, connection, session)
302		if len(progress) == 1 && progress[0].Phase == phase && progress[0].Code != nil && progress[0].Code.Offset == 0 && progress[0].Code.Text == code {
303			return progress
304		}
305		if time.Now().After(deadline) {
306			t.Fatal("reset snapshots never exposed the provider's live progress")
307		}
308		time.Sleep(10 * time.Millisecond)
309	}
310}