| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "log" |
| 8 | "slices" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "reasonix/internal/config" |
| 14 | ) |
| 15 | |
| 16 | func TestRemoteTabReconnectDoesNotLogEnsureServerSecrets(t *testing.T) { |
| 17 | const secret = "sk-sensitive-reconnect-error" |
| 18 | kernel := &fakeRemoteKernel{ensureErr: errors.New("remote bootstrap failed: " + secret)} |
| 19 | a := &App{ |
| 20 | remoteRuntime: kernel, |
| 21 | remoteTabs: map[string]*remoteTab{ |
| 22 | "remote-1": { |
| 23 | id: "remote-1", |
| 24 | ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, |
| 25 | state: "reconnecting", |
| 26 | }, |
| 27 | }, |
| 28 | } |
| 29 | |
| 30 | var logs bytes.Buffer |
| 31 | previousWriter := log.Writer() |
| 32 | log.SetOutput(&logs) |
| 33 | t.Cleanup(func() { log.SetOutput(previousWriter) }) |
| 34 | |
| 35 | previousDelays := remoteTabReattachDelays |
| 36 | remoteTabReattachDelays = nil |
| 37 | t.Cleanup(func() { remoteTabReattachDelays = previousDelays }) |
| 38 | |
| 39 | a.reattachRemoteTab("remote-1") |
| 40 | if strings.Contains(logs.String(), secret) { |
| 41 | t.Fatalf("reconnect log exposed EnsureServer error: %q", logs.String()) |
| 42 | } |
| 43 | if !strings.Contains(logs.String(), "EnsureServer NOT-READY") { |
| 44 | t.Fatalf("reconnect failure was not logged structurally: %q", logs.String()) |
| 45 | } |
| 46 | a.remoteTabMu.Lock() |
| 47 | state := a.remoteTabs["remote-1"].state |
| 48 | a.remoteTabMu.Unlock() |
| 49 | if kernel.ensureCalls != len(remoteTabReattachDelays)+1 || state != "serve_down" { |
| 50 | t.Fatalf("reattach exhaustion calls/state = %d/%q", kernel.ensureCalls, state) |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | func TestCompleteRemoteTabTurnMakesFreshSessionNonReusable(t *testing.T) { |
| 55 | a := &App{remoteTabs: map[string]*remoteTab{ |
| 56 | "remote": { |
| 57 | id: "remote", |
| 58 | gen: 7, |
| 59 | session: remoteTabSessionState{reset: true}, |
| 60 | pendingEvents: map[string]json.RawMessage{"approval_request:1": json.RawMessage(`{"kind":"approval_request"}`)}, |
| 61 | }, |
| 62 | }} |
| 63 | a.completeRemoteTabTurn("remote", 6) |
| 64 | if !a.remoteTabs["remote"].session.reset { |
| 65 | t.Fatal("a stale stream generation cleared the reusable blank marker") |
| 66 | } |
| 67 | a.completeRemoteTabTurn("remote", 7) |
| 68 | if a.remoteTabs["remote"].session.reset { |
| 69 | t.Fatal("a completed turn remained eligible for blank-session reuse") |
| 70 | } |
| 71 | if len(a.remoteTabs["remote"].pendingEvents) != 0 { |
| 72 | t.Fatal("turn completion did not clear pending prompt replay") |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | func TestMigrateBlankRemoteSessionTitleOverride(t *testing.T) { |
| 77 | isolateDesktopUserDirs(t) |
| 78 | if err := setRemoteSessionTitleOverride("box", "~/app", "", "My first topic"); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | if err := setRemoteSessionPinned("box", "~/app", "", true); err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | got, err := migrateRemoteSessionTitleOverride("box", "~/app", "session-1") |
| 85 | if err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | if got != "My first topic" || remoteSessionTitleOverride("box", "~/app", "session-1") != got { |
| 89 | t.Fatalf("migrated title = %q, named preference = %q", got, remoteSessionTitleOverride("box", "~/app", "session-1")) |
| 90 | } |
| 91 | if blank := remoteSessionTitleOverride("box", "~/app", ""); blank != "" { |
| 92 | t.Fatalf("blank preference survived migration: %q", blank) |
| 93 | } |
| 94 | if !remoteSessionPinned("box", "~/app", "session-1") || remoteSessionPinned("box", "~/app", "") { |
| 95 | t.Fatal("blank-session pin did not move to the durable session identity") |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | func TestRemoteProxyModelCatalogOffersProtocolsForSnapshotSwitch(t *testing.T) { |
| 100 | isolateDesktopUserDirs(t) |
| 101 | setDesktopTestCredential(t, "OPENAI_TEST_KEY", "sk-openai") |
| 102 | setDesktopTestCredential(t, "ANTHROPIC_TEST_KEY", "sk-anthropic") |
| 103 | cfg := config.Default() |
| 104 | cfg.DefaultModel = "chat/gpt-test" |
| 105 | cfg.Providers = []config.ProviderEntry{ |
| 106 | {Name: "chat", Kind: "openai", BaseURL: "https://chat.example/v1", Models: []string{"gpt-test", "gpt-next"}, Default: "gpt-test", APIKeyEnv: "OPENAI_TEST_KEY"}, |
| 107 | {Name: "claude", Kind: "anthropic", BaseURL: "https://claude.example", Models: []string{"claude-test"}, Default: "claude-test", APIKeyEnv: "ANTHROPIC_TEST_KEY"}, |
| 108 | } |
| 109 | if err := cfg.UpsertRemoteHost(config.RemoteHostEntry{Name: "box", Host: "127.0.0.1", CredentialMode: "local-proxy"}); err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | a := &App{remoteTabs: map[string]*remoteTab{ |
| 116 | "remote": {id: "remote", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "ready", model: "chat/gpt-test"}, |
| 117 | }} |
| 118 | models := a.ModelsForTab("remote") |
| 119 | if len(models) != 3 || !slices.ContainsFunc(models, func(model ModelInfo) bool { return model.Provider == "claude" }) { |
| 120 | t.Fatalf("local-proxy catalog omitted an available protocol: %+v", models) |
| 121 | } |
| 122 | err := a.SetRemoteTabModel("remote", "claude/claude-test") |
| 123 | if err == nil || !strings.Contains(err.Error(), "not connected") { |
| 124 | t.Fatalf("cross-protocol switch error = %v", err) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // TestRemoteTabSnapshotMergesServeMembers: all six GETs merge in parallel; |
| 129 | // only /history is required — its failure errors, optional members degrade. |
| 130 | func TestRemoteTabSnapshotMergesServeMembers(t *testing.T) { |
| 131 | fs := newFakeServe(t, "s3cret", nil) |
| 132 | kernel := &fakeRemoteKernel{ |
| 133 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 134 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 135 | ensureToken: "s3cret", |
| 136 | } |
| 137 | seedBridgeTestHost(t, "box") |
| 138 | a := &App{remoteRuntime: kernel} |
| 139 | cleanupRemoteTabPumps(t, a) |
| 140 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true}) |
| 141 | |
| 142 | snap, err := a.RemoteTabSnapshot(meta.ID) |
| 143 | if err != nil { |
| 144 | t.Fatal(err) |
| 145 | } |
| 146 | for name, raw := range map[string]json.RawMessage{ |
| 147 | "history": snap.History, "context": snap.Context, "todos": snap.Todos, |
| 148 | "checkpoints": snap.Checkpoints, "models": snap.Models, "status": snap.Status, |
| 149 | } { |
| 150 | if len(raw) == 0 { |
| 151 | t.Fatalf("snapshot member %s is empty", name) |
| 152 | } |
| 153 | } |
| 154 | before := len(fs.recorded()) |
| 155 | if _, err := a.RemoteTabStatus(meta.ID); err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | statusCalls := fs.recorded()[before:] |
| 159 | if len(statusCalls) != 1 || !strings.HasPrefix(statusCalls[0], "GET /status") { |
| 160 | t.Fatalf("status-only binding fetched extra snapshot members: %v", statusCalls) |
| 161 | } |
| 162 | |
| 163 | fs.mu.Lock() |
| 164 | fs.failHistory = true |
| 165 | fs.mu.Unlock() |
| 166 | if _, err := a.RemoteTabSnapshot(meta.ID); err == nil { |
| 167 | t.Fatal("snapshot with failing /history must error") |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | // TestRemoteProjectSessionsWithoutOpenTab pins the read-only one-shot path: |
| 172 | // listing sessions for a workspace with no live tab reuses the registry's |
| 173 | // ready registration, handshakes, and maps entries to the frontend view — |
| 174 | // without ever ensuring a serve. |
| 175 | func TestRemoteProjectSessionsWithoutOpenTab(t *testing.T) { |
| 176 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{ |
| 177 | {Name: "s1", Path: "/x.jsonl", Title: "First", Turns: 2}, |
| 178 | }) |
| 179 | kernel := &fakeRemoteKernel{ |
| 180 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 181 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 182 | ensureToken: "s3cret", |
| 183 | } |
| 184 | seedBridgeTestHost(t, "box") |
| 185 | a := &App{remoteRuntime: kernel} |
| 186 | |
| 187 | sessions, err := a.RemoteProjectSessions("box", "~/app") |
| 188 | if err != nil { |
| 189 | t.Fatal(err) |
| 190 | } |
| 191 | if len(sessions) != 1 || sessions[0].Name != "s1" || sessions[0].Title != "First" || sessions[0].Turns != 2 { |
| 192 | t.Fatalf("sessions = %+v, want the mapped s1 entry", sessions) |
| 193 | } |
| 194 | found := false |
| 195 | for _, c := range fs.recorded() { |
| 196 | if c == "GET /sessions " { |
| 197 | found = true |
| 198 | } |
| 199 | } |
| 200 | if !found { |
| 201 | t.Fatalf("GET /sessions not reached: %v", fs.recorded()) |
| 202 | } |
| 203 | if kernel.ensureCalls != 0 { |
| 204 | t.Fatalf("listing woke the serve: %d EnsureServer calls", kernel.ensureCalls) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | // TestRemoteProjectSessionsNeverWakesServe: a query path must never |
| 209 | // cold-start a serve — no ready registration means an error, and EnsureServer |
| 210 | // must not even be attempted (the old behavior here starved tab bootstraps |
| 211 | // on the per-host serve lock). |
| 212 | func TestRemoteProjectSessionsNeverWakesServe(t *testing.T) { |
| 213 | kernel := &fakeRemoteKernel{ |
| 214 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 215 | // No ready registration: ServeSnapshot reports nothing. |
| 216 | } |
| 217 | seedBridgeTestHost(t, "box") |
| 218 | a := &App{remoteRuntime: kernel} |
| 219 | |
| 220 | if _, err := a.RemoteProjectSessions("box", "~/app"); err == nil { |
| 221 | t.Fatal("listing must report the serve as not running") |
| 222 | } |
| 223 | if kernel.ensureCalls != 0 { |
| 224 | t.Fatalf("listing woke the serve: %d EnsureServer calls", kernel.ensureCalls) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | // TestResolveOverlappingWorkspace pins the merge rules for overlapping pins: |
| 229 | // exact match wins, then the nearest ancestor, then the shallowest |
| 230 | // descendant; disjoint paths never merge. |
| 231 | func TestResolveOverlappingWorkspace(t *testing.T) { |
| 232 | entries := []config.RemoteProjectEntry{ |
| 233 | {HostID: "box", Workspace: "/srv/app"}, |
| 234 | {HostID: "box", Workspace: "/srv/app/sub"}, |
| 235 | {HostID: "other", Workspace: "/srv/app"}, |
| 236 | } |
| 237 | for _, tc := range []struct { |
| 238 | ws string |
| 239 | want string |
| 240 | ok bool |
| 241 | }{ |
| 242 | {ws: "/srv/app", want: "/srv/app", ok: true}, // exact |
| 243 | {ws: "/srv/app/", want: "/srv/app", ok: true}, // trailing slash normalizes to exact |
| 244 | {ws: "/srv/app/sub/deep", want: "/srv/app/sub", ok: true}, // nearest ancestor |
| 245 | {ws: "/srv", want: "/srv/app", ok: true}, // ancestor request merges into shallowest descendant |
| 246 | {ws: "/srv/other", want: "", ok: false}, // sibling never merges |
| 247 | {ws: "", want: "", ok: false}, // empty never merges |
| 248 | } { |
| 249 | got, ok := resolveOverlappingWorkspace(entries, "box", tc.ws) |
| 250 | if ok != tc.ok || got != tc.want { |
| 251 | t.Fatalf("resolveOverlappingWorkspace(%q) = (%q, %v), want (%q, %v)", tc.ws, got, ok, tc.want, tc.ok) |
| 252 | } |
| 253 | } |
| 254 | // Host scoping: the same path on another host must not capture the merge. |
| 255 | if got, ok := resolveOverlappingWorkspace(entries, "other", "/srv/app/sub/x"); !ok || got != "/srv/app" { |
| 256 | t.Fatalf("cross-host overlap merged: (%q, %v)", got, ok) |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | // TestRemoteTabCommandSurfacesServeErrorBody: the serve's error text (the |
| 261 | // session-in-use close hint) rides through to the caller. |
| 262 | func TestRemoteTabCommandSurfacesServeErrorBody(t *testing.T) { |
| 263 | fs := newFakeServe(t, "s3cret", nil) |
| 264 | kernel := &fakeRemoteKernel{ |
| 265 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 266 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 267 | ensureToken: "s3cret", |
| 268 | } |
| 269 | seedBridgeTestHost(t, "box") |
| 270 | a := &App{remoteRuntime: kernel} |
| 271 | cleanupRemoteTabPumps(t, a) |
| 272 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true}) |
| 273 | |
| 274 | fs.mu.Lock() |
| 275 | fs.failNext = "session in use; close the remote tab first" |
| 276 | fs.mu.Unlock() |
| 277 | err := a.SubmitRemoteTab(meta.ID, "hello") |
| 278 | if err == nil || !strings.Contains(err.Error(), "close the remote tab first") { |
| 279 | t.Fatalf("err = %v, want the serve error body surfaced", err) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | // TestCloseRemoteTabRefusesTheSoleSurface: the one-surface policy keeps the |
| 284 | // last visible surface occupied, so closing it is refused — and a repeated |
| 285 | // attempt must change nothing, rather than tearing the tab down half way. |
| 286 | // Closing a surface that is not the last one is covered by the host-removal |
| 287 | // path, which closes the final surface and hands the slot to a blank local. |
| 288 | func TestCloseRemoteTabRefusesTheSoleSurface(t *testing.T) { |
| 289 | fs := newFakeServe(t, "s3cret", nil) |
| 290 | kernel := &fakeRemoteKernel{ |
| 291 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 292 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 293 | ensureToken: "s3cret", |
| 294 | } |
| 295 | seedBridgeTestHost(t, "box") |
| 296 | a := &App{remoteRuntime: kernel} |
| 297 | cleanupRemoteTabPumps(t, a) |
| 298 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true}) |
| 299 | |
| 300 | for attempt := 1; attempt <= 2; attempt += 1 { |
| 301 | err := a.CloseRemoteTab(meta.ID) |
| 302 | if err == nil || !strings.Contains(err.Error(), "cannot close the last tab") { |
| 303 | t.Fatalf("close attempt %d = %v, want the last-surface refusal", attempt, err) |
| 304 | } |
| 305 | } |
| 306 | a.remoteTabMu.Lock() |
| 307 | _, present := a.remoteTabs[meta.ID] |
| 308 | a.remoteTabMu.Unlock() |
| 309 | if !present { |
| 310 | t.Fatal("a refused close must leave the tab in the registry") |
| 311 | } |
| 312 | if err := a.SubmitRemoteTab(meta.ID, "hi"); err != nil { |
| 313 | t.Fatalf("a refused close must leave the tab usable: %v", err) |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | // TestRemoteTabFollowsHostReconnect pins the SSH-driven lifecycle: a |
| 318 | // transient drop suspends the pump and flags reconnecting, the regained |
| 319 | // connection re-attaches a fresh pump to the still-running serve, and a |
| 320 | // terminal failure parks the tab in error. |
| 321 | func TestRemoteTabFollowsHostReconnect(t *testing.T) { |
| 322 | fs := newFakeServe(t, "s3cret", nil) |
| 323 | kernel := &fakeRemoteKernel{ |
| 324 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 325 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 326 | ensureToken: "s3cret", |
| 327 | } |
| 328 | seedBridgeTestHost(t, "box") |
| 329 | events := &eventLog{} |
| 330 | a := &App{remoteRuntime: kernel, remoteEventHook: events.add} |
| 331 | cleanupRemoteTabPumps(t, a) |
| 332 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true}) |
| 333 | firstConns := fs.eventsCount() |
| 334 | |
| 335 | a.remoteTabsHostStatus("box", "reconnecting", "") |
| 336 | waitForTabState(t, a, meta.ID, "reconnecting") |
| 337 | if events.count("remote-tab:"+meta.ID+":state ") == 0 { |
| 338 | t.Fatal("host disconnect changed backend state without emitting a tab-state event") |
| 339 | } |
| 340 | if err := a.SubmitRemoteTab(meta.ID, "hi"); err == nil { |
| 341 | t.Fatal("commands during reconnecting must fail") |
| 342 | } |
| 343 | |
| 344 | a.remoteTabsHostStatus("box", "connected", "") |
| 345 | waitForTabState(t, a, meta.ID, "ready") |
| 346 | if fs.eventsCount() <= firstConns { |
| 347 | t.Fatalf("re-attach did not open a new event stream: %d then %d", firstConns, fs.eventsCount()) |
| 348 | } |
| 349 | if err := a.SubmitRemoteTab(meta.ID, "back online"); err != nil { |
| 350 | t.Fatalf("submit after reconnect: %v", err) |
| 351 | } |
| 352 | |
| 353 | a.remoteTabsHostStatus("box", "stopped", "ssh: auth failed") |
| 354 | waitForTabState(t, a, meta.ID, "error") |
| 355 | a.remoteTabMu.Lock() |
| 356 | tabErr := a.remoteTabs[meta.ID].err |
| 357 | a.remoteTabMu.Unlock() |
| 358 | if !strings.Contains(tabErr, "ssh: auth failed") { |
| 359 | t.Fatalf("tab error = %q, want the host failure text", tabErr) |
| 360 | } |
| 361 | } |
| 362 | |
| 363 | func TestCloseActiveRemoteTabSelectsAdjacentLocalTab(t *testing.T) { |
| 364 | a := &App{} |
| 365 | seedLocalTab(a, "local-a") |
| 366 | seedLocalTab(a, "local-b") |
| 367 | a.mu.Lock() |
| 368 | a.activeTabID = "local-a" |
| 369 | a.mu.Unlock() |
| 370 | a.remoteTabMu.Lock() |
| 371 | a.remoteTabs = map[string]*remoteTab{"remote": {id: "remote", state: "ready"}} |
| 372 | a.remoteTabLayout = remoteTabLayoutState{activeID: "remote", order: []string{"remote"}, stripOrder: []string{"local-a", "remote", "local-b"}} |
| 373 | a.remoteTabMu.Unlock() |
| 374 | if err := a.CloseRemoteTab("remote"); err != nil { |
| 375 | t.Fatal(err) |
| 376 | } |
| 377 | a.mu.RLock() |
| 378 | active := a.activeTabID |
| 379 | a.mu.RUnlock() |
| 380 | if active != "local-b" { |
| 381 | t.Fatalf("active local tab = %q, want adjacent local-b", active) |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | func TestStopRemoteServerParksTabsWithoutRestart(t *testing.T) { |
| 386 | fs := newFakeServe(t, "s3cret", nil) |
| 387 | kernel := &fakeRemoteKernel{ |
| 388 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 389 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret", |
| 390 | } |
| 391 | seedBridgeTestHost(t, "box") |
| 392 | a := &App{remoteRuntime: kernel} |
| 393 | cleanupRemoteTabPumps(t, a) |
| 394 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true}) |
| 395 | before := kernel.ensureCalls |
| 396 | if err := a.StopRemoteServer("box", "~/app"); err != nil { |
| 397 | t.Fatal(err) |
| 398 | } |
| 399 | time.Sleep(100 * time.Millisecond) |
| 400 | a.remoteTabMu.Lock() |
| 401 | state, client := a.remoteTabs[meta.ID].state, a.remoteTabs[meta.ID].client |
| 402 | a.remoteTabMu.Unlock() |
| 403 | if state != "serve_down" || client != nil { |
| 404 | t.Fatalf("stopped tab state/client = %q/%v", state, client) |
| 405 | } |
| 406 | if kernel.ensureCalls != before { |
| 407 | t.Fatalf("explicit stop restarted Serve: EnsureServer calls %d -> %d", before, kernel.ensureCalls) |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | // TestListTabsIncludesRemoteEntries pins the strip integration: open remote |
| 412 | // tabs appear in ListTabs, a highlighted remote tab deactivates the local |
| 413 | // entries, and SetActiveTab routes by registry membership. |
| 414 | func TestListTabsIncludesRemoteEntries(t *testing.T) { |
| 415 | fs := newFakeServe(t, "s3cret", nil) |
| 416 | kernel := &fakeRemoteKernel{ |
| 417 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 418 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 419 | ensureToken: "s3cret", |
| 420 | } |
| 421 | seedBridgeTestHost(t, "box") |
| 422 | a := &App{remoteRuntime: kernel} |
| 423 | cleanupRemoteTabPumps(t, a) |
| 424 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true}) |
| 425 | |
| 426 | tabs := a.ListTabs() |
| 427 | var remote TabMeta |
| 428 | found := false |
| 429 | for _, tab := range tabs { |
| 430 | if tab.ID == meta.ID { |
| 431 | remote, found = tab, true |
| 432 | } |
| 433 | } |
| 434 | if !found { |
| 435 | t.Fatalf("remote tab missing from ListTabs: %+v", tabs) |
| 436 | } |
| 437 | if remote.Remote == nil || remote.Remote.HostID != "box" { |
| 438 | t.Fatalf("remote meta ref = %+v", remote.Remote) |
| 439 | } |
| 440 | if !remote.Active { |
| 441 | t.Fatal("freshly opened remote tab must carry the strip highlight") |
| 442 | } |
| 443 | |
| 444 | if err := a.SetActiveTab(meta.ID); err != nil { |
| 445 | t.Fatalf("SetActiveTab(remote): %v", err) |
| 446 | } |
| 447 | a.remoteTabMu.Lock() |
| 448 | active := a.remoteTabLayout.activeID |
| 449 | a.remoteTabMu.Unlock() |
| 450 | if active != meta.ID { |
| 451 | t.Fatalf("remoteActiveTabID = %q, want %q", active, meta.ID) |
| 452 | } |
| 453 | // It is the sole visible surface, so the one-surface policy refuses to |
| 454 | // remove it and the entry stays listed. |
| 455 | err := a.CloseTabWithPolicy(meta.ID, "keep_running") |
| 456 | if err == nil || !strings.Contains(err.Error(), "cannot close the last tab") { |
| 457 | t.Fatalf("CloseTabWithPolicy(remote) = %v, want the last-surface refusal", err) |
| 458 | } |
| 459 | a.remoteTabMu.Lock() |
| 460 | _, present := a.remoteTabs[meta.ID] |
| 461 | a.remoteTabMu.Unlock() |
| 462 | if !present { |
| 463 | t.Fatal("a refused CloseTabWithPolicy must leave the remote tab registered") |
| 464 | } |
| 465 | } |
| 466 | |
| 467 | // TestRemoteTabTitleAdoptsServeSession pins the title pipeline: the serve's |
| 468 | // LLM-generated title for the current session replaces the workspace-name |
| 469 | // default and reaches the chrome through the metadata-only update channel. |
| 470 | func TestRemoteTabTitleAdoptsServeSession(t *testing.T) { |
| 471 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{ |
| 472 | {Name: "s1", Path: "/x.jsonl", Title: "Fix the login bug", Turns: 1, Current: true}, |
| 473 | }) |
| 474 | kernel := &fakeRemoteKernel{ |
| 475 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 476 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 477 | ensureToken: "s3cret", |
| 478 | } |
| 479 | seedBridgeTestHost(t, "box") |
| 480 | log := &eventLog{} |
| 481 | a := &App{remoteRuntime: kernel, remoteEventHook: log.add} |
| 482 | cleanupRemoteTabPumps(t, a) |
| 483 | // Resume the seeded session so it stays current; /new would abandon it. |
| 484 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s1"}) |
| 485 | log.mu.Lock() |
| 486 | log.events = nil |
| 487 | log.mu.Unlock() |
| 488 | a.remoteTabMu.Lock() |
| 489 | a.remoteTabs[meta.ID].topicTitle = remoteWorkspaceName("~/app") |
| 490 | a.remoteTabMu.Unlock() |
| 491 | |
| 492 | a.refreshRemoteTabTitle(meta.ID) |
| 493 | |
| 494 | a.remoteTabMu.Lock() |
| 495 | title := a.remoteTabs[meta.ID].topicTitle |
| 496 | a.remoteTabMu.Unlock() |
| 497 | if title != "Fix the login bug" { |
| 498 | t.Fatalf("topicTitle = %q, want the serve title", title) |
| 499 | } |
| 500 | found := false |
| 501 | for _, e := range log.recorded() { |
| 502 | if strings.HasPrefix(e, "remote-tab:updated ") && strings.Contains(e, meta.ID) && strings.Contains(e, "Fix the login bug") { |
| 503 | found = true |
| 504 | } |
| 505 | } |
| 506 | if !found { |
| 507 | t.Fatalf("title refresh not pushed to the chrome: %v", log.recorded()) |
| 508 | } |
| 509 | for _, tab := range a.ListTabs() { |
| 510 | if tab.ID == meta.ID && tab.TopicTitle != "Fix the login bug" { |
| 511 | t.Fatalf("ListTabs title = %q", tab.TopicTitle) |
| 512 | } |
| 513 | } |
| 514 | } |
| 515 | |
| 516 | func TestRemoteTabTitleRefreshRejectsRotatedSession(t *testing.T) { |
| 517 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "a", Path: "/a.jsonl", Title: "Title A", Current: true}}) |
| 518 | kernel := &fakeRemoteKernel{ |
| 519 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 520 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret", |
| 521 | } |
| 522 | seedBridgeTestHost(t, "box") |
| 523 | a := &App{remoteRuntime: kernel} |
| 524 | cleanupRemoteTabPumps(t, a) |
| 525 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "a"}) |
| 526 | fs.mu.Lock() |
| 527 | fs.sessionsStarted = make(chan struct{}, 1) |
| 528 | fs.sessionsRelease = make(chan struct{}) |
| 529 | started, release := fs.sessionsStarted, fs.sessionsRelease |
| 530 | fs.mu.Unlock() |
| 531 | done := make(chan struct{}) |
| 532 | go func() { a.refreshRemoteTabTitle(meta.ID); close(done) }() |
| 533 | select { |
| 534 | case <-started: |
| 535 | case <-time.After(time.Second): |
| 536 | t.Fatal("title refresh did not start") |
| 537 | } |
| 538 | a.remoteTabMu.Lock() |
| 539 | tab := a.remoteTabs[meta.ID] |
| 540 | tab.session.name, tab.session.path = "b", "/b.jsonl" |
| 541 | tab.routing.currentPath, tab.topicTitle = "/b.jsonl", "Selected B" |
| 542 | a.remoteTabMu.Unlock() |
| 543 | close(release) |
| 544 | select { |
| 545 | case <-done: |
| 546 | case <-time.After(time.Second): |
| 547 | t.Fatal("title refresh did not return") |
| 548 | } |
| 549 | a.remoteTabMu.Lock() |
| 550 | name, path, route, title := tab.session.name, tab.session.path, tab.routing.currentPath, tab.topicTitle |
| 551 | a.remoteTabMu.Unlock() |
| 552 | if name != "b" || path != "/b.jsonl" || route != "/b.jsonl" || title != "Selected B" { |
| 553 | t.Fatalf("stale title refresh replaced rotated session: name=%q path=%q route=%q title=%q", name, path, route, title) |
| 554 | } |
| 555 | } |
| 556 | |
| 557 | // TestRemoteTabNewSessionResetsServeSession: a NewSession open on an |
| 558 | // existing tab POSTs /new (the old session stays in the history list) and |
| 559 | // re-emits ready so the frontend re-syncs its snapshot. |
| 560 | func TestRemoteTabNewSessionResetsServeSession(t *testing.T) { |
| 561 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{ |
| 562 | {Name: "s1", Path: "/remote/sessions/s1.jsonl", Title: "Serve title", Turns: 1, Current: true}, |
| 563 | }) |
| 564 | kernel := &fakeRemoteKernel{ |
| 565 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 566 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 567 | ensureToken: "s3cret", |
| 568 | } |
| 569 | seedBridgeTestHost(t, "box") |
| 570 | log := &eventLog{} |
| 571 | a := &App{remoteRuntime: kernel, remoteEventHook: log.add} |
| 572 | cleanupRemoteTabPumps(t, a) |
| 573 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true}) |
| 574 | |
| 575 | // The bootstrap already entered a fresh session; the listing carries the |
| 576 | // desktop-view blank (the serve abandoned s1 and lists no current row). |
| 577 | sessions, err := a.RemoteProjectSessions("box", "~/app") |
| 578 | if err != nil { |
| 579 | t.Fatal(err) |
| 580 | } |
| 581 | if len(sessions) == 0 || sessions[0].Name != "" || !sessions[0].Current || sessions[0].Title != "新的会话" { |
| 582 | t.Fatalf("sessions = %+v, want the synthetic blank leading the listing", sessions) |
| 583 | } |
| 584 | if err := a.RenameRemoteProjectSession("box", "~/app", "", "空白会话标题"); err != nil { |
| 585 | t.Fatal(err) |
| 586 | } |
| 587 | sessions, err = a.RemoteProjectSessions("box", "~/app") |
| 588 | if err != nil || len(sessions) == 0 || sessions[0].Title != "空白会话标题" { |
| 589 | t.Fatalf("renamed blank session = %+v, err=%v", sessions, err) |
| 590 | } |
| 591 | |
| 592 | // A further new-session open reuses the blank: no extra POST /new — the |
| 593 | // same contract as the local reusable-blank tab. |
| 594 | newBefore, _, _ := fs.snapshot() |
| 595 | if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true}); err != nil { |
| 596 | t.Fatal(err) |
| 597 | } |
| 598 | if newAfter, _, _ := fs.snapshot(); newAfter != newBefore { |
| 599 | t.Fatalf("POST /new called %d times after reuse, want %d", newAfter, newBefore) |
| 600 | } |
| 601 | |
| 602 | // Resuming a listed session clears the blank and restores it as current. |
| 603 | if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "s1"}); err != nil { |
| 604 | t.Fatal(err) |
| 605 | } |
| 606 | deadline := time.Now().Add(2 * time.Second) |
| 607 | for { |
| 608 | sessions, err = a.RemoteProjectSessions("box", "~/app") |
| 609 | if err != nil { |
| 610 | t.Fatal(err) |
| 611 | } |
| 612 | if len(sessions) > 0 && sessions[0].Name == "s1" && sessions[0].Current { |
| 613 | break |
| 614 | } |
| 615 | if time.Now().After(deadline) { |
| 616 | t.Fatalf("resume did not restore s1 as current: %+v", sessions) |
| 617 | } |
| 618 | time.Sleep(10 * time.Millisecond) |
| 619 | } |
| 620 | a.remoteTabMu.Lock() |
| 621 | reset := a.remoteTabs[meta.ID].session.reset |
| 622 | title := a.remoteTabs[meta.ID].topicTitle |
| 623 | a.remoteTabMu.Unlock() |
| 624 | if reset { |
| 625 | waitForRemoteSessionIdentity(t, a, meta.ID, "s1", sessions[0].Path) |
| 626 | a.remoteTabMu.Lock() |
| 627 | reset = a.remoteTabs[meta.ID].session.reset |
| 628 | title = a.remoteTabs[meta.ID].topicTitle |
| 629 | a.remoteTabMu.Unlock() |
| 630 | } |
| 631 | if reset { |
| 632 | t.Fatal("sessionReset must clear after a resume") |
| 633 | } |
| 634 | if title != "Serve title" { |
| 635 | t.Fatalf("topicTitle after resume = %q, want the serve title", title) |
| 636 | } |
| 637 | } |
| 638 | |
| 639 | // TestRenameRemoteProjectSession pins the desktop-owned title chain: the |
| 640 | // override wins in the session listing, and a live tab holding that session |
| 641 | // adopts the new title immediately; clearing falls back to the serve title. |
| 642 | func TestRenameRemoteProjectSession(t *testing.T) { |
| 643 | home := t.TempDir() |
| 644 | t.Setenv("REASONIX_HOME", home) |
| 645 | t.Setenv("HOME", home) |
| 646 | if err := editUserConfig(func(c *config.Config) error { |
| 647 | return c.UpsertRemoteHost(config.RemoteHostEntry{Name: "box", Host: "127.0.0.1", Port: 22, User: "dev"}) |
| 648 | }); err != nil { |
| 649 | t.Fatal(err) |
| 650 | } |
| 651 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{ |
| 652 | {Name: "s1", Path: "/x.jsonl", Title: "Serve title", Turns: 1, Current: true, MtimeMilli: 1700000000000}, |
| 653 | }) |
| 654 | kernel := &fakeRemoteKernel{ |
| 655 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 656 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 657 | ensureToken: "s3cret", |
| 658 | } |
| 659 | log := &eventLog{} |
| 660 | a := &App{remoteRuntime: kernel, remoteEventHook: log.add} |
| 661 | cleanupRemoteTabPumps(t, a) |
| 662 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s1"}) |
| 663 | |
| 664 | sessions, err := a.RemoteProjectSessions("box", "~/app") |
| 665 | if err != nil { |
| 666 | t.Fatal(err) |
| 667 | } |
| 668 | if len(sessions) != 1 || sessions[0].LastActivityAt != 1700000000000 || sessions[0].Title != "Serve title" { |
| 669 | t.Fatalf("sessions = %+v, want serve title + mtime passthrough", sessions) |
| 670 | } |
| 671 | |
| 672 | if err := a.RenameRemoteProjectSession("box", "~/app", "s1", "我的新标题"); err != nil { |
| 673 | t.Fatal(err) |
| 674 | } |
| 675 | sessions, err = a.RemoteProjectSessions("box", "~/app") |
| 676 | if err != nil { |
| 677 | t.Fatal(err) |
| 678 | } |
| 679 | if sessions[0].Title != "我的新标题" { |
| 680 | t.Fatalf("override title = %q", sessions[0].Title) |
| 681 | } |
| 682 | a.remoteTabMu.Lock() |
| 683 | title := a.remoteTabs[meta.ID].topicTitle |
| 684 | a.remoteTabMu.Unlock() |
| 685 | if title != "我的新标题" { |
| 686 | t.Fatalf("live tab title = %q, want the override", title) |
| 687 | } |
| 688 | a.refreshRemoteTabTitle(meta.ID) |
| 689 | a.remoteTabMu.Lock() |
| 690 | title = a.remoteTabs[meta.ID].topicTitle |
| 691 | a.remoteTabMu.Unlock() |
| 692 | if title != "我的新标题" { |
| 693 | t.Fatalf("automatic refresh replaced the manual title with %q", title) |
| 694 | } |
| 695 | |
| 696 | if err := a.RenameRemoteProjectSession("box", "~/app", "s1", ""); err != nil { |
| 697 | t.Fatal(err) |
| 698 | } |
| 699 | sessions, _ = a.RemoteProjectSessions("box", "~/app") |
| 700 | if sessions[0].Title != "Serve title" { |
| 701 | t.Fatalf("cleared override title = %q, want the serve title", sessions[0].Title) |
| 702 | } |
| 703 | } |
| 704 | |
| 705 | // TestRemoteSessionPinnedOrderingAndProjectTitle pins the desktop-owned |
| 706 | // row pin (pinned-first listing) and the registry-backed project rename. |
| 707 | func TestRemoteSessionPinnedOrderingAndProjectTitle(t *testing.T) { |
| 708 | home := t.TempDir() |
| 709 | t.Setenv("REASONIX_HOME", home) |
| 710 | t.Setenv("HOME", home) |
| 711 | if err := editUserConfig(func(c *config.Config) error { |
| 712 | return c.UpsertRemoteHost(config.RemoteHostEntry{Name: "box", Host: "127.0.0.1", Port: 22, User: "dev"}) |
| 713 | }); err != nil { |
| 714 | t.Fatal(err) |
| 715 | } |
| 716 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{ |
| 717 | {Name: "a", Path: "/a.jsonl", Title: "First", Current: false, MtimeMilli: 1}, |
| 718 | {Name: "b", Path: "/b.jsonl", Title: "Second", Current: true, MtimeMilli: 2}, |
| 719 | }) |
| 720 | kernel := &fakeRemoteKernel{ |
| 721 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 722 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 723 | ensureToken: "s3cret", |
| 724 | } |
| 725 | a := &App{remoteRuntime: kernel} |
| 726 | cleanupRemoteTabPumps(t, a) |
| 727 | |
| 728 | if err := a.SetRemoteSessionPinned("box", "~/app", "a", true); err != nil { |
| 729 | t.Fatal(err) |
| 730 | } |
| 731 | sessions, err := a.RemoteProjectSessions("box", "~/app") |
| 732 | if err != nil { |
| 733 | t.Fatal(err) |
| 734 | } |
| 735 | if len(sessions) != 2 || sessions[0].Name != "a" || !sessions[0].Pinned || sessions[1].Pinned { |
| 736 | t.Fatalf("sessions = %+v, want pinned a first", sessions) |
| 737 | } |
| 738 | |
| 739 | meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true}) |
| 740 | if err != nil { |
| 741 | t.Fatal(err) |
| 742 | } |
| 743 | _ = meta |
| 744 | if err := a.SetRemoteProjectTitle("box", "~/app", "云端演示"); err != nil { |
| 745 | t.Fatal(err) |
| 746 | } |
| 747 | for _, node := range a.ListTabs() { |
| 748 | _ = node |
| 749 | } |
| 750 | found := false |
| 751 | for _, node := range a.GetProjectTreeSnapshot().Projects { |
| 752 | if node.Remote != nil && node.Remote.HostID == "box" { |
| 753 | found = true |
| 754 | if node.Label != "云端演示" { |
| 755 | t.Fatalf("group label = %q, want the renamed title", node.Label) |
| 756 | } |
| 757 | } |
| 758 | } |
| 759 | if !found { |
| 760 | t.Fatal("remote group missing from the snapshot") |
| 761 | } |
| 762 | } |
| 763 |