返回 DeepSeek-Reasonix
session_workspace_open_test.go
根目录 / desktop / session_workspace_open_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "testing"
10
11 "reasonix/internal/boot"
12 "reasonix/internal/config"
13 "reasonix/internal/control"
14 "reasonix/internal/event"
15 "reasonix/internal/provider"
16 "reasonix/internal/session"
17 "reasonix/internal/transcript"
18 )
19
20 func canonicalWorkspaceOpenFixture(t *testing.T) (*App, *WorkspaceTab, *session.Runtime, string, string) {
21 t.Helper()
22 isolateDesktopUserDirs(t)
23 model, _ := configureSwitchableDefaultModels(t)
24 app := NewApp()
25 t.Cleanup(app.closeSessionServices)
26 app.ctx = context.Background()
27 // Identical display names must not collapse distinct workspace identities.
28 rootA, rootB := filepath.Join(t.TempDir(), "project"), filepath.Join(t.TempDir(), "project")
29 for _, root := range []string{rootA, rootB} {
30 if err := os.MkdirAll(root, 0o700); err != nil {
31 t.Fatal(err)
32 }
33 }
34 workspaceA, err := app.ensureDesktopWorkspace(t.Context(), "project", rootA)
35 if err != nil {
36 t.Fatal(err)
37 }
38 workspaceB, err := app.ensureDesktopWorkspace(t.Context(), "project", rootB)
39 if err != nil {
40 t.Fatal(err)
41 }
42 service := app.desktopSessionService("")
43 create := func(id, cwd, workspace string) *session.Runtime {
44 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: id, CWD: cwd, Origin: session.SessionOriginNew})
45 if err != nil {
46 t.Fatal(err)
47 }
48 appendSessionTestModel(t, runtime, id+"-model", model)
49 appendSessionTestMessage(t, runtime, id+"-system", provider.Message{ID: id + "-system", Role: provider.RoleSystem, Content: "test system"})
50 appendSessionTestMessage(t, runtime, id+"-message", provider.Message{ID: id + "-user", Role: provider.RoleUser, Content: id})
51 if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspace, id, ""); err != nil {
52 t.Fatal(err)
53 }
54 return runtime
55 }
56 source, target := create("session-A", rootA, workspaceA), create("session-B", rootB, workspaceB)
57 ctrl, err := app.buildTabControllerBoot(app.ctx, boot.Options{Model: model, WorkspaceRoot: rootA, SessionDir: desktopSessionDir(rootA), Sink: event.Discard})
58 if err != nil {
59 t.Fatal(err)
60 }
61 if _, err := ctrl.(control.IdentityLifecycle).OpenSession(t.Context(), source.Ref()); err != nil {
62 t.Fatal(err)
63 }
64 tab := &WorkspaceTab{ID: "source-tab", Scope: "project", WorkspaceRoot: rootA, SessionID: source.Ref().SessionID, Ready: true, Ctrl: ctrl, model: model, sink: &tabEventSink{tabID: "source-tab", app: app}, disabledMCP: map[string]ServerView{}}
65 tab.SessionWorkspace.ID = workspaceA
66 app.tabs[tab.ID], app.tabOrder, app.activeTabID = tab, []string{tab.ID}, tab.ID
67 app.newSessionRuntimeLocked(tab, sessionRuntimeKey(tab.currentSessionIdentity()))
68 t.Cleanup(func() {
69 if live := app.controllerForTab(tab); live != nil {
70 live.Close()
71 }
72 if tab.SharedHostKey != "" {
73 app.releaseSharedHost(tab.SharedHostKey)
74 }
75 })
76 return app, tab, target, rootB, workspaceB
77 }
78
79 func TestCanonicalOpenRefreshesSameWorkspaceIdentity(t *testing.T) {
80 app, tab, _, _, _ := canonicalWorkspaceOpenFixture(t)
81 want, ctrl := tab.SessionWorkspace.ID, tab.Ctrl
82 tab.SessionWorkspace.ID = "stale-workspace"
83 if _, err := app.OpenSession(session.SessionRef{HostID: localDesktopHostID, SessionID: tab.SessionID}); err != nil {
84 t.Fatal(err)
85 }
86 if tab.SessionWorkspace.ID != want || tab.Ctrl != ctrl {
87 t.Fatal("same-session navigation did not reconcile its workspace identity")
88 }
89 }
90
91 func TestCanonicalOpenFailurePreservesSource(t *testing.T) {
92 app, tab, target, _, _ := canonicalWorkspaceOpenFixture(t)
93 previous := persistedDesktopTabEntry(tab)
94 ctrl := tab.Ctrl
95 if err := app.desktopSessionService("").Close(t.Context(), target.Ref()); err != nil {
96 t.Fatal(err)
97 }
98 other, err := session.NewService(localDesktopHostID, session.NewFilesystemPersistence(app.desktopSessions.root))
99 if err != nil {
100 t.Fatal(err)
101 }
102 t.Cleanup(func() { _ = other.Shutdown(context.Background()) })
103 lease, err := other.Open(t.Context(), target.Ref())
104 if err != nil {
105 t.Fatal(err)
106 }
107 t.Cleanup(func() { _ = lease.Release(context.Background()) })
108 if _, err := app.OpenSession(target.Ref()); err == nil {
109 t.Fatal("external writer conflict accepted")
110 }
111 if tab.Ctrl != ctrl || tab.SessionID != previous.SessionID || tab.WorkspaceRoot != previous.WorkspaceRoot || !tab.Ready {
112 t.Fatal("failed open replaced source")
113 }
114 if err := ctrl.Snapshot(); err != nil {
115 t.Fatalf("source lost its writer: %v", err)
116 }
117 }
118
119 func TestEmbeddedBotSessionOpensReadOnlyFromSidebar(t *testing.T) {
120 app, _, _, root, _ := canonicalWorkspaceOpenFixture(t)
121 botRoot := session.RootForLegacyDir(config.ProjectSessionDir(root))
122 bot, err := app.historicalSessionService(botRoot)
123 if err != nil {
124 t.Fatal(err)
125 }
126 runtime, err := bot.Create(t.Context(), session.CreateOptions{SessionID: "bot-conversation", CWD: root, Origin: session.SessionOriginNew})
127 if err != nil {
128 t.Fatal(err)
129 }
130 appendSessionTestMessage(t, runtime, "bot-message", provider.Message{ID: "bot-message", Role: provider.RoleAssistant, Content: "reply from bot"})
131 meta, err := app.OpenTopicSession("project", root, "", embeddedBotSessionPrefix+runtime.Ref().SessionID)
132 if err != nil {
133 t.Fatalf("open project bot session from Bots sidebar: %v", err)
134 }
135 if !meta.ReadOnly {
136 t.Fatal("bot transcript was opened writable")
137 }
138 opened := waitForTabReady(t, app, meta.ID)
139 if opened.Ctrl == nil || !opened.ReadOnly {
140 t.Fatal("bot transcript did not finish as a read-only controller")
141 }
142 if history := opened.Ctrl.History(); len(history) == 0 || history[len(history)-1].Content != "reply from bot" {
143 t.Fatalf("read project bot history: %+v", history)
144 }
145 update, err := json.Marshal(map[string]any{"message": provider.Message{ID: "bot-live", Role: provider.RoleAssistant, Content: "continued from bot"}})
146 if err != nil {
147 t.Fatal(err)
148 }
149 if _, err := runtime.Session().AppendBatch(t.Context(), "bot-live", []session.Event{{Kind: "message/complete", Payload: update}}); err != nil {
150 t.Fatal(err)
151 }
152 if _, err := runtime.Session().Flush(t.Context()); err != nil {
153 t.Fatal(err)
154 }
155 if history := opened.Ctrl.History(); len(history) == 0 || history[len(history)-1].Content != "continued from bot" {
156 t.Fatalf("desktop missed bot's live append: %+v", history)
157 }
158 snapshot, err := app.TranscriptSnapshotForTab(meta.ID, transcript.PageRequest{})
159 if err != nil || snapshot.Identity.SessionID != runtime.Ref().SessionID || snapshot.TotalRecords < 2 {
160 t.Fatalf("desktop transcript projection missed bot update: %+v, %v", snapshot, err)
161 }
162 }
163
164 func TestEmbeddedBotSessionOpenRejectsAnotherWorkspace(t *testing.T) {
165 app, tab, _, root, _ := canonicalWorkspaceOpenFixture(t)
166 botRoot := session.RootForLegacyDir(config.ProjectSessionDir(root))
167 bot, err := app.historicalSessionService(botRoot)
168 if err != nil {
169 t.Fatal(err)
170 }
171 if _, err := bot.Create(t.Context(), session.CreateOptions{SessionID: "wrong-workspace", CWD: tab.WorkspaceRoot, Origin: session.SessionOriginNew}); err != nil {
172 t.Fatal(err)
173 }
174 if _, err := app.OpenTopicSession("project", root, "", embeddedBotSessionPrefix+"wrong-workspace"); !errors.Is(err, errSessionWorkspaceConflict) {
175 t.Fatalf("cross-workspace bot session was accepted: %v", err)
176 }
177 }
178
179 func TestEmbeddedGlobalBotSessionOpensFromSidebar(t *testing.T) {
180 app, _, _, _, _ := canonicalWorkspaceOpenFixture(t)
181 root := globalTabWorkspaceRoot()
182 botRoot := session.RootForLegacyDir(config.ProjectSessionDir(root))
183 bot, err := app.historicalSessionService(botRoot)
184 if err != nil {
185 t.Fatal(err)
186 }
187 runtime, err := bot.Create(t.Context(), session.CreateOptions{SessionID: "global-bot", CWD: root, Origin: session.SessionOriginNew})
188 if err != nil {
189 t.Fatal(err)
190 }
191 appendSessionTestMessage(t, runtime, "global-bot-message", provider.Message{ID: "global-bot-message", Role: provider.RoleAssistant, Content: "global bot reply"})
192 meta, err := app.OpenTopicSession("global", "", "", embeddedBotSessionPrefix+runtime.Ref().SessionID)
193 if err != nil {
194 t.Fatal(err)
195 }
196 opened := waitForTabReady(t, app, meta.ID)
197 if !opened.ReadOnly {
198 t.Fatal("global bot transcript opened writable")
199 }
200 if history := opened.Ctrl.History(); len(history) == 0 || history[len(history)-1].Content != "global bot reply" {
201 t.Fatalf("global bot history = %+v", history)
202 }
203 }
204
205 func TestCanonicalOpenRejectsConflictingMembership(t *testing.T) {
206 app, tab, target, _, workspaceB := canonicalWorkspaceOpenFixture(t)
207 ctrl := tab.Ctrl
208 state, err := app.workspaceRegistry().Load(t.Context())
209 if err != nil {
210 t.Fatal(err)
211 }
212 owner := state.Workspaces[workspaceB]
213 owner.Root = tab.WorkspaceRoot
214 state.Workspaces[workspaceB] = owner
215 body, err := json.Marshal(state)
216 if err != nil {
217 t.Fatal(err)
218 }
219 if err := os.WriteFile(app.workspaceRegistry().Path(), body, 0o600); err != nil {
220 t.Fatal(err)
221 }
222 if _, err := app.OpenSession(target.Ref()); !errors.Is(err, errSessionWorkspaceConflict) {
223 t.Fatalf("conflict: %v", err)
224 }
225 if tab.Ctrl != ctrl || tab.SessionID != "session-A" {
226 t.Fatal("conflict modified current runtime")
227 }
228 if got := friendlySessionLoadError(errSessionWorkspaceConflict); !errors.Is(got, errSessionWorkspaceConflict) {
229 t.Fatalf("conflict disguised as corruption: %v", got)
230 }
231 }
232
233 func TestCanonicalStartupRepairsPersistedWorkspace(t *testing.T) {
234 app, tab, target, rootB, workspaceB := canonicalWorkspaceOpenFixture(t)
235 tab.Ctrl.Close()
236 tab.Ctrl, tab.Ready = nil, false
237 tab.SessionID = target.Ref().SessionID
238 tab.TopicID, tab.TopicTitle = "old-topic", "old title"
239 tab.SessionPath = "/old/project/transcript.jsonl"
240 app.saveTabsFromRemote()
241 if !app.prepareTabControllerWorkspace(tab, t.Context(), 0, app.ctx) {
242 t.Fatal(tab.StartupErr)
243 }
244 if tab.WorkspaceRoot != rootB || tab.SessionWorkspace.ID != workspaceB || tab.TopicID != "" || tab.SessionPath != "" {
245 t.Fatalf("cold repair retained old binding: %+v", persistedDesktopTabEntry(tab))
246 }
247 app.buildTabController(tab)
248 if tab.Ctrl == nil || !tab.Ready || tab.StartupErr != "" {
249 t.Fatalf("restart failed: %s", tab.StartupErr)
250 }
251 if root, ok := safeControllerWorkspaceRoot(tab.Ctrl); !ok || !sameDesktopPath(root, rootB) {
252 t.Fatalf("restart root = %s", root)
253 }
254 }
255
256 func TestCanonicalOpenRecoversFailedSurface(t *testing.T) {
257 app, tab, target, rootB, _ := canonicalWorkspaceOpenFixture(t)
258 tab.Ctrl.Close()
259 tab.Ctrl, tab.Ready, tab.StartupErr = nil, false, errSessionWorkspaceConflict.Error()
260 if _, err := app.OpenSession(target.Ref()); err != nil {
261 t.Fatal(err)
262 }
263 if !tab.Ready || tab.StartupErr != "" || !sameDesktopPath(tab.WorkspaceRoot, rootB) {
264 t.Fatal("failed surface did not recover")
265 }
266 }
267
268 func TestCanonicalOpenCommitsTargetWorkspace(t *testing.T) {
269 app, tab, target, rootB, workspaceB := canonicalWorkspaceOpenFixture(t)
270 tab.HistoricalSource = &SessionSourceRef{Path: "/fixture/old.jsonl"}
271 if err := app.workspaceRegistry().EnsureSessionTopic(t.Context(), target.Ref().SessionID, "topic-B", "Target topic"); err != nil {
272 t.Fatal(err)
273 }
274 if _, err := app.OpenSession(target.Ref()); err != nil {
275 t.Fatalf("open: %v", err)
276 }
277 if tab.SessionID != target.Ref().SessionID || !sameDesktopPath(tab.WorkspaceRoot, rootB) || tab.SessionWorkspace.ID != workspaceB {
278 t.Fatalf("wrong destination binding; got session=%s root=%s workspace=%s", tab.SessionID, tab.WorkspaceRoot, tab.SessionWorkspace.ID)
279 }
280 if tab.TopicID != "topic-B" || tab.TopicTitle != "Target topic" {
281 t.Fatalf("wrong destination topic; got id=%q title=%q", tab.TopicID, tab.TopicTitle)
282 }
283 if tab.HistoricalSource != nil {
284 t.Fatal("canonical activation retained the preparation action")
285 }
286 persisted := loadTabsFile()
287 if len(persisted.Tabs) != 1 || persisted.Tabs[0].SessionID != "session-B" || persisted.Tabs[0].TopicID != "topic-B" || !sameDesktopPath(persisted.Tabs[0].WorkspaceRoot, rootB) {
288 t.Fatalf("destination binding not persisted: %+v", persisted)
289 }
290 err := app.validateDesktopWorkspaceMembership(t.Context(), workspaceB, target.Ref())
291 if err != nil {
292 t.Fatalf("restart validation: %v", err)
293 }
294 if root, ok := safeControllerWorkspaceRoot(tab.Ctrl); !ok || !sameDesktopPath(root, rootB) {
295 t.Fatalf("controller workspace = %q, available=%v", root, ok)
296 }
297 }
298
298 lines GO