返回 DeepSeek-Reasonix
remote_transcript_api_test.go
根目录 / desktop / remote_transcript_api_test.go
1 package main
2
3 import (
4 "encoding/base64"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "strings"
10 "sync/atomic"
11 "testing"
12 "time"
13
14 "reasonix/internal/servecontract"
15 "reasonix/internal/session"
16 "reasonix/internal/sessioncontent"
17 "reasonix/internal/transcript"
18 )
19
20 func remoteTranscriptFixture(server *httptest.Server) (*App, *remoteTab) {
21 tab := &remoteTab{id: "remote", state: "ready", client: server.Client(), base: server.URL, gen: 1,
22 routing: remoteTabSessionRouting{currentPath: "/session.jsonl"}}
23 return &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}, tab
24 }
25
26 func TestRemoteFollowRequiresV2WithoutLegacyProbe(t *testing.T) {
27 var requests atomic.Int64
28 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29 requests.Add(1)
30 if r.URL.Path != "/transcript/follow" {
31 t.Errorf("unexpected legacy fallback: %s", r.URL.Path)
32 }
33 _ = json.NewEncoder(w).Encode(transcript.FollowResponse{ProtocolVersion: 2, Subscription: "sub", Changes: []transcript.Change{}})
34 }))
35 defer server.Close()
36 app, tab := remoteTranscriptFixture(server)
37 if _, err := app.RemoteTranscriptFollowForTab(tab.id, transcript.FollowRequest{}); err == nil || !strings.Contains(err.Error(), "upgrade") {
38 t.Fatalf("old Serve must produce upgrade error: %v", err)
39 }
40 if requests.Load() != 0 {
41 t.Fatal("unnegotiated server was probed")
42 }
43 tab.capabilities = map[string]bool{servecontract.TranscriptV2: true}
44 response, err := app.RemoteTranscriptFollowForTab(tab.id, transcript.FollowRequest{Subscription: "sub"})
45 if err != nil || response.ProtocolVersion != 2 || requests.Load() != 1 {
46 t.Fatalf("v2 follow: %+v, %v", response, err)
47 }
48 }
49
50 func TestRemoteTranscriptNegotiatesOldServeWithoutMutation(t *testing.T) {
51 for _, status := range []int{http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented, http.StatusOK} {
52 t.Run(http.StatusText(status), func(t *testing.T) {
53 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
54 if r.Method != http.MethodGet || r.URL.Path != "/transcript/snapshot" || r.URL.Query().Get("session") != "/session.jsonl" {
55 t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
56 }
57 w.WriteHeader(status)
58 _, _ = w.Write([]byte("<html>old Serve index</html>"))
59 }))
60 defer server.Close()
61 app, tab := remoteTranscriptFixture(server)
62 result, err := app.RemoteTranscriptSnapshotForTab(tab.id, transcript.PageRequest{})
63 if err != nil || result.Supported || result.Snapshot != nil {
64 t.Fatalf("negotiation = %+v, %v", result, err)
65 }
66 if tab.state != "ready" || tab.gen != 1 {
67 t.Fatal("capability probe changed the connection")
68 }
69 })
70 }
71 }
72
73 // A Serve that does not advertise the outline capability must never be probed:
74 // the client keeps its loaded-turn rail instead of spending a round trip.
75 func TestRemoteTranscriptOutlineRequiresAdvertisedCapability(t *testing.T) {
76 var requests atomic.Int64
77 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
78 requests.Add(1)
79 w.WriteHeader(http.StatusOK)
80 _, _ = w.Write([]byte("<html>old Serve index</html>"))
81 }))
82 defer server.Close()
83 app, tab := remoteTranscriptFixture(server)
84
85 page, err := app.RemoteTranscriptOutlineForTab(tab.id, transcript.OutlineRequest{SnapshotID: "cut"})
86 if err == nil || page.SnapshotID != "" {
87 t.Fatalf("unadvertised outline = %+v, %v", page, err)
88 }
89 if requests.Load() != 0 {
90 t.Fatalf("an unadvertised capability issued %d requests", requests.Load())
91 }
92
93 // An advertised capability that answers with something other than protocol
94 // data is a real error, not a silent downgrade to "unsupported".
95 tab.capabilities = map[string]bool{servecontract.TranscriptOutlineV1: true}
96 if _, err := app.RemoteTranscriptOutlineForTab(tab.id, transcript.OutlineRequest{SnapshotID: "cut"}); err == nil {
97 t.Fatal("an HTML homepage response was accepted as an outline")
98 }
99 if requests.Load() != 1 {
100 t.Fatalf("advertised capability issued %d requests, want 1", requests.Load())
101 }
102 }
103
104 func TestRemoteTranscriptOutlineReadsAdvertisedEndpoint(t *testing.T) {
105 want := transcript.OutlinePage{
106 Boundary: transcript.Boundary{ProtocolVersion: transcript.ProtocolVersion, SnapshotID: "cut"},
107 Entries: []transcript.OutlineEntry{{ID: "m:2", MessageID: "2", Turn: 2, Order: 4, Prompt: "second", Answer: "answer"}},
108 Total: 2, NextOffset: 2, Done: true,
109 }
110 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
111 if r.URL.Path != "/transcript/outline" || r.URL.Query().Get("session") != "/session.jsonl" {
112 t.Errorf("unexpected request %s", r.URL.String())
113 }
114 var request transcript.OutlineRequest
115 if err := json.Unmarshal([]byte(r.URL.Query().Get("request")), &request); err != nil || request.SnapshotID != "cut" {
116 t.Errorf("request = %+v, %v", request, err)
117 }
118 _ = json.NewEncoder(w).Encode(want)
119 }))
120 defer server.Close()
121 app, tab := remoteTranscriptFixture(server)
122 tab.capabilities = map[string]bool{servecontract.TranscriptOutlineV1: true}
123
124 page, err := app.RemoteTranscriptOutlineForTab(tab.id, transcript.OutlineRequest{SnapshotID: "cut"})
125 if err != nil || page.Total != 2 || len(page.Entries) != 1 || page.Entries[0].ID != "m:2" {
126 t.Fatalf("outline = %+v, %v", page, err)
127 }
128 }
129
130 func TestRemoteTranscriptRejectsLateSessionResponse(t *testing.T) {
131 entered, release := make(chan struct{}), make(chan struct{})
132 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
133 close(entered)
134 <-release
135 _ = json.NewEncoder(w).Encode(transcript.Snapshot{Boundary: transcript.Boundary{ProtocolVersion: 1, SnapshotID: "old"}})
136 }))
137 defer server.Close()
138 app, tab := remoteTranscriptFixture(server)
139 done := make(chan error, 1)
140 go func() { _, err := app.RemoteTranscriptSnapshotForTab(tab.id, transcript.PageRequest{}); done <- err }()
141 select {
142 case <-entered:
143 case <-time.After(time.Second):
144 t.Fatal("request did not start")
145 }
146 app.remoteTabMu.Lock()
147 tab.routing.currentPath = "/new-session.jsonl"
148 app.remoteTabMu.Unlock()
149 close(release)
150 if err := <-done; err == nil || !strings.Contains(err.Error(), "replaced session") {
151 t.Fatalf("late response accepted: %v", err)
152 }
153 }
154
155 func TestRemoteTranscriptRefreshesIdentityAfterConflict(t *testing.T) {
156 var snapshotReads atomic.Int32
157 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
158 switch r.URL.Path {
159 case "/transcript/snapshot":
160 if n := snapshotReads.Add(1); n == 1 {
161 if got := r.URL.Query().Get("session"); got != "/session.jsonl" {
162 t.Errorf("first session route = %q", got)
163 }
164 w.WriteHeader(http.StatusConflict)
165 _, _ = w.Write([]byte("active session changed"))
166 return
167 }
168 if got := r.URL.Query().Get("session"); got != "session-id:current" {
169 t.Errorf("refreshed session route = %q", got)
170 }
171 _ = json.NewEncoder(w).Encode(transcript.Snapshot{Boundary: transcript.Boundary{ProtocolVersion: transcript.ProtocolVersion, SnapshotID: "current"}})
172 case "/status":
173 _ = json.NewEncoder(w).Encode(map[string]any{"sessionPath": "", "sessionId": "current", "running": false})
174 default:
175 http.NotFound(w, r)
176 }
177 }))
178 defer server.Close()
179 app, tab := remoteTranscriptFixture(server)
180 result, err := app.RemoteTranscriptSnapshotForTab(tab.id, transcript.PageRequest{})
181 if err != nil || !result.Supported || result.Snapshot == nil || snapshotReads.Load() != 2 {
182 t.Fatalf("conflict retry = %+v, %v, reads=%d", result, err, snapshotReads.Load())
183 }
184 if got := tab.routing.currentPath; got != "session-id:current" {
185 t.Fatalf("refreshed tab route = %q", got)
186 }
187 }
188
189 func TestRemoteTabMetadataDoesNotRequestHistory(t *testing.T) {
190 var historyReads atomic.Int32
191 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
192 if r.URL.Path == "/history" {
193 historyReads.Add(1)
194 }
195 w.Header().Set("Content-Type", "application/json")
196 if r.URL.Path == "/status" {
197 _, _ = w.Write([]byte(`{"sessionPath":"/session.jsonl","running":false}`))
198 return
199 }
200 _, _ = w.Write([]byte(`[]`))
201 }))
202 defer server.Close()
203 app, tab := remoteTranscriptFixture(server)
204 metadata, err := app.RemoteTabMetadata(tab.id)
205 if err != nil || historyReads.Load() != 0 || len(metadata.History) != 0 {
206 t.Fatalf("metadata requested history: count=%d error=%v", historyReads.Load(), err)
207 }
208 }
209
210 func TestRemoteCanonicalSessionHistoryUsesNegotiatedIdentity(t *testing.T) {
211 ref := sessioncontent.Ref{Digest: strings.Repeat("a", 64), Bytes: 3, MediaType: "text/plain"}
212 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
213 if got := r.URL.Query().Get("sessionId"); got != "canonical" {
214 t.Errorf("sessionId = %q", got)
215 }
216 switch r.URL.Path {
217 case "/session/open":
218 _ = json.NewEncoder(w).Encode(session.SessionOpenView{SnapshotSequence: 9, Recent: session.RecentSnapshot{Entries: []session.PersistentMessage{{MessageID: "m1", Role: "user"}}}})
219 case "/session-history/page":
220 if r.URL.Query().Get("cursor") != "next" || r.URL.Query().Get("limit") != "7" {
221 t.Errorf("page query = %q", r.URL.RawQuery)
222 }
223 _ = json.NewEncoder(w).Encode(session.MessageHistoryPage{Messages: []session.PersistentMessage{{MessageID: "m1", Role: "user", ContentRef: &ref}}, SnapshotSequence: 9})
224 case "/session-history/locate":
225 if r.URL.Query().Get("messageId") != "m1" || r.URL.Query().Get("snapshot") != "9" {
226 t.Errorf("locate query = %q", r.URL.RawQuery)
227 }
228 _ = json.NewEncoder(w).Encode(session.MessageLocation{Status: "ready", MessageID: "m1", SnapshotSequence: 9, Cursor: "located"})
229 case "/session-history/content":
230 var request struct {
231 Ref sessioncontent.Ref `json:"ref"`
232 Offset int64 `json:"offset"`
233 Length int64 `json:"length"`
234 }
235 if err := json.Unmarshal([]byte(r.URL.Query().Get("request")), &request); err != nil {
236 t.Fatal(err)
237 }
238 if request.Ref.Digest != ref.Digest || request.Offset != 0 || request.Length != 3 {
239 t.Errorf("content request = %+v", request)
240 }
241 _ = json.NewEncoder(w).Encode(SessionHistoryContentChunk{Data: base64.StdEncoding.EncodeToString([]byte("big")), NextOffset: 3, Done: true})
242 case "/session-history/search":
243 if r.URL.Query().Get("q") != "needle" || r.URL.Query().Get("cursor") != "older" || r.URL.Query().Get("limit") != "5" {
244 t.Errorf("search query = %q", r.URL.RawQuery)
245 }
246 _ = json.NewEncoder(w).Encode(session.SearchHistoryPage{Hits: []session.SearchHistoryHit{{MessageID: "m1", Preview: "needle"}}, SnapshotSequence: 9})
247 default:
248 http.NotFound(w, r)
249 }
250 }))
251 defer server.Close()
252 app, tab := remoteTranscriptFixture(server)
253 tab.capabilities = map[string]bool{serveCapabilitySessions: true, serveCapabilitySessionContentV1: true, serveCapabilitySessionReadV2: true}
254 tab.session.sessionID = "canonical"
255 view, err := app.RemoteSessionOpenForTab(tab.id)
256 if err != nil || view.SnapshotSequence != 9 || len(view.Recent.Entries) != 1 {
257 t.Fatalf("open = %+v, %v", view, err)
258 }
259 page, err := app.RemoteSessionHistoryPageForTab(tab.id, "next", 7)
260 if err != nil || page.SnapshotSequence != 9 || len(page.Messages) != 1 {
261 t.Fatalf("page = %+v, %v", page, err)
262 }
263 location, err := app.RemoteLocateSessionMessageForTab(tab.id, "m1", 9)
264 if err != nil || location.Status != "ready" || location.Cursor != "located" {
265 t.Fatalf("location = %+v, %v", location, err)
266 }
267 chunk, err := app.RemoteSessionHistoryContentForTab(tab.id, ref, 0)
268 if err != nil || chunk.Data != base64.StdEncoding.EncodeToString([]byte("big")) || !chunk.Done {
269 t.Fatalf("chunk = %+v, %v", chunk, err)
270 }
271 search, err := app.RemoteSearchSessionHistoryForTab(tab.id, "needle", "older", 5)
272 if err != nil || len(search.Hits) != 1 || search.Hits[0].MessageID != "m1" {
273 t.Fatalf("search = %+v, %v", search, err)
274 }
275 }
276
277 func TestRemoteCanonicalSessionHistoryRequiresCapability(t *testing.T) {
278 var reads atomic.Int32
279 server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reads.Add(1) }))
280 defer server.Close()
281 app, tab := remoteTranscriptFixture(server)
282 if _, err := app.RemoteSessionHistoryPageForTab(tab.id, "", 0); err == nil {
283 t.Fatal("canonical history unexpectedly enabled")
284 }
285 if reads.Load() != 0 {
286 t.Fatalf("network reads = %d", reads.Load())
287 }
288 }
289
290 // historyWindowFixture builds a remote tab that advertises exactly the
291 // capabilities the window protocol needs.
292 func historyWindowFixture(server *httptest.Server) (*App, *remoteTab) {
293 app, tab := remoteTranscriptFixture(server)
294 tab.session.sessionID = "canonical"
295 tab.capabilities = map[string]bool{
296 serveCapabilitySessionContentV1: true,
297 serveCapabilityHistoryWindowV1: true,
298 }
299 return app, tab
300 }
301
302 func TestRemoteSessionHistoryWindowRequiresCapability(t *testing.T) {
303 var reads atomic.Int32
304 server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reads.Add(1) }))
305 defer server.Close()
306
307 // Without session-content-v1 the canonical routes are not negotiated at
308 // all, so a window request must not reach the network.
309 app, tab := remoteTranscriptFixture(server)
310 tab.session.sessionID = "canonical"
311 if page, err := app.RemoteSessionHistoryWindowForTab(tab.id, session.HistoryWindowRequest{Anchor: "newest"}); err != nil || page.Status != session.HistoryWindowUnsupported {
312 t.Fatalf("window read without canonical history = %+v, %v", page, err)
313 }
314
315 // With content but without history-window-v1 the service is an older Serve:
316 // a capability answer, not a failure. The caller gets a typed unsupported
317 // status and still no round trip, so no service fakes a bounded window.
318 tab.capabilities = map[string]bool{serveCapabilitySessionContentV1: true}
319 page, err := app.RemoteSessionHistoryWindowForTab(tab.id, session.HistoryWindowRequest{Anchor: "newest"})
320 if err != nil || page.Status != session.HistoryWindowUnsupported {
321 t.Fatalf("unadvertised window = %+v, %v", page, err)
322 }
323 field, err := app.RemoteSessionMessageFieldForTab(tab.id, "m1", 0, "content", 0, 64)
324 if err != nil || field.Status != session.HistoryWindowUnsupported || field.MessageID != "m1" {
325 t.Fatalf("unadvertised field read = %+v, %v", field, err)
326 }
327 if reads.Load() != 0 {
328 t.Fatalf("unadvertised capabilities issued %d requests", reads.Load())
329 }
330 }
331
332 func TestRemoteSessionHistoryWindowSendsAnchorsAndReturnsTypedStatus(t *testing.T) {
333 var seen []url.Values
334 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
335 if r.URL.Path == "/session-history/window" {
336 seen = append(seen, r.URL.Query())
337 w.Header().Set("Content-Type", "application/json")
338 page := session.HistoryWindowPage{Status: "ready", SnapshotSequence: 9, TotalTurns: 3,
339 HasOlder: true, HasNewer: true, OlderCursor: "older", NewerCursor: "newer",
340 AnchorMessageID: r.URL.Query().Get("messageId"),
341 Messages: []session.PersistentMessage{{MessageID: "m7", Position: 7, Version: 1, Role: "user"}},
342 }
343 _ = json.NewEncoder(w).Encode(page)
344 return
345 }
346 if r.URL.Path == "/session-message-field" {
347 seen = append(seen, r.URL.Query())
348 w.Header().Set("Content-Type", "application/json")
349 _ = json.NewEncoder(w).Encode(session.MessageFieldPage{Status: "ready", MessageID: "m7", Field: "content",
350 TotalBytes: 100, Offset: 0, NextOffset: 64, Encoding: "utf-8", Data: []byte("fragment")})
351 return
352 }
353 t.Errorf("unexpected request %s", r.URL.Path)
354 }))
355 defer server.Close()
356 app, tab := historyWindowFixture(server)
357
358 page, err := app.RemoteSessionHistoryWindowForTab(tab.id, session.HistoryWindowRequest{
359 Anchor: "message", MessageID: "m7", Direction: "older", Limit: 32,
360 })
361 if err != nil || page.Status != "ready" || page.SnapshotSequence != 9 || len(page.Messages) != 1 {
362 t.Fatalf("window = %+v, %v", page, err)
363 }
364 if page.AnchorMessageID != "m7" || !page.HasOlder || !page.HasNewer || page.OlderCursor != "older" || page.NewerCursor != "newer" {
365 t.Fatalf("window metadata = %+v", page)
366 }
367 field, err := app.RemoteSessionMessageFieldForTab(tab.id, "m7", 3, "content", 0, 64)
368 if err != nil || field.Status != "ready" || field.NextOffset != 64 || string(field.Data) != "fragment" {
369 t.Fatalf("field = %+v, %v", field, err)
370 }
371 if len(seen) != 2 {
372 t.Fatalf("requests = %d", len(seen))
373 }
374 // The tab's binding identity is stamped by the host, never by the caller:
375 // a window read cannot name another session.
376 for index, query := range seen {
377 if query.Get("sessionId") != "canonical" {
378 t.Fatalf("request %d session=%q", index, query.Get("sessionId"))
379 }
380 }
381 if got := seen[0]; got.Get("anchor") != "message" || got.Get("messageId") != "m7" || got.Get("direction") != "older" || got.Get("limit") != "32" {
382 t.Fatalf("window query = %v", got)
383 }
384 if got := seen[1]; got.Get("messageId") != "m7" || got.Get("field") != "content" || got.Get("version") != "3" || got.Get("offset") != "0" || got.Get("length") != "64" {
385 t.Fatalf("field query = %v", got)
386 }
387 }
388
389 // TestRemoteSessionHistoryWindowKeepsTypedStatuses pins the transport's
390 // contract with the reader: a stale cursor and a not-found anchor are answers
391 // the caller reasons about, not transport failures.
392 func TestRemoteSessionHistoryWindowKeepsTypedStatuses(t *testing.T) {
393 for _, status := range []string{"stale_cursor", "not_found", "preparing", "failed"} {
394 t.Run(status, func(t *testing.T) {
395 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
396 w.Header().Set("Content-Type", "application/json")
397 _ = json.NewEncoder(w).Encode(session.HistoryWindowPage{Status: status})
398 }))
399 defer server.Close()
400 app, tab := historyWindowFixture(server)
401 page, err := app.RemoteSessionHistoryWindowForTab(tab.id, session.HistoryWindowRequest{Anchor: "newest"})
402 if err != nil || page.Status != status {
403 t.Fatalf("status %q surfaced as %+v, %v", status, page, err)
404 }
405 })
406 }
407 }
408
409 // TestSessionHistoryWindowRequiresCanonicalBinding keeps the local command off
410 // every path that has no exclusive canonical session behind it.
411 func TestSessionHistoryWindowRequiresCanonicalBinding(t *testing.T) {
412 app := &App{}
413 if _, err := app.SessionHistoryWindowForTab("missing", session.HistoryWindowRequest{Anchor: "newest"}); err == nil {
414 t.Fatal("window read without a bound session")
415 }
416 if _, err := app.SessionMessageFieldForTab("missing", "m1", 0, "content", 0, 64); err == nil {
417 t.Fatal("field read without a bound session")
418 }
419 }
420
421 func TestRemoteCanonicalSessionHistoryPageDoesNotRequireContentCapability(t *testing.T) {
422 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
423 if r.URL.Path != "/session-history/page" {
424 t.Fatalf("unexpected route %s", r.URL.Path)
425 }
426 _ = json.NewEncoder(w).Encode(session.MessageHistoryPage{SnapshotSequence: 4})
427 }))
428 defer server.Close()
429 app, tab := remoteTranscriptFixture(server)
430 tab.capabilities = map[string]bool{serveCapabilitySessions: true}
431 tab.session.sessionID = "canonical"
432 page, err := app.RemoteSessionHistoryPageForTab(tab.id, "", 0)
433 if err != nil || page.SnapshotSequence != 4 {
434 t.Fatalf("page = %+v, %v", page, err)
435 }
436 }
437
438 func TestRemoteCanonicalSessionHistoryContentRequiresContentCapability(t *testing.T) {
439 var reads atomic.Int32
440 server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reads.Add(1) }))
441 defer server.Close()
442 app, tab := remoteTranscriptFixture(server)
443 tab.capabilities = map[string]bool{serveCapabilitySessions: true}
444 tab.session.sessionID = "canonical"
445 ref := sessioncontent.Ref{Digest: strings.Repeat("b", 64), Bytes: 1, MediaType: "text/plain"}
446 if _, err := app.RemoteSessionHistoryContentForTab(tab.id, ref, 0); err == nil {
447 t.Fatal("content unexpectedly enabled without content capability")
448 }
449 if reads.Load() != 0 {
450 t.Fatalf("network reads = %d", reads.Load())
451 }
452 }
453
453 lines GO