返回 DeepSeek-Reasonix
topic_activation_canonical_test.go
根目录 / desktop / topic_activation_canonical_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "sync"
9 "testing"
10 "time"
11
12 "reasonix/internal/control"
13 "reasonix/internal/session"
14 )
15
16 func canonicalTopicHistoryReady(t *testing.T, app *App, tabID string) session.HistoryWindowPage {
17 t.Helper()
18 deadline := time.Now().Add(5 * time.Second)
19 for {
20 page, err := app.SessionHistoryWindowForTab(tabID, session.HistoryWindowRequest{Anchor: "newest", Limit: 32})
21 if err != nil {
22 t.Fatal(err)
23 }
24 if page.Status != "preparing" {
25 if page.Status != "ready" || len(page.Messages) == 0 {
26 t.Fatalf("history is not readable: status=%s messages=%d", page.Status, len(page.Messages))
27 }
28 return page
29 }
30 if time.Now().After(deadline) {
31 t.Fatal("independent history preparation did not finish")
32 }
33 time.Sleep(time.Millisecond)
34 }
35 }
36
37 func TestCanonicalSidebarActivationReplacesRemoteSurface(t *testing.T) {
38 app, source, target, root, _ := canonicalWorkspaceOpenFixture(t)
39 app.readyHook = func() {}
40 installNoopRuntimeEvents(app, source.sink)
41 events := newActivationEventRecorder(app)
42 t.Cleanup(func() { app.shutdown(context.Background()) })
43
44 remoteCtx, cancelRemote := context.WithCancel(context.Background())
45 app.remoteTabMu.Lock()
46 app.remoteTabs = map[string]*remoteTab{
47 "remote": {id: "remote", ref: RemoteTabRef{HostID: "box", Workspace: "/work"}, state: "ready", cancel: cancelRemote},
48 }
49 app.remoteTabLayout = remoteTabLayoutState{activeID: "remote", order: []string{"remote"}, stripOrder: []string{"remote"}}
50 app.remoteTabMu.Unlock()
51
52 ref := target.Ref()
53 ticket, err := app.StartTopicActivation(TopicActivationRequest{
54 Selector: &SessionSelector{Ref: &ref}, Scope: "project", WorkspaceRoot: root,
55 TopicID: "existing-topic", SessionPath: sessionRoute(ref.SessionID), RequestID: "sidebar-local",
56 })
57 if err != nil {
58 t.Fatal(err)
59 }
60 if ticket.Meta.SessionID != ref.SessionID || ticket.Meta.WorkspaceRoot != root {
61 t.Fatalf("selected local session = %+v, want %s in %s", ticket.Meta, ref.SessionID, root)
62 }
63 if tabs := app.ListTabs(); len(tabs) == 0 {
64 t.Fatal("local tab disappeared after canonical sidebar activation")
65 } else {
66 for _, tab := range tabs {
67 if tab.Active != (tab.ID == ticket.TabID) {
68 t.Fatalf("tab activity after canonical sidebar activation = %+v", tabs)
69 }
70 }
71 }
72 events.waitFor(t, activationEventFor(ticket.RequestID, "ready"))
73 flushActivationCompletions(app)
74 if tabs := app.ListTabs(); len(tabs) != 1 || tabs[0].ID != ticket.TabID || !tabs[0].Active {
75 t.Fatalf("tabs after local activation = %+v", tabs)
76 }
77 select {
78 case <-remoteCtx.Done():
79 default:
80 t.Fatal("remote tab remained attached after selecting a local session")
81 }
82 }
83
84 func TestCanonicalTopicActivationPublishesReadableHistoryBeforeRuntime(t *testing.T) {
85 app, source, target, root, workspace := canonicalWorkspaceOpenFixture(t)
86 app.readyHook = func() {}
87 installNoopRuntimeEvents(app, source.sink)
88 events := newActivationEventRecorder(app)
89 gate := newTabBuildGate(app)
90 t.Cleanup(func() { app.shutdown(context.Background()) })
91 t.Cleanup(gate.releaseAll)
92 app.runtimeRebuildMu.Lock()
93 unlock := sync.OnceFunc(app.runtimeRebuildMu.Unlock)
94 defer unlock()
95
96 type result struct {
97 ticket TopicActivationTicket
98 err error
99 }
100 done := make(chan result, 1)
101 ref := target.Ref()
102 go func() {
103 ticket, err := app.StartTopicActivation(TopicActivationRequest{
104 Selector: &SessionSelector{Ref: &ref}, Scope: "project", WorkspaceRoot: source.WorkspaceRoot,
105 SessionPath: sessionRoute(ref.SessionID), RequestID: "canonical-history",
106 })
107 done <- result{ticket, err}
108 }()
109 var ticket TopicActivationTicket
110 select {
111 case got := <-done:
112 if got.err != nil {
113 t.Fatal(got.err)
114 }
115 ticket = got.ticket
116 case <-time.After(5 * time.Second):
117 unlock()
118 <-done
119 t.Fatal("canonical history activation waited for the runtime rebuild mutex before publishing its identity")
120 }
121 gate.waitEntered(t, ticket.TabID)
122 if ticket.Meta.SessionID != ref.SessionID || ticket.Meta.WorkspaceID != workspace || ticket.Meta.WorkspaceRoot != root || ticket.Meta.Ready {
123 t.Fatalf("cold canonical ticket = %+v", ticket.Meta)
124 }
125 page := canonicalTopicHistoryReady(t, app, ticket.TabID)
126 if page.Messages[len(page.Messages)-1].MessageID != "session-B-user" {
127 t.Fatal("history read returned another session's messages")
128 }
129 events.waitFor(t, activationEventFor("canonical-history", "starting"))
130 app.mu.RLock()
131 sourcePreserved := app.tabs[source.ID] == source && source.Ctrl != nil && source.SessionID == "session-A"
132 app.mu.RUnlock()
133 if !sourcePreserved {
134 t.Fatal("source runtime was replaced before target startup completed")
135 }
136 unlock()
137 gate.release(ticket.TabID)
138 events.waitFor(t, activationEventFor("canonical-history", "ready"))
139 app.mu.RLock()
140 controller := app.tabs[ticket.TabID].Ctrl
141 app.mu.RUnlock()
142 bound, ok := controller.(control.IdentityLifecycle).SessionRef()
143 if !ok || bound != ref {
144 t.Fatalf("startup bound a different session: %+v, want %+v", bound, ref)
145 }
146 }
147
148 func TestCanonicalTopicOpenInactivePublishesIdentityWithoutChangingVisibleSession(t *testing.T) {
149 app, source, target, root, workspace := canonicalWorkspaceOpenFixture(t)
150 app.readyHook = func() {}
151 installNoopRuntimeEvents(app, source.sink)
152 gate := newTabBuildGate(app)
153 t.Cleanup(func() { app.shutdown(context.Background()) })
154 t.Cleanup(gate.releaseAll)
155 if err := app.workspaceRegistry().EnsureSessionTopic(t.Context(), target.Ref().SessionID, "target-topic", "Target title"); err != nil {
156 t.Fatal(err)
157 }
158 meta, err := app.openProjectTabInactive(root, "target-topic")
159 if err != nil {
160 t.Fatal(err)
161 }
162 gate.waitEntered(t, meta.ID)
163 if meta.SessionID != target.Ref().SessionID || meta.WorkspaceID != workspace || meta.TopicTitle != "Target title" || meta.Ready {
164 t.Fatalf("inactive canonical metadata = %+v", meta)
165 }
166 app.mu.RLock()
167 active := app.activeTabID
168 app.mu.RUnlock()
169 if active != source.ID {
170 t.Fatal("inactive history open changed the visible session")
171 }
172 canonicalTopicHistoryReady(t, app, meta.ID)
173 }
174
175 func TestCanonicalTopicActivationSupersededBuildKeepsRunningSource(t *testing.T) {
176 app, source, target, root, _ := canonicalWorkspaceOpenFixture(t)
177 app.readyHook = func() {}
178 active := &activeNavigationController{
179 SessionAPI: source.Ctrl, IdentityLifecycle: source.Ctrl.(control.IdentityLifecycle),
180 status: control.RuntimeStatus{Running: true},
181 }
182 source.Ctrl = active
183 installNoopRuntimeEvents(app, source.sink)
184 events := newActivationEventRecorder(app)
185 gate := newTabBuildGate(app)
186 t.Cleanup(func() { app.shutdown(context.Background()) })
187 t.Cleanup(gate.releaseAll)
188 first, err := app.StartTopicActivation(TopicActivationRequest{
189 Scope: "project", WorkspaceRoot: root, SessionPath: sessionRoute(target.Ref().SessionID), RequestID: "first",
190 })
191 if err != nil {
192 t.Fatal(err)
193 }
194 gate.waitEntered(t, first.TabID)
195 app.mu.RLock()
196 buildDone := app.tabs[first.TabID].buildDone
197 app.mu.RUnlock()
198 second, err := app.StartTopicActivation(TopicActivationRequest{
199 Scope: "project", WorkspaceRoot: source.WorkspaceRoot, SessionPath: sessionRoute(source.SessionID), RequestID: "second",
200 })
201 if err != nil {
202 t.Fatal(err)
203 }
204 events.waitFor(t, activationEventFor("first", "cancelled"))
205 events.waitFor(t, activationEventFor("second", "ready"))
206 gate.release(first.TabID)
207 select {
208 case <-buildDone:
209 case <-time.After(5 * time.Second):
210 t.Fatal("superseded build did not stop")
211 }
212 app.mu.RLock()
213 current := app.tabs[app.activeTabID]
214 correct := current == source && current.Ctrl == active && second.TabID == source.ID
215 app.mu.RUnlock()
216 if !correct || active.closed {
217 t.Fatal("late history build displaced or closed the running source")
218 }
219 }
220
221 func TestCanonicalTopicActivationKeepsHistoryReadableWhenWriterUnavailable(t *testing.T) {
222 app, source, target, root, _ := canonicalWorkspaceOpenFixture(t)
223 app.readyHook = func() {}
224 installNoopRuntimeEvents(app, source.sink)
225 events := newActivationEventRecorder(app)
226 t.Cleanup(func() { app.shutdown(context.Background()) })
227 if err := app.desktopSessionService("").Close(t.Context(), target.Ref()); err != nil {
228 t.Fatal(err)
229 }
230 other, err := session.NewService(localDesktopHostID, session.NewFilesystemPersistence(app.desktopSessions.root))
231 if err != nil {
232 t.Fatal(err)
233 }
234 t.Cleanup(func() { _ = other.Shutdown(context.Background()) })
235 lease, err := other.Open(t.Context(), target.Ref())
236 if err != nil {
237 t.Fatal(err)
238 }
239 t.Cleanup(func() { _ = lease.Release(context.Background()) })
240 ticket, err := app.StartTopicActivation(TopicActivationRequest{
241 Scope: "project", WorkspaceRoot: root, SessionPath: sessionRoute(target.Ref().SessionID), RequestID: "writer-busy",
242 })
243 if err != nil {
244 t.Fatalf("writer ownership blocked history navigation: %v", err)
245 }
246 events.waitFor(t, activationEventFor("writer-busy", "failed"))
247 canonicalTopicHistoryReady(t, app, ticket.TabID)
248 app.mu.RLock()
249 tab := app.tabs[ticket.TabID]
250 correct := tab != nil && tab.SessionID == target.Ref().SessionID && tab.StartupErr != "" && !tab.Ready
251 app.mu.RUnlock()
252 if !correct {
253 t.Fatal("runtime failure lost the target identity or failure state")
254 }
255 }
256
257 func TestCanonicalTopicOpenRejectsConflictingWorkspaceBeforePublishing(t *testing.T) {
258 app, source, target, root, workspaceID := canonicalWorkspaceOpenFixture(t)
259 state, err := app.workspaceRegistry().Load(t.Context())
260 if err != nil {
261 t.Fatal(err)
262 }
263 workspace := state.Workspaces[workspaceID]
264 workspace.Root = source.WorkspaceRoot
265 state.Workspaces[workspaceID] = workspace
266 data, err := json.Marshal(state)
267 if err != nil {
268 t.Fatal(err)
269 }
270 if err := os.WriteFile(app.workspaceRegistry().Path(), data, 0o600); err != nil {
271 t.Fatal(err)
272 }
273 _, err = app.OpenTopicSession("project", root, "", sessionRoute(target.Ref().SessionID))
274 if !errors.Is(err, errSessionWorkspaceConflict) {
275 t.Fatalf("conflicting canonical workspace accepted: %v", err)
276 }
277 if len(app.tabs) != 1 || app.activeTabID != source.ID || source.SessionID != "session-A" || !source.Ready {
278 t.Fatal("failed identity validation changed the visible session")
279 }
280 }
281
282 func TestCanonicalTopicActivationReattachesRunningSourceWithoutRebuild(t *testing.T) {
283 app, source, target, root, _ := canonicalWorkspaceOpenFixture(t)
284 app.readyHook = func() {}
285 active := &activeNavigationController{
286 SessionAPI: source.Ctrl, IdentityLifecycle: source.Ctrl.(control.IdentityLifecycle),
287 status: control.RuntimeStatus{Running: true},
288 }
289 source.Ctrl = active
290 installNoopRuntimeEvents(app, source.sink)
291 events := newActivationEventRecorder(app)
292 t.Cleanup(func() { app.shutdown(context.Background()) })
293 sourceID, sourceRoot := source.SessionID, source.WorkspaceRoot
294 first, err := app.StartTopicActivation(TopicActivationRequest{
295 Scope: "project", WorkspaceRoot: root, SessionPath: sessionRoute(target.Ref().SessionID), RequestID: "target",
296 })
297 if err != nil {
298 t.Fatal(err)
299 }
300 events.waitFor(t, activationEventFor("target", "ready"))
301 // Complete pruning before the return click so this covers detached reuse,
302 // not merely reuse of a source that is still in the visible tabs map.
303 app.singleSurfaceMu.Lock()
304 _, err = app.keepOnlyVisibleTab(first.TabID)
305 app.singleSurfaceMu.Unlock()
306 if err != nil {
307 t.Fatal(err)
308 }
309 app.mu.RLock()
310 _, visible := app.tabs[source.ID]
311 app.mu.RUnlock()
312 if visible || active.closed {
313 t.Fatal("running source was not preserved as a detached runtime")
314 }
315 second, err := app.StartTopicActivation(TopicActivationRequest{
316 Scope: "project", WorkspaceRoot: sourceRoot, SessionPath: sessionRoute(sourceID), RequestID: "return",
317 })
318 if err != nil {
319 t.Fatal(err)
320 }
321 events.waitFor(t, activationEventFor("return", "ready"))
322 app.mu.RLock()
323 current := app.tabs[second.TabID]
324 correct := current != nil && current.Ctrl == active && current.SessionID == sourceID
325 app.mu.RUnlock()
326 if !correct || active.closed {
327 t.Fatal("returning to running history replaced its controller")
328 }
329 }
330
330 lines GO