返回 DeepSeek-Reasonix
session_catalog_app_test.go
根目录 / desktop / session_catalog_app_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "reasonix/internal/agent"
8 "reasonix/internal/config"
9 "reasonix/internal/control"
10 "reasonix/internal/history"
11 "reasonix/internal/provider"
12 "reasonix/internal/sessioncatalog"
13 "strings"
14 "testing"
15 "time"
16 )
17
18 func installSessionCatalogForTest(t *testing.T, app *App, path, scope, workspaceRoot string) {
19 t.Helper()
20 catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true})
21 if err != nil {
22 t.Fatalf("open in-memory session catalog: %v", err)
23 }
24 target := sessioncatalog.DirectoryTarget{Path: path, Scope: scope, WorkspaceRoot: workspaceRoot}
25 if err := catalog.ReconcileDirectory(context.Background(), target); err != nil {
26 _ = catalog.Close(context.Background())
27 t.Fatalf("reconcile session catalog %q: %v", target.Path, err)
28 }
29 app.sessionCatalog.Store(catalog)
30 t.Cleanup(func() {
31 app.stopSessionCatalog(time.Second)
32 })
33 }
34
35 func reconcileSessionCatalogForTest(t *testing.T, app *App, path, scope, workspaceRoot string) {
36 t.Helper()
37 catalog := app.sessionCatalog.Load()
38 if catalog == nil {
39 t.Fatal("session catalog is not installed")
40 }
41 target := sessioncatalog.DirectoryTarget{Path: path, Scope: scope, WorkspaceRoot: workspaceRoot}
42 if err := catalog.ReconcileDirectory(context.Background(), target); err != nil {
43 t.Fatalf("reconcile session catalog %q: %v", path, err)
44 }
45 }
46
47 func TestProjectTreeSnapshotReturnsProjectShellWithoutMigratingSessions(t *testing.T) {
48 isolateDesktopUserDirs(t)
49 root := t.TempDir()
50 if err := addProject(root, "Large Project"); err != nil {
51 t.Fatal(err)
52 }
53 sessionDir := desktopSessionDir(root)
54 if err := os.MkdirAll(sessionDir, 0o755); err != nil {
55 t.Fatal(err)
56 }
57 legacyPath := filepath.Join(sessionDir, "legacy.jsonl")
58 if err := os.WriteFile(legacyPath, []byte(`{"role":"user","content":"legacy"}`+"\n"), 0o600); err != nil {
59 t.Fatal(err)
60 }
61
62 snapshot := mustProjectTreeSnapshot(t, NewApp())
63 if len(snapshot.Projects) != 1 || snapshot.Projects[0].Root != root {
64 t.Fatalf("snapshot = %#v, want project shell %q", snapshot, root)
65 }
66 if snapshot.Projects[0].Children == nil {
67 t.Fatal("project shell children encoded as null, want []")
68 }
69 if _, err := os.Stat(legacyPath + ".meta"); !os.IsNotExist(err) {
70 t.Fatalf("snapshot migrated session metadata: %v", err)
71 }
72 }
73
74 func TestProjectTreeSnapshotIncludesPinnedTopicsForCollapsedFolders(t *testing.T) {
75 isolateDesktopUserDirs(t)
76 root := t.TempDir()
77 if err := addProject(root, "Pinned Project"); err != nil {
78 t.Fatal(err)
79 }
80 if err := setTopicTitle(root, "ordinary", "Ordinary chat"); err != nil {
81 t.Fatal(err)
82 }
83 if err := setTopicTitle(root, "pinned", "Pinned chat"); err != nil {
84 t.Fatal(err)
85 }
86 app := NewApp()
87 if err := app.SetTopicPinned("pinned", true); err != nil {
88 t.Fatal(err)
89 }
90
91 snapshot := mustProjectTreeSnapshot(t, app)
92 if len(snapshot.Projects) != 1 {
93 t.Fatalf("project shells = %#v, want one project", snapshot.Projects)
94 }
95 children := snapshot.Projects[0].Children
96 if len(children) != 1 || children[0].TopicID != "pinned" || !children[0].Pinned {
97 t.Fatalf("collapsed project children = %#v, want only pinned topic shell", children)
98 }
99 if children[0].Label != "Pinned chat" {
100 t.Fatalf("pinned topic label = %q, want %q", children[0].Label, "Pinned chat")
101 }
102 }
103
104 func TestCompatibilityProjectTreeDoesNotMigrateLegacySession(t *testing.T) {
105 isolateDesktopUserDirs(t)
106 root := t.TempDir()
107 if err := addProject(root, "Project"); err != nil {
108 t.Fatal(err)
109 }
110 sessionDir := desktopSessionDir(root)
111 if err := os.MkdirAll(sessionDir, 0o755); err != nil {
112 t.Fatal(err)
113 }
114 legacyPath := filepath.Join(sessionDir, "legacy.jsonl")
115 if err := os.WriteFile(legacyPath, []byte(`{"role":"user","content":"legacy"}`+"\n"), 0o600); err != nil {
116 t.Fatal(err)
117 }
118
119 _ = mustListProjectTree(t, NewApp())
120 if _, err := os.Stat(legacyPath + ".meta"); !os.IsNotExist(err) {
121 t.Fatalf("ListProjectTree migrated legacy session: %v", err)
122 }
123 }
124
125 func TestProjectTreeShellSurvivesCatalogRevisionRace(t *testing.T) {
126 isolateDesktopUserDirs(t)
127 root := t.TempDir()
128 if err := addProject(root, "Shell Race"); err != nil {
129 t.Fatal(err)
130 }
131 app := NewApp()
132 // Catalog not open yet: revision stays 0 while the shell still returns projects.
133 snapshot := mustProjectTreeSnapshot(t, app)
134 if snapshot.Revision != 0 {
135 t.Fatalf("revision = %d, want 0 while catalog is opening", snapshot.Revision)
136 }
137 if len(snapshot.Projects) == 0 {
138 t.Fatal("project shell empty while catalog opening")
139 }
140 found := false
141 for _, project := range snapshot.Projects {
142 if project.Root == root || project.Label == "Shell Race" {
143 found = true
144 break
145 }
146 }
147 if !found {
148 t.Fatalf("snapshot projects = %#v, want Shell Race", snapshot.Projects)
149 }
150 }
151
152 func TestUpgradeLegacyRecoveryChainNeedsNoUserActionForOneRowSidebar(t *testing.T) {
153 isolateDesktopUserDirs(t)
154 dir := desktopSessionDir(globalWorkspaceRoot())
155 if err := os.MkdirAll(dir, 0o755); err != nil {
156 t.Fatal(err)
157 }
158 save := func(path, topic string, messages ...provider.Message) {
159 t.Helper()
160 session := agent.NewSession("sys")
161 for _, message := range messages {
162 session.Add(message)
163 }
164 if err := session.Save(path); err != nil {
165 t.Fatal(err)
166 }
167 if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
168 ID: agent.BranchID(path), Scope: "global", TopicID: topic,
169 TopicTitle: "Upgraded conversation",
170 }); err != nil {
171 t.Fatal(err)
172 }
173 }
174 q := provider.Message{Role: provider.RoleUser, Content: "question"}
175 a := provider.Message{Role: provider.RoleAssistant, Content: "answer"}
176 next := provider.Message{Role: provider.RoleUser, Content: "continue"}
177 done := provider.Message{Role: provider.RoleAssistant, Content: "done"}
178 root := filepath.Join(dir, "root.jsonl")
179 copy := filepath.Join(dir, "copy.jsonl")
180 leaf := filepath.Join(dir, "leaf.jsonl")
181 save(root, "conversation", q, a)
182 save(copy, "legacy-copy-topic", q, a)
183 save(leaf, "legacy-leaf-topic", q, a, next, done)
184 for path, meta := range map[string]agent.BranchMeta{
185 copy: {ID: "copy", Scope: "global", TopicID: "legacy-copy-topic", Recovered: true, ParentID: "root", RecoveryDepth: 1},
186 leaf: {ID: "leaf", Scope: "global", TopicID: "legacy-leaf-topic", Recovered: true, ParentID: "copy", RecoveryDepth: 2},
187 } {
188 if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil {
189 t.Fatal(err)
190 }
191 }
192 app := NewApp()
193 installSessionCatalogForTest(t, app, dir, "global", "")
194 page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
195 if err != nil {
196 t.Fatal(err)
197 }
198 if len(page.Items) != 1 || page.Items[0].TopicID != "conversation" || len(page.Items[0].Children) != 0 {
199 t.Fatalf("zero-touch upgraded sidebar = %+v, want one ordinary conversation row", page.Items)
200 }
201 if page.Items[0].RecoveryBranchCount != 0 || page.Items[0].RecoveryUnresolvedCount != 0 ||
202 page.Items[0].Status == topicStatusDivergedRecovery {
203 // Recovery counts and conflict status belong only to History advanced
204 // views; the ordinary root row must stay a plain conversation line.
205 t.Fatalf("ordinary row leaked recovery decoration: %+v", page.Items[0])
206 }
207 }
208
209 func TestListProjectTopicsSurfacesLiveTabWhileCatalogLags(t *testing.T) {
210 isolateDesktopUserDirs(t)
211 root := t.TempDir()
212 if err := addProject(root, "Lagging Catalog"); err != nil {
213 t.Fatal(err)
214 }
215 sessionDir := desktopSessionDir(root)
216 if err := os.MkdirAll(sessionDir, 0o755); err != nil {
217 t.Fatal(err)
218 }
219 app := NewApp()
220 installSessionCatalogForTest(t, app, sessionDir, "project", root)
221 app.tabs["tab-1"] = &WorkspaceTab{
222 ID: "tab-1", Scope: "project", WorkspaceRoot: root,
223 TopicID: "topic_20260812-082637_live", TopicTitle: "Ownership Hub",
224 }
225
226 page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root})
227 if err != nil {
228 t.Fatal(err)
229 }
230 if len(page.Items) != 1 || page.Items[0].TopicID != "topic_20260812-082637_live" {
231 t.Fatalf("page items = %#v, want the live tab's topic", page.Items)
232 }
233 if page.Items[0].CreatedAt <= 0 {
234 t.Fatalf("createdAt = %d, want the topic's creation time", page.Items[0].CreatedAt)
235 }
236 }
237
238 func TestListProjectTopicsDoesNotDuplicateIndexedLiveTab(t *testing.T) {
239 isolateDesktopUserDirs(t)
240 root := t.TempDir()
241 if err := addProject(root, "Indexed"); err != nil {
242 t.Fatal(err)
243 }
244 sessionDir := desktopSessionDir(root)
245 if err := os.MkdirAll(sessionDir, 0o755); err != nil {
246 t.Fatal(err)
247 }
248 sessionPath := filepath.Join(sessionDir, "live.jsonl")
249 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o600); err != nil {
250 t.Fatal(err)
251 }
252 if err := agent.UpdateBranchMeta(sessionPath, false, func(meta *agent.BranchMeta) error {
253 meta.TopicID = "topic-indexed"
254 meta.TopicTitle = "Indexed Topic"
255 meta.WorkspaceRoot = root
256 meta.Scope = "project"
257 return nil
258 }); err != nil {
259 t.Fatal(err)
260 }
261 app := NewApp()
262 installSessionCatalogForTest(t, app, sessionDir, "project", root)
263 app.tabs["tab-1"] = &WorkspaceTab{
264 ID: "tab-1", Scope: "project", WorkspaceRoot: root,
265 TopicID: "topic-indexed", TopicTitle: "Indexed Topic", SessionPath: sessionPath,
266 }
267
268 page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root})
269 if err != nil {
270 t.Fatal(err)
271 }
272 if len(page.Items) != 1 {
273 t.Fatalf("page items = %#v, want the catalog row only", page.Items)
274 }
275 }
276
277 func TestListProjectTopicsHonorsConversationSortMode(t *testing.T) {
278 isolateDesktopUserDirs(t)
279 root := t.TempDir()
280 if err := addProject(root, "Sortable Conversations"); err != nil {
281 t.Fatal(err)
282 }
283 sessionDir := desktopSessionDir(root)
284 if err := os.MkdirAll(sessionDir, 0o755); err != nil {
285 t.Fatal(err)
286 }
287 app := NewApp()
288 installSessionCatalogForTest(t, app, sessionDir, "project", root)
289 catalog := app.sessionCatalog.Load()
290 if catalog == nil {
291 t.Fatal("session catalog not installed")
292 }
293 for _, record := range []sessioncatalog.SessionRecord{
294 {Path: filepath.Join(sessionDir, "created-new.jsonl"), Directory: sessionDir, Scope: "project", WorkspaceRoot: root, TopicID: "created-new", TopicTitle: "Created New", CreatedAt: 300, LastActivityAt: 100, Turns: 1, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK},
295 {Path: filepath.Join(sessionDir, "updated-new.jsonl"), Directory: sessionDir, Scope: "project", WorkspaceRoot: root, TopicID: "updated-new", TopicTitle: "Updated New", CreatedAt: 100, LastActivityAt: 300, Turns: 1, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK},
296 } {
297 if err := catalog.UpsertSession(context.Background(), record); err != nil {
298 t.Fatal(err)
299 }
300 }
301
302 created, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, SortMode: "created"})
303 if err != nil {
304 t.Fatal(err)
305 }
306 updated, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, SortMode: "updated"})
307 if err != nil {
308 t.Fatal(err)
309 }
310 if len(created.Items) != 2 || created.Items[0].TopicID != "created-new" {
311 t.Fatalf("created sort = %#v, want created-new first", created.Items)
312 }
313 if len(updated.Items) != 2 || updated.Items[0].TopicID != "updated-new" {
314 t.Fatalf("updated sort = %#v, want updated-new first", updated.Items)
315 }
316 }
317
318 func TestListProjectTopicsFoldsRestoredLegacyTopicTabIntoOneRow(t *testing.T) {
319 isolateDesktopUserDirs(t)
320 dir := desktopSessionDir(globalWorkspaceRoot())
321 if err := os.MkdirAll(dir, 0o755); err != nil {
322 t.Fatal(err)
323 }
324 save := func(path, topic string) {
325 t.Helper()
326 session := agent.NewSession("sys")
327 session.Add(provider.Message{Role: provider.RoleUser, Content: "question"})
328 session.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer"})
329 if err := session.Save(path); err != nil {
330 t.Fatal(err)
331 }
332 if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
333 ID: agent.BranchID(path), Scope: "global", TopicID: topic,
334 TopicTitle: "Upgraded conversation",
335 }); err != nil {
336 t.Fatal(err)
337 }
338 }
339 root := filepath.Join(dir, "root.jsonl")
340 copyPath := filepath.Join(dir, "copy.jsonl")
341 save(root, "conversation")
342 save(copyPath, "legacy-copy-topic")
343 if err := agent.SaveBranchMetaPreserveUpdated(copyPath, agent.BranchMeta{
344 ID: "copy", Scope: "global", TopicID: "legacy-copy-topic",
345 Recovered: true, ParentID: "root", RecoveryDepth: 1,
346 }); err != nil {
347 t.Fatal(err)
348 }
349 app := NewApp()
350 installSessionCatalogForTest(t, app, dir, "global", "")
351 // A restored pre-upgrade tab still carries the legacy topic ID for the
352 // recovery copy that the catalog re-anchored onto the root logical topic.
353 // withLiveTopics must dedupe by the projected topic, not the tab's one,
354 // so the sidebar keeps exactly one row for the conversation.
355 app.tabs["tab-1"] = &WorkspaceTab{
356 ID: "tab-1", Scope: "global",
357 TopicID: "legacy-copy-topic", TopicTitle: "Upgraded conversation",
358 SessionPath: copyPath,
359 }
360 page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
361 if err != nil {
362 t.Fatal(err)
363 }
364 if len(page.Items) != 1 || page.Items[0].TopicID != "conversation" {
365 t.Fatalf("page items = %#v, want one folded conversation row", page.Items)
366 }
367 if !page.Items[0].Open {
368 t.Fatal("open recovery tab must aggregate onto the logical row")
369 }
370 }
371
372 // The catalog goroutine outlives every request, so an unbounded metadata sync
373 // there can hold the catalog's single-writer mutex forever and freeze the whole
374 // sidebar. Guard the call shape, not just today's behaviour.
375 func TestSessionCatalogGoroutineOnlySyncsMetadataWithATimeout(t *testing.T) {
376 source, err := os.ReadFile("session_catalog.go")
377 if err != nil {
378 t.Fatal(err)
379 }
380 body := string(source)
381 start := strings.Index(body, "func (a *App) startSessionCatalog(")
382 if start < 0 {
383 t.Fatal("startSessionCatalog not found")
384 }
385 end := strings.Index(body[start+1:], "\nfunc ")
386 if end < 0 {
387 t.Fatal("end of startSessionCatalog not found")
388 }
389 for line := range strings.SplitSeq(body[start:start+1+end], "\n") {
390 if !strings.Contains(line, "syncSessionCatalogMetadata(") {
391 continue
392 }
393 if !strings.Contains(line, "syncSessionCatalogMetadataBounded(") {
394 t.Fatalf("unbounded metadata sync in startSessionCatalog: %s", strings.TrimSpace(line))
395 }
396 }
397 }
398
399 func TestListProjectTopicsKeepsMetadataWhileFirstCatalogScanIsPending(t *testing.T) {
400 isolateDesktopUserDirs(t)
401 root := t.TempDir()
402 if err := addProject(root, "Upgraded App"); err != nil {
403 t.Fatal(err)
404 }
405 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
406 for i := range f.Projects {
407 if sameProjectRoot(f.Projects[i].Root, root) {
408 f.Projects[i].Topics = []string{"topic-keep"}
409 return true, nil
410 }
411 }
412 return false, nil
413 }); err != nil {
414 t.Fatal(err)
415 }
416 if err := saveTopicTitles(root, map[string]string{"topic-keep": "Previous chat"}); err != nil {
417 t.Fatal(err)
418 }
419 app := NewApp()
420 catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true})
421 if err != nil {
422 t.Fatal(err)
423 }
424 t.Cleanup(func() {
425 app.sessionCatalog.CompareAndSwap(catalog, nil)
426 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
427 defer cancel()
428 _ = catalog.Close(ctx)
429 })
430 app.sessionCatalog.Store(catalog)
431
432 page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 50})
433 if err != nil {
434 t.Fatal(err)
435 }
436 if len(page.Items) != 1 || page.Items[0].TopicID != "topic-keep" || page.Items[0].Label != "Previous chat" {
437 t.Fatalf("topics while v6 catalog is still empty = %#v, want the desktop-projects conversation", page.Items)
438 }
439 }
440
441 func TestProjectTreeSnapshotIndexingWaitsForFirstDirectoryScan(t *testing.T) {
442 isolateDesktopUserDirs(t)
443 app := NewApp()
444 catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true})
445 if err != nil {
446 t.Fatal(err)
447 }
448 t.Cleanup(func() {
449 app.sessionCatalog.CompareAndSwap(catalog, nil)
450 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
451 defer cancel()
452 _ = catalog.Close(ctx)
453 })
454 app.sessionCatalog.Store(catalog)
455 snapshot := mustProjectTreeSnapshot(t, app)
456 if snapshot.IndexingDone {
457 t.Fatal("indexingDone must stay false until the first directory scan finishes")
458 }
459 }
460
461 func TestContinuePathForOpenFollowsCoveringLeafFromParent(t *testing.T) {
462 isolateDesktopUserDirs(t)
463 dir := desktopSessionDir(globalWorkspaceRoot())
464 if err := os.MkdirAll(dir, 0o755); err != nil {
465 t.Fatal(err)
466 }
467 q := provider.Message{Role: provider.RoleUser, Content: "question"}
468 a := provider.Message{Role: provider.RoleAssistant, Content: "answer"}
469 next := provider.Message{Role: provider.RoleUser, Content: "next"}
470 done := provider.Message{Role: provider.RoleAssistant, Content: "done"}
471 save := func(path, topic string, messages ...provider.Message) {
472 t.Helper()
473 session := agent.NewSession("sys")
474 for _, message := range messages {
475 session.Add(message)
476 }
477 if err := session.Save(path); err != nil {
478 t.Fatal(err)
479 }
480 if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
481 ID: agent.BranchID(path), Scope: "global", TopicID: topic, TopicTitle: "Upgraded",
482 }); err != nil {
483 t.Fatal(err)
484 }
485 }
486 root := filepath.Join(dir, "root.jsonl")
487 leaf := filepath.Join(dir, "leaf.jsonl")
488 save(root, "conversation", q, a)
489 save(leaf, "legacy-leaf-topic", q, a, next, done)
490 if err := agent.SaveBranchMetaPreserveUpdated(leaf, agent.BranchMeta{
491 ID: "leaf", Scope: "global", TopicID: "legacy-leaf-topic",
492 Recovered: true, ParentID: "root", RecoveryDepth: 1,
493 }); err != nil {
494 t.Fatal(err)
495 }
496 app := NewApp()
497 installSessionCatalogForTest(t, app, dir, "global", "")
498 if got := app.continuePathForOpen(root); got != leaf {
499 t.Fatalf("continue parent = %q, want covering leaf %q", got, leaf)
500 }
501 if got := app.continuePathForOpen(leaf); got != "" {
502 t.Fatalf("continue leaf = %q, want keep", got)
503 }
504 }
505
506 func TestListProjectTopicsUsesAvailableProjectionBeforeEveryGlobalDirectoryIsScanned(t *testing.T) {
507 isolateDesktopUserDirs(t)
508 legacy := config.SessionDir()
509 global := desktopSessionDir(globalWorkspaceRoot())
510 for _, dir := range []string{legacy, global} {
511 if err := os.MkdirAll(dir, 0o755); err != nil {
512 t.Fatal(err)
513 }
514 }
515 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
516 f.GlobalTopics = []string{"topic-keep"}
517 return true, nil
518 }); err != nil {
519 t.Fatal(err)
520 }
521 if err := saveTopicTitles("", map[string]string{"topic-keep": "Previous chat"}); err != nil {
522 t.Fatal(err)
523 }
524 app := NewApp()
525 catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true})
526 if err != nil {
527 t.Fatal(err)
528 }
529 t.Cleanup(func() {
530 app.sessionCatalog.CompareAndSwap(catalog, nil)
531 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
532 defer cancel()
533 _ = catalog.Close(ctx)
534 })
535 app.sessionCatalog.Store(catalog)
536 if err := catalog.ReconcileDirectory(context.Background(), sessioncatalog.DirectoryTarget{Path: legacy, Scope: "global"}); err != nil {
537 t.Fatal(err)
538 }
539
540 page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
541 if err != nil {
542 t.Fatal(err)
543 }
544 if len(page.Items) != 1 || page.Items[0].TopicID != "topic-keep" || page.Items[0].Label != "Previous chat" {
545 t.Fatalf("topics after only one global dir scanned = %#v, want merged metadata", page.Items)
546 }
547 if page.Complete || page.ReadyDirectories != 1 || page.PendingDirectories != 1 {
548 t.Fatalf("partial directory completeness = %+v", page)
549 }
550 }
551
552 func TestMetadataCatalogAvailabilityUsesDiscoveryStateForAbsentRoots(t *testing.T) {
553 isolateDesktopUserDirs(t)
554 app := NewApp()
555 catalog, err := sessioncatalog.Open(t.Context(), sessioncatalog.Options{InMemory: true, MetadataOnly: true})
556 if err != nil {
557 t.Fatal(err)
558 }
559 app.sessionCatalog.Store(catalog)
560 t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
561 legacy := config.SessionDir()
562 if err := os.MkdirAll(legacy, 0700); err != nil {
563 t.Fatal(err)
564 }
565 path := filepath.Join(legacy, "retained.jsonl")
566 if err := os.WriteFile(path, []byte("body must not be read\n"), 0600); err != nil {
567 t.Fatal(err)
568 }
569 before := app.catalogWorkspaceAvailability(catalog, "global", "")
570 if before.complete || before.pending == 0 {
571 t.Fatalf("unknown directories declared complete: %+v", before)
572 }
573 for _, target := range app.sessionCatalogTargets() {
574 if err := catalog.ReconcileDirectory(t.Context(), target); err != nil {
575 t.Fatal(err)
576 }
577 }
578 ready := app.catalogWorkspaceAvailability(catalog, "global", "")
579 if !ready.complete || ready.failed != 0 || ready.pending != 0 {
580 t.Fatalf("optional absent roots did not settle: %+v", ready)
581 }
582 if err := os.Rename(legacy, legacy+"-offline"); err != nil {
583 t.Fatal(err)
584 }
585 if err := catalog.ReconcileDirectory(t.Context(), sessioncatalog.DirectoryTarget{Path: legacy, Scope: "global"}); !os.IsNotExist(err) {
586 t.Fatalf("known missing root accepted: %v", err)
587 }
588 offline := app.catalogWorkspaceAvailability(catalog, "global", "")
589 if offline.complete || !offline.usable || offline.failed != 1 {
590 t.Fatalf("unavailable history was hidden from completeness: %+v", offline)
591 }
592 row, found, err := catalog.GetSession(t.Context(), path)
593 if err != nil || !found || row.MissingSince != 0 {
594 t.Fatalf("unavailable root lost retained row: %+v %v", row, err)
595 }
596 }
597
598 func TestListProjectTopicsPaginatesMetadataWhileCatalogIsPartiallyAvailable(t *testing.T) {
599 isolateDesktopUserDirs(t)
600 legacy := config.SessionDir()
601 global := desktopSessionDir(globalWorkspaceRoot())
602 for _, dir := range []string{legacy, global} {
603 if err := os.MkdirAll(dir, 0o755); err != nil {
604 t.Fatal(err)
605 }
606 }
607 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
608 f.GlobalTopics = []string{"topic-a", "topic-b", "topic-c"}
609 return true, nil
610 }); err != nil {
611 t.Fatal(err)
612 }
613 if err := saveTopicTitles("", map[string]string{
614 "topic-a": "A", "topic-b": "B", "topic-c": "C",
615 }); err != nil {
616 t.Fatal(err)
617 }
618 if err := saveTopicCreatedAts("", map[string]int64{
619 "topic-a": 100, "topic-b": 200, "topic-c": 300,
620 }); err != nil {
621 t.Fatal(err)
622 }
623 app := NewApp()
624 catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true})
625 if err != nil {
626 t.Fatal(err)
627 }
628 t.Cleanup(func() {
629 app.sessionCatalog.CompareAndSwap(catalog, nil)
630 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
631 defer cancel()
632 _ = catalog.Close(ctx)
633 })
634 app.sessionCatalog.Store(catalog)
635 if err := catalog.ReconcileDirectory(context.Background(), sessioncatalog.DirectoryTarget{Path: legacy, Scope: "global"}); err != nil {
636 t.Fatal(err)
637 }
638
639 first, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 2})
640 if err != nil {
641 t.Fatal(err)
642 }
643 if len(first.Items) != 2 || first.Items[0].TopicID != "topic-c" || first.Items[1].TopicID != "topic-b" {
644 t.Fatalf("first page topics = %#v", first.Items)
645 }
646 if first.NextCursor == "" {
647 t.Fatal("first page omitted metadata continuation cursor")
648 }
649 second, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 2, Cursor: first.NextCursor})
650 if err != nil {
651 t.Fatal(err)
652 }
653 if len(second.Items) != 1 || second.Items[0].TopicID != "topic-a" {
654 t.Fatalf("second page topics = %#v", second.Items)
655 }
656 if second.NextCursor != "" {
657 t.Fatalf("second page next cursor = %q, want empty", second.NextCursor)
658 }
659 }
660
661 func TestAuthoritativeBotStylePersistIndexesSessionImmediately(t *testing.T) {
662 isolateDesktopUserDirs(t)
663 root := t.TempDir()
664 if err := addProject(root, "Bot project"); err != nil {
665 t.Fatal(err)
666 }
667 dir := desktopSessionDir(root)
668 if err := os.MkdirAll(dir, 0o755); err != nil {
669 t.Fatal(err)
670 }
671 app := NewApp()
672 catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true})
673 if err != nil {
674 t.Fatal(err)
675 }
676 app.sessionCatalog.Store(catalog)
677 history.RegisterSessionPersistObserver(desktopSessionCatalogPersistObserverKey, desktopSessionCatalogPersistObserver{app: app})
678 t.Cleanup(func() {
679 history.RegisterSessionPersistObserver(desktopSessionCatalogPersistObserverKey, nil)
680 app.sessionCatalog.CompareAndSwap(catalog, nil)
681 _ = catalog.Close(context.Background())
682 })
683
684 path := filepath.Join(dir, "bot-session.jsonl")
685 if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
686 ID: agent.BranchID(path), Scope: "project", WorkspaceRoot: root,
687 TopicID: "bot-topic", TopicTitle: "Bot conversation",
688 }); err != nil {
689 t.Fatal(err)
690 }
691 session := agent.NewSession("system")
692 session.SetPersistObserver(history.PersistObserver())
693 session.Add(provider.Message{Role: provider.RoleUser, Content: "hello from bot"})
694 if err := session.Save(path); err != nil {
695 t.Fatal(err)
696 }
697
698 deadline := time.Now().Add(2 * time.Second)
699 for time.Now().Before(deadline) {
700 page, listErr := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 50})
701 if listErr == nil && len(page.Items) == 1 && page.Items[0].TopicID == "bot-topic" {
702 return
703 }
704 time.Sleep(10 * time.Millisecond)
705 }
706 t.Fatal("authoritative bot-style persist did not enter the project catalog immediately")
707 }
708
709 type retargetRuntimeController struct {
710 stubSessionAPI
711 status control.RuntimeStatus
712 path string
713 }
714
715 func (c *retargetRuntimeController) RuntimeStatus() control.RuntimeStatus { return c.status }
716 func (c *retargetRuntimeController) SessionPath() string { return c.path }
717 func (c *retargetRuntimeController) PlanMode() bool { return false }
718 func (c *retargetRuntimeController) AutoApproveTools() bool { return false }
719 func (c *retargetRuntimeController) Goal() string { return "" }
720 func (c *retargetRuntimeController) GoalStatus() string { return "" }
721 func (c *retargetRuntimeController) ToolApprovalMode() string { return control.ToolApprovalAsk }
722
723 func TestRetargetOpenTabsSkipsRunningSessions(t *testing.T) {
724 isolateDesktopUserDirs(t)
725 dir := desktopSessionDir(globalWorkspaceRoot())
726 if err := os.MkdirAll(dir, 0o755); err != nil {
727 t.Fatal(err)
728 }
729 q := provider.Message{Role: provider.RoleUser, Content: "question"}
730 a := provider.Message{Role: provider.RoleAssistant, Content: "answer"}
731 save := func(path, topic string, messages ...provider.Message) {
732 t.Helper()
733 session := agent.NewSession("sys")
734 for _, message := range messages {
735 session.Add(message)
736 }
737 if err := session.Save(path); err != nil {
738 t.Fatal(err)
739 }
740 if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
741 ID: agent.BranchID(path), Scope: "global", TopicID: topic, TopicTitle: "Upgraded",
742 }); err != nil {
743 t.Fatal(err)
744 }
745 }
746 root := filepath.Join(dir, "root.jsonl")
747 leaf := filepath.Join(dir, "leaf.jsonl")
748 save(root, "conversation", q, a)
749 save(leaf, "legacy-leaf-topic", q, a,
750 provider.Message{Role: provider.RoleUser, Content: "next"},
751 provider.Message{Role: provider.RoleAssistant, Content: "done"})
752 if err := agent.SaveBranchMetaPreserveUpdated(leaf, agent.BranchMeta{
753 ID: "leaf", Scope: "global", TopicID: "legacy-leaf-topic",
754 Recovered: true, ParentID: "root", RecoveryDepth: 1,
755 }); err != nil {
756 t.Fatal(err)
757 }
758 app := NewApp()
759 installSessionCatalogForTest(t, app, dir, "global", "")
760 idle := &WorkspaceTab{ID: "idle", Scope: "global", SessionPath: root}
761 running := &WorkspaceTab{
762 ID: "running", Scope: "global", SessionPath: root,
763 Ctrl: &retargetRuntimeController{status: control.RuntimeStatus{Running: true}, path: root},
764 }
765 app.tabs = map[string]*WorkspaceTab{"idle": idle, "running": running}
766
767 app.retargetOpenTabsToContinuations()
768 if idle.SessionPath != leaf {
769 t.Fatalf("idle tab path = %q, want covering leaf %q", idle.SessionPath, leaf)
770 }
771 if running.SessionPath != root {
772 t.Fatalf("running tab path = %q, want original parent %q", running.SessionPath, root)
773 }
774 }
775
775 lines GO