返回 DeepSeek-Reasonix
session_switch_page_test.go
根目录 / desktop / session_switch_page_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/control"
14 "reasonix/internal/event"
15 "reasonix/internal/provider"
16 "reasonix/internal/transcript"
17 )
18
19 // appendHistoryTestSessionTurn writes one more turn to a session's durable log
20 // while a switch into that session is in flight. A switch that re-reads the log
21 // to build its first screen surfaces the appended turn; one that reuses the
22 // transcript it already loaded for the rebind does not.
23 func appendHistoryTestSessionTurn(t *testing.T, path, prompt string) {
24 t.Helper()
25 session, err := agent.LoadSession(path)
26 if err != nil {
27 t.Fatalf("load %s: %v", path, err)
28 }
29 session.Add(provider.Message{Role: provider.RoleUser, Content: prompt})
30 if err := session.Save(path); err != nil {
31 t.Fatalf("save %s: %v", path, err)
32 }
33 }
34
35 // appendOnCommit makes the durable log grow once the switch has already loaded
36 // it, so a page built from a second read would show the extra turn.
37 func appendOnCommit(t *testing.T, app *App, path, prompt string) {
38 t.Helper()
39 app.rebindCandidateHook = func(stage string) error {
40 if stage == "committed" {
41 appendHistoryTestSessionTurn(t, path, prompt)
42 }
43 return nil
44 }
45 }
46
47 func requireSingleDurableRead(t *testing.T, page HistoryPage) {
48 t.Helper()
49 if page.Switch == nil {
50 t.Fatal("switch page carries no phase breakdown")
51 }
52 if page.Switch.Outcome != "ok" {
53 t.Fatalf("switch outcome = %q, want ok", page.Switch.Outcome)
54 }
55 if page.Switch.DurableReads != 1 {
56 t.Fatalf("switch durable reads = %d, want 1", page.Switch.DurableReads)
57 }
58 if page.Switch.LoadedCount == 0 || page.Switch.LoadedBytes == 0 {
59 t.Fatalf("switch phase counts missing: %+v", page.Switch)
60 }
61 }
62
63 func requireHistoryPagesMatch(t *testing.T, want, got HistoryPage, label string) {
64 t.Helper()
65 if got.StartTurn != want.StartTurn || got.EndTurn != want.EndTurn ||
66 got.TotalTurns != want.TotalTurns || got.HasOlder != want.HasOlder {
67 t.Fatalf("%s window = %d-%d/%d older=%v, want %d-%d/%d older=%v", label,
68 got.StartTurn, got.EndTurn, got.TotalTurns, got.HasOlder,
69 want.StartTurn, want.EndTurn, want.TotalTurns, want.HasOlder)
70 }
71 if got.Digest != want.Digest {
72 t.Fatalf("%s digest = %q, want %q", label, got.Digest, want.Digest)
73 }
74 if got.Revision != want.Revision {
75 t.Fatalf("%s revision = %d, want %d", label, got.Revision, want.Revision)
76 }
77 wantJSON, err := json.Marshal(want.Messages)
78 if err != nil {
79 t.Fatalf("marshal want messages: %v", err)
80 }
81 gotJSON, err := json.Marshal(got.Messages)
82 if err != nil {
83 t.Fatalf("marshal got messages: %v", err)
84 }
85 if string(gotJSON) != string(wantJSON) {
86 t.Fatalf("%s messages differ:\n got %s\nwant %s", label, gotJSON, wantJSON)
87 }
88 }
89
90 // resumedCleanTarget writes a target session whose persisted system prompt is
91 // already the composed one. The rebound controller is then content-identical to
92 // the log, so a switch that re-reads the log is the only way the page can pick
93 // up a write that lands after the load.
94 func resumedCleanTarget(t *testing.T, app *App, tab *WorkspaceTab, name string) string {
95 t.Helper()
96 dir := filepath.Dir(tab.currentSessionPath())
97 probe := filepath.Join(dir, name+".probe.jsonl")
98 writeHistoryTestSession(t, probe, "probe prompt")
99 if _, err := app.ResumeSessionPageForTab(tab.ID, probe, defaultHistoryPageTurns); err != nil {
100 t.Fatalf("probe switch: %v", err)
101 }
102 prompt := systemPromptFrom(app.controllerForTab(tab).History())
103 if strings.TrimSpace(prompt) == "" {
104 t.Fatal("rebuilt controller composed no system prompt")
105 }
106 path := filepath.Join(dir, name)
107 session := agent.NewSession(prompt)
108 session.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
109 if err := session.Save(path); err != nil {
110 t.Fatalf("save %s: %v", path, err)
111 }
112 return path
113 }
114
115 func requireHistoryTurnCount(t *testing.T, page HistoryPage, want int, label string) {
116 t.Helper()
117 if page.TotalTurns != want {
118 t.Fatalf("%s totalTurns = %d, want %d", label, page.TotalTurns, want)
119 }
120 }
121
122 func TestResumeSessionPageBuildsFirstScreenFromOneDurableRead(t *testing.T) {
123 app, tab, _, _, _, _ := newAtomicRebindTestApp(t)
124 targetPath := resumedCleanTarget(t, app, tab, "resume-once-target.jsonl")
125 appendOnCommit(t, app, targetPath, "appended mid-switch")
126
127 page, err := app.ResumeSessionPageForTab(tab.ID, targetPath, defaultHistoryPageTurns)
128 if err != nil {
129 t.Fatalf("ResumeSessionPageForTab: %v", err)
130 }
131 requireSingleDurableRead(t, page)
132 requireHistoryTurnCount(t, page, 1, "switch page")
133 // The switch uses its one captured read. A subsequent refresh reads the
134 // native source again and must see the append that arrived after that cut.
135 requireHistoryTurnCount(t, app.HistoryPageForTab(tab.ID, 0, defaultHistoryPageTurns), 2, "native refresh")
136 }
137
138 func TestOpenChannelSessionPageBuildsFirstScreenFromOneDurableRead(t *testing.T) {
139 app, tab, _, _, _, _ := newAtomicRebindTestApp(t)
140 targetPath := resumedCleanTarget(t, app, tab, "channel-once-target.jsonl")
141 appendOnCommit(t, app, targetPath, "appended mid-switch")
142
143 page, err := app.OpenChannelSessionPageForTab(tab.ID, targetPath, defaultHistoryPageTurns)
144 if err != nil {
145 t.Fatalf("OpenChannelSessionPageForTab: %v", err)
146 }
147 requireSingleDurableRead(t, page)
148 requireHistoryTurnCount(t, page, 1, "channel switch page")
149 if !tab.ReadOnly {
150 t.Fatal("channel switch must leave the tab read-only")
151 }
152 }
153
154 func TestSwitchFirstScreenMatchesDurablePage(t *testing.T) {
155 app, tab, _, _, targetPath, loaded := newAtomicRebindTestApp(t)
156
157 page, err := app.ResumeSessionPageForTab(tab.ID, targetPath, defaultHistoryPageTurns)
158 if err != nil {
159 t.Fatalf("ResumeSessionPageForTab: %v", err)
160 }
161 durable := app.HistoryPageForTab(tab.ID, 0, defaultHistoryPageTurns)
162 requireHistoryPagesMatch(t, durable, page, "switch page")
163
164 // The same page built from the preloaded transcript must be byte-identical to
165 // the one built by a real durable read, which is what makes the reuse safe.
166 preloaded, readLog := historyPageForController(tab, app.controllerForTab(tab), loaded, targetPath, 0, defaultHistoryPageTurns)
167 if readLog {
168 t.Fatal("a preloaded transcript must satisfy the durable branch without another read")
169 }
170 requireHistoryPagesMatch(t, durable, preloaded, "preloaded page")
171 }
172
173 func TestSequentialSwitchesKeepPageIdentityWithTheirSession(t *testing.T) {
174 app, tab, _, _, targetPath, _ := newAtomicRebindTestApp(t)
175 dir := filepath.Dir(targetPath)
176 thirdPath := filepath.Join(dir, "sequential-third.jsonl")
177 writeHistoryTestSession(t, thirdPath, "third prompt")
178
179 first, err := app.ResumeSessionPageForTab(tab.ID, targetPath, defaultHistoryPageTurns)
180 if err != nil {
181 t.Fatalf("switch to target: %v", err)
182 }
183 second, err := app.ResumeSessionPageForTab(tab.ID, thirdPath, defaultHistoryPageTurns)
184 if err != nil {
185 t.Fatalf("switch to third: %v", err)
186 }
187 if first.Digest == "" || second.Digest == "" || first.Digest == second.Digest {
188 t.Fatalf("page digests = %q then %q, want distinct non-empty fingerprints", first.Digest, second.Digest)
189 }
190 if got := tab.currentSessionPath(); !sameDesktopPath(got, thirdPath) {
191 t.Fatalf("native tab lost execution path %q", got)
192 }
193 snapshot, snapshotErr := app.TranscriptSnapshotForTab(tab.ID, transcript.PageRequest{})
194 if snapshotErr != nil || tab.SessionID != "" || snapshot.Identity.SessionID != agent.BranchID(thirdPath) {
195 t.Fatalf("tab session id after sequential switches = %q, snapshot = %q, err = %v", tab.SessionID, snapshot.Identity.SessionID, snapshotErr)
196 }
197 requireHistoryPagesMatch(t, app.HistoryPageForTab(tab.ID, 0, defaultHistoryPageTurns), second, "final page")
198 }
199
200 func TestResumeSessionPageKeepsUnsavedControllerTail(t *testing.T) {
201 isolateDesktopUserDirs(t)
202 root := globalTabWorkspaceRoot()
203 dir := desktopSessionDir(root)
204 if err := os.MkdirAll(dir, 0o755); err != nil {
205 t.Fatal(err)
206 }
207 sessionPath := filepath.Join(dir, "unsaved-tail.jsonl")
208 writeHistoryTestSession(t, sessionPath, "durable prompt")
209
210 session, err := agent.LoadSession(sessionPath)
211 if err != nil {
212 t.Fatalf("load session: %v", err)
213 }
214 exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
215 ctrl := control.New(control.Options{
216 Executor: exec, SessionDir: dir, SessionPath: sessionPath, Label: "tail", Sink: event.Discard,
217 })
218 ctrl.Resume(session, sessionPath)
219 // An in-memory turn with no save behind it: the durable log must not displace
220 // it, which is the running-session side of the switch contract.
221 session.Add(provider.Message{Role: provider.RoleUser, Content: "unsaved tail"})
222
223 app := newRebindTestApp(t, root, sessionPath, ctrl, "unsaved-tail")
224 page, err := app.ResumeSessionPageForTab("unsaved-tail", sessionPath, defaultHistoryPageTurns)
225 if err != nil {
226 t.Fatalf("ResumeSessionPageForTab: %v", err)
227 }
228 if page.TotalTurns != 2 {
229 t.Fatalf("page totalTurns = %d, want 2: the durable log displaced the controller's unsaved tail", page.TotalTurns)
230 }
231 }
232
233 func TestResumeSessionPageRebindFailureKeepsSourceRuntime(t *testing.T) {
234 app, tab, oldCtrl, sourcePath, targetPath, _ := newAtomicRebindTestApp(t)
235 app.mu.RLock()
236 oldEpoch := app.sessionRuntimeViewLocked(tab).Epoch
237 app.mu.RUnlock()
238
239 holder, err := agent.TryAcquireSessionLease(targetPath)
240 if err != nil {
241 t.Fatalf("hold target lease: %v", err)
242 }
243 defer holder.Release()
244
245 page, err := app.ResumeSessionPageForTab(tab.ID, targetPath, defaultHistoryPageTurns)
246 if !errors.Is(err, agent.ErrSessionLeaseHeld) {
247 t.Fatalf("switch error = %v, want ErrSessionLeaseHeld", err)
248 }
249 if len(page.Messages) != 0 || page.Switch != nil {
250 t.Fatalf("failed switch returned a committed surface: %+v", page)
251 }
252 assertAtomicRebindFailurePreservedSource(t, app, tab, oldCtrl, sourcePath, targetPath, oldEpoch)
253 }
254
255 func TestResumeSessionPageFromCanonicalToNativeContinuation(t *testing.T) {
256 isolateDesktopUserDirs(t)
257 root := globalTabWorkspaceRoot()
258 dir := desktopSessionDir(root)
259 if err := os.MkdirAll(dir, 0o755); err != nil {
260 t.Fatal(err)
261 }
262 question := provider.Message{Role: provider.RoleUser, Content: "question"}
263 answer := provider.Message{Role: provider.RoleAssistant, Content: "answer"}
264 next := provider.Message{Role: provider.RoleUser, Content: "next"}
265 done := provider.Message{Role: provider.RoleAssistant, Content: "done"}
266 save := func(path, topic string, messages ...provider.Message) {
267 t.Helper()
268 session := agent.NewSession("sys")
269 for _, message := range messages {
270 session.Add(message)
271 }
272 if err := session.Save(path); err != nil {
273 t.Fatal(err)
274 }
275 if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
276 ID: agent.BranchID(path), Scope: "global", TopicID: topic, TopicTitle: "Upgraded",
277 }); err != nil {
278 t.Fatal(err)
279 }
280 }
281 parentPath := filepath.Join(dir, "continuation-parent.jsonl")
282 leafPath := filepath.Join(dir, "continuation-leaf.jsonl")
283 save(parentPath, "conversation", question, answer)
284 save(leafPath, "legacy-leaf-topic", question, answer, next, done)
285 if err := agent.SaveBranchMetaPreserveUpdated(leafPath, agent.BranchMeta{
286 ID: agent.BranchID(leafPath), Scope: "global", TopicID: "legacy-leaf-topic",
287 Recovered: true, ParentID: agent.BranchID(parentPath), RecoveryDepth: 1,
288 }); err != nil {
289 t.Fatal(err)
290 }
291
292 parent, err := agent.LoadSession(parentPath)
293 if err != nil {
294 t.Fatalf("load parent: %v", err)
295 }
296 exec := agent.New(nil, nil, parent, agent.Options{}, event.Discard)
297 ctrl := control.New(control.Options{
298 Executor: exec, SessionDir: dir, SessionPath: parentPath, Label: "parent", Sink: event.Discard,
299 })
300 ctrl.Resume(parent, parentPath)
301
302 app := newRebindTestApp(t, root, parentPath, ctrl, "continuation")
303 service := app.desktopSessionService(dir)
304 v3Ctrl := control.New(control.Options{
305 Executor: agent.New(nil, nil, parent, agent.Options{}, event.Discard),
306 SessionDir: dir, Label: "parent", Sink: event.Discard,
307 SessionService: service, ExclusiveSession: true,
308 })
309 // A unified import refuses to wait behind a live retired sidecar writer.
310 // The host retires its legacy producer before preparing the replacement.
311 ctrl.Close()
312 ref, err := v3Ctrl.ContinueLegacySession(t.Context(), parentPath, "")
313 if err != nil {
314 t.Fatalf("migrate parent: %v", err)
315 }
316 tab := app.tabs["continuation"]
317 app.mu.Lock()
318 delete(app.runtimeBySessionKey, sessionRuntimeKey(parentPath))
319 tab.Ctrl = v3Ctrl
320 tab.SessionID = ref.SessionID
321 tab.SessionPath = ""
322 app.newSessionRuntimeLocked(tab, sessionRuntimeKey(tab.currentSessionIdentity()))
323 app.mu.Unlock()
324 installSessionCatalogForTest(t, app, dir, "global", "")
325 if got := app.continuePathForOpen(parentPath); got != leafPath {
326 t.Fatalf("continuePathForOpen = %q, want covering leaf %q", got, leafPath)
327 }
328
329 // An explicitly opened recovery leaf remains native even when its parent
330 // has already been adopted. A mapped parent itself stays canonical.
331 page, err := app.ResumeSessionPageForTab("continuation", leafPath, defaultHistoryPageTurns)
332 if err != nil {
333 t.Fatalf("ResumeSessionPageForTab: %v", err)
334 }
335 requireSingleDurableRead(t, page)
336 bound := app.controllerForTab(tab)
337 if tab.SessionID != "" || bound.SessionPath() != leafPath {
338 t.Fatalf("bound identity = session %q path %q, want native continuation", tab.SessionID, bound.SessionPath())
339 }
340 // The window must be the leaf's two turns, not the parent's one.
341 requireHistoryTurnCount(t, page, 2, "continuation page")
342 // The fingerprint must describe the transcript the page displays: it is what
343 // the next slice compares against to detect real drift.
344 digest, err := agent.ContentDigestForMessages(bound.History())
345 if err != nil {
346 t.Fatalf("digest bound transcript: %v", err)
347 }
348 if page.Digest != digest {
349 t.Fatalf("page digest = %q, want bound transcript digest %q", page.Digest, digest)
350 }
351 parentDigest, err := agent.ContentDigestForMessages(parent.Snapshot())
352 if err != nil {
353 t.Fatalf("digest parent: %v", err)
354 }
355 if page.Digest == parentDigest {
356 t.Fatal("page fingerprint still names the pre-continuation parent")
357 }
358 }
359
360 // newRebindTestApp builds the smallest App a switch needs: one global-scope tab
361 // with a live controller, its session lease, a published runtime, and a ready
362 // sink.
363 func newRebindTestApp(t *testing.T, root, sessionPath string, ctrl control.SessionAPI, tabID string) *App {
364 t.Helper()
365 app := NewApp()
366 app.ctx = context.Background()
367 app.readyHook = func() {}
368 tab := &WorkspaceTab{
369 ID: tabID,
370 Scope: "global",
371 WorkspaceRoot: root,
372 SessionPath: sessionPath,
373 Ctrl: ctrl,
374 Ready: true,
375 sink: &tabEventSink{tabID: tabID, app: app, ctx: app.ctx},
376 disabledMCP: map[string]ServerView{},
377 }
378 app.tabs[tab.ID] = tab
379 app.tabOrder = []string{tab.ID}
380 app.activeTabID = tab.ID
381 if err := tab.ensureSessionLease(sessionPath); err != nil {
382 t.Fatalf("lease session: %v", err)
383 }
384 app.mu.Lock()
385 app.newSessionRuntimeLocked(tab, sessionRuntimeKey(sessionPath))
386 app.advanceSessionRuntimeEpochLocked(tab)
387 app.mu.Unlock()
388 t.Cleanup(func() {
389 if live := app.controllerForTab(tab); live != nil {
390 live.Close()
391 }
392 tab.releaseSessionLease()
393 })
394 return app
395 }
396
396 lines GO