返回 DeepSeek-Reasonix
session_catalog_recovery_test.go
根目录 / desktop / session_catalog_recovery_test.go
1 package main
2
3 import (
4 "context"
5 "path/filepath"
6 "testing"
7 "time"
8
9 "reasonix/internal/sessioncatalog"
10 )
11
12 func TestSessionMetaFromCatalogPropagatesRecoveryCopy(t *testing.T) {
13 meta := sessionMetaFromCatalog(sessioncatalog.SessionRecord{
14 Path: "/s/copy.jsonl", Preview: "hello", Turns: 2, TurnsState: sessioncatalog.TurnsValid,
15 Recovered: true, RecoveryCopy: true, LastActivityAt: 42,
16 }, false, false)
17 if !meta.Recovered || !meta.RecoveryCopy {
18 t.Fatalf("meta = %+v, want recovered recoveryCopy", meta)
19 }
20 meta = sessionMetaFromCatalog(sessioncatalog.SessionRecord{
21 Path: "/s/adopted.jsonl", Recovered: true, RecoveryCopy: false,
22 }, false, false)
23 if !meta.Recovered || meta.RecoveryCopy {
24 t.Fatalf("meta = %+v, want recovered without recoveryCopy", meta)
25 }
26 }
27
28 func TestProjectNodeFromCatalogTopicFiltersIdleRecoveryCopies(t *testing.T) {
29 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
30 topic := sessioncatalog.TopicRecord{
31 Scope: "global", TopicID: "t1", Title: "Topic", Turns: 3,
32 TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
33 LastActivityAt: 100, Sessions: []sessioncatalog.SessionRecord{
34 {Path: "/s/parent.jsonl", Turns: 3, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, LastActivityAt: 90},
35 {Path: "/s/copy.jsonl", Turns: 3, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
36 Recovered: true, RecoveryCopy: true, LastActivityAt: 100},
37 {Path: "/s/open-copy.jsonl", Turns: 1, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
38 Recovered: true, RecoveryCopy: true, LastActivityAt: 95},
39 },
40 }
41 sessionOverlays := map[string]catalogRuntimeOverlay{
42 sessionRuntimeKey("/s/open-copy.jsonl"): {open: true},
43 }
44 node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, sessionOverlays, nil)
45 if !ok {
46 t.Fatal("mixed topic should stay visible")
47 }
48 // Ordinary list is always one logical row; open recovery only flips Open.
49 if len(node.Children) != 0 {
50 t.Fatalf("children = %d, want collapsed logical row", len(node.Children))
51 }
52 if !node.Open {
53 t.Fatal("open recovery member must aggregate onto the logical row")
54 }
55 }
56
57 func TestProjectNodesFromCatalogTopicKeepsSharedTopicSessionsIndependent(t *testing.T) {
58 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
59 dir := t.TempDir()
60 pathA := filepath.Join(dir, "a.jsonl")
61 pathB := filepath.Join(dir, "b.jsonl")
62 topic := sessioncatalog.TopicRecord{
63 Scope: "global", TopicID: "shared", Title: "Shared title", Turns: 5,
64 TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
65 Sessions: []sessioncatalog.SessionRecord{
66 {Path: pathA, CustomTitle: "A", Turns: 5, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, LastActivityAt: 100},
67 {Path: pathB, CustomTitle: "B", Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, LastActivityAt: 90},
68 },
69 }
70 overlays := map[string]catalogRuntimeOverlay{
71 sessionRuntimeKey(pathA): {open: true, running: true, status: topicStatusThinking},
72 }
73 nodes := app.projectNodesFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, overlays, nil)
74 if len(nodes) != 2 {
75 t.Fatalf("nodes = %#v, want two independent rows", nodes)
76 }
77 byPath := map[string]ProjectNode{}
78 for _, node := range nodes {
79 byPath[node.SessionPath] = node
80 }
81 if !byPath[pathA].Running || byPath[pathA].Label != "A" {
82 t.Fatalf("A = %+v, want its own runtime and title", byPath[pathA])
83 }
84 if byPath[pathB].Running || byPath[pathB].Label != "B" {
85 t.Fatalf("B = %+v, must not inherit A runtime", byPath[pathB])
86 }
87 if byPath[pathA].Key == byPath[pathB].Key {
88 t.Fatal("shared-topic sessions must have distinct row keys")
89 }
90 }
91
92 func TestProjectNodeFromCatalogTopicShowsNonEmptyRecoveryOnlyTopic(t *testing.T) {
93 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
94 topic := sessioncatalog.TopicRecord{
95 Scope: "global", TopicID: "only-copy", Title: "Copy", RecoveryState: "recovery_only",
96 Sessions: []sessioncatalog.SessionRecord{
97 {Path: "/s/only.jsonl", Recovered: true, RecoveryCopy: true, Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK},
98 },
99 }
100 node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil)
101 if !ok {
102 t.Fatal("non-empty recovery-only topic must remain discoverable")
103 }
104 if !node.Recovered || node.RecoveryState != "recovery_only" || node.SessionPath != "/s/only.jsonl" {
105 t.Fatalf("recovery-only node = %+v, want a recoverable logical row", node)
106 }
107 }
108
109 func TestProjectNodeFromCatalogTopicMarksCanonicalRecovery(t *testing.T) {
110 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
111 topic := sessioncatalog.TopicRecord{
112 Scope: "global", TopicID: "adopted", Title: "Recovered", RecoveryState: "adopted",
113 Sessions: []sessioncatalog.SessionRecord{
114 {Path: "/s/root.jsonl", Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK},
115 {Path: "/s/canonical.jsonl", Recovered: true, RecoveryRole: sessioncatalog.RecoveryRoleAdopted,
116 RecoveryCanonical: true, Turns: 4, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK},
117 },
118 }
119 node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil)
120 if !ok || !node.Recovered || node.RecoveryState != "adopted" {
121 t.Fatalf("canonical recovery node = %+v, visible=%v; want recovered adopted row", node, ok)
122 }
123 }
124
125 func TestProjectNodeFromCatalogTopicKeepsPinnedRecoveryOnlyTopic(t *testing.T) {
126 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
127 topic := sessioncatalog.TopicRecord{
128 Scope: "global", TopicID: "pinned-copy", Title: "Pinned", Pinned: true, RecoveryState: "recovery_only",
129 Sessions: []sessioncatalog.SessionRecord{
130 {Path: "/s/pinned.jsonl", Recovered: true, RecoveryCopy: true, Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK},
131 },
132 }
133 node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil)
134 if !ok {
135 t.Fatal("pinned recovery-only topic must remain visible")
136 }
137 // After filtering idle copies nothing is left as children, so single-line topic.
138 if len(node.Children) != 0 {
139 t.Fatalf("children = %d, want collapsed single-line pinned topic", len(node.Children))
140 }
141 }
142
143 func TestProjectNodeFromCatalogTopicCollapsesWhenOnlyOneVisible(t *testing.T) {
144 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
145 topic := sessioncatalog.TopicRecord{
146 Scope: "global", TopicID: "collapse", Title: "Collapse", Turns: 2,
147 Sessions: []sessioncatalog.SessionRecord{
148 {Path: "/s/parent.jsonl", Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK},
149 {Path: "/s/copy.jsonl", Recovered: true, RecoveryCopy: true, Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK},
150 },
151 }
152 node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil)
153 if !ok {
154 t.Fatal("topic with parent should stay visible")
155 }
156 if len(node.Children) != 0 {
157 t.Fatalf("children = %d, want collapsed single effective session", len(node.Children))
158 }
159 }
160
161 func TestProjectNodeFromCatalogTopicCollapsesDivergedForksToOne(t *testing.T) {
162 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
163 topic := sessioncatalog.TopicRecord{
164 Scope: "global", TopicID: "forks", Title: "WebView", Turns: 5,
165 Sessions: []sessioncatalog.SessionRecord{
166 {Path: "/s/root.jsonl", Turns: 3, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, LastActivityAt: 10},
167 {Path: "/s/fork-a.jsonl", Turns: 5, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
168 Recovered: true, RecoveryRole: sessioncatalog.RecoveryRoleDiverged, ParentID: "root", RecoveryGroupID: "root", LastActivityAt: 30},
169 {Path: "/s/fork-b.jsonl", Turns: 4, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
170 Recovered: true, RecoveryRole: sessioncatalog.RecoveryRoleDiverged, ParentID: "root", RecoveryGroupID: "root", LastActivityAt: 40},
171 },
172 }
173 node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil)
174 if !ok {
175 t.Fatal("topic with parent should stay visible")
176 }
177 // Parent remains; idle diverged forks hide while the normal root exists.
178 if len(node.Children) != 0 {
179 t.Fatalf("children = %d, want collapsed parent-only row, children=%+v", len(node.Children), node.Children)
180 }
181 if node.RecoveryCopyCount != 0 {
182 t.Fatalf("recoveryCopyCount = %d, want compatibility field hidden from ordinary tree", node.RecoveryCopyCount)
183 }
184 }
185
186 func TestProjectNodeFromCatalogTopicHidesFoldedCoveredCopyCount(t *testing.T) {
187 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
188 topic := sessioncatalog.TopicRecord{
189 Scope: "global", TopicID: "t1", Title: "Topic", Turns: 3,
190 TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
191 LastActivityAt: 100, Sessions: []sessioncatalog.SessionRecord{
192 {Path: "/s/parent.jsonl", Turns: 3, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, LastActivityAt: 90},
193 {Path: "/s/copy-a.jsonl", Turns: 3, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
194 Recovered: true, RecoveryCopy: true, LastActivityAt: 100},
195 {Path: "/s/copy-b.jsonl", Turns: 3, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
196 Recovered: true, RecoveryCopy: true, LastActivityAt: 95},
197 },
198 }
199 node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil)
200 if !ok {
201 t.Fatal("topic with parent should stay visible")
202 }
203 if node.RecoveryCopyCount != 0 {
204 t.Fatalf("recoveryCopyCount = %d, want compatibility field hidden from ordinary tree", node.RecoveryCopyCount)
205 }
206 }
207
208 func TestProjectNodeFromCatalogTopicHidesOrphanForkTopicsWhenPreferredElsewhere(t *testing.T) {
209 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
210 // Separate topic that only holds a non-preferred diverged fork.
211 topic := sessioncatalog.TopicRecord{
212 Scope: "global", TopicID: "orphan-fork", Title: "WebView",
213 Sessions: []sessioncatalog.SessionRecord{
214 {Path: "/s/fork-only.jsonl", Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
215 Recovered: true, RecoveryRole: sessioncatalog.RecoveryRoleDiverged, ParentID: "root", RecoveryGroupID: "root"},
216 },
217 }
218 // Workspace preference already chose the parent path for this lineage.
219 preferred := map[string]struct{}{"/s/root.jsonl": {}}
220 if _, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, preferred); ok {
221 t.Fatal("orphan non-preferred recovery topic must stay out of the ordinary tree")
222 }
223 }
224
225 func TestListProjectTopicsSkipsRecoveryOnlyPages(t *testing.T) {
226 isolateDesktopUserDirs(t)
227 ctx := context.Background()
228 catalog, err := sessioncatalog.Open(ctx, sessioncatalog.Options{
229 Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true,
230 })
231 if err != nil {
232 t.Fatal(err)
233 }
234 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
235 base := time.Date(2026, 8, 11, 10, 0, 0, 0, time.UTC)
236 // Two idle recovery-only topics (newer) then one ordinary topic (older).
237 for i, topicID := range []string{"copy-b", "copy-a", "real"} {
238 record := sessioncatalog.SessionRecord{
239 Path: filepath.Join("/s", topicID+".jsonl"), Directory: "/s", Scope: "global",
240 TopicID: topicID, TopicTitle: topicID, Turns: 1, TurnsState: sessioncatalog.TurnsValid,
241 Health: sessioncatalog.HealthOK, LastActivityAt: base.Add(time.Duration(2-i) * time.Minute).UnixMilli(),
242 }
243 if topicID != "real" {
244 record.Recovered = true
245 record.RecoveryCopy = true
246 }
247 if err := catalog.UpsertSession(ctx, record); err != nil {
248 t.Fatal(err)
249 }
250 }
251 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
252 app.sessionCatalog.Store(catalog)
253 page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 1})
254 if err != nil {
255 t.Fatal(err)
256 }
257 if len(page.Items) != 1 || page.Items[0].TopicID != "real" {
258 t.Fatalf("page = %+v, want the ordinary topic after skipping recovery-only pages", page.Items)
259 }
260 }
261
261 lines GO