返回 DeepSeek-Reasonix
remote_tab_multisession_test.go
根目录 / desktop / remote_tab_multisession_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/cookiejar"
8 "net/http/httptest"
9 "slices"
10 "strings"
11 "testing"
12 "time"
13 )
14
15 func TestRemoteTabAllSessionEventsRouteOnlyCurrentFrames(t *testing.T) {
16 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
17 {Name: "current", Path: "/sessions/current.jsonl", Current: true},
18 {Name: "background", Path: "/sessions/background.jsonl", Running: true},
19 })
20 fs.mu.Lock()
21 fs.eventFrames = []string{
22 `{"kind":"turn_started","sessionPath":"/sessions/background.jsonl"}`,
23 `{"kind":"turn_started","sessionPath":"/sessions/current.jsonl"}`,
24 `{"kind":"ready","sessionPath":"/sessions/current.jsonl"}`,
25 }
26 fs.mu.Unlock()
27 kernel := &fakeRemoteKernel{
28 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
29 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
30 ensureToken: "s3cret",
31 }
32 seedBridgeTestHost(t, "box")
33 log := &eventLog{}
34 a := &App{remoteRuntime: kernel, remoteEventHook: log.add}
35 cleanupRemoteTabPumps(t, a)
36 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "current", SessionPath: "/sessions/current.jsonl", SessionTitle: "Current"})
37 events := log.recorded()
38 for _, event := range events {
39 if strings.HasPrefix(event, "remote-tab:"+meta.ID+":event") && strings.Contains(event, "/sessions/background.jsonl") {
40 t.Fatalf("background frame leaked to foreground reducer: %v", events)
41 }
42 }
43 if !slices.ContainsFunc(events, func(event string) bool {
44 return strings.HasPrefix(event, "remote-tab:"+meta.ID+":event") && strings.Contains(event, "/sessions/current.jsonl")
45 }) {
46 t.Fatalf("current-session frame was not forwarded: %v", events)
47 }
48 a.remoteTabMu.Lock()
49 backgroundRunning := a.remoteTabs[meta.ID].routing.running["/sessions/background.jsonl"]
50 a.remoteTabMu.Unlock()
51 if !backgroundRunning {
52 t.Fatal("background running state was not retained for the project tree")
53 }
54 sessions, err := a.RemoteProjectSessions("box", "~/app")
55 if err != nil {
56 t.Fatal(err)
57 }
58 if !slices.ContainsFunc(sessions, func(session RemoteSessionView) bool {
59 return session.Path == "/sessions/background.jsonl" && session.Running
60 }) {
61 t.Fatalf("background session is not marked running: %+v", sessions)
62 }
63 fs.mu.Lock()
64 fs.sessions[1].Running = false
65 fs.mu.Unlock()
66 sessions, err = a.RemoteProjectSessions("box", "~/app")
67 if err != nil {
68 t.Fatal(err)
69 }
70 if slices.ContainsFunc(sessions, func(session RemoteSessionView) bool {
71 return session.Path == "/sessions/background.jsonl" && session.Running
72 }) {
73 t.Fatalf("authoritative idle listing did not clear stale running state: %+v", sessions)
74 }
75 }
76
77 func TestBackgroundCompletionNoticeRefreshesRemoteRows(t *testing.T) {
78 const currentPath = "/sessions/current.jsonl"
79 const backgroundPath = "/sessions/background.jsonl"
80 log := &eventLog{}
81 a := &App{remoteEventHook: log.add, remoteTabs: map[string]*remoteTab{
82 "remote-1": {
83 id: "remote-1", gen: 7,
84 routing: remoteTabSessionRouting{
85 currentPath: currentPath,
86 running: map[string]bool{backgroundPath: true},
87 },
88 },
89 }}
90 if a.routeRemoteTabFrame("remote-1", 7, backgroundPath, "notice") {
91 t.Fatal("background completion notice was routed to the foreground")
92 }
93 if got := log.count("remote-tab:updated"); got != 1 {
94 t.Fatalf("background completion emitted %d row refreshes, want 1", got)
95 }
96 }
97
98 func TestRecoveredBackgroundTerminalFrameRefreshesRemoteRows(t *testing.T) {
99 const currentPath = "/sessions/current.jsonl"
100 const originalPath = "/sessions/background.jsonl"
101 const recoveredPath = "/sessions/background-recovered.jsonl"
102 log := &eventLog{}
103 a := &App{remoteEventHook: log.add, remoteTabs: map[string]*remoteTab{
104 "remote-1": {
105 id: "remote-1", gen: 7,
106 routing: remoteTabSessionRouting{
107 currentPath: currentPath,
108 running: map[string]bool{originalPath: true},
109 },
110 },
111 }}
112 if a.routeRemoteTabFrame("remote-1", 7, recoveredPath, "turn_done") {
113 t.Fatal("recovered background terminal frame was routed to the foreground")
114 }
115 if got := log.count("remote-tab:updated"); got != 1 {
116 t.Fatalf("recovered terminal frame emitted %d row refreshes, want 1", got)
117 }
118 }
119
120 func TestFocusOnlyAttachRoutesImmediatePendingPrompt(t *testing.T) {
121 const currentPath = "/sessions/current.jsonl"
122 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "current", Path: currentPath, Current: true}})
123 fs.mu.Lock()
124 fs.eventFrames = []string{
125 `{"kind":"approval_request","sessionPath":"/sessions/current.jsonl","approval":{"id":"approval-1"}}`,
126 `{"kind":"ready","sessionPath":"/sessions/current.jsonl"}`,
127 }
128 fs.mu.Unlock()
129 kernel := &fakeRemoteKernel{
130 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
131 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
132 ensureToken: "s3cret",
133 }
134 seedBridgeTestHost(t, "box")
135 log := &eventLog{}
136 a := &App{remoteRuntime: kernel, remoteEventHook: log.add}
137 cleanupRemoteTabPumps(t, a)
138 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{})
139 events := log.recorded()
140 if !slices.ContainsFunc(events, func(event string) bool {
141 return strings.HasPrefix(event, "remote-tab:"+meta.ID+":event") && strings.Contains(event, `"approval-1"`)
142 }) {
143 t.Fatalf("focus-only attach dropped the immediate pending prompt: %v", events)
144 }
145 a.remoteTabMu.Lock()
146 gotPath := a.remoteTabs[meta.ID].routing.currentPath
147 a.remoteTabMu.Unlock()
148 if gotPath != currentPath {
149 t.Fatalf("focus-only path = %q, want %q", gotPath, currentPath)
150 }
151 }
152
153 func TestNamedAttachPublishesResolvedRouteBeforeResume(t *testing.T) {
154 const targetPath = "/sessions/target.jsonl"
155 feed := make(chan string, 1)
156 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "target", Path: targetPath}})
157 fs.mu.Lock()
158 fs.eventFeed = feed
159 fs.resumeStarted = make(chan string, 1)
160 fs.resumeRelease = make(chan struct{})
161 started, release := fs.resumeStarted, fs.resumeRelease
162 fs.mu.Unlock()
163 t.Cleanup(func() {
164 select {
165 case <-release:
166 default:
167 close(release)
168 }
169 })
170 kernel := &fakeRemoteKernel{
171 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
172 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
173 }
174 seedBridgeTestHost(t, "box")
175 log := &eventLog{}
176 a := &App{remoteRuntime: kernel, remoteEventHook: log.add}
177 cleanupRemoteTabPumps(t, a)
178 meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "target"})
179 if err != nil {
180 t.Fatal(err)
181 }
182 select {
183 case <-started:
184 case <-time.After(time.Second):
185 t.Fatal("named resume request did not start")
186 }
187 feed <- `{"kind":"approval_request","sessionPath":"/sessions/target.jsonl","approval":{"id":"approval-target"}}`
188 deadline := time.Now().Add(time.Second)
189 for !slices.ContainsFunc(log.recorded(), func(event string) bool {
190 return strings.HasPrefix(event, "remote-tab:"+meta.ID+":event") && strings.Contains(event, "approval-target")
191 }) {
192 if time.Now().After(deadline) {
193 t.Fatalf("named target prompt was dropped while /resume was pending: %v", log.recorded())
194 }
195 time.Sleep(time.Millisecond)
196 }
197 close(release)
198 waitForTabState(t, a, meta.ID, "ready")
199 }
200
201 func TestForegroundRecoveryPathIsReconciledBeforeRoutingFrame(t *testing.T) {
202 const oldPath = "/sessions/current.jsonl"
203 const recoveryPath = "/sessions/current-recovery.jsonl"
204 feed := make(chan string, 1)
205 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "current", Path: oldPath, Current: true}})
206 fs.mu.Lock()
207 fs.eventFrames = []string{`{"kind":"ready","sessionPath":"/sessions/current.jsonl"}`}
208 fs.eventFeed = feed
209 fs.statusPayload = `{"running":false,"sessionName":"current","sessionPath":"/sessions/current.jsonl"}`
210 fs.mu.Unlock()
211 kernel := &fakeRemoteKernel{
212 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
213 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
214 }
215 seedBridgeTestHost(t, "box")
216 log := &eventLog{}
217 a := &App{remoteRuntime: kernel, remoteEventHook: log.add}
218 cleanupRemoteTabPumps(t, a)
219 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{})
220 fs.mu.Lock()
221 fs.statusPayload = `{"running":false,"sessionName":"current-recovery","sessionPath":"/sessions/current-recovery.jsonl"}`
222 fs.mu.Unlock()
223 feed <- `{"kind":"notice","text":"continued on recovery","sessionPath":"/sessions/current-recovery.jsonl"}`
224 deadline := time.Now().Add(2 * time.Second)
225 for {
226 events := log.recorded()
227 if slices.ContainsFunc(events, func(event string) bool {
228 return strings.HasPrefix(event, "remote-tab:"+meta.ID+":event") && strings.Contains(event, "continued on recovery")
229 }) {
230 break
231 }
232 if time.Now().After(deadline) {
233 t.Fatalf("recovery frame was not routed after status reconciliation: %v", events)
234 }
235 time.Sleep(time.Millisecond)
236 }
237 a.remoteTabMu.Lock()
238 got := a.remoteTabs[meta.ID].routing.currentPath
239 a.remoteTabMu.Unlock()
240 if got != recoveryPath {
241 t.Fatalf("foreground route = %q, want recovered path %q", got, recoveryPath)
242 }
243 }
244
245 func TestUnknownBackgroundPathReconcilesStatusOnlyOnce(t *testing.T) {
246 const currentPath = "/sessions/current.jsonl"
247 const backgroundPath = "/sessions/background.jsonl"
248 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "current", Path: currentPath, Current: true}})
249 fs.mu.Lock()
250 fs.statusPayload = `{"running":false,"sessionName":"current","sessionPath":"/sessions/current.jsonl"}`
251 fs.mu.Unlock()
252 kernel := &fakeRemoteKernel{
253 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
254 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
255 }
256 seedBridgeTestHost(t, "box")
257 log := &eventLog{}
258 a := &App{remoteRuntime: kernel, remoteEventHook: log.add}
259 cleanupRemoteTabPumps(t, a)
260 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{})
261 a.remoteTabMu.Lock()
262 gen := a.remoteTabs[meta.ID].gen
263 a.remoteTabMu.Unlock()
264 countStatusRequests := func() int {
265 count := 0
266 for _, call := range fs.recorded() {
267 if strings.HasPrefix(call, "GET /status") {
268 count++
269 }
270 }
271 return count
272 }
273 before := countStatusRequests()
274 beforeRefreshes := log.count("remote-tab:updated")
275 if a.routeRemoteTabFrameReconciled(meta.ID, gen, backgroundPath, "notice") {
276 t.Fatal("unknown background frame was routed to the foreground")
277 }
278 if a.routeRemoteTabFrameReconciled(meta.ID, gen, backgroundPath, "turn_done") {
279 t.Fatal("known background frame was routed to the foreground")
280 }
281 after := countStatusRequests()
282 if after-before != 1 {
283 t.Fatalf("unknown background path triggered %d status requests, want 1", after-before)
284 }
285 if got := log.count("remote-tab:updated") - beforeRefreshes; got != 2 {
286 t.Fatalf("unknown background notice and terminal frame emitted %d row refreshes, want 2", got)
287 }
288 }
289
290 func TestKnownBackgroundPathAdoptsFrameForegroundMarker(t *testing.T) {
291 const currentPath = "/sessions/current.jsonl"
292 const resumedPath = "/sessions/background.jsonl"
293 log := &eventLog{}
294 a := &App{remoteEventHook: log.add, remoteTabs: map[string]*remoteTab{
295 "remote-1": {
296 id: "remote-1", gen: 7,
297 session: remoteTabSessionState{path: currentPath},
298 routing: remoteTabSessionRouting{
299 currentPath: currentPath,
300 running: map[string]bool{resumedPath: true},
301 },
302 },
303 }}
304 a.adoptRemoteTabFrameCurrent("remote-1", 7, resumedPath, false)
305 if !a.routeRemoteTabFrameReconciled("remote-1", 7, resumedPath, "text") {
306 t.Fatal("publication-time foreground marker did not reclassify a cached background path")
307 }
308 a.remoteTabMu.Lock()
309 path := a.remoteTabs["remote-1"].routing.currentPath
310 revision := a.remoteTabs["remote-1"].routing.revision
311 a.remoteTabMu.Unlock()
312 if path != resumedPath || revision != 1 {
313 t.Fatalf("adopted route = %q revision %d, want %q revision 1", path, revision, resumedPath)
314 }
315 if got := log.count("remote-tab:updated"); got != 1 {
316 t.Fatalf("foreground adoption emitted %d row refreshes, want 1", got)
317 }
318 }
319
320 func TestForegroundMarkerPublishesRehydrateBeforeForwardedFrame(t *testing.T) {
321 const currentPath = "/sessions/current.jsonl"
322 const resumedPath = "/sessions/resumed.jsonl"
323 log := &eventLog{}
324 a := &App{remoteEventHook: log.add, remoteTabs: map[string]*remoteTab{
325 "remote-1": {
326 id: "remote-1", gen: 7, state: "ready",
327 session: remoteTabSessionState{path: currentPath},
328 routing: remoteTabSessionRouting{currentPath: currentPath, running: map[string]bool{}},
329 },
330 }}
331 if !a.routeRemoteTabWireFrame("remote-1", 7, resumedPath, "text", true, false) {
332 t.Fatal("new foreground frame was not routed")
333 }
334 a.emitRemoteEvent("remote-tab:remote-1:event", map[string]any{"kind": "text", "sessionPath": resumedPath})
335 events := log.recorded()
336 stateIndex, frameIndex := -1, -1
337 for i, got := range events {
338 if strings.HasPrefix(got, "remote-tab:remote-1:state ") {
339 stateIndex = i
340 }
341 if strings.HasPrefix(got, "remote-tab:remote-1:event ") {
342 frameIndex = i
343 }
344 }
345 if stateIndex < 0 || frameIndex < 0 || stateIndex >= frameIndex {
346 t.Fatalf("session rehydrate was not published before the frame: %v", events)
347 }
348 }
349
350 func TestKnownBackgroundPromptReconcilesLegacyServeStatus(t *testing.T) {
351 const currentPath = "/sessions/current.jsonl"
352 const resumedPath = "/sessions/background.jsonl"
353 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "current", Path: currentPath, Current: true}})
354 fs.mu.Lock()
355 fs.statusPayload = `{"running":true,"sessionName":"background","sessionPath":"/sessions/background.jsonl"}`
356 fs.mu.Unlock()
357 kernel := &fakeRemoteKernel{
358 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
359 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
360 }
361 seedBridgeTestHost(t, "box")
362 a := &App{remoteRuntime: kernel}
363 cleanupRemoteTabPumps(t, a)
364 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionPath: currentPath})
365 a.remoteTabMu.Lock()
366 tab := a.remoteTabs[meta.ID]
367 tab.routing.running[resumedPath] = true
368 gen := tab.gen
369 a.remoteTabMu.Unlock()
370 if !a.routeRemoteTabFrameReconciled(meta.ID, gen, resumedPath, "approval_request") {
371 t.Fatal("legacy foreground prompt from a cached background path was discarded")
372 }
373 a.remoteTabMu.Lock()
374 got := tab.routing.currentPath
375 a.remoteTabMu.Unlock()
376 if got != resumedPath {
377 t.Fatalf("legacy status reconciliation route = %q, want %q", got, resumedPath)
378 }
379 }
380
381 func TestProvisionalResumeRouteFencesStaleSessionListing(t *testing.T) {
382 const currentPath = "/sessions/current.jsonl"
383 const targetPath = "/sessions/target.jsonl"
384 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
385 {Name: "current", Path: currentPath, Current: true},
386 {Name: "target", Path: targetPath},
387 })
388 kernel := &fakeRemoteKernel{
389 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
390 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
391 }
392 seedBridgeTestHost(t, "box")
393 a := &App{remoteRuntime: kernel}
394 cleanupRemoteTabPumps(t, a)
395 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionPath: currentPath})
396
397 fs.mu.Lock()
398 fs.sessionsStarted = make(chan struct{}, 1)
399 fs.sessionsRelease = make(chan struct{})
400 sessionsStarted, sessionsRelease := fs.sessionsStarted, fs.sessionsRelease
401 fs.resumeStarted = make(chan string, 1)
402 fs.resumeRelease = make(chan struct{})
403 resumeStarted, resumeRelease := fs.resumeStarted, fs.resumeRelease
404 fs.mu.Unlock()
405 t.Cleanup(func() {
406 for _, ch := range []chan struct{}{sessionsRelease, resumeRelease} {
407 select {
408 case <-ch:
409 default:
410 close(ch)
411 }
412 }
413 })
414
415 listingDone := make(chan error, 1)
416 go func() {
417 _, err := a.RemoteProjectSessions("box", "~/app")
418 listingDone <- err
419 }()
420 select {
421 case <-sessionsStarted:
422 case <-time.After(time.Second):
423 t.Fatal("session listing did not start")
424 }
425 resumeDone := make(chan struct{})
426 go func() {
427 a.resumeRemoteTabSessionPath(meta.ID, "target", targetPath, "Target")
428 close(resumeDone)
429 }()
430 select {
431 case <-resumeStarted:
432 case <-time.After(time.Second):
433 t.Fatal("resume request did not start")
434 }
435 close(sessionsRelease)
436 if err := <-listingDone; err != nil {
437 t.Fatal(err)
438 }
439 a.remoteTabMu.Lock()
440 got := a.remoteTabs[meta.ID].routing.currentPath
441 a.remoteTabMu.Unlock()
442 if got != targetPath {
443 t.Fatalf("stale /sessions response replaced provisional route with %q, want %q", got, targetPath)
444 }
445 close(resumeRelease)
446 select {
447 case <-resumeDone:
448 case <-time.After(time.Second):
449 t.Fatal("resume did not finish after release")
450 }
451 }
452
453 func TestRemoteNewSessionFencesStaleSessionListing(t *testing.T) {
454 const currentPath = "/sessions/current.jsonl"
455 const rotatedPath = "/sessions/rotated.jsonl"
456 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "current", Path: currentPath, Current: true}})
457 kernel := &fakeRemoteKernel{
458 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
459 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
460 }
461 seedBridgeTestHost(t, "box")
462 a := &App{remoteRuntime: kernel}
463 cleanupRemoteTabPumps(t, a)
464 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionPath: currentPath})
465
466 fs.mu.Lock()
467 fs.sessionsStarted = make(chan struct{}, 1)
468 fs.sessionsRelease = make(chan struct{})
469 fs.newSessionPath = rotatedPath
470 started, release := fs.sessionsStarted, fs.sessionsRelease
471 fs.mu.Unlock()
472 t.Cleanup(func() {
473 select {
474 case <-release:
475 default:
476 close(release)
477 }
478 })
479
480 listingDone := make(chan error, 1)
481 go func() {
482 _, err := a.RemoteProjectSessions("box", "~/app")
483 listingDone <- err
484 }()
485 select {
486 case <-started:
487 case <-time.After(time.Second):
488 t.Fatal("session listing did not start")
489 }
490 if err := a.resetRemoteTabSession(meta.ID); err != nil {
491 t.Fatal(err)
492 }
493 close(release)
494 if err := <-listingDone; err != nil {
495 t.Fatal(err)
496 }
497 a.remoteTabMu.Lock()
498 path := a.remoteTabs[meta.ID].routing.currentPath
499 a.remoteTabMu.Unlock()
500 if path != rotatedPath {
501 t.Fatalf("stale /sessions response replaced rotated route with %q, want %q", path, rotatedPath)
502 }
503 }
504
505 func TestFailedProvisionalResumeRestoresRouteWithNewRevision(t *testing.T) {
506 const currentPath = "/sessions/current.jsonl"
507 const targetPath = "/sessions/target.jsonl"
508 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "current", Path: currentPath, Current: true}})
509 kernel := &fakeRemoteKernel{
510 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
511 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
512 }
513 seedBridgeTestHost(t, "box")
514 a := &App{remoteRuntime: kernel}
515 cleanupRemoteTabPumps(t, a)
516 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionPath: currentPath})
517 a.remoteTabMu.Lock()
518 before := a.remoteTabs[meta.ID].routing.revision
519 a.remoteTabMu.Unlock()
520 fs.mu.Lock()
521 fs.failEnter = "resume rejected"
522 fs.mu.Unlock()
523 a.resumeRemoteTabSessionPath(meta.ID, "target", targetPath, "Target")
524 a.remoteTabMu.Lock()
525 tab := a.remoteTabs[meta.ID]
526 path, revision := tab.routing.currentPath, tab.routing.revision
527 a.remoteTabMu.Unlock()
528 if path != currentPath || revision != before+2 {
529 t.Fatalf("failed resume route = %q revision %d, want %q revision %d", path, revision, currentPath, before+2)
530 }
531 }
532
533 func TestRemoteProjectSessionsAdoptsAuthoritativeCurrentRoute(t *testing.T) {
534 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "a", Path: "/a.jsonl"}, {Name: "b", Path: "/b.jsonl", Current: true}})
535 seedBridgeTestHost(t, "box")
536 client, _ := remoteSessionTestClient(t, fs)
537 log := &eventLog{}
538 tab := &remoteTab{
539 id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "ready",
540 client: client, base: fs.server.URL, gen: 7,
541 session: remoteTabSessionState{name: "a", path: "/a.jsonl"},
542 routing: remoteTabSessionRouting{currentPath: "/a.jsonl", running: map[string]bool{}},
543 pendingEvents: map[string]json.RawMessage{"approval_request:1": json.RawMessage(`{"kind":"approval_request"}`)},
544 runtime: remoteTabRuntimeState{pendingPrompt: true},
545 }
546 a := &App{remoteEventHook: log.add, remoteTabs: map[string]*remoteTab{tab.id: tab}}
547 readyPrefix := "remote-tab:" + tab.id + ":state"
548 sessions, err := a.RemoteProjectSessions("box", "~/app")
549 if err != nil {
550 t.Fatal(err)
551 }
552 current := slices.DeleteFunc(append([]RemoteSessionView(nil), sessions...), func(session RemoteSessionView) bool { return !session.Current })
553 if len(current) != 1 || current[0].Path != "/b.jsonl" {
554 t.Fatalf("current rows = %+v, want only authoritative session b", current)
555 }
556 a.remoteTabMu.Lock()
557 name, path, route := tab.session.name, tab.session.path, tab.routing.currentPath
558 pendingEvents, pendingPrompt := len(tab.pendingEvents), tab.runtime.pendingPrompt
559 a.remoteTabMu.Unlock()
560 if name != "b" || path != "/b.jsonl" || route != "/b.jsonl" {
561 t.Fatalf("adopted identity = %q/%q/%q, want b//b.jsonl//b.jsonl", name, path, route)
562 }
563 if pendingEvents != 0 || pendingPrompt {
564 t.Fatalf("listing adoption retained stale prompts: events=%d pending=%v", pendingEvents, pendingPrompt)
565 }
566 if got := log.count(readyPrefix); got != 1 {
567 t.Fatalf("listing adoption emitted %d ready barriers, want 1", got)
568 }
569 }
570
571 func TestExternalSessionResetPreservesBlankIdentity(t *testing.T) {
572 const oldPath = "/sessions/old.jsonl"
573 const freshPath = "/sessions/fresh.jsonl"
574 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "old", Path: oldPath}})
575 seedBridgeTestHost(t, "box")
576 client, _ := remoteSessionTestClient(t, fs)
577 log := &eventLog{}
578 tab := &remoteTab{
579 id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "ready",
580 client: client, base: fs.server.URL, gen: 7,
581 topicTitle: "Old title",
582 session: remoteTabSessionState{name: "old", path: oldPath},
583 routing: remoteTabSessionRouting{currentPath: oldPath, running: map[string]bool{}},
584 pendingEvents: map[string]json.RawMessage{
585 "approval_request:old": json.RawMessage(`{"kind":"approval_request"}`),
586 },
587 runtime: remoteTabRuntimeState{running: true, pendingPrompt: true, cancellable: true},
588 }
589 a := &App{remoteEventHook: log.add, remoteTabs: map[string]*remoteTab{tab.id: tab}}
590 if !a.routeRemoteTabWireFrame(tab.id, tab.gen, freshPath, "session_changed", true, true) {
591 t.Fatal("fresh external session barrier was not routed")
592 }
593 a.remoteTabMu.Lock()
594 name, path, route, title := tab.session.name, tab.session.path, tab.routing.currentPath, tab.topicTitle
595 reset, newSession := tab.session.reset, tab.session.newSession
596 pending := len(tab.pendingEvents)
597 running, pendingPrompt := tab.runtime.running, tab.runtime.pendingPrompt
598 a.remoteTabMu.Unlock()
599 if name != "" || path != freshPath || route != freshPath || title != a.localizedDefaultTopicTitle() || !reset || !newSession {
600 t.Fatalf("external reset identity = %q/%q/%q/%q reset=%v new=%v", name, path, route, title, reset, newSession)
601 }
602 if pending != 0 || running || pendingPrompt {
603 t.Fatalf("external reset retained old runtime: pending=%d running=%v prompt=%v", pending, running, pendingPrompt)
604 }
605 if got := log.count("remote-tab:" + tab.id + ":state"); got != 1 {
606 t.Fatalf("external reset emitted %d ready barriers, want 1", got)
607 }
608 sessions, err := a.RemoteProjectSessions("box", "~/app")
609 if err != nil {
610 t.Fatal(err)
611 }
612 if len(sessions) != 2 || !slices.ContainsFunc(sessions, func(session RemoteSessionView) bool {
613 return session.Name == "" && session.Path == freshPath && session.Current
614 }) {
615 t.Fatalf("external blank session missing from listing: %+v", sessions)
616 }
617 }
618
619 func remoteSessionTestClient(t *testing.T, fs *fakeServe) (*http.Client, context.Context) {
620 t.Helper()
621 jar, err := cookiejar.New(nil)
622 if err != nil {
623 t.Fatal(err)
624 }
625 client := &http.Client{Jar: jar}
626 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
627 t.Cleanup(cancel)
628 if err := serveHandshake(ctx, client, fs.server.URL, "s3cret"); err != nil {
629 t.Fatal(err)
630 }
631 return client, ctx
632 }
633
634 func TestEnterRemoteSessionPathSkipsSessionCatalog(t *testing.T) {
635 fs := newFakeServe(t, "s3cret", nil)
636 client, ctx := remoteSessionTestClient(t, fs)
637 target, err := enterRemoteSessionTarget(ctx, client, fs.server.URL, RemoteTabOpenOptions{SessionName: "known", SessionPath: "/remote/sessions/known.jsonl", SessionTitle: "Known"})
638 if err != nil {
639 t.Fatal(err)
640 }
641 if target.Path != "/remote/sessions/known.jsonl" || target.Title != "Known" {
642 t.Fatalf("target = %+v", target)
643 }
644 for _, call := range fs.recorded() {
645 if strings.HasPrefix(call, "GET /sessions") {
646 t.Fatalf("explicit path unnecessarily fetched the session catalog: %v", fs.recorded())
647 }
648 }
649 }
650
651 func TestEnterRemoteSessionPathReturnsSpectatorMountSynchronously(t *testing.T) {
652 const path = "/remote/sessions/taken-over.jsonl"
653 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
654 if r.Method != http.MethodPost || r.URL.Path != "/resume" {
655 http.NotFound(w, r)
656 return
657 }
658 w.Header().Set("X-Reasonix-Session-Path", path)
659 w.WriteHeader(http.StatusNoContent)
660 }))
661 defer srv.Close()
662
663 target, err := enterRemoteSessionTarget(context.Background(), srv.Client(), srv.URL, RemoteTabOpenOptions{SessionPath: path})
664 if err != nil {
665 t.Fatal(err)
666 }
667 if !target.TakenOver {
668 t.Fatalf("resume target = %+v, want synchronous spectator marker", target)
669 }
670 tab := &remoteTab{
671 id: "remote-1", gen: 3,
672 routing: remoteTabSessionRouting{currentPath: path, running: map[string]bool{}},
673 }
674 app := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
675 if !app.commitRemoteTabAttachResponse(tab.id, tab, tab.gen, tab.routing.pathRevision, target, false) {
676 t.Fatal("spectator attach response was not committed")
677 }
678 if !tab.session.takenOver {
679 t.Fatal("spectator marker was not committed before ready publication")
680 }
681 }
682
683 func TestRemoteTabResumeCommitsSpectatorBeforeReady(t *testing.T) {
684 const path = "/remote/sessions/taken-over.jsonl"
685 client := &http.Client{}
686 tab := &remoteTab{
687 id: "remote-1", gen: 4, state: "ready", client: client,
688 routing: remoteTabSessionRouting{currentPath: path, running: map[string]bool{}},
689 }
690 app := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
691 route := remoteTabProvisionalResume{targetPath: path, pathRevision: tab.routing.pathRevision}
692 meta, committed := app.commitRemoteTabResume(tab.id, tab, client, tab.gen, route, serveSessionEntry{Path: path, TakenOver: true}, "Taken over")
693 if !committed || !tab.session.takenOver || !meta.TakenOver || !meta.ReadOnly {
694 t.Fatalf("resume spectator commit = committed=%v tab=%v meta=%+v", committed, tab.session.takenOver, meta)
695 }
696 }
697
698 func TestEnterRemoteSessionUnknownName(t *testing.T) {
699 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "s1", Path: "/x.jsonl"}})
700 client, ctx := remoteSessionTestClient(t, fs)
701 err := enterRemoteSession(ctx, client, fs.server.URL, RemoteTabOpenOptions{SessionName: "missing"})
702 if err == nil || !strings.Contains(err.Error(), `"missing" not found`) {
703 t.Fatalf("err = %v, want unknown session error", err)
704 }
705 }
706
707 func TestRemoteSessionResumeBodyUsesCanonicalIdentity(t *testing.T) {
708 body, err := remoteSessionResumeBody(serveSessionEntry{Name: "chat-title", SessionID: "stable-session"})
709 if err != nil {
710 t.Fatal(err)
711 }
712 var got map[string]string
713 if err := json.Unmarshal(body, &got); err != nil {
714 t.Fatal(err)
715 }
716 if got["sessionId"] != "stable-session" || got["path"] != "" || got["name"] != "chat-title" {
717 t.Fatalf("canonical resume body = %v", got)
718 }
719 }
720
721 func TestRemoteSessionResumeBodyRejectsMissingIdentity(t *testing.T) {
722 if _, err := remoteSessionResumeBody(serveSessionEntry{Name: "unresolved"}); err == nil || !strings.Contains(err.Error(), "no resumable identity") {
723 t.Fatalf("missing identity error = %v", err)
724 }
725 }
726
726 lines GO