返回 DeepSeek-Reasonix
transcript_api_test.go
根目录 / internal / serve / transcript_api_test.go
1 package serve
2
3 import (
4 "context"
5 "encoding/base64"
6 "encoding/json"
7 "fmt"
8 "net/http"
9 "net/http/httptest"
10 "net/url"
11 "path/filepath"
12 "slices"
13 "strings"
14 "testing"
15 "time"
16
17 "reasonix/internal/agent"
18 "reasonix/internal/config"
19 "reasonix/internal/control"
20 "reasonix/internal/provider"
21 "reasonix/internal/servecontract"
22 canonical "reasonix/internal/session"
23 "reasonix/internal/transcript"
24 )
25
26 func TestTranscriptHTTPBindsSessionAndImmutableContent(t *testing.T) {
27 dir := t.TempDir()
28 path := filepath.Join(dir, "session.jsonl")
29 session := agent.NewSession("system")
30 session.Add(provider.Message{Role: provider.RoleUser, Content: "question"})
31 session.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("body", 20000)})
32 if err := session.Save(path); err != nil {
33 t.Fatal(err)
34 }
35 bc := NewBroadcaster()
36 executor := agent.New(nil, nil, session, agent.Options{}, bc)
37 ctrl := control.New(control.Options{Executor: executor, SessionDir: dir, SessionPath: path, Sink: bc})
38 defer ctrl.Close()
39 server := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
40 defer server.Close()
41 response, err := http.Get(server.URL + "/transcript/snapshot?session=" + url.QueryEscape(path))
42 if err != nil {
43 t.Fatal(err)
44 }
45 defer response.Body.Close()
46 if response.StatusCode != http.StatusOK || response.Header.Get("Cache-Control") != "no-store" {
47 t.Fatalf("snapshot status=%d cache=%q", response.StatusCode, response.Header.Get("Cache-Control"))
48 }
49 var snapshot transcript.Snapshot
50 if err := json.NewDecoder(response.Body).Decode(&snapshot); err != nil {
51 t.Fatal(err)
52 }
53 if snapshot.ProtocolVersion != 1 || len(snapshot.Records) != 2 {
54 t.Fatalf("snapshot=%+v", snapshot)
55 }
56 ref := snapshot.Records[1].Refs[0]
57 encoded, _ := json.Marshal(transcript.ContentRequest{ContentRef: ref})
58 contentResponse, err := http.Get(server.URL + "/transcript/content?session=" + url.QueryEscape(path) + "&request=" + url.QueryEscape(string(encoded)))
59 if err != nil {
60 t.Fatal(err)
61 }
62 defer contentResponse.Body.Close()
63 var content transcript.ContentChunk
64 if err := json.NewDecoder(contentResponse.Body).Decode(&content); err != nil || len(content.Data) != 64<<10 {
65 t.Fatalf("content bytes=%d err=%v", len(content.Data), err)
66 }
67 wrong, err := http.Get(server.URL + "/transcript/snapshot?session=" + url.QueryEscape(filepath.Join(dir, "different.jsonl")))
68 if err != nil {
69 t.Fatal(err)
70 }
71 wrong.Body.Close()
72 if wrong.StatusCode != http.StatusConflict {
73 t.Fatalf("wrong-session status=%d", wrong.StatusCode)
74 }
75 malformed, err := http.Get(server.URL + "/transcript/page?request=%7B")
76 if err != nil {
77 t.Fatal(err)
78 }
79 malformed.Body.Close()
80 if malformed.StatusCode != http.StatusBadRequest {
81 t.Fatalf("malformed status=%d", malformed.StatusCode)
82 }
83 }
84
85 // outlineLessController embeds the interface, not the concrete controller, so
86 // its method set is exactly SessionAPI and the optional outline capability is
87 // genuinely absent.
88 type outlineLessController struct{ control.SessionAPI }
89
90 func TestTranscriptOutlineHTTPPaginatesAndAdvertisesCapability(t *testing.T) {
91 dir := t.TempDir()
92 path := filepath.Join(dir, "session.jsonl")
93 session := agent.NewSession("system")
94 for i := range 4 {
95 session.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("question %d", i)})
96 session.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("answer %d", i)})
97 }
98 if err := session.Save(path); err != nil {
99 t.Fatal(err)
100 }
101 bc := NewBroadcaster()
102 ctrl := control.New(control.Options{Executor: agent.New(nil, nil, session, agent.Options{}, bc), SessionDir: dir, SessionPath: path, Sink: bc})
103 defer ctrl.Close()
104 srv := New(ctrl, bc, config.ServeConfig{})
105 server := httptest.NewServer(srv.Handler())
106 defer server.Close()
107
108 if !slices.Contains(srv.capabilities(), servecontract.TranscriptOutlineV1) {
109 t.Fatalf("serve does not advertise the outline capability: %v", srv.capabilities())
110 }
111
112 read := func(request transcript.OutlineRequest) transcript.OutlinePage {
113 t.Helper()
114 encoded, _ := json.Marshal(request)
115 response, err := http.Get(server.URL + "/transcript/outline?session=" + url.QueryEscape(path) + "&request=" + url.QueryEscape(string(encoded)))
116 if err != nil {
117 t.Fatal(err)
118 }
119 defer response.Body.Close()
120 if response.StatusCode != http.StatusOK || response.Header.Get("Cache-Control") != "no-store" {
121 t.Fatalf("outline status=%d cache=%q", response.StatusCode, response.Header.Get("Cache-Control"))
122 }
123 var page transcript.OutlinePage
124 if err := json.NewDecoder(response.Body).Decode(&page); err != nil {
125 t.Fatal(err)
126 }
127 return page
128 }
129
130 first := read(transcript.OutlineRequest{Entries: 3})
131 if first.ProtocolVersion != transcript.ProtocolVersion || first.Total != 4 || len(first.Entries) != 3 || first.Done {
132 t.Fatalf("first outline page = %+v", first)
133 }
134 if first.Entries[0].Prompt != "question 0" || first.Entries[0].Answer != "answer 0" || first.Entries[0].Turn != 1 {
135 t.Fatalf("first entry = %+v", first.Entries[0])
136 }
137 second := read(transcript.OutlineRequest{SnapshotID: first.SnapshotID, Offset: first.NextOffset, Entries: 3})
138 if second.SnapshotID != first.SnapshotID || len(second.Entries) != 1 || !second.Done || second.Entries[0].Turn != 4 {
139 t.Fatalf("second outline page = %+v", second)
140 }
141
142 // An evicted or unknown cut reports staleness instead of repositioning.
143 if stale := read(transcript.OutlineRequest{SnapshotID: "evicted"}); !stale.Stale {
144 t.Fatalf("unknown cut was answered as current: %+v", stale)
145 }
146
147 // Session binding failures stay conflicts, not empty outlines.
148 wrong, err := http.Get(server.URL + "/transcript/outline?session=" + url.QueryEscape(filepath.Join(dir, "different.jsonl")))
149 if err != nil {
150 t.Fatal(err)
151 }
152 wrong.Body.Close()
153 if wrong.StatusCode != http.StatusConflict {
154 t.Fatalf("wrong-session status=%d", wrong.StatusCode)
155 }
156
157 // A controller without the optional capability declines the route so a
158 // client can fall back to its loaded-turn rail.
159 plain := httptest.NewServer(New(outlineLessController{ctrl}, bc, config.ServeConfig{}).Handler())
160 defer plain.Close()
161 unsupported, err := http.Get(plain.URL + "/transcript/outline?session=" + url.QueryEscape(path))
162 if err != nil {
163 t.Fatal(err)
164 }
165 unsupported.Body.Close()
166 if unsupported.StatusCode != http.StatusNotImplemented {
167 t.Fatalf("unsupported status=%d", unsupported.StatusCode)
168 }
169 }
170
171 func TestCanonicalSessionHistoryHTTPUsesAuthorizedContentRanges(t *testing.T) {
172 root := filepath.Join(t.TempDir(), "sessions-v4")
173 service, err := canonical.NewService("serve", canonical.NewFilesystemPersistence(root))
174 if err != nil {
175 t.Fatal(err)
176 }
177 t.Cleanup(func() {
178 if err := service.Shutdown(context.Background()); err != nil {
179 t.Errorf("shutdown session service: %v", err)
180 }
181 })
182 runtime, err := service.Create(t.Context(), canonical.CreateOptions{SessionID: "canonical"})
183 if err != nil {
184 t.Fatal(err)
185 }
186 payload, _ := json.Marshal(map[string]any{"message": provider.Message{ID: "large", Role: provider.RoleUser, Content: strings.Repeat("range", 20_000)}})
187 if _, err := runtime.Session().AppendBatch(t.Context(), "message", []canonical.Event{{Kind: "message/complete", Payload: payload}}); err != nil {
188 t.Fatal(err)
189 }
190 if _, err := runtime.Session().Flush(t.Context()); err != nil {
191 t.Fatal(err)
192 }
193 bc := NewBroadcaster()
194 ctrl := control.New(control.Options{SessionService: service, SessionRuntime: runtime, ExclusiveSession: true, Sink: bc})
195 defer ctrl.Close()
196 server := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
197 defer server.Close()
198 openResponse, err := http.Get(server.URL + "/session/open?sessionId=canonical")
199 if err != nil {
200 t.Fatal(err)
201 }
202 defer openResponse.Body.Close()
203 var openView canonical.SessionOpenView
204 if err := json.NewDecoder(openResponse.Body).Decode(&openView); err != nil || openResponse.StatusCode != http.StatusOK || len(openView.Recent.Entries) != 1 {
205 t.Fatalf("open status=%d view=%+v err=%v", openResponse.StatusCode, openView, err)
206 }
207 // Exercise the actual HTTP Follow contract against the canonical runtime,
208 // including subscription disposal rather than leaving a long poll behind.
209 followResponse, err := http.Get(server.URL + "/transcript/follow")
210 if err != nil {
211 t.Fatal(err)
212 }
213 var followed control.TranscriptFollowResponse
214 err = json.NewDecoder(followResponse.Body).Decode(&followed)
215 _ = followResponse.Body.Close()
216 if err != nil || followResponse.StatusCode != http.StatusOK || followed.ProtocolVersion != 2 || followed.Snapshot == nil || followed.History == nil || followed.History.Status != "ready" {
217 t.Fatalf("follow status=%d response=%+v error=%v", followResponse.StatusCode, followed, err)
218 }
219 closeRequest, _ := json.Marshal(transcript.FollowRequest{Subscription: followed.Subscription, Close: true})
220 closed, err := http.Get(server.URL + "/transcript/follow?request=" + url.QueryEscape(string(closeRequest)))
221 if err != nil {
222 t.Fatal(err)
223 }
224 _ = closed.Body.Close()
225 if closed.StatusCode != http.StatusOK {
226 t.Fatalf("close follow status=%d", closed.StatusCode)
227 }
228 var page canonical.MessageHistoryPage
229 for {
230 response, requestErr := http.Get(server.URL + "/session-history/page?sessionId=canonical&limit=10")
231 if requestErr != nil {
232 t.Fatal(requestErr)
233 }
234 decodeErr := json.NewDecoder(response.Body).Decode(&page)
235 _ = response.Body.Close()
236 if decodeErr != nil || response.StatusCode != http.StatusOK {
237 t.Fatalf("history status=%d page=%+v err=%v", response.StatusCode, page, decodeErr)
238 }
239 if page.Status == "ready" {
240 break
241 }
242 if page.Status != "preparing" {
243 t.Fatalf("history preparation = %+v", page)
244 }
245 waitHistoryPreparation(t)
246 }
247 if len(page.Messages) != 1 || page.Messages[0].ContentRef == nil {
248 t.Fatalf("history page=%+v", page)
249 }
250 locationResponse, err := http.Get(server.URL + "/session-history/locate?sessionId=canonical&messageId=large&snapshot=" + fmt.Sprint(page.SnapshotSequence))
251 if err != nil {
252 t.Fatal(err)
253 }
254 defer locationResponse.Body.Close()
255 var location canonical.MessageLocation
256 if err := json.NewDecoder(locationResponse.Body).Decode(&location); err != nil || locationResponse.StatusCode != http.StatusOK || location.Status != "ready" || location.Cursor == "" {
257 t.Fatalf("location status=%d response=%+v err=%v", locationResponse.StatusCode, location, err)
258 }
259 var search canonical.SearchHistoryPage
260 for {
261 response, requestErr := http.Get(server.URL + "/session-history/search?sessionId=canonical&q=range&limit=10")
262 if requestErr != nil {
263 t.Fatal(requestErr)
264 }
265 decodeErr := json.NewDecoder(response.Body).Decode(&search)
266 _ = response.Body.Close()
267 if decodeErr != nil || response.StatusCode != http.StatusOK {
268 t.Fatalf("search status=%d page=%+v err=%v", response.StatusCode, search, decodeErr)
269 }
270 if search.Status == "ready" {
271 break
272 }
273 if search.Status != "preparing" {
274 t.Fatalf("search preparation = %+v", search)
275 }
276 waitHistoryPreparation(t)
277 }
278 if len(search.Hits) != 1 || search.Hits[0].MessageID != "large" {
279 t.Fatalf("search page=%+v", search)
280 }
281 request, _ := json.Marshal(sessionHistoryContentRequest{Ref: *page.Messages[0].ContentRef, Offset: 0, Length: 32})
282 contentResponse, err := http.Get(server.URL + "/session-history/content?sessionId=canonical&request=" + url.QueryEscape(string(request)))
283 if err != nil {
284 t.Fatal(err)
285 }
286 defer contentResponse.Body.Close()
287 var content sessionHistoryContentResponse
288 if err := json.NewDecoder(contentResponse.Body).Decode(&content); err != nil || contentResponse.StatusCode != http.StatusOK || content.Data == "" || content.NextOffset != 32 {
289 t.Fatalf("content status=%d response=%+v err=%v", contentResponse.StatusCode, content, err)
290 }
291 wrong, err := http.Get(server.URL + "/session-history/page?sessionId=other")
292 if err != nil {
293 t.Fatal(err)
294 }
295 wrong.Body.Close()
296 if wrong.StatusCode != http.StatusConflict {
297 t.Fatalf("wrong session status=%d", wrong.StatusCode)
298 }
299 }
300
301 // TestCanonicalSessionHistoryWindowHTTPPagesBothDirections exercises
302 // history-window-v1: a newest page, both continuation cursors, and a
303 // message anchor that resolves through the index instead of walking pages.
304 func TestCanonicalSessionHistoryWindowHTTPPagesBothDirections(t *testing.T) {
305 server, _ := newWindowTestServer(t)
306
307 var first canonical.HistoryWindowPage
308 getWindow(t, server, "anchor=newest&limit=4", &first)
309 if len(first.Messages) != 4 || !first.HasOlder || first.OlderCursor == "" {
310 t.Fatalf("newest window=%+v", windowShape(first))
311 }
312 if first.HasNewer || first.NewerCursor != "" {
313 t.Fatalf("newest window must not page newer: %+v", windowShape(first))
314 }
315 // The page ends at the newest message: the anchor only ever moves backward.
316 if got := first.Messages[len(first.Messages)-1].MessageID; got != "m16" {
317 t.Fatalf("newest page tail=%q", got)
318 }
319
320 // Paging older from the newest page walks strictly backward and keeps the
321 // snapshot pinned, so appends cannot invalidate the cursor.
322 var older canonical.HistoryWindowPage
323 getWindow(t, server, "anchor=cursor&cursor="+url.QueryEscape(first.OlderCursor)+"&direction=older&limit=4", &older)
324 if got := windowIDs(older); !slices.Equal(got, []string{"m9", "m10", "m11", "m12"}) {
325 t.Fatalf("older page ids=%v", got)
326 }
327 if older.SnapshotSequence != first.SnapshotSequence {
328 t.Fatalf("cursor page moved snapshot %d -> %d", first.SnapshotSequence, older.SnapshotSequence)
329 }
330
331 // Paging newer from that same page returns exactly the page we came from.
332 var newer canonical.HistoryWindowPage
333 getWindow(t, server, "anchor=cursor&cursor="+url.QueryEscape(older.NewerCursor)+"&direction=newer&limit=4", &newer)
334 if got := windowIDs(newer); !slices.Equal(got, []string{"m13", "m14", "m15", "m16"}) {
335 t.Fatalf("newer page ids=%v", got)
336 }
337
338 // A message anchor lands on a window around that message in one round trip
339 // (no newest-first walk): older paging ends at the anchor itself.
340 var anchored canonical.HistoryWindowPage
341 getWindow(t, server, "anchor=message&messageId=m6&direction=older&limit=3", &anchored)
342 if got := windowIDs(anchored); !slices.Equal(got, []string{"m4", "m5", "m6"}) {
343 t.Fatalf("message anchor ids=%v", got)
344 }
345 if anchored.AnchorMessageID != "m6" || !anchored.HasNewer {
346 t.Fatalf("message anchor metadata=%+v", windowShape(anchored))
347 }
348 if anchored.SnapshotSequence != first.SnapshotSequence {
349 t.Fatalf("anchor left the pinned snapshot: %d", anchored.SnapshotSequence)
350 }
351
352 // A turn anchor resolves through the same index.
353 var byTurn canonical.HistoryWindowPage
354 getWindow(t, server, "anchor=turn&turn=3&direction=older&limit=2", &byTurn)
355 if byTurn.AnchorTurn != 3 || len(byTurn.Messages) == 0 {
356 t.Fatalf("turn anchor=%+v", windowShape(byTurn))
357 }
358 for _, message := range byTurn.Messages {
359 if message.VisibleTurn > 3 {
360 t.Fatalf("turn anchor leaked newer turn %d", message.VisibleTurn)
361 }
362 }
363 }
364
365 // TestCanonicalSessionHistoryWindowHTTPRejectsForeignCursor keeps the cursor a
366 // bound credential: another session's cursor is stale, not a silent re-anchor.
367 func TestCanonicalSessionHistoryWindowHTTPRejectsForeignCursor(t *testing.T) {
368 server, _ := newWindowTestServer(t)
369 var page canonical.HistoryWindowPage
370 getWindow(t, server, "anchor=newest&limit=2", &page)
371 raw, err := base64.RawURLEncoding.DecodeString(page.OlderCursor)
372 if err != nil {
373 t.Fatalf("cursor is not raw-url base64: %v", err)
374 }
375 var bound map[string]any
376 if err := json.Unmarshal(raw, &bound); err != nil {
377 t.Fatalf("cursor is not JSON: %v", err)
378 }
379 if bound["sessionId"] != "canonical" {
380 t.Fatalf("cursor does not name its session: %v", bound["sessionId"])
381 }
382 bound["sessionId"] = "elsewhere"
383 reissued, err := json.Marshal(bound)
384 if err != nil {
385 t.Fatal(err)
386 }
387 foreign := base64.RawURLEncoding.EncodeToString(reissued)
388 var rejected canonical.HistoryWindowPage
389 getWindow(t, server, "anchor=cursor&cursor="+url.QueryEscape(foreign)+"&limit=2", &rejected)
390 if rejected.Status != "stale_cursor" || len(rejected.Messages) != 0 {
391 t.Fatalf("foreign cursor status=%q messages=%d", rejected.Status, len(rejected.Messages))
392 }
393 var malformed canonical.HistoryWindowPage
394 getWindow(t, server, "anchor=cursor&cursor=not-a-cursor&limit=2", &malformed)
395 if malformed.Status != "stale_cursor" {
396 t.Fatalf("malformed cursor status=%q", malformed.Status)
397 }
398 }
399
400 // TestCanonicalSessionMessageFieldHTTPStreamsAlignedFragments verifies the
401 // per-field read: a bounded fragment, a total length, and concatenated ranges
402 // that re-parse as the original value.
403 func TestCanonicalSessionMessageFieldHTTPStreamsAlignedFragments(t *testing.T) {
404 server, _ := newWindowTestServer(t)
405 var page canonical.HistoryWindowPage
406 getWindow(t, server, "anchor=message&messageId=m2&direction=older&limit=1", &page)
407 if len(page.Messages) != 1 || page.Messages[0].ContentRef == nil {
408 t.Fatalf("anchored page=%+v", windowShape(page))
409 }
410 // The window issues the content grant this cell reads against; a body over
411 // the inline preview budget must come back referenced, not inlined.
412 if ref := page.Messages[0].ContentRef; ref.Digest == "" || ref.Bytes == 0 || page.Messages[0].Inline != nil {
413 t.Fatalf("content reference=%+v inline=%v", ref, page.Messages[0].Inline)
414 }
415
416 var assembled strings.Builder
417 var offset int64
418 // Rune- and escape-safe cutting is a property of the cut size, not of the
419 // number of cuts: the first reads use a length far below one CJK rune's
420 // width to force mid-rune boundaries, then the remainder drains in large
421 // fragments. Requesting the whole body 64 bytes at a time would cost
422 // thousands of localhost round trips for no additional coverage.
423 const narrowLength, narrowReads, wideLength = 64, 8, 1 << 16
424 for reads := 0; ; reads++ {
425 length := int64(wideLength)
426 if reads < narrowReads {
427 length = narrowLength
428 }
429 request := url.Values{"sessionId": []string{"canonical"}, "messageId": []string{"m2"}, "field": []string{"content"}}
430 request.Set("offset", fmt.Sprint(offset))
431 request.Set("length", fmt.Sprint(length))
432 response, err := http.Get(server.URL + "/session-message-field?" + request.Encode())
433 if err != nil {
434 t.Fatal(err)
435 }
436 var fragment canonical.MessageFieldPage
437 decodeErr := json.NewDecoder(response.Body).Decode(&fragment)
438 _ = response.Body.Close()
439 if decodeErr != nil || response.StatusCode != http.StatusOK {
440 t.Fatalf("field status=%d page=%+v err=%v", response.StatusCode, fragment, decodeErr)
441 }
442 if fragment.Status != "ready" || fragment.Encoding != "utf-8" {
443 t.Fatalf("field page=%+v", fragment)
444 }
445 // TotalBytes counts the field's JSON source, quotes included, not the
446 // decoded value and not the whole canonical body.
447 if want := int64(len(windowTestBody) + len(`""`)); fragment.TotalBytes != want {
448 t.Fatalf("total bytes=%d want %d", fragment.TotalBytes, want)
449 }
450 if int64(len(fragment.Data)) > length {
451 t.Fatalf("fragment exceeded the requested length %d: %d", length, len(fragment.Data))
452 }
453 assembled.Write(fragment.Data)
454 if fragment.NextOffset == 0 {
455 break
456 }
457 if fragment.NextOffset <= offset {
458 t.Fatalf("field offset did not advance: %d -> %d", offset, fragment.NextOffset)
459 }
460 offset = fragment.NextOffset
461 if offset > 1<<20 {
462 t.Fatal("field read did not terminate")
463 }
464 }
465 // Concatenated fragments must re-parse as the original value: the source
466 // form is JSON, and a cut inside a rune or an escape would break this.
467 var decoded string
468 if err := json.Unmarshal([]byte(assembled.String()), &decoded); err != nil {
469 t.Fatalf("reassembled field is not valid JSON: %v", err)
470 }
471 if decoded != windowTestBody {
472 t.Fatalf("reassembled field=%d runes want %d", len([]rune(decoded)), len([]rune(windowTestBody)))
473 }
474
475 // A field the message does not carry reads as an empty, finished fragment
476 // rather than an error, so the client can distinguish it from a failure.
477 missing, err := http.Get(server.URL + "/session-message-field?sessionId=canonical&messageId=m2&field=reasoning_content")
478 if err != nil {
479 t.Fatal(err)
480 }
481 var absent canonical.MessageFieldPage
482 decodeErr := json.NewDecoder(missing.Body).Decode(&absent)
483 _ = missing.Body.Close()
484 if decodeErr != nil || missing.StatusCode != http.StatusOK || absent.Status != "ready" || absent.TotalBytes != 0 {
485 t.Fatalf("absent field status=%d page=%+v err=%v", missing.StatusCode, absent, decodeErr)
486 }
487
488 unknown, err := http.Get(server.URL + "/session-message-field?sessionId=canonical&messageId=nope&field=content")
489 if err != nil {
490 t.Fatal(err)
491 }
492 var notFound canonical.MessageFieldPage
493 decodeErr = json.NewDecoder(unknown.Body).Decode(&notFound)
494 _ = unknown.Body.Close()
495 if decodeErr != nil || unknown.StatusCode != http.StatusOK || notFound.Status != "not_found" {
496 t.Fatalf("unknown message status=%d page=%+v err=%v", unknown.StatusCode, notFound, decodeErr)
497 }
498 }
499
500 // windowTestBody is m2's content: larger than the 32 KiB inline preview
501 // budget so the window returns a content reference, and non-ASCII so a
502 // rune-splitting bug cannot pass by accident.
503 var windowTestBody = strings.Repeat("分块读取正文内容", 6000)
504
505 func newWindowTestServer(t *testing.T) (*httptest.Server, *control.Controller) {
506 t.Helper()
507 root := filepath.Join(t.TempDir(), "sessions-v4")
508 service, err := canonical.NewService("serve", canonical.NewFilesystemPersistence(root))
509 if err != nil {
510 t.Fatal(err)
511 }
512 t.Cleanup(func() {
513 if err := service.Shutdown(context.Background()); err != nil {
514 t.Errorf("shutdown session service: %v", err)
515 }
516 })
517 runtime, err := service.Create(t.Context(), canonical.CreateOptions{SessionID: "canonical"})
518 if err != nil {
519 t.Fatal(err)
520 }
521 events := make([]canonical.Event, 0, 16)
522 for index := 1; index <= 16; index++ {
523 role, content := provider.RoleUser, fmt.Sprintf("question %d", index)
524 if index%2 == 0 {
525 role, content = provider.RoleAssistant, fmt.Sprintf("answer %d", index)
526 if index == 2 {
527 // Larger than the inline preview budget, so the window hands
528 // back a content reference and the field route has to stream.
529 content = windowTestBody
530 }
531 }
532 payload, err := json.Marshal(map[string]any{"message": provider.Message{ID: fmt.Sprintf("m%d", index), Role: role, Content: content}})
533 if err != nil {
534 t.Fatal(err)
535 }
536 events = append(events, canonical.Event{Kind: "message/complete", Payload: payload})
537 }
538 if _, err := runtime.Session().AppendBatch(t.Context(), "message", events); err != nil {
539 t.Fatal(err)
540 }
541 if _, err := runtime.Session().Flush(t.Context()); err != nil {
542 t.Fatal(err)
543 }
544 bc := NewBroadcaster()
545 ctrl := control.New(control.Options{SessionService: service, SessionRuntime: runtime, ExclusiveSession: true, Sink: bc})
546 t.Cleanup(ctrl.Close)
547 server := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
548 t.Cleanup(server.Close)
549 return server, ctrl
550 }
551
552 // getWindow polls the window route out of "preparing": the first read of a
553 // cold session kicks off the locator build in the background.
554 func getWindow(t *testing.T, server *httptest.Server, query string, into *canonical.HistoryWindowPage) {
555 t.Helper()
556 for {
557 response, err := http.Get(server.URL + "/session-history/window?sessionId=canonical&" + query)
558 if err != nil {
559 t.Fatal(err)
560 }
561 decodeErr := json.NewDecoder(response.Body).Decode(into)
562 _ = response.Body.Close()
563 if decodeErr != nil || response.StatusCode != http.StatusOK {
564 t.Fatalf("window status=%d page=%+v err=%v", response.StatusCode, into, decodeErr)
565 }
566 if into.Status != "preparing" {
567 return
568 }
569 waitHistoryPreparation(t)
570 }
571 }
572
573 // Readiness is a lifecycle condition; the package alarm bounds a stuck build.
574 // Pace HTTP polling so it does not compete with the index worker for the runner.
575 func waitHistoryPreparation(t *testing.T) {
576 t.Helper()
577 select {
578 case <-t.Context().Done():
579 t.Fatal(t.Context().Err())
580 case <-time.After(25 * time.Millisecond):
581 }
582 }
583
584 func windowIDs(page canonical.HistoryWindowPage) []string {
585 ids := make([]string, 0, len(page.Messages))
586 for _, message := range page.Messages {
587 ids = append(ids, message.MessageID)
588 }
589 return ids
590 }
591
592 // windowShape keeps failure output readable: pages carry full bodies.
593 func windowShape(page canonical.HistoryWindowPage) canonical.HistoryWindowPage {
594 page.Messages = nil
595 return page
596 }
597
598 // TestCanonicalSessionHistoryHTTPAnswersColdIdentity keeps history-first
599 // hydration possible: a stored session that is not this serve's bound
600 // foreground (never activated here, or rotated away) must answer the
601 // identity-addressed history endpoints from persisted data alone, while an
602 // identity the store does not know keeps refusing.
603 func TestCanonicalSessionHistoryHTTPAnswersColdIdentity(t *testing.T) {
604 root := filepath.Join(t.TempDir(), "sessions-v4")
605 service, err := canonical.NewService("serve", canonical.NewFilesystemPersistence(root))
606 if err != nil {
607 t.Fatal(err)
608 }
609 t.Cleanup(func() {
610 if err := service.Shutdown(context.Background()); err != nil {
611 t.Errorf("shutdown session service: %v", err)
612 }
613 })
614 seed := func(id, text string) *canonical.Runtime {
615 runtime, createErr := service.Create(t.Context(), canonical.CreateOptions{SessionID: id})
616 if createErr != nil {
617 t.Fatal(createErr)
618 }
619 payload, _ := json.Marshal(map[string]any{"message": provider.Message{ID: "m-" + id, Role: provider.RoleUser, Content: text}})
620 if _, err := runtime.Session().AppendBatch(t.Context(), "message", []canonical.Event{{Kind: "message/complete", Payload: payload}}); err != nil {
621 t.Fatal(err)
622 }
623 if _, err := runtime.Session().Flush(t.Context()); err != nil {
624 t.Fatal(err)
625 }
626 return runtime
627 }
628 foreground := seed("foreground", "bound to the controller")
629 seed("cold", "stored but never foreground")
630 // The cold identity keeps a persisted session on disk but no live runtime.
631 if err := service.Close(t.Context(), canonical.SessionRef{HostID: "serve", SessionID: "cold"}); err != nil {
632 t.Fatal(err)
633 }
634 bc := NewBroadcaster()
635 ctrl := control.New(control.Options{SessionService: service, SessionRuntime: foreground, ExclusiveSession: true, Sink: bc})
636 defer ctrl.Close()
637 server := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
638 defer server.Close()
639
640 var page canonical.MessageHistoryPage
641 for attempt := 0; ; attempt++ {
642 response, requestErr := http.Get(server.URL + "/session-history/page?sessionId=cold&limit=10")
643 if requestErr != nil {
644 t.Fatal(requestErr)
645 }
646 decodeErr := json.NewDecoder(response.Body).Decode(&page)
647 _ = response.Body.Close()
648 if decodeErr != nil || response.StatusCode != http.StatusOK {
649 t.Fatalf("cold history status=%d page=%+v err=%v", response.StatusCode, page, decodeErr)
650 }
651 if page.Status == "ready" {
652 break
653 }
654 if page.Status != "preparing" || attempt > 100 {
655 t.Fatalf("cold history preparation = %+v", page)
656 }
657 waitHistoryPreparation(t)
658 }
659 if len(page.Messages) != 1 || page.Messages[0].MessageID != "m-cold" {
660 t.Fatalf("cold page=%+v", page)
661 }
662 openResponse, err := http.Get(server.URL + "/session/open?sessionId=cold")
663 if err != nil {
664 t.Fatal(err)
665 }
666 var openView canonical.SessionOpenView
667 decodeErr := json.NewDecoder(openResponse.Body).Decode(&openView)
668 _ = openResponse.Body.Close()
669 if decodeErr != nil || openResponse.StatusCode != http.StatusOK {
670 t.Fatalf("cold open status=%d view=%+v err=%v", openResponse.StatusCode, openView, decodeErr)
671 }
672 // The explicit foreground identity stays answerable through the same form.
673 foregroundResponse, err := http.Get(server.URL + "/session-history/page?sessionId=foreground&limit=10")
674 if err != nil {
675 t.Fatal(err)
676 }
677 if foregroundResponse.StatusCode != http.StatusOK {
678 _ = foregroundResponse.Body.Close()
679 t.Fatalf("foreground history status=%d", foregroundResponse.StatusCode)
680 }
681 _ = foregroundResponse.Body.Close()
682 // An identity the store does not know keeps the conflict answer.
683 unknown, err := http.Get(server.URL + "/session-history/page?sessionId=missing")
684 if err != nil {
685 t.Fatal(err)
686 }
687 _ = unknown.Body.Close()
688 if unknown.StatusCode != http.StatusConflict {
689 t.Fatalf("unknown session status=%d", unknown.StatusCode)
690 }
691 }
692
692 lines GO