| 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 |