返回 DeepSeek-Reasonix
session_recovery_cleanup_test.go
根目录 / desktop / session_recovery_cleanup_test.go
1 package main
2
3 import (
4 "errors"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9 "time"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/config"
13 "reasonix/internal/provider"
14 "reasonix/internal/store"
15 )
16
17 func saveSnapshotTurns(t *testing.T, path string, turns int) *agent.Session {
18 t.Helper()
19 s := agent.NewSession("sys")
20 for i := range turns {
21 s.Add(provider.Message{Role: provider.RoleUser, Content: "prompt " + string(rune('a'+i))})
22 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
23 if err := s.SaveSnapshot(path); err != nil {
24 t.Fatalf("SaveSnapshot turn %d: %v", i, err)
25 }
26 }
27 return s
28 }
29
30 func forkDesktopRecoveryBranch(t *testing.T, dir, name string) (parentPath, branchPath string, branchMsgs []provider.Message) {
31 t.Helper()
32 parentPath = filepath.Join(dir, name+".jsonl")
33 parent := agent.NewSession("sys")
34 parent.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
35 parent.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
36 parent.Add(provider.Message{Role: provider.RoleUser, Content: "disk " + name})
37 if err := parent.Save(parentPath); err != nil {
38 t.Fatalf("Save recovery parent: %v", err)
39 }
40 branch := agent.NewSession("sys")
41 branch.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
42 branch.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
43 branch.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name})
44 info, err := branch.SaveRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: parentPath})
45 if err != nil {
46 t.Fatalf("SaveRecoveryBranch: %v", err)
47 }
48 return parentPath, info.Path, branch.Snapshot()
49 }
50
51 func coverDesktopRecoveryParent(t *testing.T, parentPath string, branchMsgs []provider.Message) {
52 t.Helper()
53 parent, err := agent.LoadSession(parentPath)
54 if err != nil {
55 t.Fatalf("Load covering recovery parent: %v", err)
56 }
57 parent.Replace(append([]provider.Message(nil), branchMsgs...))
58 parent.Add(provider.Message{Role: provider.RoleAssistant, Content: "parent kept the recovery content"})
59 if err := parent.SaveRewrite(parentPath); err != nil {
60 t.Fatalf("Save covering recovery parent: %v", err)
61 }
62 }
63
64 func TestMergeSessionInfosCountsRecoveryActivity(t *testing.T) {
65 dir := t.TempDir()
66 parentPath, branchPath, branchMsgs := forkDesktopRecoveryBranch(t, dir, "covered")
67 coverDesktopRecoveryParent(t, parentPath, branchMsgs)
68 summaries := map[string]topicSummary{}
69 now := time.Now()
70 infos := []agent.SessionInfo{
71 {
72 Path: parentPath,
73 Turns: 3,
74 LastActivityAt: now.Add(-time.Hour),
75 Scope: "global",
76 TopicID: "topic-1",
77 },
78 {
79 Path: branchPath,
80 Turns: 5,
81 LastActivityAt: now,
82 Scope: "global",
83 TopicID: "topic-1",
84 Recovered: true,
85 },
86 }
87 mergeSessionInfos(dir, infos, map[string]string{}, map[string]agent.SessionInfo{}, map[string]string{}, summaries)
88 summary := summaries[topicSummaryKey("global", "", "topic-1")]
89 if !summary.hasNormalSession || !summary.hasRecoveryOnly {
90 t.Fatalf("summary flags = %+v, want both normal and recovery seen", summary)
91 }
92 if summary.turns != 3 {
93 t.Fatalf("turns = %d, want 3 (recovery copies must not double-count)", summary.turns)
94 }
95 // The copy is the live transcript after recovery: its newer activity must
96 // drive topic recency, unread state, and time filters.
97 if summary.lastActivityAt != now.UnixMilli() {
98 t.Fatalf("lastActivityAt = %d, want recovery activity %d", summary.lastActivityAt, now.UnixMilli())
99 }
100 }
101
102 func TestMergeSessionInfosKeepsContinuedRecoveryVisible(t *testing.T) {
103 dir := t.TempDir()
104 _, branchPath, _ := forkDesktopRecoveryBranch(t, dir, "diverged")
105 summaries := map[string]topicSummary{}
106 now := time.Now()
107 infos := []agent.SessionInfo{{
108 Path: branchPath,
109 Turns: 5,
110 LastActivityAt: now,
111 Scope: "global",
112 TopicID: "topic-continued",
113 Recovered: true,
114 }}
115
116 mergeSessionInfos(dir, infos, map[string]string{}, map[string]agent.SessionInfo{}, map[string]string{}, summaries)
117 summary := summaries[topicSummaryKey("global", "", "topic-continued")]
118 if !summary.hasAdoptedRecovery || summary.hasRecoveryOnly {
119 t.Fatalf("summary flags = %+v, want adopted recovery only", summary)
120 }
121 if topicHiddenAsRecoveryOnly(summary, false, nil) {
122 t.Fatal("continued recovery was hidden after its tab closed")
123 }
124 if got := summary.displayTurns(); got != 5 {
125 t.Fatalf("display turns = %d, want 5", got)
126 }
127 }
128
129 func TestSessionMetaSeparatesRecoveryProvenanceFromCleanupCopy(t *testing.T) {
130 dir := t.TempDir()
131 coveredParent, coveredBranch, coveredMsgs := forkDesktopRecoveryBranch(t, dir, "meta-covered")
132 coverDesktopRecoveryParent(t, coveredParent, coveredMsgs)
133 info := agent.SessionInfo{
134 Path: coveredBranch,
135 Recovered: true,
136 }
137 meta := sessionMetaFromInfo(info, "", false, false, 0, dir)
138 if !meta.Recovered || !meta.RecoveryCopy {
139 t.Fatalf("covered recovery meta = %+v, want provenance and cleanup-copy flags", meta)
140 }
141
142 _, divergedBranch, _ := forkDesktopRecoveryBranch(t, dir, "meta-diverged")
143 info.Path = divergedBranch
144 meta = sessionMetaFromInfo(info, "", false, false, 0, dir)
145 if !meta.Recovered || meta.RecoveryCopy {
146 t.Fatalf("diverged recovery meta = %+v, want provenance without cleanup-copy flag", meta)
147 }
148 }
149
150 func TestRecoveryCopyCleanupRevalidatesInBackend(t *testing.T) {
151 isolateDesktopUserDirs(t)
152 dir := config.SessionDir()
153 if err := os.MkdirAll(dir, 0o755); err != nil {
154 t.Fatal(err)
155 }
156 app := NewApp()
157
158 parentPath, branchPath, branchMsgs := forkDesktopRecoveryBranch(t, dir, "delete-guard")
159 if err := app.DeleteRecoveryCopy(branchPath); err == nil {
160 t.Fatal("DeleteRecoveryCopy accepted a branch with unique content")
161 }
162 if _, err := os.Stat(branchPath); err != nil {
163 t.Fatalf("rejected recovery branch was not preserved: %v", err)
164 }
165 coverDesktopRecoveryParent(t, parentPath, branchMsgs)
166 if err := app.DeleteRecoveryCopy(branchPath); err != nil {
167 t.Fatalf("DeleteRecoveryCopy covered branch: %v", err)
168 }
169 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(branchPath), filepath.Base(branchPath))
170 if _, err := os.Stat(trashPath); err != nil {
171 t.Fatalf("covered recovery branch was not moved to trash: %v", err)
172 }
173
174 purgeParent, purgeBranch, purgeMsgs := forkDesktopRecoveryBranch(t, dir, "purge-guard")
175 if err := app.deleteSession(purgeBranch); err != nil {
176 t.Fatalf("DeleteSession divergent branch: %v", err)
177 }
178 purgeTrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(purgeBranch), filepath.Base(purgeBranch))
179 if err := app.PurgeRecoveryCopy(purgeTrashPath); err == nil {
180 t.Fatal("PurgeRecoveryCopy accepted a trashed branch with unique content")
181 }
182 if _, err := os.Stat(purgeTrashPath); err != nil {
183 t.Fatalf("rejected trashed recovery branch was not preserved: %v", err)
184 }
185 coverDesktopRecoveryParent(t, purgeParent, purgeMsgs)
186 parentLease, err := agent.TryAcquireSessionLease(purgeParent)
187 if err != nil {
188 t.Fatalf("TryAcquireSessionLease parent: %v", err)
189 }
190 if err := app.PurgeRecoveryCopy(purgeTrashPath); !errors.Is(err, errSessionBusyElsewhere) {
191 parentLease.Release()
192 t.Fatalf("PurgeRecoveryCopy while parent is live err = %v, want errSessionBusyElsewhere", err)
193 }
194 if _, err := os.Stat(purgeTrashPath); err != nil {
195 parentLease.Release()
196 t.Fatalf("busy-parent purge did not preserve recovery branch: %v", err)
197 }
198 parentLease.Release()
199 if err := app.PurgeRecoveryCopy(purgeTrashPath); err != nil {
200 t.Fatalf("PurgeRecoveryCopy covered branch: %v", err)
201 }
202 if _, err := os.Stat(purgeTrashPath); !os.IsNotExist(err) {
203 t.Fatalf("covered recovery branch survived permanent purge: %v", err)
204 }
205 }
206
207 func TestRecoveryCopyCleanupKeepsOpenBranch(t *testing.T) {
208 isolateDesktopUserDirs(t)
209 dir := config.SessionDir()
210 if err := os.MkdirAll(dir, 0o755); err != nil {
211 t.Fatal(err)
212 }
213 app := NewApp()
214 parentPath, branchPath, branchMsgs := forkDesktopRecoveryBranch(t, dir, "open-delete-guard")
215 coverDesktopRecoveryParent(t, parentPath, branchMsgs)
216 app.tabs["open-copy"] = &WorkspaceTab{ID: "open-copy", Scope: "global", SessionPath: branchPath, Ready: true}
217
218 if err := app.DeleteRecoveryCopy(branchPath); !errors.Is(err, errSessionBusyElsewhere) {
219 t.Fatalf("DeleteRecoveryCopy(open) error = %v, want busy", err)
220 }
221 if _, err := os.Stat(branchPath); err != nil {
222 t.Fatalf("open recovery branch must remain visible: %v", err)
223 }
224 }
225
226 func TestTopicHiddenAsRecoveryOnly(t *testing.T) {
227 recoveryOnly := topicSummary{hasRecoveryOnly: true}
228 cases := []struct {
229 name string
230 summary topicSummary
231 pinned bool
232 sessions []runtimeSessionStatus
233 want bool
234 }{
235 {"recovery-only idle", recoveryOnly, false, nil, true},
236 {"normal session present", topicSummary{hasRecoveryOnly: true, hasNormalSession: true}, false, nil, false},
237 {"continued recovery present", topicSummary{hasRecoveryOnly: true, hasAdoptedRecovery: true}, false, nil, false},
238 {"pinned stays visible", recoveryOnly, true, nil, false},
239 {"single open runtime", recoveryOnly, false, []runtimeSessionStatus{{open: true}}, false},
240 // topicRuntimeStatus reports open/running only for single-session
241 // topics; the hide rule must still see a two-session topic as live.
242 {"two runtime sessions one open", recoveryOnly, false, []runtimeSessionStatus{{open: true}, {running: false}}, false},
243 {"detached running runtime", recoveryOnly, false, []runtimeSessionStatus{{running: true}, {}}, false},
244 {"idle runtime entries only", recoveryOnly, false, []runtimeSessionStatus{{}, {}}, true},
245 }
246 for _, c := range cases {
247 if got := topicHiddenAsRecoveryOnly(c.summary, c.pinned, c.sessions); got != c.want {
248 t.Errorf("%s: hidden = %v, want %v", c.name, got, c.want)
249 }
250 }
251 }
252
253 func TestTrashSessionMatchesLiveSeesEventLogDivergence(t *testing.T) {
254 dir := t.TempDir()
255 live := filepath.Join(dir, "session.jsonl")
256 s := saveSnapshotTurns(t, live, 1)
257
258 // Simulate an old trash copy taken at checkpoint time: same anchor bytes,
259 // same event log state.
260 trashDir := filepath.Join(dir, "trash")
261 if err := os.MkdirAll(trashDir, 0o755); err != nil {
262 t.Fatal(err)
263 }
264 trashPath := filepath.Join(trashDir, "session.jsonl")
265 for _, pair := range [][2]string{
266 {live, trashPath},
267 {store.SessionEventLog(live), store.SessionEventLog(trashPath)},
268 } {
269 b, err := os.ReadFile(pair[0])
270 if err != nil {
271 t.Fatal(err)
272 }
273 if err := os.WriteFile(pair[1], b, 0o644); err != nil {
274 t.Fatal(err)
275 }
276 }
277
278 same, err := trashSessionMatchesLive(live, trashPath)
279 if err != nil {
280 t.Fatalf("trashSessionMatchesLive identical: %v", err)
281 }
282 if !same {
283 t.Fatal("identical live/trash reported as different")
284 }
285
286 // The live session keeps chatting: growth lands in the event log only, so
287 // the two .jsonl checkpoints stay byte-identical. Byte comparison would
288 // call this a duplicate and delete the live session's newer history.
289 s.Add(provider.Message{Role: provider.RoleUser, Content: "newer work"})
290 if err := s.SaveSnapshot(live); err != nil {
291 t.Fatalf("SaveSnapshot diverge: %v", err)
292 }
293 liveAnchor, _ := os.ReadFile(live)
294 trashAnchor, _ := os.ReadFile(trashPath)
295 if string(liveAnchor) != string(trashAnchor) {
296 t.Skip("checkpoints diverged on disk; byte-compare trap not reproducible here")
297 }
298 same, err = trashSessionMatchesLive(live, trashPath)
299 if err != nil {
300 t.Fatalf("trashSessionMatchesLive diverged: %v", err)
301 }
302 if same {
303 t.Fatal("live session with newer event log reported as duplicate of trash copy")
304 }
305 }
306
307 func TestTrashPathsBlockedWhileLeaseHeld(t *testing.T) {
308 dir := t.TempDir()
309 path := filepath.Join(dir, "session.jsonl")
310 saveSnapshotTurns(t, path, 1)
311
312 // A live owner (any runtime — this process or another) holds the lease
313 // lock on an open handle for its whole hold. Every destructive path must
314 // refuse while it is held: probing once and deleting later would let the
315 // owner's freshly locked lease file be unlinked out from under it.
316 lease, err := agent.TryAcquireSessionLease(path)
317 if err != nil {
318 t.Fatalf("TryAcquireSessionLease: %v", err)
319 }
320 released := false
321 defer func() {
322 if !released {
323 lease.Release()
324 }
325 }()
326
327 if err := trashSessionArtifactsBeforeMove(dir, path, "session.jsonl", nil); !errors.Is(err, errSessionBusyElsewhere) {
328 t.Fatalf("trashSessionArtifactsBeforeMove err = %v, want errSessionBusyElsewhere", err)
329 }
330 if err := reconcileDesktopTrashSessionArtifacts(dir, path, "session.jsonl"); !errors.Is(err, errSessionBusyElsewhere) {
331 t.Fatalf("reconcileDesktopTrashSessionArtifacts err = %v, want errSessionBusyElsewhere", err)
332 }
333 if err := removeDesktopSessionArtifacts(path); !errors.Is(err, errSessionBusyElsewhere) {
334 t.Fatalf("removeDesktopSessionArtifacts err = %v, want errSessionBusyElsewhere", err)
335 }
336 if _, err := os.Stat(path); err != nil {
337 t.Fatalf("session file touched despite live owner: %v", err)
338 }
339 if _, err := os.Stat(store.SessionEventLog(path)); err != nil {
340 t.Fatalf("event log touched despite live owner: %v", err)
341 }
342 if _, err := os.Stat(store.SessionLeaseLock(path)); err != nil {
343 t.Fatalf("lease lock deleted while held: %v", err)
344 }
345
346 // Once the owner releases, the same trash call succeeds and the lock
347 // sidecars are gone with it.
348 lease.Release()
349 released = true
350 if err := trashSessionArtifactsBeforeMove(dir, path, "session.jsonl", nil); err != nil {
351 t.Fatalf("trashSessionArtifactsBeforeMove after release: %v", err)
352 }
353 for _, p := range []string{
354 path,
355 store.SessionLockFile(path),
356 store.SessionLeaseLock(path),
357 store.SessionLeaseInfo(path),
358 } {
359 if _, err := os.Stat(p); !os.IsNotExist(err) {
360 t.Errorf("artifact survived trash: %s (err=%v)", p, err)
361 }
362 }
363 }
364
365 func TestPromptHistorySeesEventLogPrompts(t *testing.T) {
366 dir := t.TempDir()
367 path := filepath.Join(dir, "session.jsonl")
368 s := agent.NewSession("sys")
369 s.Add(provider.Message{Role: provider.RoleUser, Content: "first prompt"})
370 if err := s.SaveSnapshot(path); err != nil {
371 t.Fatalf("SaveSnapshot: %v", err)
372 }
373 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
374 s.Add(provider.Message{Role: provider.RoleUser, Content: "second prompt"})
375 if err := s.SaveSnapshot(path); err != nil {
376 t.Fatalf("SaveSnapshot append: %v", err)
377 }
378
379 info, err := os.Stat(path)
380 if err != nil {
381 t.Fatal(err)
382 }
383 entries, err := collectPromptHistoryEntries(path, info, func(s string) string { return s })
384 if err != nil {
385 t.Fatalf("collectPromptHistoryEntries: %v", err)
386 }
387 if len(entries) != 2 {
388 t.Fatalf("prompt history entries = %d, want 2 (event-log prompts must appear)", len(entries))
389 }
390 if entries[0].Text != "first prompt" || entries[1].Text != "second prompt" {
391 t.Fatalf("prompt history texts = %q, %q", entries[0].Text, entries[1].Text)
392 }
393 if entries[1].At == 0 {
394 t.Fatal("appended prompt lost its timestamp")
395 }
396 }
397
398 func TestTopicTitleUserTurnsSeesEventLogTurns(t *testing.T) {
399 dir := t.TempDir()
400 path := filepath.Join(dir, "session.jsonl")
401 saveSnapshotTurns(t, path, 3)
402
403 users := topicTitleUserTurnsFromSession(path)
404 if len(users) != 3 {
405 t.Fatalf("user turns = %d, want 3 (≥3-turn title upgrade depends on this)", len(users))
406 }
407 }
408
409 func TestTopicTitleUserTurnsSkipHostFraming(t *testing.T) {
410 dir := t.TempDir()
411 path := filepath.Join(dir, "session.jsonl")
412 s := agent.NewSession("sys")
413 // Delivery-mode first turn: user text with the trailing runtime marker.
414 // Built from the exported constant — the preview strip is byte-exact, so a
415 // paraphrased marker would (correctly) not be stripped.
416 s.Add(provider.Message{Role: provider.RoleUser, Content: "你是谁?\n\n" + agent.DeliveryRuntimeMarker})
417 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
418 // Host-injected readiness nudge, persisted as role user.
419 s.Add(provider.Message{Role: provider.RoleUser, Content: "Host final-answer readiness check failed. Before giving a final answer, address the missing host-observable receipts: x"})
420 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
421 s.Add(provider.Message{Role: provider.RoleUser, Content: "帮我写一个魂斗罗游戏"})
422 if err := s.SaveSnapshot(path); err != nil {
423 t.Fatalf("SaveSnapshot: %v", err)
424 }
425
426 users := topicTitleUserTurnsFromSession(path)
427 if len(users) != 2 {
428 t.Fatalf("user turns = %d, want 2 (readiness nudge must not count)", len(users))
429 }
430 if users[0] != "你是谁?" {
431 t.Fatalf("first turn = %q, want the marker stripped", users[0])
432 }
433 if title := topicTitleFromText(users[0]); strings.Contains(title, "<delivery") || strings.Contains(title, "delivery-run") {
434 t.Fatalf("title = %q, delivery marker leaked", title)
435 }
436 }
437
437 lines GO