| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/boot" |
| 10 | "reasonix/internal/control" |
| 11 | "reasonix/internal/event" |
| 12 | "reasonix/internal/provider" |
| 13 | "reasonix/internal/session" |
| 14 | ) |
| 15 | |
| 16 | type activeNavigationController struct { |
| 17 | control.SessionAPI |
| 18 | control.IdentityLifecycle |
| 19 | status control.RuntimeStatus |
| 20 | closed bool |
| 21 | replays int |
| 22 | } |
| 23 | |
| 24 | func (c *activeNavigationController) RuntimeStatus() control.RuntimeStatus { return c.status } |
| 25 | func (c *activeNavigationController) Close() { c.closed = true; c.SessionAPI.Close() } |
| 26 | |
| 27 | func (c *activeNavigationController) ReplayPendingPrompts() { |
| 28 | c.replays++ |
| 29 | c.SessionAPI.ReplayPendingPrompts() |
| 30 | } |
| 31 | |
| 32 | func (c *activeNavigationController) SessionBinding() (*session.Service, *session.Runtime, bool) { |
| 33 | return c.SessionAPI.(persistedSessionBinding).SessionBinding() |
| 34 | } |
| 35 | |
| 36 | type snapshotProbeController struct { |
| 37 | activeNavigationController |
| 38 | snapshot func() error |
| 39 | } |
| 40 | |
| 41 | func (c *snapshotProbeController) Snapshot() error { |
| 42 | if c.snapshot != nil { |
| 43 | return c.snapshot() |
| 44 | } |
| 45 | return c.SessionAPI.Snapshot() |
| 46 | } |
| 47 | |
| 48 | func navigationFreezeFixture(t *testing.T) (*App, *WorkspaceTab, string) { |
| 49 | t.Helper() |
| 50 | isolateDesktopUserDirs(t) |
| 51 | model, _ := configureSwitchableDefaultModels(t) |
| 52 | app := NewApp() |
| 53 | t.Cleanup(app.closeSessionServices) |
| 54 | app.ctx = context.Background() |
| 55 | rootA, rootB := filepath.Join(t.TempDir(), "project"), filepath.Join(t.TempDir(), "project") |
| 56 | for _, root := range []string{rootA, rootB} { |
| 57 | if err := os.MkdirAll(root, 0o700); err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | } |
| 61 | workspaceA, err := app.ensureDesktopWorkspace(t.Context(), "project", rootA) |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | service := app.desktopSessionService("") |
| 66 | runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "session-A", CWD: rootA, Origin: session.SessionOriginNew}) |
| 67 | if err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | appendSessionTestModel(t, runtime, "session-A-model", model) |
| 71 | appendSessionTestMessage(t, runtime, "session-A-system", provider.Message{ID: "session-A-system", Role: provider.RoleSystem, Content: "test system"}) |
| 72 | appendSessionTestMessage(t, runtime, "session-A-message", provider.Message{ID: "session-A-user", Role: provider.RoleUser, Content: "session-A"}) |
| 73 | if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspaceA, runtime.Ref().SessionID, ""); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | ctrl, err := app.buildTabControllerBoot(app.ctx, boot.Options{Model: model, WorkspaceRoot: rootA, SessionDir: desktopSessionDir(rootA), Sink: event.Discard}) |
| 77 | if err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | if _, err := ctrl.(control.IdentityLifecycle).OpenSession(t.Context(), runtime.Ref()); err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | tab := &WorkspaceTab{ID: "source-tab", Scope: "project", WorkspaceRoot: rootA, SessionID: runtime.Ref().SessionID, Ready: true, Ctrl: ctrl, model: model, sink: &tabEventSink{tabID: "source-tab", app: app}, disabledMCP: map[string]ServerView{}} |
| 84 | tab.SessionWorkspace.ID = workspaceA |
| 85 | app.tabs[tab.ID], app.tabOrder, app.activeTabID = tab, []string{tab.ID}, tab.ID |
| 86 | app.newSessionRuntimeLocked(tab, sessionRuntimeKey(tab.currentSessionIdentity())) |
| 87 | t.Cleanup(func() { |
| 88 | if live := app.controllerForTab(tab); live != nil { |
| 89 | live.Close() |
| 90 | } |
| 91 | if tab.SharedHostKey != "" { |
| 92 | app.releaseSharedHost(tab.SharedHostKey) |
| 93 | } |
| 94 | }) |
| 95 | return app, tab, rootB |
| 96 | } |
| 97 | |
| 98 | func TestPruneRunningSessionDoesNotSnapshot(t *testing.T) { |
| 99 | app, tab, rootB := navigationFreezeFixture(t) |
| 100 | original := tab.Ctrl |
| 101 | snapped := false |
| 102 | tab.TopicID = "topic-a" |
| 103 | tab.Ctrl = &snapshotProbeController{ |
| 104 | activeNavigationController: activeNavigationController{ |
| 105 | SessionAPI: original, IdentityLifecycle: original.(control.IdentityLifecycle), |
| 106 | status: control.RuntimeStatus{Running: true}, |
| 107 | }, |
| 108 | snapshot: func() error { |
| 109 | snapped = true |
| 110 | return original.Snapshot() |
| 111 | }, |
| 112 | } |
| 113 | t.Cleanup(original.Close) |
| 114 | if _, err := app.EnsureBlankSurface("project", rootB); err != nil { |
| 115 | t.Fatal(err) |
| 116 | } |
| 117 | if snapped { |
| 118 | t.Fatal("switching away snapshotted a running session") |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | func TestActivateTopicReattachesDetachedRunningSession(t *testing.T) { |
| 123 | app, tab, rootB := navigationFreezeFixture(t) |
| 124 | rootA := tab.WorkspaceRoot |
| 125 | sourceID := tab.SessionID |
| 126 | original := tab.Ctrl |
| 127 | active := &activeNavigationController{ |
| 128 | SessionAPI: original, IdentityLifecycle: original.(control.IdentityLifecycle), |
| 129 | status: control.RuntimeStatus{Running: true}, |
| 130 | } |
| 131 | tab.Ctrl = active |
| 132 | tab.TopicID = "topic-a" |
| 133 | t.Cleanup(original.Close) |
| 134 | if _, err := app.EnsureBlankSurface("project", rootB); err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | meta, err := app.ActivateTopic("project", rootA, "topic-a", "") |
| 138 | if err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | visible := app.tabs[meta.ID] |
| 142 | if visible == nil || visible.Ctrl != active || active.closed { |
| 143 | t.Fatal("ActivateTopic did not reattach the running owner") |
| 144 | } |
| 145 | if visible.SessionID != sourceID { |
| 146 | t.Fatalf("reattached session %q, want %q", visible.SessionID, sourceID) |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | func TestBlankOnOtherProjectThenOpenSessionReattaches(t *testing.T) { |
| 151 | app, tab, rootB := navigationFreezeFixture(t) |
| 152 | source := session.SessionRef{HostID: localDesktopHostID, SessionID: tab.SessionID} |
| 153 | original := tab.Ctrl |
| 154 | active := &activeNavigationController{ |
| 155 | SessionAPI: original, IdentityLifecycle: original.(control.IdentityLifecycle), |
| 156 | status: control.RuntimeStatus{Running: true}, |
| 157 | } |
| 158 | tab.Ctrl = active |
| 159 | t.Cleanup(original.Close) |
| 160 | if _, err := app.EnsureBlankSurface("project", rootB); err != nil { |
| 161 | t.Fatal(err) |
| 162 | } |
| 163 | if _, err := app.OpenSession(source); err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | visible := app.tabs[app.activeTabID] |
| 167 | if visible == nil || visible.Ctrl != active || active.closed { |
| 168 | t.Fatal("OpenSession did not reattach the running owner") |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | func TestCanonicalNavigationPreservesActiveSource(t *testing.T) { |
| 173 | for name, status := range map[string]control.RuntimeStatus{ |
| 174 | "running": {Running: true}, "approval": {PendingPrompt: true}, "background": {BackgroundJobs: 1}, |
| 175 | } { |
| 176 | t.Run(name, func(t *testing.T) { |
| 177 | app, tab, target, _, _ := canonicalWorkspaceOpenFixture(t) |
| 178 | source := session.SessionRef{HostID: localDesktopHostID, SessionID: tab.SessionID} |
| 179 | original := tab.Ctrl |
| 180 | active := &activeNavigationController{SessionAPI: original, IdentityLifecycle: original.(control.IdentityLifecycle), status: status} |
| 181 | tab.Ctrl = active |
| 182 | t.Cleanup(original.Close) |
| 183 | sourceSink := tab.sink |
| 184 | sourceDisplay := tab.displayBufferState() |
| 185 | if _, err := app.OpenSession(target.Ref()); err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | key := sessionRuntimeKey(sessionRoute(source.SessionID)) |
| 189 | detached := app.detachedSessions[key] |
| 190 | if detached == nil || detached.Ctrl != active || active.closed { |
| 191 | t.Fatal("active source was not retained") |
| 192 | } |
| 193 | if tab.sink == sourceSink { |
| 194 | t.Fatal("target shares background source event sink") |
| 195 | } |
| 196 | if owner, _ := sourceSink.binding(); owner != detached.ID || sourceSink.context() != nil || tab.displayBufferState() == sourceDisplay { |
| 197 | t.Fatal("background events can reach the visible session") |
| 198 | } |
| 199 | if ref, _ := active.SessionRef(); ref != source { |
| 200 | t.Fatal("source writer was rebound") |
| 201 | } |
| 202 | if rt := app.runtimeBySessionKey[key]; rt == nil || rt.Owner != detached { |
| 203 | t.Fatal("source runtime registry lost its owner") |
| 204 | } |
| 205 | if _, err := app.OpenSession(source); err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | if tab.Ctrl != active || tab.sink != sourceSink || active.closed || app.detachedSessions[key] != nil { |
| 209 | t.Fatal("return did not reattach the exact active source") |
| 210 | } |
| 211 | if active.replays != 1 || tab.displayBufferState() != sourceDisplay { |
| 212 | t.Fatal("return did not restore pending prompts and display state") |
| 213 | } |
| 214 | if _, err := app.OpenSession(source); err != nil { |
| 215 | t.Fatalf("active same-session open: %v", err) |
| 216 | } |
| 217 | }) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | func TestCanonicalNavigationBetweenTwoActiveOwners(t *testing.T) { |
| 222 | app, tab, target, _, _ := canonicalWorkspaceOpenFixture(t) |
| 223 | source := session.SessionRef{HostID: localDesktopHostID, SessionID: tab.SessionID} |
| 224 | wrap := func() *activeNavigationController { |
| 225 | ctrl := tab.Ctrl |
| 226 | active := &activeNavigationController{SessionAPI: ctrl, IdentityLifecycle: ctrl.(control.IdentityLifecycle), status: control.RuntimeStatus{Running: true}} |
| 227 | tab.Ctrl = active |
| 228 | t.Cleanup(ctrl.Close) |
| 229 | return active |
| 230 | } |
| 231 | first := wrap() |
| 232 | if _, err := app.OpenSession(target.Ref()); err != nil { |
| 233 | t.Fatal(err) |
| 234 | } |
| 235 | second := wrap() |
| 236 | for range 3 { |
| 237 | if _, err := app.OpenSession(source); err != nil { |
| 238 | t.Fatal(err) |
| 239 | } |
| 240 | if tab.Ctrl != first { |
| 241 | t.Fatal("source controller replaced") |
| 242 | } |
| 243 | if _, err := app.OpenSession(target.Ref()); err != nil { |
| 244 | t.Fatal(err) |
| 245 | } |
| 246 | if tab.Ctrl != second { |
| 247 | t.Fatal("target controller replaced") |
| 248 | } |
| 249 | } |
| 250 | if first.closed || second.closed || len(app.detachedSessions) != 1 { |
| 251 | t.Fatal("active navigation closed or duplicated an owner") |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | func TestCanonicalNavigationDoesNotSnapshotActiveSource(t *testing.T) { |
| 256 | for name, status := range map[string]control.RuntimeStatus{ |
| 257 | "running": {Running: true}, "approval": {PendingPrompt: true}, "background": {BackgroundJobs: 1}, |
| 258 | } { |
| 259 | t.Run(name, func(t *testing.T) { |
| 260 | app, tab, target, _, _ := canonicalWorkspaceOpenFixture(t) |
| 261 | original := tab.Ctrl |
| 262 | t.Cleanup(original.Close) |
| 263 | tab.Ctrl = &snapshotProbeController{ |
| 264 | activeNavigationController: activeNavigationController{SessionAPI: original, IdentityLifecycle: original.(control.IdentityLifecycle), status: status}, |
| 265 | snapshot: func() error { t.Error("navigation snapshotted an active source"); return nil }, |
| 266 | } |
| 267 | if _, err := app.OpenSession(target.Ref()); err != nil { |
| 268 | t.Fatal(err) |
| 269 | } |
| 270 | }) |
| 271 | } |
| 272 | } |
| 273 |