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