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