返回 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 legacy transcript is a rebuildable display cache after v3 adoption.
134 // An external append to it cannot override the event projection owned by the
135 // rebound controller.
136 requireHistoryTurnCount(t, app.HistoryPageForTab(tab.ID, 0, defaultHistoryPageTurns), 1, "v3 page")
137 }
138
139 func TestOpenChannelSessionPageBuildsFirstScreenFromOneDurableRead(t *testing.T) {
140 app, tab, _, _, _, _ := newAtomicRebindTestApp(t)
141 targetPath := resumedCleanTarget(t, app, tab, "channel-once-target.jsonl")
142 appendOnCommit(t, app, targetPath, "appended mid-switch")
143
144 page, err := app.OpenChannelSessionPageForTab(tab.ID, targetPath, defaultHistoryPageTurns)
145 if err != nil {
146 t.Fatalf("OpenChannelSessionPageForTab: %v", err)
147 }
148 requireSingleDurableRead(t, page)
149 requireHistoryTurnCount(t, page, 1, "channel switch page")
150 if !tab.ReadOnly {
151 t.Fatal("channel switch must leave the tab read-only")
152 }
153 }
154
155 func TestSwitchFirstScreenMatchesDurablePage(t *testing.T) {
156 app, tab, _, _, targetPath, loaded := newAtomicRebindTestApp(t)
157
158 page, err := app.ResumeSessionPageForTab(tab.ID, targetPath, defaultHistoryPageTurns)
159 if err != nil {
160 t.Fatalf("ResumeSessionPageForTab: %v", err)
161 }
162 durable := app.HistoryPageForTab(tab.ID, 0, defaultHistoryPageTurns)
163 requireHistoryPagesMatch(t, durable, page, "switch page")
164
165 // The same page built from the preloaded transcript must be byte-identical to
166 // the one built by a real durable read, which is what makes the reuse safe.
167 preloaded, readLog := historyPageForController(tab, app.controllerForTab(tab), loaded, targetPath, 0, defaultHistoryPageTurns)
168 if readLog {
169 t.Fatal("a preloaded transcript must satisfy the durable branch without another read")
170 }
171 requireHistoryPagesMatch(t, durable, preloaded, "preloaded page")
172 }
173
174 func TestSequentialSwitchesKeepPageIdentityWithTheirSession(t *testing.T) {
175 app, tab, _, _, targetPath, _ := newAtomicRebindTestApp(t)
176 dir := filepath.Dir(targetPath)
177 thirdPath := filepath.Join(dir, "sequential-third.jsonl")
178 writeHistoryTestSession(t, thirdPath, "third prompt")
179
180 first, err := app.ResumeSessionPageForTab(tab.ID, targetPath, defaultHistoryPageTurns)
181 if err != nil {
182 t.Fatalf("switch to target: %v", err)
183 }
184 second, err := app.ResumeSessionPageForTab(tab.ID, thirdPath, defaultHistoryPageTurns)
185 if err != nil {
186 t.Fatalf("switch to third: %v", err)
187 }
188 if first.Digest == "" || second.Digest == "" || first.Digest == second.Digest {
189 t.Fatalf("page digests = %q then %q, want distinct non-empty fingerprints", first.Digest, second.Digest)
190 }
191 if got := tab.currentSessionPath(); got != "" {
192 t.Fatalf("v3 tab retained legacy execution path %q", got)
193 }
194 snapshot, snapshotErr := app.TranscriptSnapshotForTab(tab.ID, transcript.PageRequest{})
195 if snapshotErr != nil || tab.SessionID == "" || snapshot.Identity.SessionID != tab.SessionID {
196 t.Fatalf("tab session id after sequential switches = %q, snapshot = %q, err = %v", tab.SessionID, snapshot.Identity.SessionID, snapshotErr)
197 }
198 requireHistoryPagesMatch(t, app.HistoryPageForTab(tab.ID, 0, defaultHistoryPageTurns), second, "final page")
199 }
200
201 func TestResumeSessionPageKeepsUnsavedControllerTail(t *testing.T) {
202 isolateDesktopUserDirs(t)
203 root := globalTabWorkspaceRoot()
204 dir := desktopSessionDir(root)
205 if err := os.MkdirAll(dir, 0o755); err != nil {
206 t.Fatal(err)
207 }
208 sessionPath := filepath.Join(dir, "unsaved-tail.jsonl")
209 writeHistoryTestSession(t, sessionPath, "durable prompt")
210
211 session, err := agent.LoadSession(sessionPath)
212 if err != nil {
213 t.Fatalf("load session: %v", err)
214 }
215 exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
216 ctrl := control.New(control.Options{
217 Executor: exec, SessionDir: dir, SessionPath: sessionPath, Label: "tail", Sink: event.Discard,
218 })
219 ctrl.Resume(session, sessionPath)
220 // An in-memory turn with no save behind it: the durable log must not displace
221 // it, which is the running-session side of the switch contract.
222 session.Add(provider.Message{Role: provider.RoleUser, Content: "unsaved tail"})
223
224 app := newRebindTestApp(t, root, sessionPath, ctrl, "unsaved-tail")
225 page, err := app.ResumeSessionPageForTab("unsaved-tail", sessionPath, defaultHistoryPageTurns)
226 if err != nil {
227 t.Fatalf("ResumeSessionPageForTab: %v", err)
228 }
229 if page.TotalTurns != 2 {
230 t.Fatalf("page totalTurns = %d, want 2: the durable log displaced the controller's unsaved tail", page.TotalTurns)
231 }
232 }
233
234 func TestResumeSessionPageRebindFailureKeepsSourceRuntime(t *testing.T) {
235 app, tab, oldCtrl, sourcePath, targetPath, _ := newAtomicRebindTestApp(t)
236 app.mu.RLock()
237 oldEpoch := app.sessionRuntimeViewLocked(tab).Epoch
238 app.mu.RUnlock()
239
240 holder, err := agent.TryAcquireSessionLease(targetPath)
241 if err != nil {
242 t.Fatalf("hold target lease: %v", err)
243 }
244 defer holder.Release()
245
246 page, err := app.ResumeSessionPageForTab(tab.ID, targetPath, defaultHistoryPageTurns)
247 if !errors.Is(err, agent.ErrSessionLeaseHeld) {
248 t.Fatalf("switch error = %v, want ErrSessionLeaseHeld", err)
249 }
250 if len(page.Messages) != 0 || page.Switch != nil {
251 t.Fatalf("failed switch returned a committed surface: %+v", page)
252 }
253 assertAtomicRebindFailurePreservedSource(t, app, tab, oldCtrl, sourcePath, targetPath, oldEpoch)
254 }
255
256 func TestResumeSessionPageFollowsCanonicalContinuation(t *testing.T) {
257 isolateDesktopUserDirs(t)
258 root := globalTabWorkspaceRoot()
259 dir := desktopSessionDir(root)
260 if err := os.MkdirAll(dir, 0o755); err != nil {
261 t.Fatal(err)
262 }
263 question := provider.Message{Role: provider.RoleUser, Content: "question"}
264 answer := provider.Message{Role: provider.RoleAssistant, Content: "answer"}
265 next := provider.Message{Role: provider.RoleUser, Content: "next"}
266 done := provider.Message{Role: provider.RoleAssistant, Content: "done"}
267 save := func(path, topic string, messages ...provider.Message) {
268 t.Helper()
269 session := agent.NewSession("sys")
270 for _, message := range messages {
271 session.Add(message)
272 }
273 if err := session.Save(path); err != nil {
274 t.Fatal(err)
275 }
276 if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
277 ID: agent.BranchID(path), Scope: "global", TopicID: topic, TopicTitle: "Upgraded",
278 }); err != nil {
279 t.Fatal(err)
280 }
281 }
282 parentPath := filepath.Join(dir, "continuation-parent.jsonl")
283 leafPath := filepath.Join(dir, "continuation-leaf.jsonl")
284 save(parentPath, "conversation", question, answer)
285 save(leafPath, "legacy-leaf-topic", question, answer, next, done)
286 if err := agent.SaveBranchMetaPreserveUpdated(leafPath, agent.BranchMeta{
287 ID: agent.BranchID(leafPath), Scope: "global", TopicID: "legacy-leaf-topic",
288 Recovered: true, ParentID: agent.BranchID(parentPath), RecoveryDepth: 1,
289 }); err != nil {
290 t.Fatal(err)
291 }
292
293 parent, err := agent.LoadSession(parentPath)
294 if err != nil {
295 t.Fatalf("load parent: %v", err)
296 }
297 exec := agent.New(nil, nil, parent, agent.Options{}, event.Discard)
298 ctrl := control.New(control.Options{
299 Executor: exec, SessionDir: dir, SessionPath: parentPath, Label: "parent", Sink: event.Discard,
300 })
301 ctrl.Resume(parent, parentPath)
302
303 app := newRebindTestApp(t, root, parentPath, ctrl, "continuation")
304 service := app.desktopSessionService(dir)
305 v3Ctrl := control.New(control.Options{
306 Executor: agent.New(nil, nil, parent, agent.Options{}, event.Discard),
307 SessionDir: dir, Label: "parent", Sink: event.Discard,
308 SessionService: service, ExclusiveSession: true,
309 })
310 // A unified import refuses to wait behind a live retired sidecar writer.
311 // The host retires its legacy producer before preparing the replacement.
312 ctrl.Close()
313 ref, err := v3Ctrl.ContinueLegacySession(t.Context(), parentPath, "")
314 if err != nil {
315 t.Fatalf("migrate parent: %v", err)
316 }
317 tab := app.tabs["continuation"]
318 app.mu.Lock()
319 delete(app.runtimeBySessionKey, sessionRuntimeKey(parentPath))
320 tab.Ctrl = v3Ctrl
321 tab.SessionID = ref.SessionID
322 tab.SessionPath = ""
323 app.newSessionRuntimeLocked(tab, sessionRuntimeKey(tab.currentSessionIdentity()))
324 app.mu.Unlock()
325 installSessionCatalogForTest(t, app, dir, "global", "")
326 if got := app.continuePathForOpen(parentPath); got != leafPath {
327 t.Fatalf("continuePathForOpen = %q, want covering leaf %q", got, leafPath)
328 }
329
330 page, err := app.ResumeSessionPageForTab("continuation", parentPath, defaultHistoryPageTurns)
331 if err != nil {
332 t.Fatalf("ResumeSessionPageForTab: %v", err)
333 }
334 requireSingleDurableRead(t, page)
335 bound := app.controllerForTab(tab)
336 if tab.SessionID == "" || bound.SessionPath() != "" {
337 t.Fatalf("bound identity = session %q path %q, want exclusive v3", tab.SessionID, bound.SessionPath())
338 }
339 // The window must be the leaf's two turns, not the parent's one.
340 requireHistoryTurnCount(t, page, 2, "continuation page")
341 // The fingerprint must describe the transcript the page displays: it is what
342 // the next slice compares against to detect real drift.
343 digest, err := agent.ContentDigestForMessages(bound.History())
344 if err != nil {
345 t.Fatalf("digest bound transcript: %v", err)
346 }
347 if page.Digest != digest {
348 t.Fatalf("page digest = %q, want bound transcript digest %q", page.Digest, digest)
349 }
350 parentDigest, err := agent.ContentDigestForMessages(parent.Snapshot())
351 if err != nil {
352 t.Fatalf("digest parent: %v", err)
353 }
354 if page.Digest == parentDigest {
355 t.Fatal("page fingerprint still names the pre-continuation parent")
356 }
357 }
358
359 // newRebindTestApp builds the smallest App a switch needs: one global-scope tab
360 // with a live controller, its session lease, a published runtime, and a ready
361 // sink.
362 func newRebindTestApp(t *testing.T, root, sessionPath string, ctrl control.SessionAPI, tabID string) *App {
363 t.Helper()
364 app := NewApp()
365 app.ctx = context.Background()
366 app.readyHook = func() {}
367 tab := &WorkspaceTab{
368 ID: tabID,
369 Scope: "global",
370 WorkspaceRoot: root,
371 SessionPath: sessionPath,
372 Ctrl: ctrl,
373 Ready: true,
374 sink: &tabEventSink{tabID: tabID, app: app, ctx: app.ctx},
375 disabledMCP: map[string]ServerView{},
376 }
377 app.tabs[tab.ID] = tab
378 app.tabOrder = []string{tab.ID}
379 app.activeTabID = tab.ID
380 if err := tab.ensureSessionLease(sessionPath); err != nil {
381 t.Fatalf("lease session: %v", err)
382 }
383 app.mu.Lock()
384 app.newSessionRuntimeLocked(tab, sessionRuntimeKey(sessionPath))
385 app.advanceSessionRuntimeEpochLocked(tab)
386 app.mu.Unlock()
387 t.Cleanup(func() {
388 if live := app.controllerForTab(tab); live != nil {
389 live.Close()
390 }
391 tab.releaseSessionLease()
392 })
393 return app
394 }
395
395 lines GO