返回 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 if got := readPersistedTabsFile(t).ActiveTab; got != "remote-1" {
243 t.Fatalf("persisted active tab = %q, want remote-1", got)
244 }
245 if err := a.SetActiveTab("missing"); err == nil {
246 t.Fatal("unknown tab activation succeeded")
247 }
248 a.remoteTabMu.Lock()
249 active := a.remoteTabLayout.activeID
250 a.remoteTabMu.Unlock()
251 if active != "remote-1" {
252 t.Fatalf("unknown activation cleared remote selection: %q", active)
253 }
254 }
255
256 func TestSetActiveRemoteTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
257 path := filepath.Join(t.TempDir(), "blocked.jsonl")
258 if err := os.Mkdir(path, 0o755); err != nil {
259 t.Fatalf("mkdir blocked path: %v", err)
260 }
261 a, _ := appWithTab(t, path)
262 a.remoteTabMu.Lock()
263 a.remoteTabs = map[string]*remoteTab{
264 "remote-1": {id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "ready"},
265 }
266 a.remoteTabLayout.order = []string{"remote-1"}
267 a.remoteTabMu.Unlock()
268
269 err := a.SetActiveTab("remote-1")
270 if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
271 t.Fatalf("SetActiveTab(remote) error = %v, want persistence failure", err)
272 }
273 a.remoteTabMu.Lock()
274 active := a.remoteTabLayout.activeID
275 a.remoteTabMu.Unlock()
276 if active != "" {
277 t.Fatalf("remote active tab = %q, want local selection preserved", active)
278 }
279 }
280
281 func TestSetActiveLocalTabKeepsRemoteSelectionWhenSessionCannotPersist(t *testing.T) {
282 path := filepath.Join(t.TempDir(), "blocked.jsonl")
283 if err := os.Mkdir(path, 0o755); err != nil {
284 t.Fatalf("mkdir blocked path: %v", err)
285 }
286 a, _ := appWithTab(t, path)
287 seedLocalTab(a, "target")
288 a.remoteTabMu.Lock()
289 a.remoteTabs = map[string]*remoteTab{
290 "remote-1": {id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "ready"},
291 }
292 a.remoteTabLayout.activeID = "remote-1"
293 a.remoteTabLayout.order = []string{"remote-1"}
294 a.remoteTabMu.Unlock()
295
296 err := a.SetActiveTab("target")
297 if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
298 t.Fatalf("SetActiveTab(local) error = %v, want persistence failure", err)
299 }
300 a.remoteTabMu.Lock()
301 active := a.remoteTabLayout.activeID
302 a.remoteTabMu.Unlock()
303 if active != "remote-1" {
304 t.Fatalf("remote active tab = %q, want original remote selection", active)
305 }
306 if a.activeTabID != "test_tab" {
307 t.Fatalf("local active tab = %q, want original local tab", a.activeTabID)
308 }
309 }
310
311 func TestOpenRemoteProjectTabBlocksBeforeMutationWhenLocalSessionCannotPersist(t *testing.T) {
312 home := t.TempDir()
313 t.Setenv("REASONIX_HOME", home)
314 t.Setenv("HOME", home)
315 seedBridgeTestHost(t, "box")
316 path := filepath.Join(t.TempDir(), "blocked.jsonl")
317 if err := os.Mkdir(path, 0o755); err != nil {
318 t.Fatalf("mkdir blocked path: %v", err)
319 }
320 a, _ := appWithTab(t, path)
321
322 _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true})
323 if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
324 t.Fatalf("OpenRemoteProjectTab error = %v, want persistence failure", err)
325 }
326 a.remoteTabMu.Lock()
327 remoteCount := len(a.remoteTabs)
328 a.remoteTabMu.Unlock()
329 if remoteCount != 0 {
330 t.Fatalf("remote tab count = %d, want no mutation after failed save", remoteCount)
331 }
332 }
333
334 // TestOpenRemoteProjectTabRevivesShell: the tree-group path (ensure-open)
335 // reconnects a disconnected shell in place instead of only activating it.
336 func TestOpenRemoteProjectTabRevivesShell(t *testing.T) {
337 fs := newFakeServe(t, "s3cret", nil)
338 kernel := &fakeRemoteKernel{
339 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
340 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
341 ensureToken: "s3cret",
342 }
343 seedBridgeTestHost(t, "box")
344 a := &App{remoteRuntime: kernel}
345 cleanupRemoteTabPumps(t, a)
346 a.remoteTabMu.Lock()
347 a.remoteTabs = map[string]*remoteTab{
348 "shell-1": {id: "shell-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "disconnected", session: remoteTabSessionState{newSession: true}, hostLabel: "box", topicTitle: "app"},
349 }
350 a.remoteTabLayout.order = []string{"shell-1"}
351 a.remoteTabMu.Unlock()
352
353 meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true})
354 if err != nil {
355 t.Fatal(err)
356 }
357 if meta.ID != "shell-1" {
358 t.Fatalf("revived tab id = %q, want the shell id shell-1", meta.ID)
359 }
360 waitForTabState(t, a, "shell-1", "ready")
361 }
362
363 func TestOpenRemoteProjectTabAppliesSingleSurfacePolicy(t *testing.T) {
364 fs := newFakeServe(t, "s3cret", nil)
365 kernel := &fakeRemoteKernel{statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}, {HostID: "box-two", State: "connected"}}, ensureView: RemoteServerView{State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret"}
366 seedBridgeTestHost(t, "box")
367 if err := editUserConfig(func(c *config.Config) error {
368 if err := c.SetDesktopLayoutStyle("workbench"); err != nil {
369 return err
370 }
371 return c.UpsertRemoteHost(config.RemoteHostEntry{Name: "box-two", Host: "127.0.0.1", Port: 22, User: "dev"})
372 }); err != nil {
373 t.Fatal(err)
374 }
375 a := &App{remoteRuntime: kernel}
376 cleanupRemoteTabPumps(t, a)
377 seedLocalTab(a, "local")
378 a.mu.Lock()
379 a.activeTabID = "local"
380 a.mu.Unlock()
381
382 first, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true})
383 if err != nil {
384 t.Fatal(err)
385 }
386 waitForTabState(t, a, first.ID, "ready")
387 second, err := a.OpenRemoteProjectTab("box-two", "~/other", RemoteTabOpenOptions{NewSession: true})
388 if err != nil {
389 t.Fatal(err)
390 }
391 waitForTabState(t, a, second.ID, "ready")
392
393 tabs := a.ListTabs()
394 if len(tabs) != 1 || tabs[0].ID != second.ID || !tabs[0].Active {
395 t.Fatalf("single-surface tabs = %+v, want only active remote %q", tabs, second.ID)
396 }
397 a.mu.RLock()
398 localCount := len(a.tabs)
399 a.mu.RUnlock()
400 a.remoteTabMu.Lock()
401 remoteCount := len(a.remoteTabs)
402 a.remoteTabMu.Unlock()
403 if localCount != 0 || remoteCount != 1 {
404 t.Fatalf("single-surface registry counts local=%d remote=%d", localCount, remoteCount)
405 }
406 if err := a.CloseRemoteTab(second.ID); err == nil || !strings.Contains(err.Error(), "cannot close the last tab") {
407 t.Fatalf("close final remote surface error = %v", err)
408 }
409 a.remoteTabMu.Lock()
410 _, stillVisible := a.remoteTabs[second.ID]
411 a.remoteTabMu.Unlock()
412 if !stillVisible {
413 t.Fatal("failed final-surface close removed the remote tab")
414 }
415 }
416
417 // TestReorderTabsMixedPersistsBothOrders: the full strip order partitions into
418 // local and remote orders; both persist; unknown remote ids reject the whole
419 // reorder without mutating either side.
420 func TestReorderTabsMixedPersistsBothOrders(t *testing.T) {
421 seedBridgeTestHost(t, "box")
422 a := &App{}
423 seedLocalTab(a, "l1")
424 seedLocalTab(a, "l2")
425 a.remoteTabMu.Lock()
426 a.remoteTabs = map[string]*remoteTab{"r1": {id: "r1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}}}
427 a.remoteTabLayout.order = []string{"r1"}
428 a.remoteTabMu.Unlock()
429
430 if err := a.ReorderTabs([]string{"r1", "l2", "l1"}); err != nil {
431 t.Fatal(err)
432 }
433 a.mu.RLock()
434 order := append([]string(nil), a.tabOrder...)
435 a.mu.RUnlock()
436 if len(order) != 2 || order[0] != "l2" || order[1] != "l1" {
437 t.Fatalf("local order = %v, want [l2 l1]", order)
438 }
439 f := readPersistedTabsFile(t)
440 if len(f.RemoteTabOrder) != 1 || f.RemoteTabOrder[0] != "r1" {
441 t.Fatalf("persisted remote order = %v, want [r1]", f.RemoteTabOrder)
442 }
443 if got := strings.Join(f.TabOrder, ","); got != "r1,l2,l1" {
444 t.Fatalf("persisted mixed strip order = %q, want r1,l2,l1", got)
445 }
446
447 if err := a.ReorderTabs([]string{"l1", "l2", "ghost"}); err == nil {
448 t.Fatal("reorder accepted an unknown remote id")
449 }
450 a.mu.RLock()
451 order = append([]string(nil), a.tabOrder...)
452 a.mu.RUnlock()
453 if len(order) != 2 || order[0] != "l2" || order[1] != "l1" {
454 t.Fatalf("local order after rejected reorder = %v, want unchanged [l2 l1]", order)
455 }
456 }
457
458 func TestReconcileTabStripOrderPreservesMixedOrderAndRepairsMembership(t *testing.T) {
459 got := reconcileTabStripOrder(
460 []string{"remote-1", "gone", "local-2", "remote-1"},
461 []string{"local-1", "local-2"},
462 []string{"remote-1", "remote-2"},
463 )
464 if joined := strings.Join(got, ","); joined != "remote-1,local-2,local-1,remote-2" {
465 t.Fatalf("reconciled strip order = %q", joined)
466 }
467 }
468
469 func TestRemoteTabMetasMarksOnlySelectedTabActive(t *testing.T) {
470 a := &App{
471 remoteTabs: map[string]*remoteTab{
472 "remote-1": {
473 id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/one"},
474 runtime: remoteTabRuntimeState{
475 running: true, turnStartedAt: 123, pendingPrompt: true,
476 backgroundJobs: 2, cancelRequested: true, cancellable: true,
477 },
478 },
479 "remote-2": {id: "remote-2", ref: RemoteTabRef{HostID: "box", Workspace: "~/two"}},
480 },
481 remoteTabLayout: remoteTabLayoutState{
482 order: []string{"remote-1", "remote-2"},
483 activeID: "remote-2",
484 stripOrder: []string{"remote-1", "local-1", "remote-2"},
485 },
486 }
487 metas, active, order := a.remoteTabMetas([]string{"local-1"})
488 if active != "remote-2" || strings.Join(order, ",") != "remote-1,local-1,remote-2" {
489 t.Fatalf("active/order = %q / %v", active, order)
490 }
491 for _, meta := range metas {
492 if meta.Active != (meta.ID == "remote-2") {
493 t.Fatalf("meta %s active = %v", meta.ID, meta.Active)
494 }
495 if meta.ID == "remote-1" && (!meta.Running || meta.TurnStartedAt != 123 || !meta.PendingPrompt || meta.BackgroundJobs != 2 || !meta.CancelRequested || !meta.Cancellable) {
496 t.Fatalf("inactive remote runtime meta = %+v", meta)
497 }
498 }
499 }
500
501 func TestCloseFinalLocalTabAllowsRemainingRemoteSurface(t *testing.T) {
502 a := &App{}
503 seedLocalTab(a, "local")
504 a.remoteTabs = map[string]*remoteTab{
505 "remote": {id: "remote", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "disconnected"},
506 }
507 a.remoteTabLayout.order = []string{"remote"}
508 a.remoteTabLayout.stripOrder = []string{"local", "remote"}
509 a.remoteTabLayout.activeID = "remote"
510 if err := a.CloseTab("local"); err != nil {
511 t.Fatalf("CloseTab(local) with remote survivor: %v", err)
512 }
513 a.mu.RLock()
514 localCount := len(a.tabs)
515 a.mu.RUnlock()
516 if localCount != 0 {
517 t.Fatalf("local tab count = %d, want 0", localCount)
518 }
519 if tabs := a.ListTabs(); len(tabs) != 1 || tabs[0].ID != "remote" || !tabs[0].Active {
520 t.Fatalf("remaining surfaces = %+v, want active remote", tabs)
521 }
522 }
523
524 func TestRemoteStatusRefreshPublishesInactiveRuntimeMeta(t *testing.T) {
525 client := &http.Client{}
526 log := &eventLog{}
527 a := &App{
528 remoteEventHook: log.add,
529 remoteTabs: map[string]*remoteTab{
530 "remote-1": {id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/one"}, client: client, gen: 4},
531 "remote-2": {id: "remote-2", ref: RemoteTabRef{HostID: "box", Workspace: "~/two"}},
532 },
533 remoteTabLayout: remoteTabLayoutState{activeID: "remote-2"},
534 }
535 statusSeq := a.reserveRemoteTabStatusSequence("remote-1", client, 4)
536 a.recordRemoteTabSessionStatus("remote-1", client, 4, statusSeq, json.RawMessage(`{"running":true,"pendingPrompt":true,"backgroundJobs":3,"cancelRequested":true,"cancellable":true}`))
537 metas, _, _ := a.remoteTabMetas(nil)
538 var got TabMeta
539 for _, meta := range metas {
540 if meta.ID == "remote-1" {
541 got = meta
542 }
543 }
544 if got.Active || !got.Running || !got.PendingPrompt || got.BackgroundJobs != 3 || !got.CancelRequested || !got.Cancellable || got.TurnStartedAt <= 0 {
545 t.Fatalf("inactive status projection = %+v", got)
546 }
547 if log.count("remote-tab:updated ") != 1 {
548 t.Fatalf("runtime status update events = %v", log.recorded())
549 }
550 }
551
552 func TestRemoteStatusRefreshRejectsOutOfOrderSnapshot(t *testing.T) {
553 client := &http.Client{}
554 a := &App{remoteTabs: map[string]*remoteTab{
555 "remote-1": {id: "remote-1", client: client, gen: 4},
556 }}
557 older := a.reserveRemoteTabStatusSequence("remote-1", client, 4)
558 newer := a.reserveRemoteTabStatusSequence("remote-1", client, 4)
559 a.recordRemoteTabSessionStatus("remote-1", client, 4, newer, json.RawMessage(`{"running":false,"pendingPrompt":false}`))
560 a.recordRemoteTabSessionStatus("remote-1", client, 4, older, json.RawMessage(`{"running":true,"pendingPrompt":true}`))
561 a.remoteTabMu.Lock()
562 runtime := a.remoteTabs["remote-1"].runtime
563 a.remoteTabMu.Unlock()
564 if runtime.running || runtime.pendingPrompt {
565 t.Fatalf("older status overwrote newer settled state: %+v", runtime)
566 }
567 }
568
569 // TestRemoteTabStatusSupersededRaceReturnsSentinel: when an SSE-derived frame
570 // advances the tab revision while a /status poll is in flight, RemoteTabStatus
571 // must fail with the superseded sentinel — a benign stale snapshot, distinct
572 // from transport failures — instead of an opaque error that surfaces as a
573 // crash report.
574 func TestRemoteTabStatusSupersededRaceReturnsSentinel(t *testing.T) {
575 client := &http.Client{}
576 a := &App{remoteTabs: map[string]*remoteTab{
577 "remote-1": {id: "remote-1", client: client, gen: 4, state: "ready"},
578 }}
579 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
580 // The serve streams a turn_started between reservation and recording:
581 // the frame handler advances the revision mid-poll.
582 a.reserveRemoteTabStatusSequence("remote-1", client, 4)
583 w.Header().Set("Content-Type", "application/json")
584 _, _ = w.Write([]byte(`{"running":false,"pendingPrompt":false}`))
585 }))
586 t.Cleanup(server.Close)
587 a.remoteTabMu.Lock()
588 a.remoteTabs["remote-1"].base = server.URL
589 a.remoteTabMu.Unlock()
590
591 _, err := a.RemoteTabStatus("remote-1")
592 if err == nil {
593 t.Fatal("superseded status poll returned nil error")
594 }
595 if !errors.Is(err, errRemoteTabStatusSuperseded) {
596 t.Fatalf("superseded race error = %v, want errRemoteTabStatusSuperseded", err)
597 }
598 if want := `remote tab "remote-1" status was superseded by newer runtime state`; err.Error() != want {
599 t.Fatalf("superseded race message = %q, want %q", err.Error(), want)
600 }
601 }
602
603 func TestRemoteStatusRefreshRejectsSnapshotOlderThanTurnDone(t *testing.T) {
604 client := &http.Client{}
605 a := &App{remoteTabs: map[string]*remoteTab{
606 "remote-1": {id: "remote-1", client: client, gen: 4, runtime: remoteTabRuntimeState{running: true}},
607 }}
608 stale := a.reserveRemoteTabStatusSequence("remote-1", client, 4)
609 a.completeRemoteTabTurn("remote-1", 4)
610 a.recordRemoteTabSessionStatus("remote-1", client, 4, stale, json.RawMessage(`{"running":true,"pendingPrompt":true}`))
611 a.remoteTabMu.Lock()
612 runtime := a.remoteTabs["remote-1"].runtime
613 a.remoteTabMu.Unlock()
614 if runtime.running || runtime.pendingPrompt {
615 t.Fatalf("pre-turn_done status revived settled runtime: %+v", runtime)
616 }
617 }
618
619 // TestSingleSurfaceTabsFileCollapsesRemote: workbench/creation layouts keep
620 // exactly one surface across local and remote tabs, preferring the active one.
621 // One local entry survives even when the remote surface is active: local
622 // commands need a workspace tab to target, and the kept entry restores without
623 // a runtime so it adds no hidden startup work.
624 func TestSingleSurfaceTabsFileCollapsesRemote(t *testing.T) {
625 f := desktopTabsFile{
626 Tabs: []desktopTabEntry{{ID: "l1"}, {ID: "l2"}},
627 RemoteTabs: []desktopRemoteTabEntry{{ID: "r1", HostID: "h", Workspace: "~/a"}, {ID: "r2", HostID: "h", Workspace: "~/b"}},
628 ActiveTab: "r1",
629 }
630 out := singleSurfaceTabsFile(f)
631 if len(out.RemoteTabs) != 1 || out.RemoteTabs[0].ID != "r1" || out.ActiveTab != "r1" || !remoteSurfaceIsActiveTab(out) {
632 t.Fatalf("single-surface collapse = %+v", out)
633 }
634 if len(out.Tabs) != 1 || out.Tabs[0].ID != "l1" {
635 t.Fatalf("collapsed local entries = %+v, want the dormant l1", out.Tabs)
636 }
637 }
638
639 func TestSingleSurfaceTabsFilePrefersActiveLocalOverRemote(t *testing.T) {
640 f := desktopTabsFile{
641 Tabs: []desktopTabEntry{{ID: "l1"}, {ID: "l2"}},
642 RemoteTabs: []desktopRemoteTabEntry{{ID: "r1", HostID: "h", Workspace: "~/a"}},
643 ActiveTab: "l2",
644 }
645 out := singleSurfaceTabsFile(f)
646 if len(out.Tabs) != 1 || out.Tabs[0].ID != "l2" || len(out.RemoteTabs) != 0 || out.ActiveTab != "l2" {
647 t.Fatalf("single-surface local collapse = %+v", out)
648 }
649 }
650
651 // TestSuspendSkipsDisconnectedShells: host status transitions never flip a
652 // restored shell into a runtime state.
653 func TestSuspendSkipsDisconnectedShells(t *testing.T) {
654 a := &App{}
655 a.remoteTabMu.Lock()
656 a.remoteTabs = map[string]*remoteTab{
657 "shell": {id: "shell", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "disconnected"},
658 "live": {id: "live", ref: RemoteTabRef{HostID: "box", Workspace: "~/web"}, state: "ready"},
659 }
660 a.remoteTabMu.Unlock()
661 a.suspendRemoteTabPumps("box", "reconnecting", "")
662 a.remoteTabMu.Lock()
663 defer a.remoteTabMu.Unlock()
664 if a.remoteTabs["shell"].state != "disconnected" {
665 t.Fatalf("shell state = %q, want disconnected", a.remoteTabs["shell"].state)
666 }
667 if a.remoteTabs["live"].state != "reconnecting" {
668 t.Fatalf("live state = %q, want reconnecting", a.remoteTabs["live"].state)
669 }
670 }
671
672 // TestTabsFileWithoutRemoteTabsKeepsLegacyShape: with no remote tabs open the
673 // persisted file carries no remote keys, so local-only usage stays
674 // byte-compatible with the pre-remote format.
675 func TestTabsFileWithoutRemoteTabsKeepsLegacyShape(t *testing.T) {
676 seedBridgeTestHost(t, "box")
677 a := &App{}
678 seedLocalTab(a, "l1")
679 a.mu.Lock()
680 a.activeTabID = "l1"
681 a.mu.Unlock()
682 a.saveTabsFromRemote()
683 data, err := os.ReadFile(filepath.Join(config.ReasonixHomeDir(), tabsFileName))
684 if err != nil {
685 t.Fatal(err)
686 }
687 if strings.Contains(string(data), "remoteTabs") || strings.Contains(string(data), "remoteTabOrder") {
688 t.Fatalf("tabs file mentions remote keys with none open:\n%s", data)
689 }
690 }
691
692 // TestRemoteOnlyLayoutRestoresOneDormantLocalTab: a remote-only layout must not
693 // leave the app without a local workspace tab — local session opens and folder
694 // drops target one — while the remote shell stays the visible surface and no
695 // hidden startup work is added.
696 func TestRemoteOnlyLayoutRestoresOneDormantLocalTab(t *testing.T) {
697 isolateDesktopUserDirs(t)
698 seedBridgeTestHost(t, "box")
699 dir := desktopConfigDir()
700 if err := os.MkdirAll(dir, 0o755); err != nil {
701 t.Fatal(err)
702 }
703 body := `{"tabs":null,"activeTab":"r1","remoteTabs":[{"id":"r1","hostId":"box","workspace":"~/app"}],"remoteTabOrder":["r1"],"tabOrder":["r1"]}`
704 if err := os.WriteFile(filepath.Join(dir, tabsFileName), []byte(body), 0o644); err != nil {
705 t.Fatal(err)
706 }
707 a := NewApp()
708 a.ctx = t.Context()
709 a.remoteRuntime = &fakeRemoteKernel{}
710 t.Cleanup(func() { a.shutdown(context.Background()) })
711
712 a.restoreOrBuildTabs()
713
714 local := a.singleLocalTab()
715 a.mu.RLock()
716 tabCount, activeID := len(a.tabs), a.activeTabID
717 a.mu.RUnlock()
718 if tabCount != 1 || local == nil {
719 t.Fatalf("restored local tabs = %d, want exactly one dormant tab", tabCount)
720 }
721 if local.Ctrl != nil {
722 t.Fatal("dormant local tab built a runtime at startup")
723 }
724 if activeID != "" {
725 t.Fatalf("activeTabID = %q, want the remote surface to stay active", activeID)
726 }
727 // Activating the restored remote shell (what the frontend does once the
728 // layout is up) must keep it the visible surface: the dormant local tab
729 // never claims it.
730 a.remoteTabMu.Lock()
731 a.remoteTabLayout.activeID = "r1"
732 a.remoteTabMu.Unlock()
733 remoteSeen := false
734 for _, meta := range a.ListTabs() {
735 if meta.Remote != nil {
736 remoteSeen = true
737 if !meta.Active {
738 t.Fatalf("remote surface lost the visible surface: %+v", meta)
739 }
740 continue
741 }
742 if meta.Active {
743 t.Fatalf("dormant local tab claims the visible surface: %+v", meta)
744 }
745 }
746 if !remoteSeen {
747 t.Fatal("restored remote shell disappeared")
748 }
749 }
750
751 // TestOpenSessionUsesDormantLocalTabWithoutAnActiveTab: the state a remote-only
752 // layout restores (no active local tab, one dormant tab) must open a local
753 // canonical session instead of reporting "workspace is not ready".
754 func TestOpenSessionUsesDormantLocalTabWithoutAnActiveTab(t *testing.T) {
755 app, ref := lifecycleFixture(t)
756 app.mu.Lock()
757 app.tabs = map[string]*WorkspaceTab{}
758 app.tabOrder = nil
759 app.activeTabID = ""
760 app.mu.Unlock()
761
762 release, err := app.beginProjectRuntimeAdmission("global", globalTabWorkspaceRoot())
763 if err != nil {
764 t.Fatal(err)
765 }
766 dormant := app.createTabEntry("global", globalTabWorkspaceRoot(), "")
767 dormant.sink = &tabEventSink{tabID: dormant.ID, app: app}
768 app.publishRestoredTab(dormant, release)
769
770 if _, err := app.OpenSession(ref); err != nil {
771 t.Fatalf("open session with only a dormant local tab: %v", err)
772 }
773 app.mu.RLock()
774 got := app.tabs[dormant.ID]
775 var boundCtrl control.SessionAPI
776 var boundSessionID string
777 if got != nil {
778 boundCtrl, boundSessionID = got.Ctrl, got.SessionID
779 }
780 app.mu.RUnlock()
781 if got == nil || boundCtrl == nil || boundSessionID != ref.SessionID {
782 t.Fatalf("dormant tab after open = %+v (ctrl nil=%v sessionID=%q)", got, boundCtrl == nil, boundSessionID)
783 }
784 }
785
786 // TestResumeAdoptsTabRuntimeWhenControllerIsNil: a caller that resolved a tab
787 // before its runtime existed (a dormant tab restored for a remote-only layout)
788 // must not fail the open when a concurrent activation already built one.
789 func TestResumeAdoptsTabRuntimeWhenControllerIsNil(t *testing.T) {
790 app, tab, ctrl, _ := auditMigratedTab(t)
791 ref, ok := ctrl.SessionRef()
792 if !ok {
793 t.Fatal("fixture controller has no session ref")
794 }
795 if _, err := app.resumeCanonicalSessionForTranscript(tab, nil, sessionRoute(ref.SessionID), defaultHistoryPageTurns, false); err != nil {
796 t.Fatalf("resume with a nil controller: %v", err)
797 }
798 }
799
799 lines GO