返回 DeepSeek-Reasonix
remote_tab_persist_test.go
根目录 / desktop / remote_tab_persist_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13
14 "reasonix/internal/config"
15 "reasonix/internal/control"
16 )
17
18 func readPersistedTabsFile(t *testing.T) desktopTabsFile {
19 t.Helper()
20 data, err := os.ReadFile(filepath.Join(config.ReasonixHomeDir(), tabsFileName))
21 if err != nil {
22 t.Fatalf("read tabs file: %v", err)
23 }
24 var f desktopTabsFile
25 if err := json.Unmarshal(data, &f); err != nil {
26 t.Fatalf("parse tabs file: %v", err)
27 }
28 return f
29 }
30
31 func seedLocalTab(a *App, id string) {
32 a.mu.Lock()
33 if a.tabs == nil {
34 a.tabs = map[string]*WorkspaceTab{}
35 }
36 a.tabs[id] = &WorkspaceTab{ID: id, Scope: "global"}
37 a.tabOrder = append(a.tabOrder, id)
38 a.mu.Unlock()
39 }
40
41 func TestRemoveRemoteHostReplacesSoleRemoteSurfaceWithLocalBlank(t *testing.T) {
42 seedBridgeTestHost(t, "box")
43 a := NewApp()
44 a.remoteRuntime = &fakeRemoteKernel{}
45 t.Cleanup(func() { a.shutdown(context.Background()) })
46 a.remoteTabMu.Lock()
47 a.remoteTabs = map[string]*remoteTab{
48 "remote-only": {
49 id: "remote-only", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"},
50 state: "disconnected", session: remoteTabSessionState{newSession: true},
51 },
52 }
53 a.remoteTabLayout.order = []string{"remote-only"}
54 a.remoteTabLayout.stripOrder = []string{"remote-only"}
55 a.remoteTabLayout.activeID = "remote-only"
56 a.remoteTabMu.Unlock()
57
58 if err := a.RemoveRemoteHost("box"); err != nil {
59 t.Fatal(err)
60 }
61 tabs := a.ListTabs()
62 if len(tabs) != 1 || tabs[0].Remote != nil || !tabs[0].Active {
63 t.Fatalf("tabs after deleting sole remote host = %+v, want one active local blank", tabs)
64 }
65 a.remoteTabMu.Lock()
66 remoteCount := len(a.remoteTabs)
67 a.remoteTabMu.Unlock()
68 if remoteCount != 0 {
69 t.Fatalf("deleted host retained %d remote tabs", remoteCount)
70 }
71 }
72
73 // TestRemoteTabOpenPersistRoundTrip: an open remote tab lands in
74 // desktop-tabs.json; closing removes it again.
75 func TestRemoteTabOpenPersistRoundTrip(t *testing.T) {
76 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "s1", Path: "/remote/sessions/s1.jsonl", Title: "Prior chat", Current: true}})
77 kernel := &fakeRemoteKernel{
78 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
79 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
80 ensureToken: "s3cret",
81 }
82 seedBridgeTestHost(t, "box")
83 a := &App{remoteRuntime: kernel}
84 cleanupRemoteTabPumps(t, a)
85
86 meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "s1", SessionPath: "/remote/sessions/s1.jsonl", SessionTitle: "Prior chat"})
87 if err != nil {
88 t.Fatal(err)
89 }
90 waitForTabState(t, a, meta.ID, "ready")
91
92 f := readPersistedTabsFile(t)
93 if len(f.RemoteTabs) != 1 || len(f.RemoteTabOrder) != 1 {
94 t.Fatalf("persisted remote section = %+v / %v, want one entry", f.RemoteTabs, f.RemoteTabOrder)
95 }
96 entry := f.RemoteTabs[0]
97 if entry.ID != meta.ID || entry.HostID != "box" || entry.Workspace != "~/app" {
98 t.Fatalf("persisted entry = %+v, want id/host/workspace for %s", entry, meta.ID)
99 }
100 if entry.SessionName != "s1" || entry.SessionPath != "/remote/sessions/s1.jsonl" {
101 t.Fatalf("persisted session = %q at %q, want s1", entry.SessionName, entry.SessionPath)
102 }
103 if f.RemoteTabOrder[0] != entry.ID {
104 t.Fatalf("persisted remote order = %v, want the entry id first", f.RemoteTabOrder)
105 }
106 if f.ActiveTab != meta.ID {
107 t.Fatalf("persisted active tab = %q, want the active remote id", f.ActiveTab)
108 }
109
110 // The one-surface policy refuses a direct close of the sole visible
111 // surface, so the persisted entry survives it. A surface only leaves
112 // through host removal, which TestRemoveRemoteHost… covers.
113 if err := a.CloseRemoteTab(meta.ID); err == nil || !strings.Contains(err.Error(), "cannot close the last tab") {
114 t.Fatalf("closing the sole visible surface = %v, want the last-surface refusal", err)
115 }
116 if f = readPersistedTabsFile(t); len(f.RemoteTabs) != 1 {
117 t.Fatalf("a refused close must keep the persisted entry: %+v", f.RemoteTabs)
118 }
119 }
120
121 // TestRemoteTabRestoreBuildsDisconnectedShells: restore rebuilds shells
122 // without connecting anything; invalid and local-colliding ids are skipped.
123 // Restored shells stay in the strip but must NOT become the startup active
124 // surface — first open would otherwise land on the disconnected placeholder.
125 func TestRemoteTabRestoreBuildsDisconnectedShells(t *testing.T) {
126 seedBridgeTestHost(t, "box")
127 a := &App{}
128 seedLocalTab(a, "local-1")
129 f := desktopTabsFile{
130 RemoteTabs: []desktopRemoteTabEntry{
131 {ID: "r-1", HostID: "box", Workspace: "~/app", TopicTitle: "Fix bug", SessionName: "s1", SessionPath: "/remote/sessions/s1.jsonl"},
132 {ID: "r-2", HostID: "box", Workspace: "~/web", SessionPath: "/remote/sessions/blank.jsonl", SessionReset: true},
133 {ID: "r-3", HostID: "box", Workspace: "~/canonical", TopicTitle: "canonical-session-id", SessionName: "canonical-session-id", SessionID: "canonical-session-id"},
134 {ID: "", HostID: "box", Workspace: "~/skip"},
135 {ID: "local-1", HostID: "box", Workspace: "~/dup"},
136 },
137 RemoteTabOrder: []string{"r-2", "r-1"},
138 ActiveTab: "r-1",
139 }
140 a.restoreRemoteTabShells(f)
141
142 a.remoteTabMu.Lock()
143 defer a.remoteTabMu.Unlock()
144 if len(a.remoteTabs) != 3 || a.remoteTabs["r-1"] == nil || a.remoteTabs["r-2"] == nil || a.remoteTabs["r-3"] == nil {
145 t.Fatalf("restored shells = %+v", a.remoteTabs)
146 }
147 for id, tab := range a.remoteTabs {
148 if tab.state != "disconnected" {
149 t.Fatalf("shell %s state = %q, want disconnected", id, tab.state)
150 }
151 if tab.client != nil || tab.cancel != nil {
152 t.Fatalf("shell %s connected during restore", id)
153 }
154 }
155 if got := a.remoteTabs["r-1"].topicTitle; got != "Fix bug" {
156 t.Fatalf("restored title = %q, want the persisted one", got)
157 }
158 if got := a.remoteTabs["r-3"].topicTitle; got != remoteWorkspaceName("~/canonical") {
159 t.Fatalf("canonical identity leaked into restored title = %q, want workspace fallback", got)
160 }
161 if tab := a.remoteTabs["r-1"]; tab.session.newSession || tab.session.name != "s1" || tab.session.path != "/remote/sessions/s1.jsonl" {
162 t.Fatalf("restored session identity = %+v", tab.session)
163 }
164 if tab := a.remoteTabs["r-2"]; !tab.session.newSession || !tab.session.reset || tab.session.path != "/remote/sessions/blank.jsonl" {
165 t.Fatalf("restored blank session identity = %+v", tab.session)
166 }
167 if got := strings.Join(a.remoteTabLayout.order, ","); got != "r-2,r-1,r-3" {
168 t.Fatalf("restored remote order = %q, want r-2,r-1,r-3", got)
169 }
170 if a.remoteTabLayout.activeID != "" {
171 t.Fatalf("remoteActiveTabID = %q, want local startup surface", a.remoteTabLayout.activeID)
172 }
173 }
174
175 func TestRemoteTabBlankSessionPersistsResetState(t *testing.T) {
176 tab := &remoteTab{
177 id: "blank", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"},
178 session: remoteTabSessionState{newSession: true, path: "/remote/sessions/blank.jsonl", reset: true},
179 routing: remoteTabSessionRouting{currentPath: "/remote/sessions/blank.jsonl", running: map[string]bool{}},
180 }
181 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}, remoteTabLayout: remoteTabLayoutState{order: []string{tab.id}}}
182 entries, _, _, _ := a.remoteTabsFileEntries(nil)
183 if len(entries) != 1 || !entries[0].SessionReset || entries[0].SessionPath != tab.session.path {
184 t.Fatalf("persisted blank entry = %+v", entries)
185 }
186 }
187
188 // TestActivateDisconnectedShellReconnects resumes the persisted session in
189 // the existing shell instead of replacing it with a blank conversation.
190 func TestActivateDisconnectedShellReconnects(t *testing.T) {
191 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "s1", Path: "/remote/sessions/s1.jsonl"}})
192 kernel := &fakeRemoteKernel{
193 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
194 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
195 ensureToken: "s3cret",
196 }
197 seedBridgeTestHost(t, "box")
198 a := &App{remoteRuntime: kernel}
199 cleanupRemoteTabPumps(t, a)
200 a.remoteTabMu.Lock()
201 a.remoteTabs = map[string]*remoteTab{
202 "shell-1": {id: "shell-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "disconnected", session: remoteTabSessionState{name: "s1", path: "/remote/sessions/s1.jsonl"}, hostLabel: "box", topicTitle: "Prior chat"},
203 }
204 a.remoteTabLayout.order = []string{"shell-1"}
205 a.remoteTabMu.Unlock()
206
207 if err := a.SetActiveTab("shell-1"); err != nil {
208 t.Fatal(err)
209 }
210 waitForTabState(t, a, "shell-1", "ready")
211 newCalled, resumePath, _ := fs.snapshot()
212 if newCalled != 0 || resumePath != "/remote/sessions/s1.jsonl" {
213 t.Fatalf("revive called new=%d resume=%q, want persisted s1", newCalled, resumePath)
214 }
215 a.remoteTabMu.Lock()
216 active := a.remoteTabLayout.activeID
217 a.remoteTabMu.Unlock()
218 if active != "shell-1" {
219 t.Fatalf("remoteActiveTabID = %q, want shell-1", active)
220 }
221 }
222
223 func TestSetActiveRemoteTabPersistsAndUnknownKeepsSelection(t *testing.T) {
224 home := t.TempDir()
225 t.Setenv("REASONIX_HOME", home)
226 t.Setenv("HOME", home)
227 a := &App{}
228 seedLocalTab(a, "local-1")
229 a.mu.Lock()
230 a.activeTabID = "local-1"
231 a.mu.Unlock()
232 a.remoteTabMu.Lock()
233 a.remoteTabs = map[string]*remoteTab{
234 "remote-1": {id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "ready"},
235 }
236 a.remoteTabLayout.order = []string{"remote-1"}
237 a.remoteTabMu.Unlock()
238
239 if err := a.SetActiveTab("remote-1"); err != nil {
240 t.Fatal(err)
241 }
242 // Navigation layout is coalesced; the shutdown barrier owns durability.
243 a.flushTabLayoutWrites()
244 if got := readPersistedTabsFile(t).ActiveTab; got != "remote-1" {
245 t.Fatalf("persisted active tab = %q, want remote-1", got)
246 }
247 if err := a.SetActiveTab("missing"); err == nil {
248 t.Fatal("unknown tab activation succeeded")
249 }
250 a.remoteTabMu.Lock()
251 active := a.remoteTabLayout.activeID
252 a.remoteTabMu.Unlock()
253 if active != "remote-1" {
254 t.Fatalf("unknown activation cleared remote selection: %q", active)
255 }
256 }
257
258 func TestSetActiveRemoteTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
259 path := filepath.Join(t.TempDir(), "blocked.jsonl")
260 if err := os.Mkdir(path, 0o755); err != nil {
261 t.Fatalf("mkdir blocked path: %v", err)
262 }
263 a, _ := appWithTab(t, path)
264 a.remoteTabMu.Lock()
265 a.remoteTabs = map[string]*remoteTab{
266 "remote-1": {id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "ready"},
267 }
268 a.remoteTabLayout.order = []string{"remote-1"}
269 a.remoteTabMu.Unlock()
270
271 err := a.SetActiveTab("remote-1")
272 if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
273 t.Fatalf("SetActiveTab(remote) error = %v, want persistence failure", err)
274 }
275 a.remoteTabMu.Lock()
276 active := a.remoteTabLayout.activeID
277 a.remoteTabMu.Unlock()
278 if active != "" {
279 t.Fatalf("remote active tab = %q, want local selection preserved", active)
280 }
281 }
282
283 func TestSetActiveLocalTabKeepsRemoteSelectionWhenSessionCannotPersist(t *testing.T) {
284 path := filepath.Join(t.TempDir(), "blocked.jsonl")
285 if err := os.Mkdir(path, 0o755); err != nil {
286 t.Fatalf("mkdir blocked path: %v", err)
287 }
288 a, _ := appWithTab(t, path)
289 seedLocalTab(a, "target")
290 a.remoteTabMu.Lock()
291 a.remoteTabs = map[string]*remoteTab{
292 "remote-1": {id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "ready"},
293 }
294 a.remoteTabLayout.activeID = "remote-1"
295 a.remoteTabLayout.order = []string{"remote-1"}
296 a.remoteTabMu.Unlock()
297
298 err := a.SetActiveTab("target")
299 if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
300 t.Fatalf("SetActiveTab(local) error = %v, want persistence failure", err)
301 }
302 a.remoteTabMu.Lock()
303 active := a.remoteTabLayout.activeID
304 a.remoteTabMu.Unlock()
305 if active != "remote-1" {
306 t.Fatalf("remote active tab = %q, want original remote selection", active)
307 }
308 if a.activeTabID != "test_tab" {
309 t.Fatalf("local active tab = %q, want original local tab", a.activeTabID)
310 }
311 }
312
313 func TestOpenRemoteProjectTabBlocksBeforeMutationWhenLocalSessionCannotPersist(t *testing.T) {
314 home := t.TempDir()
315 t.Setenv("REASONIX_HOME", home)
316 t.Setenv("HOME", home)
317 seedBridgeTestHost(t, "box")
318 path := filepath.Join(t.TempDir(), "blocked.jsonl")
319 if err := os.Mkdir(path, 0o755); err != nil {
320 t.Fatalf("mkdir blocked path: %v", err)
321 }
322 a, _ := appWithTab(t, path)
323
324 _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true})
325 if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
326 t.Fatalf("OpenRemoteProjectTab error = %v, want persistence failure", err)
327 }
328 a.remoteTabMu.Lock()
329 remoteCount := len(a.remoteTabs)
330 a.remoteTabMu.Unlock()
331 if remoteCount != 0 {
332 t.Fatalf("remote tab count = %d, want no mutation after failed save", remoteCount)
333 }
334 }
335
336 // TestOpenRemoteProjectTabRevivesShell: the tree-group path (ensure-open)
337 // reconnects a disconnected shell in place instead of only activating it.
338 func TestOpenRemoteProjectTabRevivesShell(t *testing.T) {
339 fs := newFakeServe(t, "s3cret", nil)
340 kernel := &fakeRemoteKernel{
341 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
342 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
343 ensureToken: "s3cret",
344 }
345 seedBridgeTestHost(t, "box")
346 a := &App{remoteRuntime: kernel}
347 cleanupRemoteTabPumps(t, a)
348 a.remoteTabMu.Lock()
349 a.remoteTabs = map[string]*remoteTab{
350 "shell-1": {id: "shell-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "disconnected", session: remoteTabSessionState{newSession: true}, hostLabel: "box", topicTitle: "app"},
351 }
352 a.remoteTabLayout.order = []string{"shell-1"}
353 a.remoteTabMu.Unlock()
354
355 meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true})
356 if err != nil {
357 t.Fatal(err)
358 }
359 if meta.ID != "shell-1" {
360 t.Fatalf("revived tab id = %q, want the shell id shell-1", meta.ID)
361 }
362 waitForTabState(t, a, "shell-1", "ready")
363 }
364
365 func TestOpenRemoteProjectTabAppliesSingleSurfacePolicy(t *testing.T) {
366 fs := newFakeServe(t, "s3cret", nil)
367 kernel := &fakeRemoteKernel{statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}, {HostID: "box-two", State: "connected"}}, ensureView: RemoteServerView{State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret"}
368 seedBridgeTestHost(t, "box")
369 if err := editUserConfig(func(c *config.Config) error {
370 if err := c.SetDesktopLayoutStyle("workbench"); err != nil {
371 return err
372 }
373 return c.UpsertRemoteHost(config.RemoteHostEntry{Name: "box-two", Host: "127.0.0.1", Port: 22, User: "dev"})
374 }); err != nil {
375 t.Fatal(err)
376 }
377 a := &App{remoteRuntime: kernel}
378 cleanupRemoteTabPumps(t, a)
379 seedLocalTab(a, "local")
380 a.mu.Lock()
381 a.activeTabID = "local"
382 a.mu.Unlock()
383
384 first, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true})
385 if err != nil {
386 t.Fatal(err)
387 }
388 waitForTabState(t, a, first.ID, "ready")
389 second, err := a.OpenRemoteProjectTab("box-two", "~/other", RemoteTabOpenOptions{NewSession: true})
390 if err != nil {
391 t.Fatal(err)
392 }
393 waitForTabState(t, a, second.ID, "ready")
394
395 tabs := a.ListTabs()
396 if len(tabs) != 1 || tabs[0].ID != second.ID || !tabs[0].Active {
397 t.Fatalf("single-surface tabs = %+v, want only active remote %q", tabs, second.ID)
398 }
399 a.mu.RLock()
400 localCount := len(a.tabs)
401 a.mu.RUnlock()
402 a.remoteTabMu.Lock()
403 remoteCount := len(a.remoteTabs)
404 a.remoteTabMu.Unlock()
405 if localCount != 0 || remoteCount != 1 {
406 t.Fatalf("single-surface registry counts local=%d remote=%d", localCount, remoteCount)
407 }
408 if err := a.CloseRemoteTab(second.ID); err == nil || !strings.Contains(err.Error(), "cannot close the last tab") {
409 t.Fatalf("close final remote surface error = %v", err)
410 }
411 a.remoteTabMu.Lock()
412 _, stillVisible := a.remoteTabs[second.ID]
413 a.remoteTabMu.Unlock()
414 if !stillVisible {
415 t.Fatal("failed final-surface close removed the remote tab")
416 }
417 }
418
419 // TestReorderTabsMixedPersistsBothOrders: the full strip order partitions into
420 // local and remote orders; both persist; unknown remote ids reject the whole
421 // reorder without mutating either side.
422 func TestReorderTabsMixedPersistsBothOrders(t *testing.T) {
423 seedBridgeTestHost(t, "box")
424 a := &App{}
425 seedLocalTab(a, "l1")
426 seedLocalTab(a, "l2")
427 a.remoteTabMu.Lock()
428 a.remoteTabs = map[string]*remoteTab{"r1": {id: "r1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}}}
429 a.remoteTabLayout.order = []string{"r1"}
430 a.remoteTabMu.Unlock()
431
432 if err := a.ReorderTabs([]string{"r1", "l2", "l1"}); err != nil {
433 t.Fatal(err)
434 }
435 a.mu.RLock()
436 order := append([]string(nil), a.tabOrder...)
437 a.mu.RUnlock()
438 if len(order) != 2 || order[0] != "l2" || order[1] != "l1" {
439 t.Fatalf("local order = %v, want [l2 l1]", order)
440 }
441 f := readPersistedTabsFile(t)
442 if len(f.RemoteTabOrder) != 1 || f.RemoteTabOrder[0] != "r1" {
443 t.Fatalf("persisted remote order = %v, want [r1]", f.RemoteTabOrder)
444 }
445 if got := strings.Join(f.TabOrder, ","); got != "r1,l2,l1" {
446 t.Fatalf("persisted mixed strip order = %q, want r1,l2,l1", got)
447 }
448
449 if err := a.ReorderTabs([]string{"l1", "l2", "ghost"}); err == nil {
450 t.Fatal("reorder accepted an unknown remote id")
451 }
452 a.mu.RLock()
453 order = append([]string(nil), a.tabOrder...)
454 a.mu.RUnlock()
455 if len(order) != 2 || order[0] != "l2" || order[1] != "l1" {
456 t.Fatalf("local order after rejected reorder = %v, want unchanged [l2 l1]", order)
457 }
458 }
459
460 func TestReconcileTabStripOrderPreservesMixedOrderAndRepairsMembership(t *testing.T) {
461 got := reconcileTabStripOrder(
462 []string{"remote-1", "gone", "local-2", "remote-1"},
463 []string{"local-1", "local-2"},
464 []string{"remote-1", "remote-2"},
465 )
466 if joined := strings.Join(got, ","); joined != "remote-1,local-2,local-1,remote-2" {
467 t.Fatalf("reconciled strip order = %q", joined)
468 }
469 }
470
471 func TestRemoteTabMetasMarksOnlySelectedTabActive(t *testing.T) {
472 a := &App{
473 remoteTabs: map[string]*remoteTab{
474 "remote-1": {
475 id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/one"},
476 runtime: remoteTabRuntimeState{
477 running: true, turnStartedAt: 123, pendingPrompt: true,
478 backgroundJobs: 2, cancelRequested: true, cancellable: true,
479 },
480 },
481 "remote-2": {id: "remote-2", ref: RemoteTabRef{HostID: "box", Workspace: "~/two"}},
482 },
483 remoteTabLayout: remoteTabLayoutState{
484 order: []string{"remote-1", "remote-2"},
485 activeID: "remote-2",
486 stripOrder: []string{"remote-1", "local-1", "remote-2"},
487 },
488 }
489 metas, active, order := a.remoteTabMetas([]string{"local-1"})
490 if active != "remote-2" || strings.Join(order, ",") != "remote-1,local-1,remote-2" {
491 t.Fatalf("active/order = %q / %v", active, order)
492 }
493 for _, meta := range metas {
494 if meta.Active != (meta.ID == "remote-2") {
495 t.Fatalf("meta %s active = %v", meta.ID, meta.Active)
496 }
497 if meta.ID == "remote-1" && (!meta.Running || meta.TurnStartedAt != 123 || !meta.PendingPrompt || meta.BackgroundJobs != 2 || !meta.CancelRequested || !meta.Cancellable) {
498 t.Fatalf("inactive remote runtime meta = %+v", meta)
499 }
500 }
501 }
502
503 func TestCloseFinalLocalTabAllowsRemainingRemoteSurface(t *testing.T) {
504 a := &App{}
505 seedLocalTab(a, "local")
506 a.remoteTabs = map[string]*remoteTab{
507 "remote": {id: "remote", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "disconnected"},
508 }
509 a.remoteTabLayout.order = []string{"remote"}
510 a.remoteTabLayout.stripOrder = []string{"local", "remote"}
511 a.remoteTabLayout.activeID = "remote"
512 if err := a.CloseTab("local"); err != nil {
513 t.Fatalf("CloseTab(local) with remote survivor: %v", err)
514 }
515 a.mu.RLock()
516 localCount := len(a.tabs)
517 a.mu.RUnlock()
518 if localCount != 0 {
519 t.Fatalf("local tab count = %d, want 0", localCount)
520 }
521 if tabs := a.ListTabs(); len(tabs) != 1 || tabs[0].ID != "remote" || !tabs[0].Active {
522 t.Fatalf("remaining surfaces = %+v, want active remote", tabs)
523 }
524 }
525
526 func TestRemoteStatusRefreshPublishesInactiveRuntimeMeta(t *testing.T) {
527 client := &http.Client{}
528 log := &eventLog{}
529 a := &App{
530 remoteEventHook: log.add,
531 remoteTabs: map[string]*remoteTab{
532 "remote-1": {id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/one"}, client: client, gen: 4},
533 "remote-2": {id: "remote-2", ref: RemoteTabRef{HostID: "box", Workspace: "~/two"}},
534 },
535 remoteTabLayout: remoteTabLayoutState{activeID: "remote-2"},
536 }
537 statusSeq := a.reserveRemoteTabStatusSequence("remote-1", client, 4)
538 a.recordRemoteTabSessionStatus("remote-1", client, 4, statusSeq, json.RawMessage(`{"running":true,"pendingPrompt":true,"backgroundJobs":3,"cancelRequested":true,"cancellable":true}`))
539 metas, _, _ := a.remoteTabMetas(nil)
540 var got TabMeta
541 for _, meta := range metas {
542 if meta.ID == "remote-1" {
543 got = meta
544 }
545 }
546 if got.Active || !got.Running || !got.PendingPrompt || got.BackgroundJobs != 3 || !got.CancelRequested || !got.Cancellable || got.TurnStartedAt <= 0 {
547 t.Fatalf("inactive status projection = %+v", got)
548 }
549 if log.count("remote-tab:updated ") != 1 {
550 t.Fatalf("runtime status update events = %v", log.recorded())
551 }
552 }
553
554 func TestRemoteStatusRefreshRejectsOutOfOrderSnapshot(t *testing.T) {
555 client := &http.Client{}
556 a := &App{remoteTabs: map[string]*remoteTab{
557 "remote-1": {id: "remote-1", client: client, gen: 4},
558 }}
559 older := a.reserveRemoteTabStatusSequence("remote-1", client, 4)
560 newer := a.reserveRemoteTabStatusSequence("remote-1", client, 4)
561 a.recordRemoteTabSessionStatus("remote-1", client, 4, newer, json.RawMessage(`{"running":false,"pendingPrompt":false}`))
562 a.recordRemoteTabSessionStatus("remote-1", client, 4, older, json.RawMessage(`{"running":true,"pendingPrompt":true}`))
563 a.remoteTabMu.Lock()
564 runtime := a.remoteTabs["remote-1"].runtime
565 a.remoteTabMu.Unlock()
566 if runtime.running || runtime.pendingPrompt {
567 t.Fatalf("older status overwrote newer settled state: %+v", runtime)
568 }
569 }
570
571 // TestRemoteTabStatusSupersededRaceReturnsSentinel: when an SSE-derived frame
572 // advances the tab revision while a /status poll is in flight, RemoteTabStatus
573 // must fail with the superseded sentinel — a benign stale snapshot, distinct
574 // from transport failures — instead of an opaque error that surfaces as a
575 // crash report.
576 func TestRemoteTabStatusSupersededRaceReturnsSentinel(t *testing.T) {
577 client := &http.Client{}
578 a := &App{remoteTabs: map[string]*remoteTab{
579 "remote-1": {id: "remote-1", client: client, gen: 4, state: "ready"},
580 }}
581 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
582 // The serve streams a turn_started between reservation and recording:
583 // the frame handler advances the revision mid-poll.
584 a.reserveRemoteTabStatusSequence("remote-1", client, 4)
585 w.Header().Set("Content-Type", "application/json")
586 _, _ = w.Write([]byte(`{"running":false,"pendingPrompt":false}`))
587 }))
588 t.Cleanup(server.Close)
589 a.remoteTabMu.Lock()
590 a.remoteTabs["remote-1"].base = server.URL
591 a.remoteTabMu.Unlock()
592
593 _, err := a.RemoteTabStatus("remote-1")
594 if err == nil {
595 t.Fatal("superseded status poll returned nil error")
596 }
597 if !errors.Is(err, errRemoteTabStatusSuperseded) {
598 t.Fatalf("superseded race error = %v, want errRemoteTabStatusSuperseded", err)
599 }
600 if want := `remote tab "remote-1" status was superseded by newer runtime state`; err.Error() != want {
601 t.Fatalf("superseded race message = %q, want %q", err.Error(), want)
602 }
603 }
604
605 func TestRemoteStatusRefreshRejectsSnapshotOlderThanTurnDone(t *testing.T) {
606 client := &http.Client{}
607 a := &App{remoteTabs: map[string]*remoteTab{
608 "remote-1": {id: "remote-1", client: client, gen: 4, runtime: remoteTabRuntimeState{running: true}},
609 }}
610 stale := a.reserveRemoteTabStatusSequence("remote-1", client, 4)
611 a.completeRemoteTabTurn("remote-1", 4)
612 a.recordRemoteTabSessionStatus("remote-1", client, 4, stale, json.RawMessage(`{"running":true,"pendingPrompt":true}`))
613 a.remoteTabMu.Lock()
614 runtime := a.remoteTabs["remote-1"].runtime
615 a.remoteTabMu.Unlock()
616 if runtime.running || runtime.pendingPrompt {
617 t.Fatalf("pre-turn_done status revived settled runtime: %+v", runtime)
618 }
619 }
620
621 // TestSingleSurfaceTabsFileCollapsesRemote: workbench/creation layouts keep
622 // exactly one surface across local and remote tabs, preferring the active one.
623 // One local entry survives even when the remote surface is active: local
624 // commands need a workspace tab to target, and the kept entry restores without
625 // a runtime so it adds no hidden startup work.
626 func TestSingleSurfaceTabsFileCollapsesRemote(t *testing.T) {
627 f := desktopTabsFile{
628 Tabs: []desktopTabEntry{{ID: "l1"}, {ID: "l2"}},
629 RemoteTabs: []desktopRemoteTabEntry{{ID: "r1", HostID: "h", Workspace: "~/a"}, {ID: "r2", HostID: "h", Workspace: "~/b"}},
630 ActiveTab: "r1",
631 }
632 out := singleSurfaceTabsFile(f)
633 if len(out.RemoteTabs) != 1 || out.RemoteTabs[0].ID != "r1" || out.ActiveTab != "r1" || !remoteSurfaceIsActiveTab(out) {
634 t.Fatalf("single-surface collapse = %+v", out)
635 }
636 if len(out.Tabs) != 1 || out.Tabs[0].ID != "l1" {
637 t.Fatalf("collapsed local entries = %+v, want the dormant l1", out.Tabs)
638 }
639 }
640
641 func TestSingleSurfaceTabsFilePrefersActiveLocalOverRemote(t *testing.T) {
642 f := desktopTabsFile{
643 Tabs: []desktopTabEntry{{ID: "l1"}, {ID: "l2"}},
644 RemoteTabs: []desktopRemoteTabEntry{{ID: "r1", HostID: "h", Workspace: "~/a"}},
645 ActiveTab: "l2",
646 }
647 out := singleSurfaceTabsFile(f)
648 if len(out.Tabs) != 1 || out.Tabs[0].ID != "l2" || len(out.RemoteTabs) != 0 || out.ActiveTab != "l2" {
649 t.Fatalf("single-surface local collapse = %+v", out)
650 }
651 }
652
653 // TestSuspendSkipsDisconnectedShells: host status transitions never flip a
654 // restored shell into a runtime state.
655 func TestSuspendSkipsDisconnectedShells(t *testing.T) {
656 a := &App{}
657 a.remoteTabMu.Lock()
658 a.remoteTabs = map[string]*remoteTab{
659 "shell": {id: "shell", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "disconnected"},
660 "live": {id: "live", ref: RemoteTabRef{HostID: "box", Workspace: "~/web"}, state: "ready"},
661 }
662 a.remoteTabMu.Unlock()
663 a.suspendRemoteTabPumps("box", "reconnecting", "")
664 a.remoteTabMu.Lock()
665 defer a.remoteTabMu.Unlock()
666 if a.remoteTabs["shell"].state != "disconnected" {
667 t.Fatalf("shell state = %q, want disconnected", a.remoteTabs["shell"].state)
668 }
669 if a.remoteTabs["live"].state != "reconnecting" {
670 t.Fatalf("live state = %q, want reconnecting", a.remoteTabs["live"].state)
671 }
672 }
673
674 // TestTabsFileWithoutRemoteTabsKeepsLegacyShape: with no remote tabs open the
675 // persisted file carries no remote keys, so local-only usage stays
676 // byte-compatible with the pre-remote format.
677 func TestTabsFileWithoutRemoteTabsKeepsLegacyShape(t *testing.T) {
678 seedBridgeTestHost(t, "box")
679 a := &App{}
680 seedLocalTab(a, "l1")
681 a.mu.Lock()
682 a.activeTabID = "l1"
683 a.mu.Unlock()
684 a.saveTabsFromRemote()
685 data, err := os.ReadFile(filepath.Join(config.ReasonixHomeDir(), tabsFileName))
686 if err != nil {
687 t.Fatal(err)
688 }
689 if strings.Contains(string(data), "remoteTabs") || strings.Contains(string(data), "remoteTabOrder") {
690 t.Fatalf("tabs file mentions remote keys with none open:\n%s", data)
691 }
692 }
693
694 // TestRemoteOnlyLayoutRestoresOneDormantLocalTab: a remote-only layout must not
695 // leave the app without a local workspace tab — local session opens and folder
696 // drops target one — while the remote shell stays the visible surface and no
697 // hidden startup work is added.
698 func TestRemoteOnlyLayoutRestoresOneDormantLocalTab(t *testing.T) {
699 isolateDesktopUserDirs(t)
700 seedBridgeTestHost(t, "box")
701 dir := desktopConfigDir()
702 if err := os.MkdirAll(dir, 0o755); err != nil {
703 t.Fatal(err)
704 }
705 body := `{"tabs":null,"activeTab":"r1","remoteTabs":[{"id":"r1","hostId":"box","workspace":"~/app"}],"remoteTabOrder":["r1"],"tabOrder":["r1"]}`
706 if err := os.WriteFile(filepath.Join(dir, tabsFileName), []byte(body), 0o644); err != nil {
707 t.Fatal(err)
708 }
709 a := NewApp()
710 a.ctx = t.Context()
711 a.remoteRuntime = &fakeRemoteKernel{}
712 t.Cleanup(func() { a.shutdown(context.Background()) })
713
714 a.restoreOrBuildTabs()
715
716 local := a.singleLocalTab()
717 a.mu.RLock()
718 tabCount, activeID := len(a.tabs), a.activeTabID
719 a.mu.RUnlock()
720 if tabCount != 1 || local == nil {
721 t.Fatalf("restored local tabs = %d, want exactly one dormant tab", tabCount)
722 }
723 if local.Ctrl != nil {
724 t.Fatal("dormant local tab built a runtime at startup")
725 }
726 if activeID != "" {
727 t.Fatalf("activeTabID = %q, want the remote surface to stay active", activeID)
728 }
729 // Activating the restored remote shell (what the frontend does once the
730 // layout is up) must keep it the visible surface: the dormant local tab
731 // never claims it.
732 a.remoteTabMu.Lock()
733 a.remoteTabLayout.activeID = "r1"
734 a.remoteTabMu.Unlock()
735 remoteSeen := false
736 for _, meta := range a.ListTabs() {
737 if meta.Remote != nil {
738 remoteSeen = true
739 if !meta.Active {
740 t.Fatalf("remote surface lost the visible surface: %+v", meta)
741 }
742 continue
743 }
744 if meta.Active {
745 t.Fatalf("dormant local tab claims the visible surface: %+v", meta)
746 }
747 }
748 if !remoteSeen {
749 t.Fatal("restored remote shell disappeared")
750 }
751 }
752
753 // TestOpenSessionUsesDormantLocalTabWithoutAnActiveTab: the state a remote-only
754 // layout restores (no active local tab, one dormant tab) must open a local
755 // canonical session instead of reporting "workspace is not ready".
756 func TestOpenSessionUsesDormantLocalTabWithoutAnActiveTab(t *testing.T) {
757 app, ref := lifecycleFixture(t)
758 app.mu.Lock()
759 app.tabs = map[string]*WorkspaceTab{}
760 app.tabOrder = nil
761 app.activeTabID = ""
762 app.mu.Unlock()
763
764 release, err := app.beginProjectRuntimeAdmission("global", globalTabWorkspaceRoot())
765 if err != nil {
766 t.Fatal(err)
767 }
768 dormant := app.createTabEntry("global", globalTabWorkspaceRoot(), "")
769 dormant.sink = &tabEventSink{tabID: dormant.ID, app: app}
770 app.publishRestoredTab(dormant, release)
771
772 if _, err := app.OpenSession(ref); err != nil {
773 t.Fatalf("open session with only a dormant local tab: %v", err)
774 }
775 app.mu.RLock()
776 got := app.tabs[dormant.ID]
777 var boundCtrl control.SessionAPI
778 var boundSessionID string
779 if got != nil {
780 boundCtrl, boundSessionID = got.Ctrl, got.SessionID
781 }
782 app.mu.RUnlock()
783 if got == nil || boundCtrl == nil || boundSessionID != ref.SessionID {
784 t.Fatalf("dormant tab after open = %+v (ctrl nil=%v sessionID=%q)", got, boundCtrl == nil, boundSessionID)
785 }
786 }
787
788 // TestResumeAdoptsTabRuntimeWhenControllerIsNil: a caller that resolved a tab
789 // before its runtime existed (a dormant tab restored for a remote-only layout)
790 // must not fail the open when a concurrent activation already built one.
791 func TestResumeAdoptsTabRuntimeWhenControllerIsNil(t *testing.T) {
792 app, tab, ctrl, _ := auditMigratedTab(t)
793 ref, ok := ctrl.SessionRef()
794 if !ok {
795 t.Fatal("fixture controller has no session ref")
796 }
797 if _, err := app.resumeCanonicalSessionForTranscript(tab, nil, sessionRoute(ref.SessionID), defaultHistoryPageTurns, false); err != nil {
798 t.Fatalf("resume with a nil controller: %v", err)
799 }
800 }
801
801 lines GO