返回 DeepSeek-Reasonix
tabs_topic_test.go
根目录 / desktop / tabs_topic_test.go
1 package main
2
3 import (
4 "errors"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strconv"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "reasonix/internal/agent"
15 "reasonix/internal/config"
16 "reasonix/internal/control"
17 )
18
19 type runtimeStatusSessionController struct {
20 control.SessionAPI
21 status control.RuntimeStatus
22 }
23
24 func (c *runtimeStatusSessionController) RuntimeStatus() control.RuntimeStatus {
25 return c.status
26 }
27
28 func waitForTabReady(t *testing.T, app *App, tabID string) *WorkspaceTab {
29 t.Helper()
30 deadline := time.Now().Add(5 * time.Second)
31 for time.Now().Before(deadline) {
32 app.mu.RLock()
33 tab := app.tabs[tabID]
34 ready := tab != nil && tab.Ready
35 startupErr := ""
36 if tab != nil {
37 startupErr = tab.StartupErr
38 }
39 app.mu.RUnlock()
40 if tab == nil {
41 t.Fatalf("tab %q was not found", tabID)
42 }
43 if ready {
44 if startupErr != "" {
45 t.Fatalf("tab %q startup error: %s", tabID, startupErr)
46 }
47 if tab.Ctrl != nil {
48 t.Cleanup(func() { tab.Ctrl.Close() })
49 }
50 return tab
51 }
52 time.Sleep(10 * time.Millisecond)
53 }
54 t.Fatalf("tab %q was not ready before timeout", tabID)
55 return nil
56 }
57
58 func writeTopicSession(t *testing.T, dir, name, topicID, topicTitle, workspaceRoot string) string {
59 t.Helper()
60 path := filepath.Join(dir, name)
61 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
62 t.Fatalf("write session: %v", err)
63 }
64 if err := agent.SaveBranchMeta(path, agent.BranchMeta{
65 CreatedAt: time.Now().Add(-time.Minute),
66 UpdatedAt: time.Now(),
67 Scope: "project",
68 WorkspaceRoot: workspaceRoot,
69 TopicID: topicID,
70 TopicTitle: topicTitle,
71 }); err != nil {
72 t.Fatalf("save branch meta: %v", err)
73 }
74 return path
75 }
76
77 func writeTopicSessionWithPrompt(t *testing.T, dir, name, topicID, topicTitle, workspaceRoot, prompt string, updatedAt time.Time) string {
78 t.Helper()
79 path := filepath.Join(dir, name)
80 if err := os.WriteFile(path, []byte(`{"role":"user","content":`+strconv.Quote(prompt)+`}`+"\n"), 0o644); err != nil {
81 t.Fatalf("write session: %v", err)
82 }
83 scope := "global"
84 if strings.TrimSpace(workspaceRoot) != "" {
85 scope = "project"
86 }
87 if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
88 CreatedAt: updatedAt.Add(-time.Minute),
89 UpdatedAt: updatedAt,
90 Scope: scope,
91 WorkspaceRoot: workspaceRoot,
92 TopicID: topicID,
93 TopicTitle: topicTitle,
94 }); err != nil {
95 t.Fatalf("save branch meta: %v", err)
96 }
97 return path
98 }
99
100 func writeLegacySession(t *testing.T, dir, name, prompt string, modTime time.Time) string {
101 t.Helper()
102 path := filepath.Join(dir, name)
103 if err := os.WriteFile(path, []byte(`{"role":"user","content":`+strconv.Quote(prompt)+`}`+"\n"), 0o644); err != nil {
104 t.Fatalf("write legacy session: %v", err)
105 }
106 if err := os.Chtimes(path, modTime, modTime); err != nil {
107 t.Fatalf("chtimes legacy session: %v", err)
108 }
109 return path
110 }
111
112 func writeLegacyEventSession(t *testing.T, dir, name, prompt, reply string, modTime time.Time) string {
113 t.Helper()
114 if err := os.MkdirAll(dir, 0o755); err != nil {
115 t.Fatalf("mkdir legacy sessions: %v", err)
116 }
117 path := filepath.Join(dir, name)
118 body := `{"type":"user.message","id":1,"ts":"t","turn":0,"text":` + strconv.Quote(prompt) + `}` + "\n" +
119 `{"type":"model.final","id":2,"ts":"t","turn":0,"content":` + strconv.Quote(reply) + `,"toolCalls":[],"usage":{},"costUsd":0}` + "\n"
120 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
121 t.Fatalf("write legacy event session: %v", err)
122 }
123 if err := os.Chtimes(path, modTime, modTime); err != nil {
124 t.Fatalf("chtimes legacy event session: %v", err)
125 }
126 return path
127 }
128
129 func TestSessionListCacheRefillsAfterInvalidate(t *testing.T) {
130 cache := &sessionListCache{byDir: map[string]sessionListCacheEntry{}}
131 dir := t.TempDir()
132 first := []agent.SessionInfo{{Path: filepath.Join(dir, "first.jsonl")}}
133 second := []agent.SessionInfo{{Path: filepath.Join(dir, "second.jsonl")}}
134
135 token := cache.versionToken(dir)
136 cache.put(dir, first, map[string]string{"first.jsonl": "First"}, token)
137 if infos, titles, ok := cache.get(dir); !ok || len(infos) != 1 || filepath.Base(infos[0].Path) != "first.jsonl" || titles["first.jsonl"] != "First" {
138 t.Fatalf("initial cache entry = %+v, %+v, %v", infos, titles, ok)
139 }
140
141 cache.invalidate()
142 if _, _, ok := cache.get(dir); ok {
143 t.Fatalf("cache entry survived invalidate")
144 }
145 cache.put(dir, first, map[string]string{"first.jsonl": "stale"}, token)
146 if _, _, ok := cache.get(dir); ok {
147 t.Fatalf("stale token repopulated cache after invalidate")
148 }
149
150 token = cache.versionToken(dir)
151 cache.put(dir, second, map[string]string{"second.jsonl": "Second"}, token)
152 if infos, titles, ok := cache.get(dir); !ok || len(infos) != 1 || filepath.Base(infos[0].Path) != "second.jsonl" || titles["second.jsonl"] != "Second" {
153 t.Fatalf("refilled cache entry = %+v, %+v, %v", infos, titles, ok)
154 }
155 }
156
157 func TestSessionListCacheInvalidatesOnlyChangedDirectory(t *testing.T) {
158 cache := &sessionListCache{byDir: map[string]sessionListCacheEntry{}}
159 changedDir := filepath.Join(t.TempDir(), "changed")
160 untouchedDir := filepath.Join(t.TempDir(), "untouched")
161 changedToken := cache.versionToken(changedDir)
162 untouchedToken := cache.versionToken(untouchedDir)
163 cache.put(changedDir, []agent.SessionInfo{{Path: filepath.Join(changedDir, "old.jsonl")}}, nil, changedToken)
164 cache.put(untouchedDir, []agent.SessionInfo{{Path: filepath.Join(untouchedDir, "keep.jsonl")}}, nil, untouchedToken)
165
166 if !cache.invalidateDirs(changedDir) {
167 t.Fatal("invalidateDirs reported no changed directory")
168 }
169 if _, _, ok := cache.get(changedDir); ok {
170 t.Fatal("changed directory survived scoped invalidation")
171 }
172 if infos, _, ok := cache.get(untouchedDir); !ok || len(infos) != 1 || filepath.Base(infos[0].Path) != "keep.jsonl" {
173 t.Fatalf("unrelated directory was invalidated: %+v, %v", infos, ok)
174 }
175
176 cache.put(changedDir, []agent.SessionInfo{{Path: filepath.Join(changedDir, "stale.jsonl")}}, nil, changedToken)
177 if _, _, ok := cache.get(changedDir); ok {
178 t.Fatal("stale directory token repopulated cache after scoped invalidation")
179 }
180
181 equivalentDir := filepath.Join(untouchedDir, ".")
182 if !cache.invalidateDirs(equivalentDir) {
183 t.Fatal("invalidateDirs rejected an equivalent cleaned directory")
184 }
185 if _, _, ok := cache.get(untouchedDir); ok {
186 t.Fatal("equivalent directory spelling did not invalidate the absolute cache key")
187 }
188 if got := sessionListCacheDirForPath(""); got != "" {
189 t.Fatalf("empty session path resolved to cache directory %q", got)
190 }
191 }
192
193 func TestSessionListCacheExpiresForExternalProcessReconciliation(t *testing.T) {
194 cache := &sessionListCache{byDir: map[string]sessionListCacheEntry{}}
195 dir := t.TempDir()
196 token := cache.versionToken(dir)
197 cache.put(dir, []agent.SessionInfo{{Path: filepath.Join(dir, "old.jsonl")}}, nil, token)
198
199 key := sessionListCacheDirKey(dir)
200 cache.mu.Lock()
201 entry := cache.byDir[key]
202 entry.cachedAt = time.Now().Add(-sessionListCacheTTL)
203 cache.byDir[key] = entry
204 cache.mu.Unlock()
205
206 if _, _, ok := cache.get(dir); ok {
207 t.Fatal("expired directory listing remained cached")
208 }
209 if _, exists := cache.byDir[key]; exists {
210 t.Fatal("expired directory listing was not removed")
211 }
212 }
213
214 func TestProjectTreeMetadataChangePreservesSessionListings(t *testing.T) {
215 oldProjectCache := projectSessionCache
216 projectSessionCache = &sessionListCache{byDir: map[string]sessionListCacheEntry{}}
217 t.Cleanup(func() {
218 projectSessionCache = oldProjectCache
219 })
220
221 dir := t.TempDir()
222 projectSessionCache.put(dir, []agent.SessionInfo{{Path: filepath.Join(dir, "keep.jsonl")}}, nil, projectSessionCache.versionToken(dir))
223 emitted := 0
224 app := NewApp()
225 app.projectTreeChangedHook = func() { emitted++ }
226 app.emitProjectTreeMetadataChanged()
227
228 if _, _, ok := projectSessionCache.get(dir); !ok {
229 t.Fatal("metadata-only project tree change invalidated session listing")
230 }
231 if emitted != 1 {
232 t.Fatalf("project tree hook calls = %d, want 1", emitted)
233 }
234 }
235
236 func TestSessionListCacheForgetRejectsInFlightFillAndReadd(t *testing.T) {
237 cache := &sessionListCache{byDir: map[string]sessionListCacheEntry{}}
238 dir := t.TempDir()
239 staleToken := cache.versionToken(dir)
240 cache.put(dir, []agent.SessionInfo{{Path: filepath.Join(dir, "old.jsonl")}}, nil, staleToken)
241
242 if !cache.forgetDirs(dir) {
243 t.Fatal("forgetDirs reported no removed directory")
244 }
245 if _, _, ok := cache.get(dir); ok {
246 t.Fatal("forgotten directory remained cached")
247 }
248 cache.mu.Lock()
249 _, versionRetained := cache.dirVersions[sessionListCacheDirKey(dir)]
250 cache.mu.Unlock()
251 if versionRetained {
252 t.Fatal("forgotten directory retained lifecycle metadata")
253 }
254
255 cache.put(dir, []agent.SessionInfo{{Path: filepath.Join(dir, "stale.jsonl")}}, nil, staleToken)
256 if _, _, ok := cache.get(dir); ok {
257 t.Fatal("in-flight pre-removal fill repopulated forgotten directory")
258 }
259
260 readdToken := cache.versionToken(dir)
261 if readdToken.dirVersion == staleToken.dirVersion {
262 t.Fatal("re-added directory reused its pre-removal token")
263 }
264 cache.put(dir, []agent.SessionInfo{{Path: filepath.Join(dir, "fresh.jsonl")}}, nil, readdToken)
265 infos, _, ok := cache.get(dir)
266 if !ok || len(infos) != 1 || filepath.Base(infos[0].Path) != "fresh.jsonl" {
267 t.Fatalf("re-added directory did not accept fresh listing: %+v, %v", infos, ok)
268 }
269 }
270
271 func TestRemoveWorkspaceForgetsProjectSessionCache(t *testing.T) {
272 isolateDesktopUserDirs(t)
273 oldProjectCache := projectSessionCache
274 projectSessionCache = &sessionListCache{byDir: map[string]sessionListCacheEntry{}}
275 t.Cleanup(func() { projectSessionCache = oldProjectCache })
276
277 projectRoot := t.TempDir()
278 if err := addProject(projectRoot, "Cached Project"); err != nil {
279 t.Fatalf("add project: %v", err)
280 }
281 dir := desktopSessionDir(projectRoot)
282 staleToken := projectSessionCache.versionToken(dir)
283 projectSessionCache.put(dir, []agent.SessionInfo{{Path: filepath.Join(dir, "old.jsonl")}}, nil, staleToken)
284
285 app := NewApp()
286 if err := app.RemoveWorkspace(projectRoot); err != nil {
287 t.Fatalf("RemoveWorkspace: %v", err)
288 }
289 if _, _, ok := projectSessionCache.get(dir); ok {
290 t.Fatal("removed workspace session listing remained cached")
291 }
292
293 projectSessionCache.put(dir, []agent.SessionInfo{{Path: filepath.Join(dir, "stale.jsonl")}}, nil, staleToken)
294 if _, _, ok := projectSessionCache.get(dir); ok {
295 t.Fatal("removed workspace accepted an in-flight stale cache fill")
296 }
297 }
298
299 func TestRenameSessionInvalidatesProjectTreeCache(t *testing.T) {
300 isolateDesktopUserDirs(t)
301 oldProjectCache := projectSessionCache
302 projectSessionCache = &sessionListCache{byDir: map[string]sessionListCacheEntry{}}
303 t.Cleanup(func() {
304 projectSessionCache = oldProjectCache
305 })
306
307 dir := t.TempDir()
308 otherDir := t.TempDir()
309 sessionPath := filepath.Join(dir, "rename-me.jsonl")
310 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
311 t.Fatalf("write session: %v", err)
312 }
313 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test"})
314 defer ctrl.Close()
315 app := NewApp()
316 app.setTestCtrl(ctrl, "")
317
318 token := projectSessionCache.versionToken(dir)
319 projectSessionCache.put(dir, []agent.SessionInfo{{Path: sessionPath}}, map[string]string{"rename-me.jsonl": "old"}, token)
320 projectSessionCache.put(otherDir, []agent.SessionInfo{{Path: filepath.Join(otherDir, "keep.jsonl")}}, nil, projectSessionCache.versionToken(otherDir))
321 if _, _, ok := projectSessionCache.get(dir); !ok {
322 t.Fatalf("expected primed project tree cache")
323 }
324 if err := app.RenameSession(sessionPath, "new title"); err != nil {
325 t.Fatalf("RenameSession: %v", err)
326 }
327 if _, _, ok := projectSessionCache.get(dir); ok {
328 t.Fatalf("RenameSession should invalidate project tree cache")
329 }
330 if _, _, ok := projectSessionCache.get(otherDir); !ok {
331 t.Fatalf("RenameSession invalidated an unrelated session directory")
332 }
333 }
334
335 func TestArchiveAndRestoreSessionInvalidateOnlyOwningDirectory(t *testing.T) {
336 isolateDesktopUserDirs(t)
337 oldProjectCache := projectSessionCache
338 projectSessionCache = &sessionListCache{byDir: map[string]sessionListCacheEntry{}}
339 t.Cleanup(func() {
340 projectSessionCache = oldProjectCache
341 })
342
343 dir := config.SessionDir()
344 if err := os.MkdirAll(dir, 0o755); err != nil {
345 t.Fatalf("mkdir sessions: %v", err)
346 }
347 path := writeLegacySession(t, dir, "scoped-cache.jsonl", "cache scope", time.Now())
348 otherDir := t.TempDir()
349 prime := func(cacheDir, sessionPath string) {
350 projectSessionCache.put(cacheDir, []agent.SessionInfo{{Path: sessionPath}}, nil, projectSessionCache.versionToken(cacheDir))
351 }
352 prime(dir, path)
353 prime(otherDir, filepath.Join(otherDir, "keep.jsonl"))
354
355 app := NewApp()
356 if err := app.DeleteSession(path); err != nil {
357 t.Fatalf("DeleteSession: %v", err)
358 }
359 if _, _, ok := projectSessionCache.get(dir); ok {
360 t.Fatal("DeleteSession kept the owning directory cached")
361 }
362 if _, _, ok := projectSessionCache.get(otherDir); !ok {
363 t.Fatal("DeleteSession invalidated an unrelated directory")
364 }
365
366 trashPath := filepath.Join(dir, sessionTrashDir, "scoped-cache.jsonl", "scoped-cache.jsonl")
367 prime(dir, path)
368 if err := app.RestoreSession(trashPath); err != nil {
369 t.Fatalf("RestoreSession: %v", err)
370 }
371 if _, _, ok := projectSessionCache.get(dir); ok {
372 t.Fatal("RestoreSession kept the owning directory cached")
373 }
374 if _, _, ok := projectSessionCache.get(otherDir); !ok {
375 t.Fatal("RestoreSession invalidated an unrelated directory")
376 }
377 }
378
379 func TestTopicMetadataUpdatesPreserveExistingEntriesWhenTimedReadSlotsFull(t *testing.T) {
380 isolateDesktopUserDirs(t)
381
382 projectRoot := t.TempDir()
383 if err := saveTopicTitles(projectRoot, map[string]string{"old": "Old"}); err != nil {
384 t.Fatalf("save old title: %v", err)
385 }
386 if err := saveTopicTitleSources(projectRoot, map[string]string{"old": topicTitleSourceManual}); err != nil {
387 t.Fatalf("save old source: %v", err)
388 }
389 if err := saveTopicCreatedAts(projectRoot, map[string]int64{"old": 100}); err != nil {
390 t.Fatalf("save old created-at: %v", err)
391 }
392
393 release := occupyReadFileWithTimeoutSlots(t)
394 if err := setTopicTitleWithSource(projectRoot, "new", "New", topicTitleSourceAuto); err != nil {
395 t.Fatalf("setTopicTitleWithSource: %v", err)
396 }
397 if err := setTopicCreatedAt(projectRoot, "new", 200); err != nil {
398 t.Fatalf("setTopicCreatedAt: %v", err)
399 }
400 release()
401
402 titles := loadTopicTitles(projectRoot)
403 if got := titles["old"]; got != "Old" {
404 t.Fatalf("old title = %q, want Old (all titles: %v)", got, titles)
405 }
406 if got := titles["new"]; got != "New" {
407 t.Fatalf("new title = %q, want New (all titles: %v)", got, titles)
408 }
409 sources := loadTopicTitleSources(projectRoot)
410 if got := sources["old"]; got != topicTitleSourceManual {
411 t.Fatalf("old source = %q, want %q (all sources: %v)", got, topicTitleSourceManual, sources)
412 }
413 if got := sources["new"]; got != topicTitleSourceAuto {
414 t.Fatalf("new source = %q, want %q (all sources: %v)", got, topicTitleSourceAuto, sources)
415 }
416 created := loadTopicCreatedAts(projectRoot)
417 if got := created["old"]; got != 100 {
418 t.Fatalf("old created-at = %d, want 100 (all created: %v)", got, created)
419 }
420 if got := created["new"]; got != 200 {
421 t.Fatalf("new created-at = %d, want 200 (all created: %v)", got, created)
422 }
423 }
424
425 func TestDeleteTopicKeepsSessionHistory(t *testing.T) {
426 isolateDesktopUserDirs(t)
427
428 projectRoot := t.TempDir()
429 topicID := "topic_keep_history"
430 if err := addProject(projectRoot, ""); err != nil {
431 t.Fatalf("add project: %v", err)
432 }
433 if err := setTopicTitle(projectRoot, topicID, "Keep history"); err != nil {
434 t.Fatalf("set topic title: %v", err)
435 }
436 dir := config.SessionDir()
437 if err := os.MkdirAll(dir, 0o755); err != nil {
438 t.Fatalf("mkdir sessions: %v", err)
439 }
440 sessionPath := writeTopicSession(t, dir, "keep.jsonl", topicID, "Keep history", projectRoot)
441
442 if err := NewApp().DeleteTopic(topicID); err != nil {
443 t.Fatalf("delete topic: %v", err)
444 }
445 if _, err := os.Stat(sessionPath); err != nil {
446 t.Fatalf("delete topic should keep session history: %v", err)
447 }
448 if got := loadTopicTitle(projectRoot, topicID); got != "" {
449 t.Fatalf("topic title should be removed, got %q", got)
450 }
451 }
452
453 func TestSetTopicPinnedOrdersProjectTopics(t *testing.T) {
454 isolateDesktopUserDirs(t)
455
456 projectRoot := t.TempDir()
457 if err := addProject(projectRoot, ""); err != nil {
458 t.Fatalf("add project: %v", err)
459 }
460 if err := setTopicTitle(projectRoot, "topic_a", "Alpha"); err != nil {
461 t.Fatalf("set topic a title: %v", err)
462 }
463 if err := setTopicTitle(projectRoot, "topic_b", "Beta"); err != nil {
464 t.Fatalf("set topic b title: %v", err)
465 }
466 app := NewApp()
467 nodes := app.ListProjectTree()
468 if got := []string{nodes[0].Children[0].TopicID, nodes[0].Children[1].TopicID}; got[0] != "topic_a" || got[1] != "topic_b" {
469 t.Fatalf("initial topic order = %v, want [topic_a topic_b]", got)
470 }
471
472 if err := app.SetTopicPinned("topic_b", true); err != nil {
473 t.Fatalf("pin topic: %v", err)
474 }
475 nodes = app.ListProjectTree()
476 if got := []string{nodes[0].Children[0].TopicID, nodes[0].Children[1].TopicID}; got[0] != "topic_b" || got[1] != "topic_a" {
477 t.Fatalf("pinned topic order = %v, want [topic_b topic_a]", got)
478 }
479 if !nodes[0].Children[0].Pinned {
480 t.Fatalf("pinned topic should expose pinned=true")
481 }
482
483 if err := app.SetTopicPinned("topic_b", false); err != nil {
484 t.Fatalf("unpin topic: %v", err)
485 }
486 nodes = app.ListProjectTree()
487 if nodes[0].Children[0].Pinned || nodes[0].Children[1].Pinned {
488 t.Fatalf("unpin should clear pinned flags: %#v", nodes[0].Children)
489 }
490 }
491
492 func TestSetProjectPinnedOrdersProjectFolders(t *testing.T) {
493 isolateDesktopUserDirs(t)
494
495 first := t.TempDir()
496 second := t.TempDir()
497 third := t.TempDir()
498 if err := addProject(first, "First"); err != nil {
499 t.Fatalf("add first project: %v", err)
500 }
501 if err := addProject(second, "Second"); err != nil {
502 t.Fatalf("add second project: %v", err)
503 }
504 if err := addProject(third, "Third"); err != nil {
505 t.Fatalf("add third project: %v", err)
506 }
507
508 app := NewApp()
509 if err := app.ReorderProjects([]string{third, first, second}); err != nil {
510 t.Fatalf("ReorderProjects: %v", err)
511 }
512 if err := app.SetProjectPinned(second, true); err != nil {
513 t.Fatalf("pin project: %v", err)
514 }
515 nodes := app.ListProjectTree()
516 if got := []string{nodes[0].Root, nodes[1].Root, nodes[2].Root}; got[0] != second || got[1] != third || got[2] != first {
517 t.Fatalf("pinned project order = %v, want %v", got, []string{second, third, first})
518 }
519 if !nodes[0].Pinned {
520 t.Fatalf("pinned project should expose pinned=true")
521 }
522
523 if err := app.SetProjectPinned(second, false); err != nil {
524 t.Fatalf("unpin project: %v", err)
525 }
526 nodes = app.ListProjectTree()
527 if got := []string{nodes[0].Root, nodes[1].Root, nodes[2].Root}; got[0] != third || got[1] != first || got[2] != second {
528 t.Fatalf("unpinned project order = %v, want %v", got, []string{third, first, second})
529 }
530 if nodes[0].Pinned || nodes[1].Pinned || nodes[2].Pinned {
531 t.Fatalf("unpin should clear pinned flags: %#v", nodes)
532 }
533 }
534
535 func TestDeleteTopicClearsPinnedTopic(t *testing.T) {
536 isolateDesktopUserDirs(t)
537
538 projectRoot := t.TempDir()
539 if err := addProject(projectRoot, ""); err != nil {
540 t.Fatalf("add project: %v", err)
541 }
542 if err := setTopicTitle(projectRoot, "topic_pinned_delete", "Pinned"); err != nil {
543 t.Fatalf("set topic title: %v", err)
544 }
545 app := NewApp()
546 if err := app.SetTopicPinned("topic_pinned_delete", true); err != nil {
547 t.Fatalf("pin topic: %v", err)
548 }
549 if err := app.DeleteTopic("topic_pinned_delete"); err != nil {
550 t.Fatalf("delete topic: %v", err)
551 }
552 projects := loadProjectsFile().Projects
553 if len(projects) != 1 {
554 t.Fatalf("projects len = %d, want 1", len(projects))
555 }
556 if got := projects[0].PinnedTopics; len(got) != 0 {
557 t.Fatalf("pinned topics after delete = %v, want empty", got)
558 }
559 }
560
561 func assertTopicFullyDeleted(t *testing.T, projectRoot, topicID string) {
562 t.Helper()
563 if got := loadTopicTitle(projectRoot, topicID); got != "" {
564 t.Fatalf("topic title = %q, want deleted", got)
565 }
566 if got := loadTopicTitleSources(projectRoot); got[topicID] != "" {
567 t.Fatalf("title source = %q, want deleted (all sources: %v)", got[topicID], got)
568 }
569 if got := loadTopicCreatedAts(projectRoot); got[topicID] != 0 {
570 t.Fatalf("created-at = %d, want deleted (all created: %v)", got[topicID], got)
571 }
572 if got := loadTopicAutoTitleMeta(projectRoot); got != nil {
573 if meta, ok := got[topicID]; ok {
574 t.Fatalf("auto-title meta = %+v, want deleted (all auto-title meta: %v)", meta, got)
575 }
576 }
577 f := loadProjectsFile()
578 i := projectIndexByRoot(f.Projects, projectRoot)
579 if i < 0 {
580 t.Fatalf("projects = %#v, want entry for %q", f.Projects, projectRoot)
581 }
582 if containsDesktopString(f.Projects[i].Topics, topicID) {
583 t.Fatalf("project topics = %#v, %q should be removed", f.Projects[i].Topics, topicID)
584 }
585 if !containsDesktopString(f.DeletedTopics, topicID) {
586 t.Fatalf("deletedTopics = %#v, want tombstone for %q", f.DeletedTopics, topicID)
587 }
588 }
589
590 func TestDeleteTopicRetryAfterPartialFailureCompletesCleanup(t *testing.T) {
591 isolateDesktopUserDirs(t)
592
593 projectRoot := t.TempDir()
594 topicID := "topic_partial_delete"
595 if err := addProject(projectRoot, ""); err != nil {
596 t.Fatalf("add project: %v", err)
597 }
598 if err := setTopicTitle(projectRoot, topicID, "Doomed"); err != nil {
599 t.Fatalf("set topic title: %v", err)
600 }
601 if err := setTopicCreatedAt(projectRoot, topicID, 4242); err != nil {
602 t.Fatalf("set created-at: %v", err)
603 }
604 if err := prependTopicInProjectsFile(projectRoot, topicID, false); err != nil {
605 t.Fatalf("index topic: %v", err)
606 }
607
608 // Inject a failure before title removal: swapping the title-sources file
609 // for a directory makes its load fail while the title locator is intact.
610 sourcesPath := topicTitleSourcesPath(projectRoot)
611 backupPath := sourcesPath + ".bak"
612 if err := os.Rename(sourcesPath, backupPath); err != nil {
613 t.Fatalf("stash title sources: %v", err)
614 }
615 if err := os.Mkdir(sourcesPath, 0o755); err != nil {
616 t.Fatalf("block title sources: %v", err)
617 }
618
619 app := NewApp()
620 if err := app.DeleteTopic(topicID); err == nil {
621 t.Fatalf("delete with failing title-sources load should report an error")
622 }
623 if got := loadTopicTitle(projectRoot, topicID); got != "Doomed" {
624 t.Fatalf("failed attempt must keep the title as the root locator, got %q", got)
625 }
626
627 // Heal the fault and retry: the retry must finish the remaining cleanup
628 // instead of treating a partially-deleted topic as an already-finished
629 // deletion.
630 if err := os.Remove(sourcesPath); err != nil {
631 t.Fatalf("unblock title sources: %v", err)
632 }
633 if err := os.Rename(backupPath, sourcesPath); err != nil {
634 t.Fatalf("restore title sources: %v", err)
635 }
636 if err := app.DeleteTopic(topicID); err != nil {
637 t.Fatalf("retry delete: %v", err)
638 }
639 assertTopicFullyDeleted(t, projectRoot, topicID)
640 }
641
642 func TestDeleteTopicWithoutTitleEntryStillRemovesIndexAndTombstones(t *testing.T) {
643 isolateDesktopUserDirs(t)
644
645 projectRoot := t.TempDir()
646 topicID := "topic_leftover_delete"
647 if err := addProject(projectRoot, ""); err != nil {
648 t.Fatalf("add project: %v", err)
649 }
650 if err := setTopicTitle(projectRoot, topicID, "Doomed"); err != nil {
651 t.Fatalf("set topic title: %v", err)
652 }
653 if err := setTopicCreatedAt(projectRoot, topicID, 4242); err != nil {
654 t.Fatalf("set created-at: %v", err)
655 }
656 if err := prependTopicInProjectsFile(projectRoot, topicID, false); err != nil {
657 t.Fatalf("index topic: %v", err)
658 }
659 // Strip just the title entry to mimic an interrupted earlier deletion:
660 // sources, created-at, and the sidebar index survived it.
661 titles, err := loadTopicTitlesForUpdate(projectRoot)
662 if err != nil {
663 t.Fatalf("load titles: %v", err)
664 }
665 delete(titles, topicID)
666 if err := saveTopicTitles(projectRoot, titles); err != nil {
667 t.Fatalf("save titles: %v", err)
668 }
669
670 if err := NewApp().DeleteTopic(topicID); err != nil {
671 t.Fatalf("delete topic: %v", err)
672 }
673 assertTopicFullyDeleted(t, projectRoot, topicID)
674 }
675
676 func TestDeleteTopicTitleOnlyRetryAfterSourceFailureCompletesCleanup(t *testing.T) {
677 isolateDesktopUserDirs(t)
678
679 projectRoot := t.TempDir()
680 topicID := "topic_title_only_delete"
681 if err := addProject(projectRoot, ""); err != nil {
682 t.Fatalf("add project: %v", err)
683 }
684 // No prependTopicInProjectsFile: the topic renders purely through the
685 // orderedTopicIDs title-map fallback, so the title entry is the only
686 // locator a retry can use.
687 if err := setTopicTitle(projectRoot, topicID, "Doomed"); err != nil {
688 t.Fatalf("set topic title: %v", err)
689 }
690 if err := setTopicCreatedAt(projectRoot, topicID, 4242); err != nil {
691 t.Fatalf("set created-at: %v", err)
692 }
693
694 sourcesPath := topicTitleSourcesPath(projectRoot)
695 backupPath := sourcesPath + ".bak"
696 if err := os.Rename(sourcesPath, backupPath); err != nil {
697 t.Fatalf("stash title sources: %v", err)
698 }
699 if err := os.Mkdir(sourcesPath, 0o755); err != nil {
700 t.Fatalf("block title sources: %v", err)
701 }
702
703 app := NewApp()
704 if err := app.DeleteTopic(topicID); err == nil {
705 t.Fatalf("delete with failing title-sources load should report an error")
706 }
707 if got := loadTopicTitle(projectRoot, topicID); got != "Doomed" {
708 t.Fatalf("failed attempt must keep the title as the root locator, got %q", got)
709 }
710
711 if err := os.Remove(sourcesPath); err != nil {
712 t.Fatalf("unblock title sources: %v", err)
713 }
714 if err := os.Rename(backupPath, sourcesPath); err != nil {
715 t.Fatalf("restore title sources: %v", err)
716 }
717 if err := app.DeleteTopic(topicID); err != nil {
718 t.Fatalf("retry delete: %v", err)
719 }
720 assertTopicFullyDeleted(t, projectRoot, topicID)
721 }
722
723 func TestDeleteTopicTitleOnlyRetryAfterSecondaryMetadataFailureCompletesCleanup(t *testing.T) {
724 tests := []struct {
725 name string
726 path func(string) string
727 }{
728 {name: "created-at", path: topicCreatedAtsPath},
729 {name: "auto-title-meta", path: topicAutoTitleMetaPath},
730 }
731
732 for _, tt := range tests {
733 t.Run(tt.name, func(t *testing.T) {
734 isolateDesktopUserDirs(t)
735
736 projectRoot := t.TempDir()
737 topicID := "topic_title_only_" + strings.ReplaceAll(tt.name, "-", "_")
738 if err := addProject(projectRoot, ""); err != nil {
739 t.Fatalf("add project: %v", err)
740 }
741 if err := setTopicTitle(projectRoot, topicID, "Doomed"); err != nil {
742 t.Fatalf("set topic title: %v", err)
743 }
744 if err := setTopicCreatedAt(projectRoot, topicID, 4242); err != nil {
745 t.Fatalf("set created-at: %v", err)
746 }
747 if err := recordTopicAutoTitleMeta(projectRoot, topicID, autoTopicTitleProposal{
748 Stage: 1, UserTurns: 1, BasisHash: "review-basis",
749 }); err != nil {
750 t.Fatalf("record auto-title meta: %v", err)
751 }
752
753 blockedPath := tt.path(projectRoot)
754 backupPath := blockedPath + ".bak"
755 if err := os.Rename(blockedPath, backupPath); err != nil {
756 t.Fatalf("stash %s: %v", tt.name, err)
757 }
758 if err := os.Mkdir(blockedPath, 0o755); err != nil {
759 t.Fatalf("block %s: %v", tt.name, err)
760 }
761
762 app := NewApp()
763 if err := app.DeleteTopic(topicID); err == nil {
764 t.Fatalf("delete with failing %s cleanup should report an error", tt.name)
765 }
766 if got := loadTopicTitle(projectRoot, topicID); got != "Doomed" {
767 t.Fatalf("failed attempt must keep the title as the root locator, got %q", got)
768 }
769
770 if err := os.Remove(blockedPath); err != nil {
771 t.Fatalf("unblock %s: %v", tt.name, err)
772 }
773 if err := os.Rename(backupPath, blockedPath); err != nil {
774 t.Fatalf("restore %s: %v", tt.name, err)
775 }
776 if err := app.DeleteTopic(topicID); err != nil {
777 t.Fatalf("retry delete after %s failure: %v", tt.name, err)
778 }
779 assertTopicFullyDeleted(t, projectRoot, topicID)
780 })
781 }
782 }
783
784 func TestDeleteTopicIgnoresUnrelatedProjectMetadataDamage(t *testing.T) {
785 isolateDesktopUserDirs(t)
786
787 // The broken project is added first so the cleanup sweep meets it before
788 // reaching the target root.
789 brokenRoot := t.TempDir()
790 targetRoot := t.TempDir()
791 topicID := "topic_target_delete"
792 if err := addProject(brokenRoot, ""); err != nil {
793 t.Fatalf("add broken project: %v", err)
794 }
795 if err := addProject(targetRoot, ""); err != nil {
796 t.Fatalf("add target project: %v", err)
797 }
798 if err := setTopicTitle(brokenRoot, "topic_unrelated", "Unrelated"); err != nil {
799 t.Fatalf("set unrelated topic title: %v", err)
800 }
801 if err := setTopicTitle(targetRoot, topicID, "Doomed"); err != nil {
802 t.Fatalf("set topic title: %v", err)
803 }
804 if err := setTopicCreatedAt(targetRoot, topicID, 4242); err != nil {
805 t.Fatalf("set created-at: %v", err)
806 }
807 if err := prependTopicInProjectsFile(targetRoot, topicID, false); err != nil {
808 t.Fatalf("index topic: %v", err)
809 }
810
811 // Make the unrelated project's title metadata unreadable: deleting the
812 // target topic must skip over it instead of aborting half-way.
813 for _, path := range []string{topicTitlesPath(brokenRoot), topicTitleSourcesPath(brokenRoot)} {
814 if err := os.Remove(path); err != nil {
815 t.Fatalf("remove %s: %v", path, err)
816 }
817 if err := os.Mkdir(path, 0o755); err != nil {
818 t.Fatalf("block %s: %v", path, err)
819 }
820 }
821
822 if err := NewApp().DeleteTopic(topicID); err != nil {
823 t.Fatalf("delete topic with unrelated broken project: %v", err)
824 }
825 assertTopicFullyDeleted(t, targetRoot, topicID)
826
827 // The broken project's own sidebar index must be untouched.
828 f := loadProjectsFile()
829 if i := projectIndexByRoot(f.Projects, brokenRoot); i < 0 {
830 t.Fatalf("projects = %#v, want entry for broken root", f.Projects)
831 }
832 if containsDesktopString(f.DeletedTopics, "topic_unrelated") {
833 t.Fatalf("deletedTopics = %#v, unrelated topic must not be tombstoned", f.DeletedTopics)
834 }
835 }
836
837 func TestRenameProjectUpdatesSidebarTitle(t *testing.T) {
838 isolateDesktopUserDirs(t)
839
840 projectRoot := t.TempDir()
841 if err := addProject(projectRoot, ""); err != nil {
842 t.Fatalf("add project: %v", err)
843 }
844 if err := NewApp().RenameProject(projectRoot, "Client API"); err != nil {
845 t.Fatalf("rename project: %v", err)
846 }
847
848 nodes := NewApp().ListProjectTree()
849 if len(nodes) != 1 {
850 t.Fatalf("project tree len = %d, want 1", len(nodes))
851 }
852 if got := nodes[0].Label; got != "Client API" {
853 t.Fatalf("project label = %q, want Client API", got)
854 }
855
856 if err := NewApp().RenameProject(projectRoot, ""); err != nil {
857 t.Fatalf("clear project title: %v", err)
858 }
859 nodes = NewApp().ListProjectTree()
860 if got, want := nodes[0].Label, filepath.Base(projectRoot); got != want {
861 t.Fatalf("cleared project label = %q, want %q", got, want)
862 }
863 }
864
865 func TestListWorkspacesUsesProjectRegistryTitles(t *testing.T) {
866 isolateDesktopUserDirs(t)
867
868 projectRoot := t.TempDir()
869 if err := addProject(projectRoot, "Client API"); err != nil {
870 t.Fatalf("add project: %v", err)
871 }
872
873 workspaces := NewApp().ListWorkspaces()
874 if len(workspaces) != 1 {
875 t.Fatalf("workspaces len = %d, want 1: %+v", len(workspaces), workspaces)
876 }
877 if got := workspaces[0].Path; got != projectRoot {
878 t.Fatalf("workspace path = %q, want %q", got, projectRoot)
879 }
880 if got := workspaces[0].Name; got != "Client API" {
881 t.Fatalf("workspace name = %q, want Client API", got)
882 }
883 }
884
885 func TestListWorkspacesMigratesLegacyWorkspaceList(t *testing.T) {
886 isolateDesktopUserDirs(t)
887
888 legacyRoot := t.TempDir()
889 rememberWorkspace(legacyRoot)
890
891 workspaces := NewApp().ListWorkspaces()
892 if len(workspaces) != 1 {
893 t.Fatalf("workspaces len = %d, want 1: %+v", len(workspaces), workspaces)
894 }
895 if got := workspaces[0].Path; got != legacyRoot {
896 t.Fatalf("workspace path = %q, want %q", got, legacyRoot)
897 }
898 projects := loadProjectsFile().Projects
899 if len(projects) != 1 || projects[0].Root != legacyRoot {
900 t.Fatalf("legacy workspace was not migrated into projects: %+v", projects)
901 }
902 }
903
904 func TestLegacySessionsMigrateIntoGlobalTopics(t *testing.T) {
905 isolateDesktopUserDirs(t)
906
907 dir := config.SessionDir()
908 if err := os.MkdirAll(dir, 0o755); err != nil {
909 t.Fatalf("mkdir sessions: %v", err)
910 }
911 older := writeLegacySession(t, dir, "older.jsonl", "older imported prompt", time.Now().Add(-2*time.Hour))
912 newer := writeLegacySession(t, dir, "newer.jsonl", "newer imported prompt", time.Now().Add(-time.Hour))
913
914 nodes := NewApp().ListProjectTree()
915 if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
916 t.Fatalf("project tree = %#v, want global folder", nodes)
917 }
918 if got := len(nodes[0].Children); got != 2 {
919 t.Fatalf("global migrated topics = %d, want 2: %#v", got, nodes[0].Children)
920 }
921 if got, want := nodes[0].Children[0].TopicID, legacySessionTopicID(newer); got != want {
922 t.Fatalf("newest topic first = %q, want %q", got, want)
923 }
924 if got, want := nodes[0].Children[1].TopicID, legacySessionTopicID(older); got != want {
925 t.Fatalf("older topic second = %q, want %q", got, want)
926 }
927
928 meta, ok, err := agent.LoadBranchMeta(newer)
929 if err != nil || !ok {
930 t.Fatalf("load migrated meta: ok=%v err=%v", ok, err)
931 }
932 if meta.Scope != "global" || meta.WorkspaceRoot != "" || meta.TopicID != legacySessionTopicID(newer) {
933 t.Fatalf("migrated meta = %+v", meta)
934 }
935
936 nodes = NewApp().ListProjectTree()
937 if got := len(nodes[0].Children); got != 2 {
938 t.Fatalf("migration should be idempotent, global topics = %d", got)
939 }
940 }
941
942 func TestAmbiguousLegacyRecoverySessionsMigrateIntoTopics(t *testing.T) {
943 isolateDesktopUserDirs(t)
944
945 dir := config.SessionDir()
946 if err := os.MkdirAll(dir, 0o755); err != nil {
947 t.Fatalf("mkdir sessions: %v", err)
948 }
949 normal := writeLegacySession(t, dir, "normal.jsonl", "normal imported prompt", time.Now().Add(-2*time.Hour))
950 recovery := writeLegacySession(t, dir, "normal-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now().Add(-time.Hour))
951 // Simulate an upgrade from the filename-only classifier: the v1 marker
952 // must not prevent the new conservative pass from recovering this history.
953 if err := os.WriteFile(filepath.Join(dir, ".topics-migrated"), nil, 0o644); err != nil {
954 t.Fatalf("write v1 migration marker: %v", err)
955 }
956
957 nodes := NewApp().ListProjectTree()
958 if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
959 t.Fatalf("project tree = %#v, want global folder", nodes)
960 }
961 if got := len(nodes[0].Children); got != 2 {
962 t.Fatalf("global migrated topics = %d, want both sessions preserved: %#v", got, nodes[0].Children)
963 }
964 wantTopics := map[string]bool{legacySessionTopicID(normal): true, legacySessionTopicID(recovery): true}
965 for _, node := range nodes[0].Children {
966 delete(wantTopics, node.TopicID)
967 }
968 if len(wantTopics) != 0 {
969 t.Fatalf("migrated topics missing %v: %#v", wantTopics, nodes[0].Children)
970 }
971 if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil || !ok {
972 t.Fatalf("load recovery meta: %v", err)
973 } else if strings.TrimSpace(meta.TopicID) == "" {
974 t.Fatal("ambiguous legacy recovery branch was not migrated into a visible topic")
975 }
976 }
977
978 func TestUnmodifiedRecoveryCopyDoesNotMigrateIntoTopics(t *testing.T) {
979 isolateDesktopUserDirs(t)
980
981 dir := config.SessionDir()
982 if err := os.MkdirAll(dir, 0o755); err != nil {
983 t.Fatalf("mkdir sessions: %v", err)
984 }
985 parent, recovery, branchMsgs := forkDesktopRecoveryBranch(t, dir, "normal")
986 coverDesktopRecoveryParent(t, parent, branchMsgs)
987
988 nodes := NewApp().ListProjectTree()
989 if len(nodes) != 1 || len(nodes[0].Children) != 1 {
990 t.Fatalf("project tree = %#v, want only the covering parent topic", nodes)
991 }
992 if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil || !ok {
993 t.Fatalf("load recovery meta: ok=%v err=%v", ok, err)
994 } else if strings.TrimSpace(meta.TopicID) != "" {
995 t.Fatalf("parent-covered recovery copy was migrated into topic %q", meta.TopicID)
996 }
997 }
998
999 func TestCoveredRecoveryCopyBecomesVisibleAfterMigratedParentDeletion(t *testing.T) {
1000 isolateDesktopUserDirs(t)
1001
1002 dir := config.SessionDir()
1003 if err := os.MkdirAll(dir, 0o755); err != nil {
1004 t.Fatalf("mkdir sessions: %v", err)
1005 }
1006 parent, recovery, branchMsgs := forkDesktopRecoveryBranch(t, dir, "parent-delete")
1007 coverDesktopRecoveryParent(t, parent, branchMsgs)
1008 app := NewApp()
1009
1010 app.ListProjectTree()
1011 for _, marker := range []string{topicMigrationMarker, topicIndexRepairMarker} {
1012 if _, err := os.Stat(filepath.Join(dir, marker)); err != nil {
1013 t.Fatalf("expected %s after migration: %v", marker, err)
1014 }
1015 }
1016 if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil || !ok {
1017 t.Fatalf("load skipped recovery meta: ok=%v err=%v", ok, err)
1018 } else if strings.TrimSpace(meta.TopicID) != "" {
1019 t.Fatalf("covered recovery copy was migrated before parent deletion: %+v", meta)
1020 }
1021
1022 if err := app.DeleteSession(parent); err != nil {
1023 t.Fatalf("DeleteSession parent: %v", err)
1024 }
1025 for _, marker := range []string{topicMigrationMarker, topicIndexRepairMarker} {
1026 if _, err := os.Stat(filepath.Join(dir, marker)); !os.IsNotExist(err) {
1027 t.Fatalf("%s survived live session deletion: %v", marker, err)
1028 }
1029 }
1030
1031 nodes := app.ListProjectTree()
1032 meta, ok, err := agent.LoadBranchMeta(recovery)
1033 if err != nil || !ok {
1034 t.Fatalf("load recovery meta after parent deletion: ok=%v err=%v", ok, err)
1035 }
1036 if meta.TopicID != legacySessionTopicID(recovery) {
1037 t.Fatalf("recovery topic after parent deletion = %q, want %q", meta.TopicID, legacySessionTopicID(recovery))
1038 }
1039 for _, root := range nodes {
1040 for _, node := range root.Children {
1041 if node.TopicID == meta.TopicID {
1042 return
1043 }
1044 }
1045 }
1046 t.Fatalf("project tree after parent deletion = %#v, want recovery topic %q", nodes, meta.TopicID)
1047 }
1048
1049 func TestHistoryMarksLegacyRecoverySessionsAsRecovered(t *testing.T) {
1050 isolateDesktopUserDirs(t)
1051
1052 dir := t.TempDir()
1053 if err := os.MkdirAll(dir, 0o755); err != nil {
1054 t.Fatalf("mkdir sessions: %v", err)
1055 }
1056 recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now())
1057 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: recovery, Label: "test"})
1058 defer ctrl.Close()
1059 app := NewApp()
1060 app.setTestCtrl(ctrl, "")
1061
1062 sessions := app.ListSessions()
1063 for _, session := range sessions {
1064 if filepath.Clean(session.Path) != filepath.Clean(recovery) {
1065 continue
1066 }
1067 if !session.Recovered {
1068 t.Fatalf("history session recovered flag = false, want true: %+v", session)
1069 }
1070 if session.RecoveryCopy {
1071 t.Fatalf("legacy filename-only recovery was marked safe for bulk cleanup: %+v", session)
1072 }
1073 return
1074 }
1075 t.Fatalf("history sessions = %#v, want recovery session %q", sessions, recovery)
1076 }
1077
1078 func TestTrashMarksLegacyRecoverySessionsAsRecovered(t *testing.T) {
1079 isolateDesktopUserDirs(t)
1080
1081 dir := config.SessionDir()
1082 if err := os.MkdirAll(dir, 0o755); err != nil {
1083 t.Fatalf("mkdir sessions: %v", err)
1084 }
1085 recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now())
1086 if err := deleteSessionFile(dir, recovery); err != nil {
1087 t.Fatalf("delete recovery session: %v", err)
1088 }
1089 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(recovery), filepath.Base(recovery))
1090
1091 sessions := NewApp().ListTrashedSessions()
1092 if len(sessions) != 1 || filepath.Clean(sessions[0].Path) != filepath.Clean(trashPath) {
1093 t.Fatalf("trashed sessions = %#v, want %q", sessions, trashPath)
1094 }
1095 if !sessions[0].Recovered {
1096 t.Fatalf("trashed recovery session recovered flag = false, want true: %+v", sessions[0])
1097 }
1098 if sessions[0].RecoveryCopy {
1099 t.Fatalf("legacy filename-only recovery was marked safe for bulk purge: %+v", sessions[0])
1100 }
1101 }
1102
1103 func TestProjectTreeKeepsAmbiguousMigratedRecoveryTopicVisible(t *testing.T) {
1104 isolateDesktopUserDirs(t)
1105
1106 dir := config.SessionDir()
1107 if err := os.MkdirAll(dir, 0o755); err != nil {
1108 t.Fatalf("mkdir sessions: %v", err)
1109 }
1110 recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now().Add(-time.Hour))
1111 topicID := legacySessionTopicID(recovery)
1112 if err := agent.SaveBranchMetaPreserveUpdated(recovery, agent.BranchMeta{
1113 ID: agent.BranchID(recovery),
1114 CreatedAt: time.Now().Add(-2 * time.Hour),
1115 UpdatedAt: time.Now().Add(-time.Hour),
1116 Scope: "global",
1117 TopicID: topicID,
1118 TopicTitle: "恢复分支",
1119 Turns: 1,
1120 Preview: "legacy recovery prompt",
1121 }); err != nil {
1122 t.Fatalf("save migrated recovery meta: %v", err)
1123 }
1124 // A v1 repair pass skipped this recovery-named record. The v2 pass must
1125 // revisit it and restore its existing topic to the sidebar index.
1126 if err := os.WriteFile(filepath.Join(dir, ".topic-indexes-repaired"), nil, 0o644); err != nil {
1127 t.Fatalf("write v1 repair marker: %v", err)
1128 }
1129
1130 nodes := NewApp().ListProjectTree()
1131 if len(nodes) == 0 {
1132 t.Fatal("project tree is empty")
1133 }
1134 for _, node := range nodes[0].Children {
1135 if node.TopicID == topicID {
1136 if node.Turns != 1 {
1137 t.Fatalf("recovery topic turns = %d, want 1", node.Turns)
1138 }
1139 return
1140 }
1141 }
1142 t.Fatalf("ambiguous recovery topic should stay visible: %#v", nodes)
1143 }
1144
1145 func TestTopicMigrationMarkerRescansWhenSessionFileChanges(t *testing.T) {
1146 isolateDesktopUserDirs(t)
1147 dir := config.SessionDir()
1148 if err := os.MkdirAll(dir, 0o755); err != nil {
1149 t.Fatalf("mkdir sessions: %v", err)
1150 }
1151 writeLegacySession(t, dir, "first.jsonl", "first legacy prompt", time.Now().Add(-time.Hour))
1152
1153 // First render migrates the legacy session and, with nothing deferred, stamps
1154 // the one-shot marker so later renders can skip the scan.
1155 NewApp().ListProjectTree()
1156 if _, err := os.Stat(filepath.Join(dir, topicMigrationMarker)); err != nil {
1157 t.Fatalf("expected migration marker after a complete pass: %v", err)
1158 }
1159
1160 // A CLI-created session added after the marker invalidates the lightweight
1161 // gate and gets a fresh migration pass.
1162 time.Sleep(10 * time.Millisecond)
1163 second := writeLegacySession(t, dir, "second.jsonl", "second legacy prompt", time.Now())
1164 NewApp().ListProjectTree()
1165 meta, ok, err := agent.LoadBranchMeta(second)
1166 if err != nil {
1167 t.Fatalf("load second meta: %v", err)
1168 }
1169 if !ok || strings.TrimSpace(meta.TopicID) != legacySessionTopicID(second) {
1170 t.Fatalf("new session after marker should be migrated, got ok=%v meta=%+v", ok, meta)
1171 }
1172 }
1173
1174 func TestProjectTreeRepairsIndexedGlobalTopicsAfterMigrationMarker(t *testing.T) {
1175 isolateDesktopUserDirs(t)
1176 dir := config.SessionDir()
1177 if err := os.MkdirAll(dir, 0o755); err != nil {
1178 t.Fatalf("mkdir sessions: %v", err)
1179 }
1180 if err := addProject(t.TempDir(), "Existing project"); err != nil {
1181 t.Fatalf("add project: %v", err)
1182 }
1183
1184 sessionPath := writeLegacySession(t, dir, "desktop-legacy.jsonl", "who are you", time.Now().Add(-time.Hour))
1185 topicID := "legacy_desktop-legacy_1234"
1186 if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
1187 ID: agent.BranchID(sessionPath),
1188 CreatedAt: time.Now().Add(-2 * time.Hour),
1189 UpdatedAt: time.Now().Add(-time.Hour),
1190 Scope: "global",
1191 TopicID: topicID,
1192 TopicTitle: "你是谁",
1193 Turns: 1,
1194 Preview: "who are you",
1195 }); err != nil {
1196 t.Fatalf("save meta: %v", err)
1197 }
1198 markTopicMigrationDone(dir)
1199
1200 repaired := migrateLegacySessionsIntoGlobalTopics(dir)
1201 if len(repaired) != 1 || repaired[0] != topicID {
1202 t.Fatalf("repaired topics = %#v, want %q", repaired, topicID)
1203 }
1204
1205 nodes := NewApp().ListProjectTree()
1206 var global *ProjectNode
1207 for i := range nodes {
1208 if nodes[i].Kind == "global_folder" {
1209 global = &nodes[i]
1210 break
1211 }
1212 }
1213 if global == nil {
1214 t.Fatalf("project tree = %#v, want repaired Global folder", nodes)
1215 }
1216 if len(global.Children) != 1 || global.Children[0].TopicID != topicID || global.Children[0].Label != "你是谁" {
1217 t.Fatalf("global children = %#v, want repaired topic %q with preserved title", global.Children, topicID)
1218 }
1219 f := loadProjectsFile()
1220 if !containsDesktopString(f.GlobalTopics, topicID) {
1221 t.Fatalf("globalTopics = %#v, want %q", f.GlobalTopics, topicID)
1222 }
1223 if got := loadTopicTitle("", topicID); got != "你是谁" {
1224 t.Fatalf("global topic title = %q, want 你是谁", got)
1225 }
1226 }
1227
1228 func TestDeletedRepairedGlobalTopicIsNotAutoRestored(t *testing.T) {
1229 isolateDesktopUserDirs(t)
1230 dir := config.SessionDir()
1231 if err := os.MkdirAll(dir, 0o755); err != nil {
1232 t.Fatalf("mkdir sessions: %v", err)
1233 }
1234 if err := addProject(t.TempDir(), "Existing project"); err != nil {
1235 t.Fatalf("add project: %v", err)
1236 }
1237
1238 sessionPath := writeLegacySession(t, dir, "desktop-delete.jsonl", "delete repaired topic", time.Now().Add(-time.Hour))
1239 topicID := "legacy_desktop-delete_1234"
1240 if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
1241 ID: agent.BranchID(sessionPath),
1242 CreatedAt: time.Now().Add(-2 * time.Hour),
1243 UpdatedAt: time.Now().Add(-time.Hour),
1244 Scope: "global",
1245 TopicID: topicID,
1246 TopicTitle: "临时 Global",
1247 Turns: 1,
1248 Preview: "delete repaired topic",
1249 }); err != nil {
1250 t.Fatalf("save meta: %v", err)
1251 }
1252 markTopicMigrationDone(dir)
1253 if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != topicID {
1254 t.Fatalf("initial repaired topics = %#v, want %q", repaired, topicID)
1255 }
1256 if err := NewApp().DeleteTopic(topicID); err != nil {
1257 t.Fatalf("delete repaired topic: %v", err)
1258 }
1259
1260 time.Sleep(10 * time.Millisecond)
1261 later := time.Now()
1262 if err := os.Chtimes(sessionPath, later, later); err != nil {
1263 t.Fatalf("touch session after delete: %v", err)
1264 }
1265 if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 0 {
1266 t.Fatalf("deleted repaired topic was restored: %#v", repaired)
1267 }
1268 f := loadProjectsFile()
1269 if containsDesktopString(f.GlobalTopics, topicID) {
1270 t.Fatalf("globalTopics = %#v, deleted topic %q should stay removed", f.GlobalTopics, topicID)
1271 }
1272 if got := loadTopicTitle("", topicID); got != "" {
1273 t.Fatalf("global topic title = %q, want deleted", got)
1274 }
1275 if !containsDesktopString(f.DeletedTopics, topicID) {
1276 t.Fatalf("deletedTopics = %#v, want tombstone for %q", f.DeletedTopics, topicID)
1277 }
1278 }
1279
1280 func TestRepairRescanKeepsIndexedTopicsUntouched(t *testing.T) {
1281 isolateDesktopUserDirs(t)
1282 dir := config.SessionDir()
1283 if err := os.MkdirAll(dir, 0o755); err != nil {
1284 t.Fatalf("mkdir sessions: %v", err)
1285 }
1286 if err := addProject(t.TempDir(), "Existing project"); err != nil {
1287 t.Fatalf("add project: %v", err)
1288 }
1289
1290 writeIndexedSession := func(name, topicID, title string, at time.Time) string {
1291 p := writeLegacySession(t, dir, name, "prompt "+title, at)
1292 if err := agent.SaveBranchMetaPreserveUpdated(p, agent.BranchMeta{
1293 ID: agent.BranchID(p),
1294 CreatedAt: at.Add(-time.Hour),
1295 UpdatedAt: at,
1296 Scope: "global",
1297 TopicID: topicID,
1298 TopicTitle: title,
1299 Turns: 1,
1300 Preview: "prompt " + title,
1301 }); err != nil {
1302 t.Fatalf("save meta %s: %v", name, err)
1303 }
1304 if err := os.Chtimes(p, at, at); err != nil {
1305 t.Fatalf("chtimes %s: %v", name, err)
1306 }
1307 return p
1308 }
1309 now := time.Now()
1310 olderPath := writeIndexedSession("older.jsonl", "legacy_older_000000000001", "旧话题", now.Add(-3*time.Hour))
1311 writeIndexedSession("newer.jsonl", "legacy_newer_000000000002", "新话题", now.Add(-time.Hour))
1312 markTopicMigrationDone(dir)
1313
1314 // First pass repairs both missing topics.
1315 if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 2 {
1316 t.Fatalf("initial repaired topics = %#v, want 2 entries", repaired)
1317 }
1318 before := loadProjectsFile()
1319 projPath := filepath.Join(desktopConfigDir(), desktopProjectsFile)
1320 statBefore, err := os.Stat(projPath)
1321 if err != nil {
1322 t.Fatalf("stat projects file: %v", err)
1323 }
1324
1325 // Ordinary session activity invalidates the repair marker; the rescan must
1326 // not reorder the indexed topics, rewrite the projects file, or report the
1327 // already-visible topics as repaired (the callers bind blank Global tabs
1328 // to repaired[0]).
1329 time.Sleep(10 * time.Millisecond)
1330 later := time.Now()
1331 if err := os.Chtimes(olderPath, later, later); err != nil {
1332 t.Fatalf("touch session: %v", err)
1333 }
1334 if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 0 {
1335 t.Fatalf("steady-state rescan reported repairs: %#v", repaired)
1336 }
1337 after := loadProjectsFile()
1338 if !sameStringList(before.GlobalTopics, after.GlobalTopics) {
1339 t.Fatalf("rescan reordered globalTopics: before=%v after=%v", before.GlobalTopics, after.GlobalTopics)
1340 }
1341 statAfter, err := os.Stat(projPath)
1342 if err != nil {
1343 t.Fatalf("stat projects file: %v", err)
1344 }
1345 if !statAfter.ModTime().Equal(statBefore.ModTime()) {
1346 t.Fatalf("steady-state rescan rewrote the projects file")
1347 }
1348 }
1349
1350 func TestBatchPrependRespectsTombstoneWrittenAfterScanSnapshot(t *testing.T) {
1351 isolateDesktopUserDirs(t)
1352
1353 topicID := "legacy_race_000000000001"
1354 // Simulate a DeleteTopic landing between a repair scan's DeletedTopics
1355 // snapshot and its batch write: the tombstone exists by the time the
1356 // prepend runs, so the batch must drop the topic instead of resurrecting it.
1357 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
1358 f.DeletedTopics = prependUniqueString(f.DeletedTopics, topicID)
1359 return true, nil
1360 }); err != nil {
1361 t.Fatalf("seed tombstone: %v", err)
1362 }
1363 if err := prependTopicsInProjectsFile("", []string{topicID, "legacy_live_000000000002"}, false); err != nil {
1364 t.Fatalf("batch prepend: %v", err)
1365 }
1366 f := loadProjectsFile()
1367 if containsDesktopString(f.GlobalTopics, topicID) {
1368 t.Fatalf("globalTopics = %#v, tombstoned topic %q must not be batch-prepended", f.GlobalTopics, topicID)
1369 }
1370 if !containsDesktopString(f.GlobalTopics, "legacy_live_000000000002") {
1371 t.Fatalf("globalTopics = %#v, live topic should still be prepended", f.GlobalTopics)
1372 }
1373 if !containsDesktopString(f.DeletedTopics, topicID) {
1374 t.Fatalf("deletedTopics = %#v, tombstone should survive a batch prepend", f.DeletedTopics)
1375 }
1376
1377 // Intentional single-topic writes (create/restore/tab indexing) clear the
1378 // tombstone and bring the topic back in the same projects-file transaction.
1379 if err := prependTopicInProjectsFile("", topicID, false); err != nil {
1380 t.Fatalf("single prepend: %v", err)
1381 }
1382 f = loadProjectsFile()
1383 if !containsDesktopString(f.GlobalTopics, topicID) {
1384 t.Fatalf("globalTopics = %#v, single prepend should restore %q", f.GlobalTopics, topicID)
1385 }
1386 if containsDesktopString(f.DeletedTopics, topicID) {
1387 t.Fatalf("deletedTopics = %#v, single prepend should clear the tombstone", f.DeletedTopics)
1388 }
1389 }
1390
1391 func TestTombstonedTitleOnlyTopicStaysHiddenInProjectTree(t *testing.T) {
1392 isolateDesktopUserDirs(t)
1393 dir := config.SessionDir()
1394 if err := os.MkdirAll(dir, 0o755); err != nil {
1395 t.Fatalf("mkdir sessions: %v", err)
1396 }
1397 if err := addProject(t.TempDir(), "Existing project"); err != nil {
1398 t.Fatalf("add project: %v", err)
1399 }
1400
1401 // Control: a legitimate title-only topic (not in GlobalTopics) must keep
1402 // rendering through the orderedTopicIDs title-map fallback.
1403 controlID := "topic_control_visible"
1404 if err := setTopicTitle("", controlID, "正常话题"); err != nil {
1405 t.Fatalf("set control title: %v", err)
1406 }
1407 // Race product: DeleteTopic landed, but a stale whole-map save wrote the
1408 // topic's title back — tombstoned, absent from GlobalTopics, title present.
1409 tombstonedID := "legacy_raced_000000000009"
1410 if err := setTopicTitle("", tombstonedID, "被删除的话题"); err != nil {
1411 t.Fatalf("set stale title: %v", err)
1412 }
1413 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
1414 f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
1415 return true, nil
1416 }); err != nil {
1417 t.Fatalf("seed tombstone: %v", err)
1418 }
1419
1420 nodes := NewApp().ListProjectTree()
1421 var global *ProjectNode
1422 for i := range nodes {
1423 if nodes[i].Kind == "global_folder" {
1424 global = &nodes[i]
1425 break
1426 }
1427 }
1428 if global == nil {
1429 t.Fatalf("project tree = %#v, want Global folder", nodes)
1430 }
1431 seen := map[string]bool{}
1432 for _, c := range global.Children {
1433 seen[c.TopicID] = true
1434 }
1435 if seen[tombstonedID] {
1436 t.Fatalf("global children = %#v, tombstoned title-only topic %q must stay hidden", global.Children, tombstonedID)
1437 }
1438 if !seen[controlID] {
1439 t.Fatalf("global children = %#v, legitimate title-only topic %q should still render", global.Children, controlID)
1440 }
1441 }
1442
1443 func TestRepairPassPrunesStaleTitleOfDeletedTopic(t *testing.T) {
1444 isolateDesktopUserDirs(t)
1445 dir := config.SessionDir()
1446 if err := os.MkdirAll(dir, 0o755); err != nil {
1447 t.Fatalf("mkdir sessions: %v", err)
1448 }
1449 if err := addProject(t.TempDir(), "Existing project"); err != nil {
1450 t.Fatalf("add project: %v", err)
1451 }
1452
1453 // Stale race product on disk: tombstoned topic whose title lingers in the
1454 // global title map. The next repair pass that saves the whole map must
1455 // prune it instead of persisting it again.
1456 tombstonedID := "legacy_raced_000000000010"
1457 if err := setTopicTitle("", tombstonedID, "被删除的话题"); err != nil {
1458 t.Fatalf("set stale title: %v", err)
1459 }
1460 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
1461 f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
1462 return true, nil
1463 }); err != nil {
1464 t.Fatalf("seed tombstone: %v", err)
1465 }
1466
1467 sessionPath := writeLegacySession(t, dir, "desktop-prune.jsonl", "needs repair", time.Now().Add(-time.Hour))
1468 repairID := "legacy_desktop-prune_1234"
1469 if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
1470 ID: agent.BranchID(sessionPath),
1471 CreatedAt: time.Now().Add(-2 * time.Hour),
1472 UpdatedAt: time.Now().Add(-time.Hour),
1473 Scope: "global",
1474 TopicID: repairID,
1475 TopicTitle: "待修复话题",
1476 Turns: 1,
1477 Preview: "needs repair",
1478 }); err != nil {
1479 t.Fatalf("save meta: %v", err)
1480 }
1481 markTopicMigrationDone(dir)
1482
1483 if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != repairID {
1484 t.Fatalf("repaired topics = %#v, want %q", repaired, repairID)
1485 }
1486 if got := loadTopicTitle("", tombstonedID); got != "" {
1487 t.Fatalf("stale title = %q, repair save should prune the tombstoned entry", got)
1488 }
1489 if got := loadTopicTitle("", repairID); got != "待修复话题" {
1490 t.Fatalf("repaired title = %q, want 待修复话题", got)
1491 }
1492 f := loadProjectsFile()
1493 if containsDesktopString(f.GlobalTopics, tombstonedID) {
1494 t.Fatalf("globalTopics = %#v, tombstoned topic must stay out", f.GlobalTopics)
1495 }
1496 if !containsDesktopString(f.GlobalTopics, repairID) {
1497 t.Fatalf("globalTopics = %#v, want repaired topic %q", f.GlobalTopics, repairID)
1498 }
1499 }
1500
1501 func TestTopicMigrationDefersEmptyLegacySession(t *testing.T) {
1502 isolateDesktopUserDirs(t)
1503 dir := config.SessionDir()
1504 if err := os.MkdirAll(dir, 0o755); err != nil {
1505 t.Fatalf("mkdir sessions: %v", err)
1506 }
1507 // An empty legacy session (no user turns) is not migratable yet but could gain
1508 // content later, so the pass must NOT mark the dir done — otherwise the gate
1509 // would hide it forever.
1510 if err := os.WriteFile(filepath.Join(dir, "empty.jsonl"), nil, 0o644); err != nil {
1511 t.Fatalf("write empty session: %v", err)
1512 }
1513
1514 NewApp().ListProjectTree()
1515 if _, err := os.Stat(filepath.Join(dir, topicMigrationMarker)); err == nil {
1516 t.Fatal("an empty legacy session must defer marking, but the dir was marked done")
1517 }
1518 }
1519
1520 func TestV05LegacyEventSessionsImportIntoGlobalTopic(t *testing.T) {
1521 home := isolateDesktopUserDirs(t)
1522
1523 legacyDir := filepath.Join(home, ".reasonix", "sessions")
1524 destDir := config.SessionDir()
1525 writeLegacyEventSession(t, legacyDir, "v053-chat.events.jsonl", "hello from v0.53", "hi from v0.53", time.Now().Add(-time.Hour))
1526
1527 imported, err := agent.MigrateLegacySessions(legacyDir, destDir, config.ProjectSessionDir)
1528 if err != nil {
1529 t.Fatalf("migrate legacy sessions: %v", err)
1530 }
1531 if imported != 1 {
1532 t.Fatalf("imported legacy sessions = %d, want 1", imported)
1533 }
1534 migratedSession := filepath.Join(destDir, "v053-chat.jsonl")
1535 if _, err := os.Stat(migratedSession); err != nil {
1536 t.Fatalf("legacy v0.5 session was not imported to %s: %v", migratedSession, err)
1537 }
1538
1539 wantTopicID := legacySessionTopicID(migratedSession)
1540 migratedTopics := migrateLegacySessionsIntoGlobalTopics(destDir)
1541 if len(migratedTopics) != 1 || migratedTopics[0] != wantTopicID {
1542 t.Fatalf("migrated topics = %#v, want imported v0.5 topic %q", migratedTopics, wantTopicID)
1543 }
1544
1545 nodes := NewApp().ListProjectTree()
1546 if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
1547 t.Fatalf("project tree = %#v, want global folder", nodes)
1548 }
1549 if len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID {
1550 t.Fatalf("global topics = %#v, want imported v0.5 topic %q", nodes[0].Children, wantTopicID)
1551 }
1552 meta, ok, err := agent.LoadBranchMeta(migratedSession)
1553 if err != nil || !ok {
1554 t.Fatalf("load imported v0.5 meta: ok=%v err=%v", ok, err)
1555 }
1556 if meta.Scope != "global" || meta.TopicID != wantTopicID {
1557 t.Fatalf("imported v0.5 meta = %+v", meta)
1558 }
1559 }
1560
1561 func TestLegacySessionTopicIDsKeepNormalizedNameCollisionsDistinct(t *testing.T) {
1562 isolateDesktopUserDirs(t)
1563
1564 dir := config.SessionDir()
1565 if err := os.MkdirAll(dir, 0o755); err != nil {
1566 t.Fatalf("mkdir sessions: %v", err)
1567 }
1568 dotted := writeLegacySession(t, dir, "chat.1.jsonl", "dotted prompt", time.Now().Add(-2*time.Hour))
1569 underscored := writeLegacySession(t, dir, "chat_1.jsonl", "underscored prompt", time.Now().Add(-time.Hour))
1570
1571 dottedTopic := legacySessionTopicID(dotted)
1572 underscoredTopic := legacySessionTopicID(underscored)
1573 if dottedTopic == underscoredTopic {
1574 t.Fatalf("normalized legacy topic IDs collided: %q", dottedTopic)
1575 }
1576
1577 nodes := NewApp().ListProjectTree()
1578 if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
1579 t.Fatalf("project tree = %#v, want global folder", nodes)
1580 }
1581 if got := len(nodes[0].Children); got != 2 {
1582 t.Fatalf("global migrated topics = %d, want 2: %#v", got, nodes[0].Children)
1583 }
1584 seen := map[string]bool{}
1585 for _, child := range nodes[0].Children {
1586 seen[child.TopicID] = true
1587 }
1588 if !seen[dottedTopic] || !seen[underscoredTopic] {
1589 t.Fatalf("global topics = %#v, want %q and %q", nodes[0].Children, dottedTopic, underscoredTopic)
1590 }
1591 }
1592
1593 func TestDefaultGlobalTabGetsMigratedTopicID(t *testing.T) {
1594 isolateDesktopUserDirs(t)
1595
1596 dir := config.SessionDir()
1597 if err := os.MkdirAll(dir, 0o755); err != nil {
1598 t.Fatalf("mkdir sessions: %v", err)
1599 }
1600 sessionPath := writeLegacySession(t, dir, "legacy-tab.jsonl", "resume this legacy tab", time.Now().Add(-time.Hour))
1601
1602 tab := &WorkspaceTab{
1603 ID: "tab_legacy",
1604 Scope: "global",
1605 WorkspaceRoot: globalTabWorkspaceRoot(),
1606 Ready: false,
1607 disabledMCP: map[string]ServerView{},
1608 }
1609 app := &App{
1610 tabs: map[string]*WorkspaceTab{"tab_legacy": tab},
1611 tabOrder: []string{"tab_legacy"},
1612 activeTabID: "tab_legacy",
1613 }
1614 app.buildTabController(tab)
1615 if tab.Ctrl != nil {
1616 defer tab.Ctrl.Close()
1617 }
1618
1619 wantTopicID := legacySessionTopicID(sessionPath)
1620 if tab.TopicID != wantTopicID {
1621 t.Fatalf("tab topicID = %q, want %q", tab.TopicID, wantTopicID)
1622 }
1623 if tab.Ctrl == nil {
1624 t.Fatalf("tab controller was not built")
1625 }
1626 if tab.Ctrl.SessionPath() != sessionPath {
1627 t.Fatalf("tab session path = %q, want %q", tab.Ctrl.SessionPath(), sessionPath)
1628 }
1629 f := loadTabsFile()
1630 if len(f.Tabs) != 1 || f.Tabs[0].ID != "tab_legacy" || f.Tabs[0].TopicID != wantTopicID {
1631 t.Fatalf("desktop tabs file = %+v, want tab id and migrated topic", f)
1632 }
1633 }
1634
1635 func TestBuildTabControllerRestoresPinnedSessionBeforeTopicFallback(t *testing.T) {
1636 isolateDesktopUserDirs(t)
1637
1638 dir := config.SessionDir()
1639 if err := os.MkdirAll(dir, 0o755); err != nil {
1640 t.Fatalf("mkdir sessions: %v", err)
1641 }
1642 topicID := "topic_same"
1643 topicTitle := "Pinned topic"
1644 pinned := writeTopicSessionWithPrompt(t, dir, "long.jsonl", topicID, topicTitle, "", "full 64-turn conversation", time.Now().Add(-2*time.Hour))
1645 _ = writeTopicSessionWithPrompt(t, dir, "short.jsonl", topicID, topicTitle, "", "early 5-turn snapshot", time.Now().Add(time.Hour))
1646
1647 app := NewApp()
1648 tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), topicID, "tab_pinned")
1649 tab.TopicTitle = topicTitle
1650 tab.SessionPath = pinned
1651 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
1652 app.tabs[tab.ID] = tab
1653 app.tabOrder = []string{tab.ID}
1654 app.activeTabID = tab.ID
1655
1656 app.buildTabController(tab)
1657 if tab.Ctrl == nil {
1658 t.Fatalf("tab controller was not built: %s", tab.StartupErr)
1659 }
1660 defer tab.Ctrl.Close()
1661
1662 if got := filepath.Clean(tab.Ctrl.SessionPath()); got != filepath.Clean(pinned) {
1663 t.Fatalf("restored session path = %q, want pinned %q", got, pinned)
1664 }
1665 history := tab.Ctrl.History()
1666 if len(history) != 2 || string(history[0].Role) != "system" || strings.TrimSpace(history[0].Content) == "" ||
1667 string(history[1].Role) != "user" || history[1].Content != "full 64-turn conversation" {
1668 t.Fatalf("restored history = %+v, want fresh system prompt and pinned long conversation", history)
1669 }
1670 f := loadTabsFile()
1671 if len(f.Tabs) != 1 || filepath.Clean(f.Tabs[0].SessionPath) != filepath.Clean(pinned) {
1672 t.Fatalf("desktop tabs file = %+v, want pinned session path %q", f, pinned)
1673 }
1674 }
1675
1676 func TestBuildTabControllerUsesPinnedSessionMetaWorkspace(t *testing.T) {
1677 isolateDesktopUserDirs(t)
1678
1679 projectA := t.TempDir()
1680 projectB := t.TempDir()
1681 if err := addProject(projectA, "Project A"); err != nil {
1682 t.Fatalf("add project A: %v", err)
1683 }
1684 if err := addProject(projectB, "Project B"); err != nil {
1685 t.Fatalf("add project B: %v", err)
1686 }
1687
1688 topicID := "topic_restore_workspace"
1689 topicTitle := "Restore workspace"
1690 sessionDirA := desktopSessionDir(projectA)
1691 if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
1692 t.Fatalf("mkdir project A sessions: %v", err)
1693 }
1694 pinned := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now())
1695
1696 app := NewApp()
1697 tab := app.createTabEntryWithID("project", projectB, topicID, "tab_stale_workspace")
1698 tab.TopicTitle = topicTitle
1699 tab.SessionPath = pinned
1700 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
1701 app.tabs[tab.ID] = tab
1702 app.tabOrder = []string{tab.ID}
1703 app.activeTabID = tab.ID
1704
1705 app.buildTabController(tab)
1706 if tab.Ctrl == nil {
1707 t.Fatalf("tab controller was not built: %s", tab.StartupErr)
1708 }
1709 defer tab.Ctrl.Close()
1710
1711 if got := filepath.Clean(tab.Ctrl.SessionPath()); got != filepath.Clean(pinned) {
1712 t.Fatalf("restored session path = %q, want pinned %q", got, pinned)
1713 }
1714 if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) {
1715 t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
1716 }
1717 history := tab.Ctrl.History()
1718 if len(history) != 2 || string(history[0].Role) != "system" || strings.TrimSpace(history[0].Content) == "" ||
1719 string(history[1].Role) != "user" || history[1].Content != "project A prompt" {
1720 t.Fatalf("restored history = %+v, want fresh system prompt and project A prompt", history)
1721 }
1722 }
1723
1724 func TestPersistTabSessionPathUsesSessionDirOwnerBeforeSavingMeta(t *testing.T) {
1725 isolateDesktopUserDirs(t)
1726
1727 projectA := t.TempDir()
1728 projectB := t.TempDir()
1729 if err := addProject(projectA, "Project A"); err != nil {
1730 t.Fatalf("add project A: %v", err)
1731 }
1732 if err := addProject(projectB, "Project B"); err != nil {
1733 t.Fatalf("add project B: %v", err)
1734 }
1735
1736 topicID := "topic_owner_before_meta"
1737 topicTitle := "Owner before meta"
1738 sessionDirA := desktopSessionDir(projectA)
1739 if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
1740 t.Fatalf("mkdir project A sessions: %v", err)
1741 }
1742 sessionPath := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now())
1743 meta, ok, err := agent.LoadBranchMeta(sessionPath)
1744 if err != nil || !ok {
1745 t.Fatalf("load branch meta: ok=%v err=%v", ok, err)
1746 }
1747 meta.WorkspaceRoot = projectB
1748 if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, meta); err != nil {
1749 t.Fatalf("pollute branch meta: %v", err)
1750 }
1751
1752 app := NewApp()
1753 tab := app.createTabEntryWithID("project", projectB, topicID, "tab_stale_workspace")
1754 tab.TopicTitle = topicTitle
1755 tab.SessionPath = sessionPath
1756 app.tabs[tab.ID] = tab
1757 app.tabOrder = []string{tab.ID}
1758 app.activeTabID = tab.ID
1759
1760 app.persistTabSessionPath(tab, sessionPath)
1761
1762 if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) {
1763 t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
1764 }
1765 gotMeta, ok, err := agent.LoadBranchMeta(sessionPath)
1766 if err != nil || !ok {
1767 t.Fatalf("reload branch meta: ok=%v err=%v", ok, err)
1768 }
1769 if gotMeta.Scope != "project" || normalizeProjectRoot(gotMeta.WorkspaceRoot) != normalizeProjectRoot(projectA) {
1770 t.Fatalf("saved branch meta scope/root = %q/%q, want project/%q", gotMeta.Scope, gotMeta.WorkspaceRoot, normalizeProjectRoot(projectA))
1771 }
1772 }
1773
1774 func TestBuildTabControllerIgnoresStaleSessionModelWhenTabModelResolves(t *testing.T) {
1775 isolateDesktopUserDirs(t)
1776 t.Setenv("REASONIX_TEST_KEY", "sk-test")
1777 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1778 t.Fatalf("mkdir config dir: %v", err)
1779 }
1780 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1781 default_model = "default-provider/default-model"
1782
1783 [[providers]]
1784 name = "default-provider"
1785 kind = "openai"
1786 base_url = "https://default.invalid/v1"
1787 model = "default-model"
1788 api_key_env = "REASONIX_TEST_KEY"
1789
1790 [[providers]]
1791 name = "tab-provider"
1792 kind = "openai"
1793 base_url = "https://tab.invalid/v1"
1794 model = "tab-model"
1795 api_key_env = "REASONIX_TEST_KEY"
1796 `), 0o644); err != nil {
1797 t.Fatalf("write config: %v", err)
1798 }
1799
1800 dir := config.SessionDir()
1801 if err := os.MkdirAll(dir, 0o755); err != nil {
1802 t.Fatalf("mkdir sessions: %v", err)
1803 }
1804 pinned := writeLegacySession(t, dir, "stale-model.jsonl", "resume with tab model", time.Now())
1805 meta, err := agent.EnsureBranchMeta(pinned)
1806 if err != nil {
1807 t.Fatal(err)
1808 }
1809 meta.Model = "missing-provider/missing-model"
1810 if err := agent.SaveBranchMetaPreserveUpdated(pinned, meta); err != nil {
1811 t.Fatal(err)
1812 }
1813
1814 app := NewApp()
1815 tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "tab_stale_model")
1816 tab.SessionPath = pinned
1817 tab.model = "tab-provider/tab-model"
1818 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
1819 app.tabs[tab.ID] = tab
1820 app.tabOrder = []string{tab.ID}
1821 app.activeTabID = tab.ID
1822
1823 app.buildTabController(tab)
1824 if tab.Ctrl == nil {
1825 t.Fatalf("tab controller was not built: %s", tab.StartupErr)
1826 }
1827 defer tab.Ctrl.Close()
1828 if tab.model != "tab-provider/tab-model" {
1829 t.Fatalf("tab model = %q, want valid tab model", tab.model)
1830 }
1831 }
1832
1833 func TestLoadPinnedTabSessionFallsBackToMigratedBasename(t *testing.T) {
1834 isolateDesktopUserDirs(t)
1835
1836 dir := config.SessionDir()
1837 if err := os.MkdirAll(dir, 0o755); err != nil {
1838 t.Fatalf("mkdir sessions: %v", err)
1839 }
1840 path := writeLegacySession(t, dir, "migrated-tab.jsonl", "resume after path migration", time.Now())
1841 oldPath := filepath.Join(t.TempDir(), "old-reasonix", "projects", "slug", "sessions", filepath.Base(path))
1842
1843 loaded, pinnedPath, ok, err := loadPinnedTabSession(dir, oldPath)
1844 if err != nil {
1845 t.Fatalf("loadPinnedTabSession: %v", err)
1846 }
1847 if !ok || loaded == nil {
1848 t.Fatalf("loadPinnedTabSession did not recover migrated basename: ok=%v loaded=%v path=%q", ok, loaded, pinnedPath)
1849 }
1850 if filepath.Clean(pinnedPath) != filepath.Clean(path) {
1851 t.Fatalf("pinned path = %q, want %q", pinnedPath, path)
1852 }
1853 }
1854
1855 func TestPinnedTabSessionPathRejectsExistingAbsolutePathOutsideDir(t *testing.T) {
1856 isolateDesktopUserDirs(t)
1857
1858 dirA := t.TempDir()
1859 dirB := t.TempDir()
1860 pathA := writeLegacySession(t, dirA, "same-name.jsonl", "project A", time.Now())
1861 _ = writeLegacySession(t, dirB, filepath.Base(pathA), "project B", time.Now())
1862
1863 if got, ok := pinnedTabSessionPath(dirB, pathA); ok {
1864 t.Fatalf("pinnedTabSessionPath mapped existing absolute path outside dir to %q", got)
1865 }
1866 }
1867
1868 func TestLoadPinnedTabSessionSkipsCleanupPending(t *testing.T) {
1869 isolateDesktopUserDirs(t)
1870
1871 dir := config.SessionDir()
1872 if err := os.MkdirAll(dir, 0o755); err != nil {
1873 t.Fatalf("mkdir sessions: %v", err)
1874 }
1875 path := writeLegacySession(t, dir, "pending-pinned.jsonl", "pending pinned", time.Now())
1876 if err := agent.MarkCleanupPending(path, "delete"); err != nil {
1877 t.Fatal(err)
1878 }
1879
1880 if loaded, pinnedPath, ok, err := loadPinnedTabSession(dir, path); err != nil || ok || loaded != nil || pinnedPath != "" {
1881 t.Fatalf("loadPinnedTabSession cleanup-pending = loaded:%v path:%q ok:%v, want skipped", loaded, pinnedPath, ok)
1882 }
1883 }
1884
1885 func TestLoadPinnedTabSessionPreservesLoadError(t *testing.T) {
1886 isolateDesktopUserDirs(t)
1887
1888 dir := config.SessionDir()
1889 if err := os.MkdirAll(dir, 0o755); err != nil {
1890 t.Fatalf("mkdir sessions: %v", err)
1891 }
1892 path := writeLegacySession(t, dir, "unsafe-pinned.jsonl", "checkpoint", time.Now())
1893 events := `{"schema_version":99,"type":"replace","messages":[{"role":"user","content":"newer"}]}` + "\n"
1894 if err := os.WriteFile(agent.SessionEventLogPath(path), []byte(events), 0o600); err != nil {
1895 t.Fatalf("write future event log: %v", err)
1896 }
1897
1898 loaded, pinnedPath, ok, err := loadPinnedTabSession(dir, path)
1899 if err == nil || !strings.Contains(err.Error(), "uses schema 99") {
1900 t.Fatalf("loadPinnedTabSession error = %v, want future-schema refusal", err)
1901 }
1902 if loaded != nil || !ok || filepath.Clean(pinnedPath) != filepath.Clean(path) {
1903 t.Fatalf("load result = loaded:%v path:%q ok:%v, want pinned path retained with hard error", loaded, pinnedPath, ok)
1904 }
1905 if got, readErr := os.ReadFile(agent.SessionEventLogPath(path)); readErr != nil || string(got) != events {
1906 t.Fatalf("event log changed after refusal: bytes=%q err=%v", got, readErr)
1907 }
1908 }
1909
1910 func TestBuildTabControllerSurfacesPinnedSessionLoadError(t *testing.T) {
1911 isolateDesktopUserDirs(t)
1912 t.Setenv("REASONIX_TEST_KEY", "sk-test")
1913 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1914 t.Fatalf("mkdir config dir: %v", err)
1915 }
1916 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1917 default_model = "test-provider/test-model"
1918
1919 [[providers]]
1920 name = "test-provider"
1921 kind = "openai"
1922 base_url = "https://test.invalid/v1"
1923 model = "test-model"
1924 api_key_env = "REASONIX_TEST_KEY"
1925 `), 0o600); err != nil {
1926 t.Fatalf("write config: %v", err)
1927 }
1928
1929 dir := desktopSessionDir(globalWorkspaceRoot())
1930 if err := os.MkdirAll(dir, 0o755); err != nil {
1931 t.Fatalf("mkdir sessions: %v", err)
1932 }
1933 path := writeLegacySession(t, dir, "unsafe-startup.jsonl", "checkpoint", time.Now())
1934 events := `{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"newer"}]}` + "\n"
1935 logPath := agent.SessionEventLogPath(path)
1936 if err := os.WriteFile(logPath, []byte(events), 0o600); err != nil {
1937 t.Fatalf("write native event log: %v", err)
1938 }
1939 const oversizedSparseLog = int64(1 << 30)
1940 if err := os.Truncate(logPath, oversizedSparseLog); err != nil {
1941 t.Fatalf("make sparse oversized event log: %v", err)
1942 }
1943
1944 app := NewApp()
1945 tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "tab_unsafe_startup")
1946 tab.SessionPath = path
1947 tab.model = "test-provider/test-model"
1948 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
1949 app.tabs[tab.ID] = tab
1950 app.tabOrder = []string{tab.ID}
1951 app.activeTabID = tab.ID
1952
1953 app.buildTabController(tab)
1954 if tab.Ctrl != nil || tab.Ready {
1955 t.Fatalf("unsafe session runtime = hasCtrl:%v ready:%v, want failed startup", tab.Ctrl != nil, tab.Ready)
1956 }
1957 if !strings.Contains(tab.StartupErr, agent.ErrSessionReplayLimitExceeded.Error()) || strings.Contains(tab.StartupErr, path) {
1958 t.Fatalf("startup error = %q, want path-free replay-budget error", tab.StartupErr)
1959 }
1960 if filepath.Clean(tab.SessionPath) != filepath.Clean(path) {
1961 t.Fatalf("session path = %q, want original %q", tab.SessionPath, path)
1962 }
1963 info, err := os.Stat(logPath)
1964 if err != nil {
1965 t.Fatalf("stat event log after startup refusal: %v", err)
1966 }
1967 if info.Size() != oversizedSparseLog {
1968 t.Fatalf("event log size after startup refusal = %d, want %d", info.Size(), oversizedSparseLog)
1969 }
1970 app.sharedHostsMu.Lock()
1971 sharedHosts := len(app.sharedHosts)
1972 app.sharedHostsMu.Unlock()
1973 if sharedHosts != 0 {
1974 t.Fatalf("shared hosts after failed startup = %d, want 0", sharedHosts)
1975 }
1976 }
1977
1978 func TestBuildTabControllerSkipsCleanupPendingPinnedSession(t *testing.T) {
1979 isolateDesktopUserDirs(t)
1980
1981 dir := config.SessionDir()
1982 if err := os.MkdirAll(dir, 0o755); err != nil {
1983 t.Fatalf("mkdir sessions: %v", err)
1984 }
1985 pending := writeLegacySession(t, dir, "pending-startup.jsonl", "pending startup", time.Now())
1986 if err := agent.MarkCleanupPending(pending, "delete"); err != nil {
1987 t.Fatal(err)
1988 }
1989
1990 app := NewApp()
1991 tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "tab_pending")
1992 tab.SessionPath = pending
1993 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
1994 app.tabs[tab.ID] = tab
1995 app.tabOrder = []string{tab.ID}
1996 app.activeTabID = tab.ID
1997
1998 app.buildTabController(tab)
1999 if tab.Ctrl == nil {
2000 t.Fatalf("tab controller was not built: %s", tab.StartupErr)
2001 }
2002 defer tab.Ctrl.Close()
2003
2004 if got := filepath.Clean(tab.Ctrl.SessionPath()); got == filepath.Clean(pending) {
2005 t.Fatalf("startup bound cleanup-pending pinned session path %q", got)
2006 }
2007 for _, msg := range tab.Ctrl.History() {
2008 if msg.Content == "pending startup" {
2009 t.Fatalf("startup loaded cleanup-pending history: %+v", tab.Ctrl.History())
2010 }
2011 }
2012 }
2013
2014 func TestBuildTabControllerKeepsMissingPinnedSessionPath(t *testing.T) {
2015 isolateDesktopUserDirs(t)
2016
2017 dir := config.SessionDir()
2018 if err := os.MkdirAll(dir, 0o755); err != nil {
2019 t.Fatalf("mkdir sessions: %v", err)
2020 }
2021 topicID := "topic_empty"
2022 topicTitle := "Empty pinned topic"
2023 _ = writeTopicSessionWithPrompt(t, dir, "old.jsonl", topicID, topicTitle, "", "old topic history", time.Now())
2024 pinned := filepath.Join(dir, "empty-new.jsonl")
2025
2026 app := NewApp()
2027 tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), topicID, "tab_empty")
2028 tab.TopicTitle = topicTitle
2029 tab.SessionPath = pinned
2030 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
2031 app.tabs[tab.ID] = tab
2032 app.tabOrder = []string{tab.ID}
2033 app.activeTabID = tab.ID
2034
2035 app.buildTabController(tab)
2036 if tab.Ctrl == nil {
2037 t.Fatalf("tab controller was not built: %s", tab.StartupErr)
2038 }
2039 defer tab.Ctrl.Close()
2040
2041 if got := filepath.Clean(tab.Ctrl.SessionPath()); got != filepath.Clean(pinned) {
2042 t.Fatalf("empty pinned session path = %q, want %q", got, pinned)
2043 }
2044 for _, msg := range tab.Ctrl.History() {
2045 if msg.Content == "old topic history" {
2046 t.Fatalf("empty pinned session loaded fallback topic history: %+v", tab.Ctrl.History())
2047 }
2048 }
2049 }
2050
2051 func TestReorderProjectsPersistsSidebarAndWorkspaceOrder(t *testing.T) {
2052 isolateDesktopUserDirs(t)
2053
2054 first := t.TempDir()
2055 second := t.TempDir()
2056 third := t.TempDir()
2057 if err := addProject(first, "First"); err != nil {
2058 t.Fatalf("add first project: %v", err)
2059 }
2060 if err := addProject(second, "Second"); err != nil {
2061 t.Fatalf("add second project: %v", err)
2062 }
2063 if err := addProject(third, "Third"); err != nil {
2064 t.Fatalf("add third project: %v", err)
2065 }
2066
2067 app := NewApp()
2068 if err := app.ReorderProjects([]string{third, first, second}); err != nil {
2069 t.Fatalf("ReorderProjects: %v", err)
2070 }
2071
2072 nodes := app.ListProjectTree()
2073 if len(nodes) != 3 {
2074 t.Fatalf("project tree len = %d, want 3: %+v", len(nodes), nodes)
2075 }
2076 if got := []string{nodes[0].Root, nodes[1].Root, nodes[2].Root}; got[0] != third || got[1] != first || got[2] != second {
2077 t.Fatalf("project tree order = %v, want %v", got, []string{third, first, second})
2078 }
2079 workspaces := app.ListWorkspaces()
2080 if len(workspaces) != 3 {
2081 t.Fatalf("workspaces len = %d, want 3: %+v", len(workspaces), workspaces)
2082 }
2083 if got := []string{workspaces[0].Path, workspaces[1].Path, workspaces[2].Path}; got[0] != third || got[1] != first || got[2] != second {
2084 t.Fatalf("workspace order = %v, want %v", got, []string{third, first, second})
2085 }
2086 }
2087
2088 func TestReorderProjectsPersistsGlobalSidebarOrder(t *testing.T) {
2089 isolateDesktopUserDirs(t)
2090
2091 first := t.TempDir()
2092 second := t.TempDir()
2093 if err := addProject(first, "First"); err != nil {
2094 t.Fatalf("add first project: %v", err)
2095 }
2096 if err := addProject(second, "Second"); err != nil {
2097 t.Fatalf("add second project: %v", err)
2098 }
2099
2100 app := NewApp()
2101 if _, err := app.CreateTopic("global", "", "Global note"); err != nil {
2102 t.Fatalf("create global topic: %v", err)
2103 }
2104 if err := app.ReorderProjects([]string{second, desktopGlobalOrderToken, first}); err != nil {
2105 t.Fatalf("ReorderProjects with global: %v", err)
2106 }
2107
2108 nodes := app.ListProjectTree()
2109 if len(nodes) != 3 {
2110 t.Fatalf("project tree len = %d, want 3: %+v", len(nodes), nodes)
2111 }
2112 if got := []string{nodes[0].Root, nodes[1].Kind, nodes[2].Root}; got[0] != second || got[1] != "global_folder" || got[2] != first {
2113 t.Fatalf("project tree order = %v, want [%s global_folder %s]", got, second, first)
2114 }
2115 workspaces := app.ListWorkspaces()
2116 if len(workspaces) != 2 {
2117 t.Fatalf("workspaces len = %d, want 2: %+v", len(workspaces), workspaces)
2118 }
2119 if got := []string{workspaces[0].Path, workspaces[1].Path}; got[0] != second || got[1] != first {
2120 t.Fatalf("workspace order = %v, want %v", got, []string{second, first})
2121 }
2122 }
2123
2124 func TestReorderProjectsRejectsInvalidOrder(t *testing.T) {
2125 isolateDesktopUserDirs(t)
2126
2127 first := t.TempDir()
2128 second := t.TempDir()
2129 if err := addProject(first, "First"); err != nil {
2130 t.Fatalf("add first project: %v", err)
2131 }
2132 if err := addProject(second, "Second"); err != nil {
2133 t.Fatalf("add second project: %v", err)
2134 }
2135 app := NewApp()
2136 for name, order := range map[string][]string{
2137 "missing": {first},
2138 "unknown": {first, filepath.Join(t.TempDir(), "missing")},
2139 "duplicate": {first, first},
2140 "duplicate-global": {desktopGlobalOrderToken, first, desktopGlobalOrderToken, second},
2141 } {
2142 t.Run(name, func(t *testing.T) {
2143 if err := app.ReorderProjects(order); err == nil {
2144 t.Fatalf("ReorderProjects(%v) succeeded, want error", order)
2145 }
2146 })
2147 }
2148
2149 nodes := app.ListProjectTree()
2150 if got := []string{nodes[0].Root, nodes[1].Root}; got[0] != first || got[1] != second {
2151 t.Fatalf("project tree order changed after invalid reorder: %v", got)
2152 }
2153 }
2154
2155 func TestRemoveWorkspaceUsesSharedProjectRegistryForCurrentProject(t *testing.T) {
2156 isolateDesktopUserDirs(t)
2157
2158 projectRoot := t.TempDir()
2159 if err := addProject(projectRoot, "Current Project"); err != nil {
2160 t.Fatalf("add project: %v", err)
2161 }
2162 app := NewApp()
2163 tab := app.createTabEntryWithID("project", projectRoot, "topic_current", "tab_current")
2164 app.tabs[tab.ID] = tab
2165 app.tabOrder = []string{tab.ID}
2166 app.activeTabID = tab.ID
2167
2168 if err := app.RemoveWorkspace(projectRoot); err != nil {
2169 t.Fatalf("remove current project: %v", err)
2170 }
2171 if got := app.ListWorkspaces(); len(got) != 0 {
2172 t.Fatalf("workspaces after remove = %+v, want empty", got)
2173 }
2174 if got := app.ListProjectTree(); len(got) != 1 || got[0].Kind != "global_folder" || len(got[0].Children) != 0 {
2175 t.Fatalf("project tree after remove = %+v, want empty Global folder", got)
2176 }
2177 }
2178
2179 func TestRestoredProjectTabUsesStoredTopicTitle(t *testing.T) {
2180 isolateDesktopUserDirs(t)
2181
2182 projectRoot := t.TempDir()
2183 topicID := "topic_stored_title"
2184 if err := addProject(projectRoot, ""); err != nil {
2185 t.Fatalf("add project: %v", err)
2186 }
2187 if err := setTopicTitle(projectRoot, topicID, "你是谁"); err != nil {
2188 t.Fatalf("set topic title: %v", err)
2189 }
2190
2191 app := NewApp()
2192 tab := app.createTabEntryWithID("project", projectRoot, topicID, "tab1")
2193 app.tabs[tab.ID] = tab
2194 app.tabOrder = []string{tab.ID}
2195 app.activeTabID = tab.ID
2196
2197 tabs := app.ListTabs()
2198 if len(tabs) != 1 {
2199 t.Fatalf("tabs len = %d, want 1", len(tabs))
2200 }
2201 if got := tabs[0].TopicTitle; got != "你是谁" {
2202 t.Fatalf("tab title = %q, want 你是谁", got)
2203 }
2204 nodes := app.ListProjectTree()
2205 if len(nodes) != 1 || len(nodes[0].Children) != 1 {
2206 t.Fatalf("project tree = %#v, want one project with one topic", nodes)
2207 }
2208 if got := nodes[0].Children[0].Label; got != tabs[0].TopicTitle {
2209 t.Fatalf("tree title = %q, want same as tab title %q", got, tabs[0].TopicTitle)
2210 }
2211 }
2212
2213 func TestUntitledProjectTopicUsesSameFallbackEverywhere(t *testing.T) {
2214 isolateDesktopUserDirs(t)
2215
2216 projectRoot := t.TempDir()
2217 topicID := "topic_without_title"
2218 if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{
2219 Root: projectRoot,
2220 Topics: []string{topicID},
2221 }}}); err != nil {
2222 t.Fatalf("save projects: %v", err)
2223 }
2224
2225 app := NewApp()
2226 tab := app.createTabEntryWithID("project", projectRoot, topicID, "tab1")
2227 app.tabs[tab.ID] = tab
2228 app.tabOrder = []string{tab.ID}
2229 app.activeTabID = tab.ID
2230
2231 tabs := app.ListTabs()
2232 if len(tabs) != 1 {
2233 t.Fatalf("tabs len = %d, want 1", len(tabs))
2234 }
2235 if got := tabs[0].TopicTitle; got != defaultTopicTitle {
2236 t.Fatalf("tab title = %q, want %q", got, defaultTopicTitle)
2237 }
2238 nodes := app.ListProjectTree()
2239 if len(nodes) != 1 || len(nodes[0].Children) != 1 {
2240 t.Fatalf("project tree = %#v, want one project with one topic", nodes)
2241 }
2242 if got := nodes[0].Children[0].Label; got != defaultTopicTitle {
2243 t.Fatalf("tree title = %q, want %q", got, defaultTopicTitle)
2244 }
2245 }
2246
2247 func TestCreateTopicDefaultsToAutoNewSessionTitle(t *testing.T) {
2248 isolateDesktopUserDirs(t)
2249
2250 projectRoot := t.TempDir()
2251 before := time.Now().UnixMilli()
2252 topic, err := NewApp().CreateTopic("project", projectRoot, "")
2253 after := time.Now().UnixMilli()
2254 if err != nil {
2255 t.Fatalf("create topic: %v", err)
2256 }
2257 if got := topic.Title; got != defaultTopicTitle {
2258 t.Fatalf("topic title = %q, want %q", got, defaultTopicTitle)
2259 }
2260 if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
2261 t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
2262 }
2263 if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
2264 t.Fatalf("title source = %q, want auto", got)
2265 }
2266 if got := loadTopicCreatedAt(projectRoot, topic.ID); got < before || got > after {
2267 t.Fatalf("createdAt = %d, want between %d and %d", got, before, after)
2268 }
2269 nodes := NewApp().ListProjectTree()
2270 if len(nodes) != 1 || len(nodes[0].Children) != 1 {
2271 t.Fatalf("project tree = %#v, want one project with one topic", nodes)
2272 }
2273 if got := nodes[0].Children[0].CreatedAt; got != topic.CreatedAt {
2274 t.Fatalf("project tree createdAt = %d, want %d", got, topic.CreatedAt)
2275 }
2276 }
2277
2278 func TestListProjectTreeFallsBackToTopicIDCreatedAt(t *testing.T) {
2279 isolateDesktopUserDirs(t)
2280
2281 const topicID = "legacy_20260606-114914_2276f13fd87c"
2282 if err := setTopicTitleWithSource("", topicID, "你好,你是谁", topicTitleSourceManual); err != nil {
2283 t.Fatalf("set topic title: %v", err)
2284 }
2285 if err := prependTopicInProjectsFile("", topicID, false); err != nil {
2286 t.Fatalf("prepend topic: %v", err)
2287 }
2288
2289 nodes := NewApp().ListProjectTree()
2290 if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
2291 t.Fatalf("project tree = %#v, want Global with one topic", nodes)
2292 }
2293 expected := time.Date(2026, 6, 6, 11, 49, 14, 0, time.UTC).UnixMilli()
2294 if got := nodes[0].Children[0].CreatedAt; got != expected {
2295 t.Fatalf("project tree createdAt = %d, want %d", got, expected)
2296 }
2297 }
2298
2299 func TestCreateTopicAppearsFirstInProjectTree(t *testing.T) {
2300 isolateDesktopUserDirs(t)
2301
2302 projectRoot := t.TempDir()
2303 app := NewApp()
2304 first, err := app.CreateTopic("project", projectRoot, "")
2305 if err != nil {
2306 t.Fatalf("create first topic: %v", err)
2307 }
2308 second, err := app.CreateTopic("project", projectRoot, "")
2309 if err != nil {
2310 t.Fatalf("create second topic: %v", err)
2311 }
2312
2313 nodes := app.ListProjectTree()
2314 if len(nodes) != 1 || len(nodes[0].Children) != 2 {
2315 t.Fatalf("project tree = %#v, want one project with two topics", nodes)
2316 }
2317 if got := nodes[0].Children[0].TopicID; got != second.ID {
2318 t.Fatalf("first visible topic = %q, want newest %q", got, second.ID)
2319 }
2320 if got := nodes[0].Children[1].TopicID; got != first.ID {
2321 t.Fatalf("second visible topic = %q, want older %q", got, first.ID)
2322 }
2323 }
2324
2325 func TestCreateGlobalTopicAppearsFirstInProjectTree(t *testing.T) {
2326 isolateDesktopUserDirs(t)
2327
2328 app := NewApp()
2329 first, err := app.CreateTopic("global", "", "")
2330 if err != nil {
2331 t.Fatalf("create first global topic: %v", err)
2332 }
2333 second, err := app.CreateTopic("global", "", "")
2334 if err != nil {
2335 t.Fatalf("create second global topic: %v", err)
2336 }
2337
2338 nodes := app.ListProjectTree()
2339 if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 2 {
2340 t.Fatalf("project tree = %#v, want Global with two topics", nodes)
2341 }
2342 if got := nodes[0].Children[0].TopicID; got != second.ID {
2343 t.Fatalf("first visible global topic = %q, want newest %q", got, second.ID)
2344 }
2345 if got := nodes[0].Children[1].TopicID; got != first.ID {
2346 t.Fatalf("second visible global topic = %q, want older %q", got, first.ID)
2347 }
2348 }
2349
2350 func TestListProjectTreeShowsEmptyGlobalWhenNoProjects(t *testing.T) {
2351 isolateDesktopUserDirs(t)
2352
2353 nodes := NewApp().ListProjectTree()
2354 if len(nodes) != 1 {
2355 t.Fatalf("project tree = %#v, want one Global folder", nodes)
2356 }
2357 if nodes[0].Kind != "global_folder" || nodes[0].Label != "Global" || len(nodes[0].Children) != 0 {
2358 t.Fatalf("project tree = %#v, want empty Global folder", nodes)
2359 }
2360 }
2361
2362 func TestSwitchWorkspaceRegistersDefaultTopicInProjectTree(t *testing.T) {
2363 isolateDesktopUserDirs(t)
2364
2365 projectRoot := t.TempDir()
2366 app := NewApp()
2367 if got, err := app.SwitchWorkspace(projectRoot); err != nil {
2368 t.Fatalf("SwitchWorkspace: %v", err)
2369 } else if got != projectRoot {
2370 t.Fatalf("SwitchWorkspace root = %q, want %q", got, projectRoot)
2371 }
2372
2373 nodes := app.ListProjectTree()
2374 if len(nodes) != 1 {
2375 t.Fatalf("project tree len = %d, want 1: %+v", len(nodes), nodes)
2376 }
2377 if got := nodes[0].Root; got != projectRoot {
2378 t.Fatalf("project root = %q, want %q", got, projectRoot)
2379 }
2380 if len(nodes[0].Children) != 1 {
2381 t.Fatalf("project children len = %d, want 1: %+v", len(nodes[0].Children), nodes[0].Children)
2382 }
2383 child := nodes[0].Children[0]
2384 if got := child.Label; got != defaultTopicTitle {
2385 t.Fatalf("default topic label = %q, want %q", got, defaultTopicTitle)
2386 }
2387 if strings.TrimSpace(child.TopicID) == "" {
2388 t.Fatalf("default topic ID should be persisted in the project tree: %+v", child)
2389 }
2390 tabs := app.ListTabs()
2391 if len(tabs) != 1 || tabs[0].TopicID != child.TopicID {
2392 t.Fatalf("opened tab should use the persisted topic, tabs=%+v child=%+v", tabs, child)
2393 }
2394 }
2395
2396 func TestRenameTopicLocksTitleManual(t *testing.T) {
2397 isolateDesktopUserDirs(t)
2398
2399 projectRoot := t.TempDir()
2400 app := NewApp()
2401 topic, err := app.CreateTopic("project", projectRoot, "")
2402 if err != nil {
2403 t.Fatalf("create topic: %v", err)
2404 }
2405 if err := app.RenameTopic(topic.ID, "手动标题"); err != nil {
2406 t.Fatalf("rename topic: %v", err)
2407 }
2408 if got := loadTopicTitle(projectRoot, topic.ID); got != "手动标题" {
2409 t.Fatalf("stored title = %q, want 手动标题", got)
2410 }
2411 if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
2412 t.Fatalf("title source = %q, want manual", got)
2413 }
2414 }
2415
2416 func TestRenameTopicUpdatesOpenTabMeta(t *testing.T) {
2417 isolateDesktopUserDirs(t)
2418
2419 projectRoot := t.TempDir()
2420 app := NewApp()
2421 topic, err := app.CreateTopic("project", projectRoot, "旧标题")
2422 if err != nil {
2423 t.Fatalf("create topic: %v", err)
2424 }
2425 tab, err := app.OpenProjectTab(projectRoot, topic.ID)
2426 if err != nil {
2427 t.Fatalf("open project tab: %v", err)
2428 }
2429 waitForTabReady(t, app, tab.ID)
2430 if tab.TopicTitle != "旧标题" {
2431 t.Fatalf("opened tab title = %q, want 旧标题", tab.TopicTitle)
2432 }
2433
2434 if err := app.RenameTopic(topic.ID, "新标题"); err != nil {
2435 t.Fatalf("rename topic: %v", err)
2436 }
2437 tabs := app.ListTabs()
2438 if len(tabs) != 1 {
2439 t.Fatalf("tabs len = %d, want 1: %+v", len(tabs), tabs)
2440 }
2441 if got := tabs[0].TopicTitle; got != "新标题" {
2442 t.Fatalf("open tab title = %q, want 新标题", got)
2443 }
2444 }
2445
2446 func TestRenameTopicRecreatesDeletedProjectTitleIndexFromOpenTab(t *testing.T) {
2447 isolateDesktopUserDirs(t)
2448
2449 projectRoot := t.TempDir()
2450 app := NewApp()
2451 topic, err := app.CreateTopic("project", projectRoot, "旧标题")
2452 if err != nil {
2453 t.Fatalf("create topic: %v", err)
2454 }
2455 tab, err := app.OpenProjectTab(projectRoot, topic.ID)
2456 if err != nil {
2457 t.Fatalf("open project tab: %v", err)
2458 }
2459 waitForTabReady(t, app, tab.ID)
2460 if err := os.Remove(topicTitlesPath(projectRoot)); err != nil {
2461 t.Fatalf("remove topic titles: %v", err)
2462 }
2463 if err := os.Remove(topicTitleSourcesPath(projectRoot)); err != nil {
2464 t.Fatalf("remove topic title sources: %v", err)
2465 }
2466
2467 if err := app.RenameTopic(topic.ID, "恢复标题"); err != nil {
2468 t.Fatalf("rename topic after deleting title index: %v", err)
2469 }
2470 if got := loadTopicTitle(projectRoot, topic.ID); got != "恢复标题" {
2471 t.Fatalf("restored topic title = %q, want 恢复标题", got)
2472 }
2473 nodes := app.ListProjectTree()
2474 if len(nodes) != 1 || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topic.ID {
2475 t.Fatalf("project tree should still contain topic, got %#v", nodes)
2476 }
2477 }
2478
2479 func TestRenameTopicRecreatesDeletedProjectTitleIndexFromSessionMeta(t *testing.T) {
2480 isolateDesktopUserDirs(t)
2481
2482 projectRoot := t.TempDir()
2483 topicID := "topic_missing_index"
2484 if err := addProject(projectRoot, ""); err != nil {
2485 t.Fatalf("add project: %v", err)
2486 }
2487 if err := setTopicTitle(projectRoot, topicID, "旧标题"); err != nil {
2488 t.Fatalf("set topic title: %v", err)
2489 }
2490 dir := config.SessionDir()
2491 if err := os.MkdirAll(dir, 0o755); err != nil {
2492 t.Fatalf("mkdir sessions: %v", err)
2493 }
2494 writeTopicSession(t, dir, "missing-index.jsonl", topicID, "旧标题", projectRoot)
2495 if err := os.Remove(topicTitlesPath(projectRoot)); err != nil {
2496 t.Fatalf("remove topic titles: %v", err)
2497 }
2498 if err := os.Remove(topicTitleSourcesPath(projectRoot)); err != nil {
2499 t.Fatalf("remove topic title sources: %v", err)
2500 }
2501
2502 if err := NewApp().RenameTopic(topicID, "恢复标题"); err != nil {
2503 t.Fatalf("rename topic from session meta after deleting title index: %v", err)
2504 }
2505 if got := loadTopicTitle(projectRoot, topicID); got != "恢复标题" {
2506 t.Fatalf("restored topic title = %q, want 恢复标题", got)
2507 }
2508 nodes := NewApp().ListProjectTree()
2509 if len(nodes) != 1 || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
2510 t.Fatalf("project tree should contain restored topic, got %#v", nodes)
2511 }
2512 }
2513
2514 func TestOpenProjectTabRecoversMissingTopicTitleFromSessionMeta(t *testing.T) {
2515 isolateDesktopUserDirs(t)
2516
2517 projectRoot := robustTempDir(t)
2518 app := NewApp()
2519 topic, err := app.CreateTopic("project", projectRoot, "旧标题")
2520 if err != nil {
2521 t.Fatalf("create topic: %v", err)
2522 }
2523 dir := desktopSessionDir(projectRoot)
2524 if err := os.MkdirAll(dir, 0o755); err != nil {
2525 t.Fatalf("mkdir sessions: %v", err)
2526 }
2527 sessionPath := writeTopicSessionWithPrompt(t, dir, "stored-meta.jsonl", topic.ID, "用户保存标题", projectRoot, "first prompt should not win", time.Now())
2528 if err := os.Remove(topicTitlesPath(projectRoot)); err != nil {
2529 t.Fatalf("remove topic titles: %v", err)
2530 }
2531 if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
2532 t.Fatalf("precondition title source = %q, want manual", got)
2533 }
2534
2535 meta, err := app.OpenProjectTab(projectRoot, topic.ID)
2536 if err != nil {
2537 t.Fatalf("open project tab: %v", err)
2538 }
2539 tab := waitForTabReady(t, app, meta.ID)
2540 if got := filepath.Clean(tab.Ctrl.SessionPath()); got != filepath.Clean(sessionPath) {
2541 t.Fatalf("opened session path = %q, want %q", got, sessionPath)
2542 }
2543 if got := meta.TopicTitle; got != "用户保存标题" {
2544 t.Fatalf("opened topic title = %q, want 用户保存标题", got)
2545 }
2546 if got := loadTopicTitle(projectRoot, topic.ID); got != "用户保存标题" {
2547 t.Fatalf("stored topic title = %q, want 用户保存标题", got)
2548 }
2549 if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
2550 t.Fatalf("title source = %q, want manual", got)
2551 }
2552 }
2553
2554 func TestOpenProjectTabRecoversMissingTopicTitleFromSessionTitle(t *testing.T) {
2555 isolateDesktopUserDirs(t)
2556
2557 projectRoot := robustTempDir(t)
2558 app := NewApp()
2559 topic, err := app.CreateTopic("project", projectRoot, "旧标题")
2560 if err != nil {
2561 t.Fatalf("create topic: %v", err)
2562 }
2563 dir := desktopSessionDir(projectRoot)
2564 if err := os.MkdirAll(dir, 0o755); err != nil {
2565 t.Fatalf("mkdir sessions: %v", err)
2566 }
2567 sessionPath := writeTopicSessionWithPrompt(t, dir, "stored-session-title.jsonl", topic.ID, "", projectRoot, "first prompt should not win", time.Now())
2568 if err := setSessionTitle(dir, sessionPath, "历史手动标题"); err != nil {
2569 t.Fatalf("set session title: %v", err)
2570 }
2571 if err := os.Remove(topicTitlesPath(projectRoot)); err != nil {
2572 t.Fatalf("remove topic titles: %v", err)
2573 }
2574 if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
2575 t.Fatalf("precondition title source = %q, want manual", got)
2576 }
2577
2578 meta, err := app.OpenProjectTab(projectRoot, topic.ID)
2579 if err != nil {
2580 t.Fatalf("open project tab: %v", err)
2581 }
2582 waitForTabReady(t, app, meta.ID)
2583 if got := meta.TopicTitle; got != "历史手动标题" {
2584 t.Fatalf("opened topic title = %q, want 历史手动标题", got)
2585 }
2586 if got := loadTopicTitle(projectRoot, topic.ID); got != "历史手动标题" {
2587 t.Fatalf("stored topic title = %q, want 历史手动标题", got)
2588 }
2589 if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
2590 t.Fatalf("title source = %q, want manual", got)
2591 }
2592 }
2593
2594 func TestOpenProjectTabPreservesManualDefaultTopicTitle(t *testing.T) {
2595 isolateDesktopUserDirs(t)
2596
2597 projectRoot := robustTempDir(t)
2598 app := NewApp()
2599 topic, err := app.CreateTopic("project", projectRoot, "")
2600 if err != nil {
2601 t.Fatalf("create topic: %v", err)
2602 }
2603 if err := app.RenameTopic(topic.ID, defaultTopicTitle); err != nil {
2604 t.Fatalf("rename topic: %v", err)
2605 }
2606 dir := desktopSessionDir(projectRoot)
2607 if err := os.MkdirAll(dir, 0o755); err != nil {
2608 t.Fatalf("mkdir sessions: %v", err)
2609 }
2610 writeTopicSessionWithPrompt(t, dir, "manual-default.jsonl", topic.ID, defaultTopicTitle, projectRoot, "first prompt should not replace manual default", time.Now())
2611
2612 meta, err := app.OpenProjectTab(projectRoot, topic.ID)
2613 if err != nil {
2614 t.Fatalf("open project tab: %v", err)
2615 }
2616 waitForTabReady(t, app, meta.ID)
2617 if got := meta.TopicTitle; got != defaultTopicTitle {
2618 t.Fatalf("opened topic title = %q, want %q", got, defaultTopicTitle)
2619 }
2620 if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
2621 t.Fatalf("stored topic title = %q, want %q", got, defaultTopicTitle)
2622 }
2623 if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
2624 t.Fatalf("title source = %q, want manual", got)
2625 }
2626 }
2627
2628 func TestEnsureTopicIndexedPreservesGlobalAutoTitleSource(t *testing.T) {
2629 isolateDesktopUserDirs(t)
2630
2631 topicID := "topic_global_auto"
2632 if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
2633 t.Fatalf("set global topic title: %v", err)
2634 }
2635 source := loadTopicTitleSource(topicTitleRoot("global", globalTabWorkspaceRoot()), topicID)
2636 if err := ensureTopicIndexed("global", globalTabWorkspaceRoot(), topicID, defaultTopicTitle, source); err != nil {
2637 t.Fatalf("ensure global topic indexed: %v", err)
2638 }
2639
2640 if got := loadTopicTitleSource("", topicID); got != topicTitleSourceAuto {
2641 t.Fatalf("global title source = %q, want %q", got, topicTitleSourceAuto)
2642 }
2643 }
2644
2645 func TestAutoTitleTopicFromFirstUserMessage(t *testing.T) {
2646 isolateDesktopUserDirs(t)
2647
2648 projectRoot := t.TempDir()
2649 topic, err := NewApp().CreateTopic("project", projectRoot, "")
2650 if err != nil {
2651 t.Fatalf("create topic: %v", err)
2652 }
2653 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
2654 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
2655 t.Fatalf("write session: %v", err)
2656 }
2657
2658 title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
2659 if !updated {
2660 t.Fatal("auto title should update")
2661 }
2662 if title != "讲讲这个代码库的架构" {
2663 t.Fatalf("generated title = %q", title)
2664 }
2665 if got := loadTopicTitle(projectRoot, topic.ID); got != title {
2666 t.Fatalf("stored title = %q, want %q", got, title)
2667 }
2668 if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
2669 t.Fatalf("title source = %q, want auto", got)
2670 }
2671 }
2672
2673 func TestAutoTitleTopicStripsReasoningLanguagePrefix(t *testing.T) {
2674 isolateDesktopUserDirs(t)
2675
2676 projectRoot := t.TempDir()
2677 topic, err := NewApp().CreateTopic("project", projectRoot, "")
2678 if err != nil {
2679 t.Fatalf("create topic: %v", err)
2680 }
2681 prompt := control.New(control.Options{ReasoningLanguage: "zh"}).Compose("讲讲这个代码库的架构")
2682 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
2683 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":`+strconv.Quote(prompt)+`}`+"\n"), 0o644); err != nil {
2684 t.Fatalf("write session: %v", err)
2685 }
2686
2687 title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
2688 if !updated {
2689 t.Fatal("auto title should update")
2690 }
2691 if title != "讲讲这个代码库的架构" {
2692 t.Fatalf("generated title = %q", title)
2693 }
2694 }
2695
2696 func TestAutoTitleTopicRefreshesOnThirdUserTurn(t *testing.T) {
2697 isolateDesktopUserDirs(t)
2698
2699 projectRoot := t.TempDir()
2700 topic, err := NewApp().CreateTopic("project", projectRoot, "")
2701 if err != nil {
2702 t.Fatalf("create topic: %v", err)
2703 }
2704 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
2705 firstTurn := strings.Join([]string{
2706 `{"role":"user","content":"帮我看看"}`,
2707 `{"role":"assistant","content":"可以"}`,
2708 }, "\n") + "\n"
2709 if err := os.WriteFile(sessionPath, []byte(firstTurn), 0o644); err != nil {
2710 t.Fatalf("write first session: %v", err)
2711 }
2712
2713 title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
2714 if !updated || title != "帮我看看" {
2715 t.Fatalf("first auto title = %q updated=%v, want 帮我看看/true", title, updated)
2716 }
2717
2718 thirdTurn := strings.Join([]string{
2719 `{"role":"user","content":"帮我看看"}`,
2720 `{"role":"assistant","content":"可以"}`,
2721 `{"role":"user","content":"继续"}`,
2722 `{"role":"assistant","content":"继续分析"}`,
2723 `{"role":"user","content":"实现自动更新会话标题"}`,
2724 `{"role":"assistant","content":"已实现"}`,
2725 }, "\n") + "\n"
2726 if err := os.WriteFile(sessionPath, []byte(thirdTurn), 0o644); err != nil {
2727 t.Fatalf("write third session: %v", err)
2728 }
2729
2730 title, updated = autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
2731 if !updated || title != "实现自动更新会话标题" {
2732 t.Fatalf("third-turn auto title = %q updated=%v, want 实现自动更新会话标题/true", title, updated)
2733 }
2734 if got := loadTopicTitle(projectRoot, topic.ID); got != "实现自动更新会话标题" {
2735 t.Fatalf("stored title = %q, want 实现自动更新会话标题", got)
2736 }
2737 meta := loadTopicAutoTitleMeta(projectRoot)[topic.ID]
2738 if meta.Stage != 3 {
2739 t.Fatalf("auto title stage = %d, want 3", meta.Stage)
2740 }
2741 }
2742
2743 func TestAutoTitleDoesNotOverrideManualTopicTitle(t *testing.T) {
2744 isolateDesktopUserDirs(t)
2745
2746 projectRoot := t.TempDir()
2747 app := NewApp()
2748 topic, err := app.CreateTopic("project", projectRoot, "")
2749 if err != nil {
2750 t.Fatalf("create topic: %v", err)
2751 }
2752 if err := app.RenameTopic(topic.ID, "手动标题"); err != nil {
2753 t.Fatalf("rename topic: %v", err)
2754 }
2755 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
2756 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
2757 t.Fatalf("write session: %v", err)
2758 }
2759
2760 if title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath); updated || title != "" {
2761 t.Fatalf("manual title should not auto-update, title=%q updated=%v", title, updated)
2762 }
2763 if got := loadTopicTitle(projectRoot, topic.ID); got != "手动标题" {
2764 t.Fatalf("stored title = %q, want 手动标题", got)
2765 }
2766 }
2767
2768 func TestAutoTitleDoesNotOverrideManualSessionTitle(t *testing.T) {
2769 isolateDesktopUserDirs(t)
2770
2771 projectRoot := t.TempDir()
2772 topic, err := NewApp().CreateTopic("project", projectRoot, "")
2773 if err != nil {
2774 t.Fatalf("create topic: %v", err)
2775 }
2776 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
2777 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
2778 t.Fatalf("write session: %v", err)
2779 }
2780 if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{CustomTitle: "手动会话标题"}); err != nil {
2781 t.Fatalf("save branch meta: %v", err)
2782 }
2783
2784 if title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath); updated || title != "" {
2785 t.Fatalf("manual session title should not auto-update, title=%q updated=%v", title, updated)
2786 }
2787 if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
2788 t.Fatalf("stored title = %q, want default title", got)
2789 }
2790 }
2791
2792 func TestRenameTopicBlankKeepsManualTitleSource(t *testing.T) {
2793 isolateDesktopUserDirs(t)
2794
2795 projectRoot := t.TempDir()
2796 app := NewApp()
2797 topic, err := app.CreateTopic("project", projectRoot, "")
2798 if err != nil {
2799 t.Fatalf("create topic: %v", err)
2800 }
2801 if err := app.RenameTopic(topic.ID, " "); err != nil {
2802 t.Fatalf("rename blank topic: %v", err)
2803 }
2804 if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
2805 t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
2806 }
2807 if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
2808 t.Fatalf("title source = %q, want manual", got)
2809 }
2810 }
2811
2812 func TestTrashTopicMovesRelatedSessionsToTrash(t *testing.T) {
2813 isolateDesktopUserDirs(t)
2814
2815 projectRoot := t.TempDir()
2816 topicID := "topic_trash_history"
2817 if err := addProject(projectRoot, ""); err != nil {
2818 t.Fatalf("add project: %v", err)
2819 }
2820 if err := setTopicTitle(projectRoot, topicID, "Trash history"); err != nil {
2821 t.Fatalf("set topic title: %v", err)
2822 }
2823 dir := config.SessionDir()
2824 if err := os.MkdirAll(dir, 0o755); err != nil {
2825 t.Fatalf("mkdir sessions: %v", err)
2826 }
2827 sessionPath := writeTopicSession(t, dir, "trash-me.jsonl", topicID, "Trash history", projectRoot)
2828 placeholderPath := filepath.Join(dir, "trash-placeholder-session.jsonl")
2829 if err := os.WriteFile(placeholderPath, nil, 0o644); err != nil {
2830 t.Fatalf("write placeholder session: %v", err)
2831 }
2832 now := time.Now()
2833 if err := agent.SaveBranchMetaPreserveUpdated(placeholderPath, agent.BranchMeta{
2834 CreatedAt: now.Add(-time.Minute),
2835 UpdatedAt: now,
2836 Scope: "project",
2837 WorkspaceRoot: projectRoot,
2838 TopicID: topicID,
2839 TopicTitle: "Trash history",
2840 }); err != nil {
2841 t.Fatalf("save placeholder branch meta: %v", err)
2842 }
2843 placeholderGoalPath := strings.TrimSuffix(placeholderPath, ".jsonl") + ".goal-state.json"
2844 if err := os.WriteFile(placeholderGoalPath, []byte(`{"done":true}`), 0o644); err != nil {
2845 t.Fatalf("write placeholder goal state: %v", err)
2846 }
2847 ref := "sa_20260102_030405_000000000_aabbccddeeff"
2848 writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))
2849
2850 if err := NewApp().TrashTopic(topicID); err != nil {
2851 t.Fatalf("trash topic: %v", err)
2852 }
2853 if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
2854 t.Fatalf("topic session should be removed from active history, stat err = %v", err)
2855 }
2856 trashPath := filepath.Join(dir, sessionTrashDir, "trash-me.jsonl", "trash-me.jsonl")
2857 if _, err := os.Stat(trashPath); err != nil {
2858 t.Fatalf("topic session should be moved to trash: %v", err)
2859 }
2860 if _, err := os.Stat(placeholderPath); !os.IsNotExist(err) {
2861 t.Fatalf("placeholder session should be removed from active history, stat err = %v", err)
2862 }
2863 placeholderTrashDir := filepath.Join(dir, sessionTrashDir, "trash-placeholder-session.jsonl")
2864 if _, err := os.Stat(filepath.Join(placeholderTrashDir, "trash-placeholder-session.jsonl")); err != nil {
2865 t.Fatalf("placeholder session should be moved to trash: %v", err)
2866 }
2867 if _, err := os.Stat(filepath.Join(placeholderTrashDir, "trash-placeholder-session.jsonl.meta")); err != nil {
2868 t.Fatalf("placeholder meta should be moved to trash: %v", err)
2869 }
2870 if _, err := os.Stat(filepath.Join(placeholderTrashDir, "trash-placeholder-session.goal-state.json")); err != nil {
2871 t.Fatalf("placeholder goal state should be moved to trash: %v", err)
2872 }
2873 if _, err := os.Stat(filepath.Join(dir, sessionTrashDir, "trash-me.jsonl", "subagents", ref+".jsonl")); err != nil {
2874 t.Fatalf("topic subagent should be moved to trash: %v", err)
2875 }
2876 if got := loadTopicTitle(projectRoot, topicID); got != "" {
2877 t.Fatalf("topic title should be removed, got %q", got)
2878 }
2879 }
2880
2881 func TestTrashTopicRemovesStaleMissingSession(t *testing.T) {
2882 isolateDesktopUserDirs(t)
2883
2884 projectRoot := t.TempDir()
2885 topicID := "topic_missing_trash"
2886 if err := addProject(projectRoot, ""); err != nil {
2887 t.Fatalf("add project: %v", err)
2888 }
2889 if err := setTopicTitle(projectRoot, topicID, "Missing trash"); err != nil {
2890 t.Fatalf("set topic title: %v", err)
2891 }
2892 dir := config.SessionDir()
2893 if err := os.MkdirAll(dir, 0o755); err != nil {
2894 t.Fatalf("mkdir sessions: %v", err)
2895 }
2896 missingPath := filepath.Join(dir, "already-gone.jsonl")
2897 app := &App{
2898 tabs: map[string]*WorkspaceTab{
2899 "stale": {
2900 ID: "stale",
2901 Scope: "project",
2902 WorkspaceRoot: projectRoot,
2903 TopicID: topicID,
2904 TopicTitle: "Missing trash",
2905 SessionPath: missingPath,
2906 Ready: true,
2907 disabledMCP: map[string]ServerView{},
2908 },
2909 "other": {ID: "other", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "other", Ready: true},
2910 },
2911 tabOrder: []string{"stale", "other"},
2912 activeTabID: "stale",
2913 }
2914
2915 if err := app.TrashTopic(topicID); err != nil {
2916 t.Fatalf("TrashTopic should remove stale missing session: %v", err)
2917 }
2918 if got := loadTopicTitle(projectRoot, topicID); got != "" {
2919 t.Fatalf("topic title should be removed, got %q", got)
2920 }
2921 if _, ok := app.tabs["stale"]; ok {
2922 t.Fatalf("stale tab should be removed")
2923 }
2924 if got := app.activeTabID; got != "other" {
2925 t.Fatalf("active tab = %q, want other", got)
2926 }
2927 }
2928
2929 func TestRestoreGlobalTopicSessionReindexesProjectTree(t *testing.T) {
2930 isolateDesktopUserDirs(t)
2931
2932 dir := config.SessionDir()
2933 if err := os.MkdirAll(dir, 0o755); err != nil {
2934 t.Fatalf("mkdir sessions: %v", err)
2935 }
2936 sessionPath := writeLegacySession(t, dir, "restore-global.jsonl", "restore global history", time.Now().Add(-time.Hour))
2937 topicID := legacySessionTopicID(sessionPath)
2938 app := NewApp()
2939
2940 nodes := app.ListProjectTree()
2941 if len(nodes) != 1 || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
2942 t.Fatalf("legacy session should start in Global, got %#v", nodes)
2943 }
2944 if err := app.TrashTopic(topicID); err != nil {
2945 t.Fatalf("trash global topic: %v", err)
2946 }
2947 trashPath := filepath.Join(dir, sessionTrashDir, "restore-global.jsonl", "restore-global.jsonl")
2948 if _, err := os.Stat(trashPath); err != nil {
2949 t.Fatalf("global session should be in trash: %v", err)
2950 }
2951 if got := app.ListProjectTree(); len(got) != 1 || got[0].Kind != "global_folder" || len(got[0].Children) != 0 {
2952 t.Fatalf("trashed global topic should leave empty Global folder, got %#v", got)
2953 }
2954
2955 if err := app.RestoreSession(trashPath); err != nil {
2956 t.Fatalf("restore global session: %v", err)
2957 }
2958 if got := app.ListTrashedSessions(); len(got) != 0 {
2959 t.Fatalf("trash should be empty after restore, got %#v", got)
2960 }
2961 nodes = app.ListProjectTree()
2962 if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
2963 t.Fatalf("restored global session should reappear in Global, got %#v", nodes)
2964 }
2965 }
2966
2967 func TestRestoreProjectTopicSessionReindexesProjectTree(t *testing.T) {
2968 isolateDesktopUserDirs(t)
2969
2970 projectRoot := t.TempDir()
2971 topicID := "topic_restore_project"
2972 if err := addProject(projectRoot, ""); err != nil {
2973 t.Fatalf("add project: %v", err)
2974 }
2975 if err := setTopicTitle(projectRoot, topicID, "Project restore"); err != nil {
2976 t.Fatalf("set topic title: %v", err)
2977 }
2978 dir := config.SessionDir()
2979 if err := os.MkdirAll(dir, 0o755); err != nil {
2980 t.Fatalf("mkdir sessions: %v", err)
2981 }
2982 writeTopicSession(t, dir, "restore-project.jsonl", topicID, "Project restore", projectRoot)
2983 app := NewApp()
2984
2985 if err := app.TrashTopic(topicID); err != nil {
2986 t.Fatalf("trash project topic: %v", err)
2987 }
2988 trashPath := filepath.Join(dir, sessionTrashDir, "restore-project.jsonl", "restore-project.jsonl")
2989 if _, err := os.Stat(trashPath); err != nil {
2990 t.Fatalf("project session should be in trash: %v", err)
2991 }
2992 if got := loadTopicTitle(projectRoot, topicID); got != "" {
2993 t.Fatalf("topic title should be removed while trashed, got %q", got)
2994 }
2995
2996 if err := app.RestoreSession(trashPath); err != nil {
2997 t.Fatalf("restore project session: %v", err)
2998 }
2999 nodes := app.ListProjectTree()
3000 if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
3001 t.Fatalf("restored project session should reappear in project tree, got %#v", nodes)
3002 }
3003 if got := loadTopicTitle(projectRoot, topicID); got != "Project restore" {
3004 t.Fatalf("restored topic title = %q, want Project restore", got)
3005 }
3006 }
3007
3008 func TestOpenProjectTabResolvesProjectSessionFromLegacyDir(t *testing.T) {
3009 isolateDesktopUserDirs(t)
3010
3011 projectRoot := t.TempDir()
3012 topicID := "topic_legacy_project"
3013 topicTitle := "Legacy project topic"
3014 if err := addProject(projectRoot, ""); err != nil {
3015 t.Fatalf("add project: %v", err)
3016 }
3017 if err := setTopicTitle(projectRoot, topicID, topicTitle); err != nil {
3018 t.Fatalf("set topic title: %v", err)
3019 }
3020 dir := config.SessionDir()
3021 if err := os.MkdirAll(dir, 0o755); err != nil {
3022 t.Fatalf("mkdir sessions: %v", err)
3023 }
3024 sessionPath := writeTopicSessionWithPrompt(t, dir, "legacy-project.jsonl", topicID, topicTitle, projectRoot, "legacy project prompt", time.Now())
3025 app := NewApp()
3026
3027 nodes := app.ListProjectTree()
3028 if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
3029 t.Fatalf("legacy project session should appear in project tree, got %#v", nodes)
3030 }
3031 meta, err := app.OpenProjectTab(projectRoot, topicID)
3032 if err != nil {
3033 t.Fatalf("OpenProjectTab: %v", err)
3034 }
3035 tab := waitForTabReady(t, app, meta.ID)
3036 if tab.Ctrl == nil {
3037 t.Fatalf("tab controller was not built")
3038 }
3039 if got := filepath.Clean(tab.Ctrl.SessionPath()); got != filepath.Clean(sessionPath) {
3040 t.Fatalf("opened session path = %q, want %q", got, sessionPath)
3041 }
3042 history := tab.Ctrl.History()
3043 if len(history) != 2 || string(history[0].Role) != "system" || strings.TrimSpace(history[0].Content) == "" ||
3044 string(history[1].Role) != "user" || history[1].Content != "legacy project prompt" {
3045 t.Fatalf("opened history = %+v, want fresh system prompt and legacy project prompt", history)
3046 }
3047 }
3048
3049 func TestRestoreSessionWithoutTopicMetadataFallsBackToGlobal(t *testing.T) {
3050 isolateDesktopUserDirs(t)
3051
3052 dir := config.SessionDir()
3053 if err := os.MkdirAll(dir, 0o755); err != nil {
3054 t.Fatalf("mkdir sessions: %v", err)
3055 }
3056 sessionPath := writeLegacySession(t, dir, "restore-orphan.jsonl", "restore orphan history", time.Now().Add(-time.Hour))
3057 topicID := legacySessionTopicID(sessionPath)
3058 app := NewApp()
3059 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
3060 app.setTestCtrl(ctrl, "")
3061 defer ctrl.Close()
3062 if err := app.DeleteSession(sessionPath); err != nil {
3063 t.Fatalf("delete orphan session: %v", err)
3064 }
3065 trashPath := filepath.Join(dir, sessionTrashDir, "restore-orphan.jsonl", "restore-orphan.jsonl")
3066
3067 if err := app.RestoreSession(trashPath); err != nil {
3068 t.Fatalf("restore orphan session: %v", err)
3069 }
3070 nodes := app.ListProjectTree()
3071 if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
3072 t.Fatalf("restored orphan session should fall back to Global, got %#v", nodes)
3073 }
3074 }
3075
3076 func TestTrashTopicMovesOpenSessionToTrash(t *testing.T) {
3077 isolateDesktopUserDirs(t)
3078
3079 projectRoot := t.TempDir()
3080 topicID := "topic_open_trash"
3081 if err := addProject(projectRoot, ""); err != nil {
3082 t.Fatalf("add project: %v", err)
3083 }
3084 if err := setTopicTitle(projectRoot, topicID, "Open trash"); err != nil {
3085 t.Fatalf("set topic title: %v", err)
3086 }
3087 dir := config.SessionDir()
3088 if err := os.MkdirAll(dir, 0o755); err != nil {
3089 t.Fatalf("mkdir sessions: %v", err)
3090 }
3091 sessionPath := filepath.Join(dir, "open-trash.jsonl")
3092 if err := agent.SaveBranchMeta(sessionPath, agent.BranchMeta{
3093 CreatedAt: time.Now().Add(-time.Minute),
3094 UpdatedAt: time.Now(),
3095 Scope: "project",
3096 WorkspaceRoot: projectRoot,
3097 TopicID: topicID,
3098 TopicTitle: "Open trash",
3099 }); err != nil {
3100 t.Fatalf("save branch meta: %v", err)
3101 }
3102 openTab := &WorkspaceTab{
3103 ID: "tab_open",
3104 Scope: "project",
3105 WorkspaceRoot: projectRoot,
3106 TopicID: topicID,
3107 TopicTitle: "Open trash",
3108 Ctrl: controllerWithContent(t, sessionPath),
3109 Ready: true,
3110 disabledMCP: map[string]ServerView{},
3111 }
3112 otherTab := &WorkspaceTab{
3113 ID: "tab_other",
3114 Scope: "project",
3115 WorkspaceRoot: projectRoot,
3116 TopicID: "topic_keep",
3117 TopicTitle: "Keep",
3118 Ready: true,
3119 disabledMCP: map[string]ServerView{},
3120 }
3121 app := &App{
3122 tabs: map[string]*WorkspaceTab{"tab_open": openTab, "tab_other": otherTab},
3123 tabOrder: []string{"tab_open", "tab_other"},
3124 activeTabID: "tab_open",
3125 }
3126
3127 if err := app.TrashTopic(topicID); err != nil {
3128 t.Fatalf("trash topic: %v", err)
3129 }
3130 if _, ok := app.tabs["tab_open"]; ok {
3131 t.Fatalf("open tab for trashed topic should be removed")
3132 }
3133 if got := app.activeTabID; got != "tab_other" {
3134 t.Fatalf("active tab = %q, want tab_other", got)
3135 }
3136 if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
3137 t.Fatalf("open topic session should be removed from active history, stat err = %v", err)
3138 }
3139 trashPath := filepath.Join(dir, sessionTrashDir, "open-trash.jsonl", "open-trash.jsonl")
3140 if _, err := os.Stat(trashPath); err != nil {
3141 t.Fatalf("open topic session should be moved to trash: %v", err)
3142 }
3143 trashed := app.ListTrashedSessions()
3144 if len(trashed) != 1 || trashed[0].Path != trashPath {
3145 t.Fatalf("trashed sessions = %#v, want %q", trashed, trashPath)
3146 }
3147 preview, err := app.PreviewSession(trashPath)
3148 if err != nil {
3149 t.Fatalf("preview trashed session: %v", err)
3150 }
3151 if !hasHistoryContent(preview, "remember this turn") {
3152 t.Fatalf("preview trashed session = %#v, want remembered turn", preview)
3153 }
3154 if got := loadTopicTitle(projectRoot, topicID); got != "" {
3155 t.Fatalf("topic title should be removed, got %q", got)
3156 }
3157 }
3158
3159 func TestTrashTopicRejectsRunningSessionRuntime(t *testing.T) {
3160 isolateDesktopUserDirs(t)
3161
3162 projectRoot := t.TempDir()
3163 topicID := "topic_running_trash"
3164 if err := addProject(projectRoot, ""); err != nil {
3165 t.Fatalf("add project: %v", err)
3166 }
3167 if err := setTopicTitle(projectRoot, topicID, "Running trash"); err != nil {
3168 t.Fatalf("set topic title: %v", err)
3169 }
3170 dir := config.SessionDir()
3171 if err := os.MkdirAll(dir, 0o755); err != nil {
3172 t.Fatalf("mkdir sessions: %v", err)
3173 }
3174 sessionPath := writeTopicSession(t, dir, "running-trash.jsonl", topicID, "Running trash", projectRoot)
3175 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
3176 ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
3177 defer ctrl.Close()
3178 app := &App{
3179 tabs: map[string]*WorkspaceTab{
3180 "running": {
3181 ID: "running",
3182 Scope: "project",
3183 WorkspaceRoot: projectRoot,
3184 TopicID: topicID,
3185 TopicTitle: "Running trash",
3186 Ctrl: ctrl,
3187 Ready: true,
3188 disabledMCP: map[string]ServerView{},
3189 },
3190 "keep": {
3191 ID: "keep",
3192 Scope: "project",
3193 WorkspaceRoot: projectRoot,
3194 TopicID: "topic_keep",
3195 TopicTitle: "Keep",
3196 Ready: true,
3197 disabledMCP: map[string]ServerView{},
3198 },
3199 },
3200 tabOrder: []string{"running", "keep"},
3201 activeTabID: "running",
3202 }
3203
3204 ctrl.Submit("long turn")
3205 <-runner.started
3206 defer close(runner.release)
3207 if err := app.TrashTopic(topicID); !errors.Is(err, errTopicHasActiveWork) {
3208 t.Fatalf("trash running topic error = %v, want %v", err, errTopicHasActiveWork)
3209 }
3210 if !ctrl.Running() {
3211 t.Fatal("rejected archive should leave the controller running")
3212 }
3213 if _, ok := app.tabs["running"]; !ok {
3214 t.Fatal("rejected archive should keep the running topic tab")
3215 }
3216 if got := app.activeTabID; got != "running" {
3217 t.Fatalf("active tab = %q, want running", got)
3218 }
3219 if _, err := os.Stat(sessionPath); err != nil {
3220 t.Fatalf("rejected archive should preserve the live session: %v", err)
3221 }
3222 trashPath := filepath.Join(dir, sessionTrashDir, "running-trash.jsonl", "running-trash.jsonl")
3223 if _, err := os.Stat(trashPath); !os.IsNotExist(err) {
3224 t.Fatalf("rejected archive created a trash entry, stat err = %v", err)
3225 }
3226 if got := loadTopicTitle(projectRoot, topicID); got != "Running trash" {
3227 t.Fatalf("rejected archive topic title = %q, want Running trash", got)
3228 }
3229 }
3230
3231 func TestTrashTopicRejectsRunningDetachedRuntime(t *testing.T) {
3232 isolateDesktopUserDirs(t)
3233
3234 projectRoot := t.TempDir()
3235 topicID := "topic_detached_running_trash"
3236 if err := addProject(projectRoot, ""); err != nil {
3237 t.Fatalf("add project: %v", err)
3238 }
3239 if err := setTopicTitle(projectRoot, topicID, "Detached running trash"); err != nil {
3240 t.Fatalf("set topic title: %v", err)
3241 }
3242 dir := config.SessionDir()
3243 if err := os.MkdirAll(dir, 0o755); err != nil {
3244 t.Fatalf("mkdir sessions: %v", err)
3245 }
3246 sessionPath := writeTopicSession(t, dir, "detached-running-trash.jsonl", topicID, "Detached running trash", projectRoot)
3247 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
3248 ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
3249 defer ctrl.Close()
3250 defer close(runner.release)
3251 detachedKey := sessionRuntimeKey(sessionPath)
3252 detached := &WorkspaceTab{
3253 ID: detachedRuntimeTabID(detachedKey),
3254 Scope: "project",
3255 WorkspaceRoot: projectRoot,
3256 TopicID: topicID,
3257 TopicTitle: "Detached running trash",
3258 SessionPath: sessionPath,
3259 Ctrl: ctrl,
3260 Ready: true,
3261 disabledMCP: map[string]ServerView{},
3262 }
3263 app := &App{
3264 tabs: map[string]*WorkspaceTab{},
3265 detachedSessions: map[string]*WorkspaceTab{detachedKey: detached},
3266 }
3267
3268 ctrl.Submit("long detached turn")
3269 <-runner.started
3270 if err := app.TrashTopic(topicID); !errors.Is(err, errTopicHasActiveWork) {
3271 t.Fatalf("trash detached running topic error = %v, want %v", err, errTopicHasActiveWork)
3272 }
3273 if !ctrl.Running() {
3274 t.Fatal("rejected archive should leave the detached controller running")
3275 }
3276 if got := app.detachedSessions[detachedKey]; got != detached {
3277 t.Fatalf("rejected archive detached runtime = %p, want %p", got, detached)
3278 }
3279 if _, err := os.Stat(sessionPath); err != nil {
3280 t.Fatalf("rejected archive should preserve the detached session: %v", err)
3281 }
3282 trashPath := filepath.Join(dir, sessionTrashDir, "detached-running-trash.jsonl", "detached-running-trash.jsonl")
3283 if _, err := os.Stat(trashPath); !os.IsNotExist(err) {
3284 t.Fatalf("rejected archive created a trash entry, stat err = %v", err)
3285 }
3286 if got := loadTopicTitle(projectRoot, topicID); got != "Detached running trash" {
3287 t.Fatalf("rejected archive topic title = %q, want Detached running trash", got)
3288 }
3289 }
3290
3291 func TestTrashTopicWaitsForConcurrentTurnAdmission(t *testing.T) {
3292 isolateDesktopUserDirs(t)
3293
3294 topicID := "topic_concurrent_turn_trash"
3295 if err := setTopicTitle("", topicID, "Concurrent turn trash"); err != nil {
3296 t.Fatalf("set topic title: %v", err)
3297 }
3298 dir := config.SessionDir()
3299 if err := os.MkdirAll(dir, 0o755); err != nil {
3300 t.Fatalf("mkdir sessions: %v", err)
3301 }
3302 sessionPath := writeTopicSessionWithPrompt(
3303 t, dir, "concurrent-turn-trash.jsonl", topicID, "Concurrent turn trash", "", "existing turn", time.Now(),
3304 )
3305 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
3306 ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: sessionPath, Label: "test"})
3307 defer ctrl.Close()
3308 defer close(runner.release)
3309 tab := &WorkspaceTab{
3310 ID: "concurrent",
3311 Scope: "global",
3312 WorkspaceRoot: globalTabWorkspaceRoot(),
3313 TopicID: topicID,
3314 TopicTitle: "Concurrent turn trash",
3315 SessionPath: sessionPath,
3316 Ctrl: ctrl,
3317 Ready: true,
3318 disabledMCP: map[string]ServerView{},
3319 }
3320 app := &App{
3321 tabs: map[string]*WorkspaceTab{tab.ID: tab},
3322 tabOrder: []string{tab.ID},
3323 activeTabID: tab.ID,
3324 }
3325
3326 // Hold the per-tab gate so SubmitToTab owns the shared admission lock but
3327 // cannot make the controller observably busy yet.
3328 tab.turnStartMu.Lock()
3329 turnGateHeld := true
3330 defer func() {
3331 if turnGateHeld {
3332 tab.turnStartMu.Unlock()
3333 }
3334 }()
3335 submitDone := make(chan error, 1)
3336 go func() { submitDone <- app.SubmitToTab(tab.ID, "concurrent turn") }()
3337
3338 deadline := time.Now().Add(5 * time.Second)
3339 for app.runtimeAdmissionMu.TryLock() {
3340 app.runtimeAdmissionMu.Unlock()
3341 if time.Now().After(deadline) {
3342 t.Fatal("concurrent turn never acquired the runtime admission read lock")
3343 }
3344 time.Sleep(time.Millisecond)
3345 }
3346
3347 trashEntered := make(chan struct{})
3348 var trashEnteredOnce sync.Once
3349 app.runtimeMutationBeforeLockHook = func(operation string) {
3350 if operation == "trash-topic" {
3351 trashEnteredOnce.Do(func() { close(trashEntered) })
3352 }
3353 }
3354 trashDone := make(chan error, 1)
3355 go func() { trashDone <- app.TrashTopic(topicID) }()
3356 select {
3357 case <-trashEntered:
3358 case <-time.After(5 * time.Second):
3359 t.Fatal("TrashTopic did not reach the runtime mutation barrier")
3360 }
3361
3362 // Wait until TrashTopic owns runtimeRebuildMu and is queued on the admission
3363 // writer. Releasing the turn gate now must let SubmitToTab publish Running
3364 // before TrashTopic can re-check active work.
3365 deadline = time.Now().Add(5 * time.Second)
3366 for app.runtimeRebuildMu.TryLock() {
3367 app.runtimeRebuildMu.Unlock()
3368 if time.Now().After(deadline) {
3369 t.Fatal("TrashTopic never acquired the runtime rebuild lock")
3370 }
3371 time.Sleep(time.Millisecond)
3372 }
3373 tab.turnStartMu.Unlock()
3374 turnGateHeld = false
3375
3376 if err := <-submitDone; err != nil {
3377 t.Fatalf("SubmitToTab: %v", err)
3378 }
3379 <-runner.started
3380 if err := <-trashDone; !errors.Is(err, errTopicHasActiveWork) {
3381 t.Fatalf("concurrent TrashTopic error = %v, want %v", err, errTopicHasActiveWork)
3382 }
3383 if !ctrl.Running() {
3384 t.Fatal("rejected archive should leave the concurrently admitted turn running")
3385 }
3386 if got := app.tabs[tab.ID]; got != tab {
3387 t.Fatalf("rejected archive tab = %p, want %p", got, tab)
3388 }
3389 if _, err := os.Stat(sessionPath); err != nil {
3390 t.Fatalf("rejected archive should preserve the concurrently active session: %v", err)
3391 }
3392 trashPath := filepath.Join(dir, sessionTrashDir, "concurrent-turn-trash.jsonl", "concurrent-turn-trash.jsonl")
3393 if _, err := os.Stat(trashPath); !os.IsNotExist(err) {
3394 t.Fatalf("rejected archive created a trash entry, stat err = %v", err)
3395 }
3396 if got := loadTopicTitle("", topicID); got != "Concurrent turn trash" {
3397 t.Fatalf("rejected archive topic title = %q, want Concurrent turn trash", got)
3398 }
3399 }
3400
3401 func TestTrashTopicRejectsPendingPrompt(t *testing.T) {
3402 isolateDesktopUserDirs(t)
3403
3404 projectRoot := t.TempDir()
3405 topicID := "topic_pending_trash"
3406 if err := addProject(projectRoot, ""); err != nil {
3407 t.Fatalf("add project: %v", err)
3408 }
3409 if err := setTopicTitle(projectRoot, topicID, "Pending trash"); err != nil {
3410 t.Fatalf("set topic title: %v", err)
3411 }
3412 app := &App{
3413 tabs: map[string]*WorkspaceTab{
3414 "pending": {
3415 ID: "pending",
3416 Scope: "project",
3417 WorkspaceRoot: projectRoot,
3418 TopicID: topicID,
3419 TopicTitle: "Pending trash",
3420 Ctrl: &runtimeStatusSessionController{status: control.RuntimeStatus{PendingPrompt: true}},
3421 Ready: true,
3422 disabledMCP: map[string]ServerView{},
3423 },
3424 },
3425 tabOrder: []string{"pending"},
3426 activeTabID: "pending",
3427 }
3428
3429 if err := app.TrashTopic(topicID); !errors.Is(err, errTopicHasActiveWork) {
3430 t.Fatalf("trash pending topic error = %v, want %v", err, errTopicHasActiveWork)
3431 }
3432 if _, ok := app.tabs["pending"]; !ok {
3433 t.Fatal("rejected archive should keep the pending topic tab")
3434 }
3435 if got := loadTopicTitle(projectRoot, topicID); got != "Pending trash" {
3436 t.Fatalf("rejected archive topic title = %q, want Pending trash", got)
3437 }
3438 }
3439
3440 func TestTrashTopicFallbackCreatesUnindexedBlank(t *testing.T) {
3441 isolateDesktopUserDirs(t)
3442
3443 projectRoot := t.TempDir()
3444 topicID := "topic_only"
3445 if err := addProject(projectRoot, ""); err != nil {
3446 t.Fatalf("add project: %v", err)
3447 }
3448 if err := setTopicTitle(projectRoot, topicID, "Only topic"); err != nil {
3449 t.Fatalf("set topic title: %v", err)
3450 }
3451 dir := config.SessionDir()
3452 if err := os.MkdirAll(dir, 0o755); err != nil {
3453 t.Fatalf("mkdir sessions: %v", err)
3454 }
3455 sessionPath := writeTopicSession(t, dir, "only-topic.jsonl", topicID, "Only topic", projectRoot)
3456 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
3457 defer ctrl.Close()
3458 app := &App{
3459 tabs: map[string]*WorkspaceTab{
3460 "only": {
3461 ID: "only",
3462 Scope: "project",
3463 WorkspaceRoot: projectRoot,
3464 TopicID: topicID,
3465 TopicTitle: "Only topic",
3466 Ctrl: ctrl,
3467 Ready: true,
3468 disabledMCP: map[string]ServerView{},
3469 },
3470 },
3471 tabOrder: []string{"only"},
3472 activeTabID: "only",
3473 }
3474
3475 if err := app.TrashTopic(topicID); err != nil {
3476 t.Fatalf("TrashTopic: %v", err)
3477 }
3478 if len(app.tabs) != 1 {
3479 t.Fatalf("fallback should create exactly one visible tab, got %d", len(app.tabs))
3480 }
3481 for id, tab := range app.tabs {
3482 if strings.TrimSpace(tab.TopicID) != "" {
3483 t.Fatalf("fallback tab %q topic ID = %q, want transient unindexed blank", id, tab.TopicID)
3484 }
3485 if strings.TrimSpace(tab.SessionPath) == "" {
3486 t.Fatalf("fallback tab %q has no precreated session path", id)
3487 }
3488 f := loadProjectsFile()
3489 if len(f.Projects) != 1 || containsDesktopString(f.Projects[0].Topics, topicID) {
3490 t.Fatalf("deleted topic %q should be removed without indexing a replacement: %#v", topicID, f.Projects)
3491 }
3492 }
3493 nodes := app.ListProjectTree()
3494 if len(nodes) != 1 || len(nodes[0].Children) != 0 {
3495 t.Fatalf("transient fallback blank should stay out of project tree: %+v", nodes)
3496 }
3497 }
3498
3499 func TestTransientFallbackIndexesOnFirstUserTurn(t *testing.T) {
3500 isolateDesktopUserDirs(t)
3501
3502 projectRoot := t.TempDir()
3503 topicID := "topic_only"
3504 if err := addProject(projectRoot, ""); err != nil {
3505 t.Fatalf("add project: %v", err)
3506 }
3507 if err := setTopicTitle(projectRoot, topicID, "Only topic"); err != nil {
3508 t.Fatalf("set topic title: %v", err)
3509 }
3510 dir := config.SessionDir()
3511 if err := os.MkdirAll(dir, 0o755); err != nil {
3512 t.Fatalf("mkdir sessions: %v", err)
3513 }
3514 sessionPath := writeTopicSession(t, dir, "only-topic.jsonl", topicID, "Only topic", projectRoot)
3515 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
3516 defer ctrl.Close()
3517 app := &App{
3518 tabs: map[string]*WorkspaceTab{
3519 "only": {
3520 ID: "only",
3521 Scope: "project",
3522 WorkspaceRoot: projectRoot,
3523 TopicID: topicID,
3524 TopicTitle: "Only topic",
3525 Ctrl: ctrl,
3526 Ready: true,
3527 disabledMCP: map[string]ServerView{},
3528 },
3529 },
3530 tabOrder: []string{"only"},
3531 activeTabID: "only",
3532 }
3533
3534 if err := app.TrashTopic(topicID); err != nil {
3535 t.Fatalf("TrashTopic: %v", err)
3536 }
3537 var fallback *WorkspaceTab
3538 for _, tab := range app.tabs {
3539 fallback = tab
3540 }
3541 if fallback == nil {
3542 t.Fatal("fallback tab missing")
3543 }
3544 if fallback.TopicID != "" {
3545 t.Fatalf("fallback topic before first turn = %q, want empty", fallback.TopicID)
3546 }
3547
3548 app.ensureTabTopicIndexedForUserTurn(fallback)
3549 if strings.TrimSpace(fallback.TopicID) == "" {
3550 t.Fatal("first user turn should assign a topic ID")
3551 }
3552 f := loadProjectsFile()
3553 if len(f.Projects) != 1 || !containsDesktopString(f.Projects[0].Topics, fallback.TopicID) {
3554 t.Fatalf("first user turn should index fallback topic %q: %#v", fallback.TopicID, f.Projects)
3555 }
3556 meta, ok, err := agent.LoadBranchMeta(fallback.SessionPath)
3557 if err != nil || !ok {
3558 t.Fatalf("LoadBranchMeta(%q): ok=%v err=%v", fallback.SessionPath, ok, err)
3559 }
3560 if meta.TopicID != fallback.TopicID || meta.TopicTitle != defaultTopicTitle {
3561 t.Fatalf("fallback session meta = %+v, want topic %q title %q", meta, fallback.TopicID, defaultTopicTitle)
3562 }
3563 }
3564
3565 func TestTransientFallbackDiscardedWhenSingleSurfaceNavigatesAway(t *testing.T) {
3566 isolateDesktopUserDirs(t)
3567
3568 projectRoot := t.TempDir()
3569 if err := addProject(projectRoot, ""); err != nil {
3570 t.Fatalf("add project: %v", err)
3571 }
3572 dir := desktopSessionDir(projectRoot)
3573 transientPath, err := createEmptySessionFile(dir, "test")
3574 if err != nil {
3575 t.Fatalf("create transient session: %v", err)
3576 }
3577 if err := agent.SaveBranchMetaPreserveUpdated(transientPath, agent.BranchMeta{
3578 Scope: "project",
3579 WorkspaceRoot: projectRoot,
3580 }); err != nil {
3581 t.Fatalf("save transient meta: %v", err)
3582 }
3583
3584 targetTopicID := "topic_target"
3585 if err := setTopicTitle(projectRoot, targetTopicID, "Target"); err != nil {
3586 t.Fatalf("set target topic title: %v", err)
3587 }
3588 targetPath := writeTopicSession(t, dir, "target.jsonl", targetTopicID, "Target", projectRoot)
3589 app := &App{
3590 tabs: map[string]*WorkspaceTab{
3591 "transient": {
3592 ID: "transient",
3593 Scope: "project",
3594 WorkspaceRoot: projectRoot,
3595 TopicTitle: defaultTopicTitle,
3596 SessionPath: transientPath,
3597 Ready: true,
3598 disabledMCP: map[string]ServerView{},
3599 },
3600 "target": {
3601 ID: "target",
3602 Scope: "project",
3603 WorkspaceRoot: projectRoot,
3604 TopicID: targetTopicID,
3605 TopicTitle: "Target",
3606 SessionPath: targetPath,
3607 Ready: true,
3608 disabledMCP: map[string]ServerView{},
3609 },
3610 },
3611 tabOrder: []string{"transient", "target"},
3612 activeTabID: "transient",
3613 }
3614
3615 if _, err := app.keepOnlyVisibleTab("target"); err != nil {
3616 t.Fatalf("keepOnlyVisibleTab: %v", err)
3617 }
3618 if _, ok := app.tabs["transient"]; ok {
3619 t.Fatal("transient tab should be removed after single-surface navigation")
3620 }
3621 if _, err := os.Stat(transientPath); !os.IsNotExist(err) {
3622 t.Fatalf("transient session artifact should be removed, stat err = %v", err)
3623 }
3624 if _, err := os.Stat(agent.BranchMetaPath(transientPath)); !os.IsNotExist(err) {
3625 t.Fatalf("transient session meta should be removed, stat err = %v", err)
3626 }
3627 f := loadProjectsFile()
3628 if len(f.Projects) != 1 || containsDesktopString(f.Projects[0].Topics, "") {
3629 t.Fatalf("transient blank should not be indexed in project topics: %#v", f.Projects)
3630 }
3631 }
3632
3633 func TestCloseTabDiscardsUnusedTransientBlankSession(t *testing.T) {
3634 isolateDesktopUserDirs(t)
3635
3636 projectRoot := t.TempDir()
3637 dir := desktopSessionDir(projectRoot)
3638 transientPath, err := createEmptySessionFile(dir, "test")
3639 if err != nil {
3640 t.Fatalf("create transient session: %v", err)
3641 }
3642 if err := agent.SaveBranchMetaPreserveUpdated(transientPath, agent.BranchMeta{
3643 Scope: "project",
3644 WorkspaceRoot: projectRoot,
3645 }); err != nil {
3646 t.Fatalf("save transient meta: %v", err)
3647 }
3648 app := &App{
3649 tabs: map[string]*WorkspaceTab{
3650 "transient": {
3651 ID: "transient",
3652 Scope: "project",
3653 WorkspaceRoot: projectRoot,
3654 TopicTitle: defaultTopicTitle,
3655 SessionPath: transientPath,
3656 Ready: true,
3657 disabledMCP: map[string]ServerView{},
3658 },
3659 "other": {
3660 ID: "other",
3661 Scope: "global",
3662 TopicID: "topic_other",
3663 TopicTitle: "Other",
3664 Ready: true,
3665 disabledMCP: map[string]ServerView{},
3666 },
3667 },
3668 tabOrder: []string{"transient", "other"},
3669 activeTabID: "transient",
3670 }
3671
3672 if err := app.CloseTab("transient"); err != nil {
3673 t.Fatalf("CloseTab: %v", err)
3674 }
3675 if _, ok := app.tabs["transient"]; ok {
3676 t.Fatal("transient tab should be closed")
3677 }
3678 if _, err := os.Stat(transientPath); !os.IsNotExist(err) {
3679 t.Fatalf("transient session artifact should be removed, stat err = %v", err)
3680 }
3681 if _, err := os.Stat(agent.BranchMetaPath(transientPath)); !os.IsNotExist(err) {
3682 t.Fatalf("transient session meta should be removed, stat err = %v", err)
3683 }
3684 }
3685
3686 func TestCloseTabKeepsIndexedBlankSession(t *testing.T) {
3687 isolateDesktopUserDirs(t)
3688
3689 projectRoot := t.TempDir()
3690 topicID := "topic_indexed_blank"
3691 if err := addProject(projectRoot, ""); err != nil {
3692 t.Fatalf("add project: %v", err)
3693 }
3694 if err := setTopicTitle(projectRoot, topicID, defaultTopicTitle); err != nil {
3695 t.Fatalf("set topic title: %v", err)
3696 }
3697 dir := desktopSessionDir(projectRoot)
3698 indexedPath, err := createEmptySessionFile(dir, "test")
3699 if err != nil {
3700 t.Fatalf("create indexed blank session: %v", err)
3701 }
3702 app := &App{
3703 tabs: map[string]*WorkspaceTab{
3704 "indexed": {
3705 ID: "indexed",
3706 Scope: "project",
3707 WorkspaceRoot: projectRoot,
3708 TopicID: topicID,
3709 TopicTitle: defaultTopicTitle,
3710 SessionPath: indexedPath,
3711 Ready: true,
3712 disabledMCP: map[string]ServerView{},
3713 },
3714 "other": {
3715 ID: "other",
3716 Scope: "global",
3717 TopicID: "topic_other",
3718 TopicTitle: "Other",
3719 Ready: true,
3720 disabledMCP: map[string]ServerView{},
3721 },
3722 },
3723 tabOrder: []string{"indexed", "other"},
3724 activeTabID: "indexed",
3725 }
3726
3727 if err := app.CloseTab("indexed"); err != nil {
3728 t.Fatalf("CloseTab: %v", err)
3729 }
3730 if _, err := os.Stat(indexedPath); err != nil {
3731 t.Fatalf("indexed blank session should be preserved, stat err = %v", err)
3732 }
3733 }
3734
3735 func TestTrashTopicTrashConflictAllowsIdleRuntime(t *testing.T) {
3736 isolateDesktopUserDirs(t)
3737
3738 projectRoot := t.TempDir()
3739 topicID := "topic_trash_conflict"
3740 if err := addProject(projectRoot, ""); err != nil {
3741 t.Fatalf("add project: %v", err)
3742 }
3743 if err := setTopicTitle(projectRoot, topicID, "Trash conflict"); err != nil {
3744 t.Fatalf("set topic title: %v", err)
3745 }
3746 dir := config.SessionDir()
3747 if err := os.MkdirAll(dir, 0o755); err != nil {
3748 t.Fatalf("mkdir sessions: %v", err)
3749 }
3750 sessionPath := writeTopicSession(t, dir, "trash-conflict.jsonl", topicID, "Trash conflict", projectRoot)
3751 if err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath)), 0o755); err != nil {
3752 t.Fatalf("create trash conflict: %v", err)
3753 }
3754 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
3755 defer ctrl.Close()
3756 app := &App{
3757 tabs: map[string]*WorkspaceTab{
3758 "idle": {
3759 ID: "idle",
3760 Scope: "project",
3761 WorkspaceRoot: projectRoot,
3762 TopicID: topicID,
3763 TopicTitle: "Trash conflict",
3764 Ctrl: ctrl,
3765 Ready: true,
3766 disabledMCP: map[string]ServerView{},
3767 },
3768 },
3769 tabOrder: []string{"idle"},
3770 activeTabID: "idle",
3771 }
3772
3773 err := app.TrashTopic(topicID)
3774 if err != nil {
3775 t.Fatalf("TrashTopic should succeed after cleaning empty trash dir: %v", err)
3776 }
3777 if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
3778 t.Fatalf("session file should be moved to trash, stat err = %v", err)
3779 }
3780 }
3781
3782 func TestTrashTopicValidTrashRemovesEmptyLiveStub(t *testing.T) {
3783 isolateDesktopUserDirs(t)
3784
3785 projectRoot := t.TempDir()
3786 topicID := "topic_valid_trash"
3787 if err := addProject(projectRoot, ""); err != nil {
3788 t.Fatalf("add project: %v", err)
3789 }
3790 if err := setTopicTitle(projectRoot, topicID, "Valid trash"); err != nil {
3791 t.Fatalf("set topic title: %v", err)
3792 }
3793 dir := config.SessionDir()
3794 if err := os.MkdirAll(dir, 0o755); err != nil {
3795 t.Fatalf("mkdir sessions: %v", err)
3796 }
3797 sessionPath := filepath.Join(dir, "valid-trash.jsonl")
3798 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
3799 t.Fatalf("write live stub: %v", err)
3800 }
3801 if err := agent.SaveBranchMeta(sessionPath, agent.BranchMeta{
3802 CreatedAt: time.Now().Add(-time.Minute),
3803 UpdatedAt: time.Now(),
3804 Scope: "project",
3805 WorkspaceRoot: projectRoot,
3806 TopicID: topicID,
3807 TopicTitle: "Valid trash",
3808 }); err != nil {
3809 t.Fatalf("save branch meta: %v", err)
3810 }
3811 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
3812 if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
3813 t.Fatalf("create trash dir: %v", err)
3814 }
3815 if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"already trashed"}`+"\n"), 0o644); err != nil {
3816 t.Fatalf("write trash session: %v", err)
3817 }
3818
3819 app := &App{
3820 tabs: map[string]*WorkspaceTab{
3821 "stale": {
3822 ID: "stale",
3823 Scope: "project",
3824 WorkspaceRoot: projectRoot,
3825 TopicID: topicID,
3826 TopicTitle: "Valid trash",
3827 SessionPath: sessionPath,
3828 Ready: true,
3829 disabledMCP: map[string]ServerView{},
3830 },
3831 "other": {ID: "other", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "other", Ready: true},
3832 },
3833 tabOrder: []string{"stale", "other"},
3834 activeTabID: "other",
3835 }
3836
3837 if err := app.TrashTopic(topicID); err != nil {
3838 t.Fatalf("TrashTopic should remove stale live stub: %v", err)
3839 }
3840 if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
3841 t.Fatalf("live stub should be removed, stat err = %v", err)
3842 }
3843 if _, err := os.Stat(trashPath); err != nil {
3844 t.Fatalf("existing trash should remain authoritative: %v", err)
3845 }
3846 }
3847
3848 func hasHistoryContent(messages []HistoryMessage, content string) bool {
3849 for _, m := range messages {
3850 if m.Content == content {
3851 return true
3852 }
3853 }
3854 return false
3855 }
3856
3857 func TestLegacyMigrationSkipsProjectScopedSessions(t *testing.T) {
3858 isolateDesktopUserDirs(t)
3859 dir := config.SessionDir()
3860 if err := os.MkdirAll(dir, 0o755); err != nil {
3861 t.Fatal(err)
3862 }
3863 path := writeLegacySession(t, dir, "scoped.jsonl", "hello", time.Now())
3864 meta, err := agent.EnsureBranchMeta(path)
3865 if err != nil {
3866 t.Fatal(err)
3867 }
3868 meta.Scope = "project"
3869 meta.WorkspaceRoot = filepath.Join(t.TempDir(), "proj")
3870 meta.TopicID = ""
3871 if err := agent.SaveBranchMeta(path, meta); err != nil {
3872 t.Fatal(err)
3873 }
3874
3875 migrateLegacySessionsIntoGlobalTopics(dir)
3876
3877 got, err := agent.EnsureBranchMeta(path)
3878 if err != nil {
3879 t.Fatal(err)
3880 }
3881 if got.Scope != "project" || got.WorkspaceRoot != meta.WorkspaceRoot {
3882 t.Fatalf("project-scoped legacy session must not be forced into Global: %+v", got)
3883 }
3884 }
3885
3886 func TestProjectTreeMigratesCLISessionFromProjectDir(t *testing.T) {
3887 isolateDesktopUserDirs(t)
3888
3889 projectRoot := t.TempDir()
3890 if err := addProject(projectRoot, ""); err != nil {
3891 t.Fatalf("add project: %v", err)
3892 }
3893 dir := config.ProjectSessionDir(projectRoot)
3894 if err := os.MkdirAll(dir, 0o755); err != nil {
3895 t.Fatal(err)
3896 }
3897 sessionPath := writeLegacySession(t, dir, "cli-project.jsonl", "cli project prompt", time.Now())
3898 wantTopicID := legacySessionTopicID(sessionPath)
3899
3900 nodes := NewApp().ListProjectTree()
3901 if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID {
3902 t.Fatalf("project CLI session should appear in project tree, got %#v; want topic %q", nodes, wantTopicID)
3903 }
3904 }
3905
3906 func TestProjectTreeMigratesNewCLISessionAfterProjectDirMarker(t *testing.T) {
3907 isolateDesktopUserDirs(t)
3908
3909 projectRoot := t.TempDir()
3910 if err := addProject(projectRoot, ""); err != nil {
3911 t.Fatalf("add project: %v", err)
3912 }
3913 dir := config.ProjectSessionDir(projectRoot)
3914 if err := os.MkdirAll(dir, 0o755); err != nil {
3915 t.Fatal(err)
3916 }
3917 first := writeLegacySession(t, dir, "first-cli-project.jsonl", "first cli project prompt", time.Now().Add(-time.Hour))
3918 firstTopicID := legacySessionTopicID(first)
3919
3920 nodes := NewApp().ListProjectTree()
3921 if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != firstTopicID {
3922 t.Fatalf("first project CLI session should appear in project tree, got %#v; want topic %q", nodes, firstTopicID)
3923 }
3924 if _, err := os.Stat(filepath.Join(dir, topicMigrationMarker)); err != nil {
3925 t.Fatalf("expected migration marker after first project pass: %v", err)
3926 }
3927
3928 time.Sleep(10 * time.Millisecond)
3929 second := writeLegacySession(t, dir, "second-cli-project.jsonl", "second cli project prompt", time.Now())
3930 secondTopicID := legacySessionTopicID(second)
3931
3932 nodes = NewApp().ListProjectTree()
3933 if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 2 {
3934 t.Fatalf("second project CLI session should trigger re-scan, got %#v", nodes)
3935 }
3936 if nodes[0].Children[0].TopicID != secondTopicID || nodes[0].Children[1].TopicID != firstTopicID {
3937 t.Fatalf("project CLI topics = %#v, want newest %q then %q", nodes[0].Children, secondTopicID, firstTopicID)
3938 }
3939 }
3940
3941 func TestProjectTreeMigratesCLISessionFromGlobalWorkspaceDir(t *testing.T) {
3942 isolateDesktopUserDirs(t)
3943
3944 globalRoot := globalWorkspaceRoot()
3945 dir := desktopSessionDir(globalRoot)
3946 if err := os.MkdirAll(dir, 0o755); err != nil {
3947 t.Fatal(err)
3948 }
3949 sessionPath := writeLegacySession(t, dir, "cli-global.jsonl", "cli global prompt", time.Now())
3950 if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
3951 CreatedAt: time.Now().Add(-time.Minute),
3952 UpdatedAt: time.Now(),
3953 Scope: "global",
3954 WorkspaceRoot: globalRoot,
3955 }); err != nil {
3956 t.Fatal(err)
3957 }
3958 wantTopicID := legacySessionTopicID(sessionPath)
3959
3960 nodes := NewApp().ListProjectTree()
3961 if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID {
3962 t.Fatalf("global workspace CLI session should appear in Global, got %#v; want topic %q", nodes, wantTopicID)
3963 }
3964 }
3965
3966 func TestLegacyMigrationConcurrentRunsHaveNoLostUpdates(t *testing.T) {
3967 isolateDesktopUserDirs(t)
3968 dir := config.SessionDir()
3969 if err := os.MkdirAll(dir, 0o755); err != nil {
3970 t.Fatal(err)
3971 }
3972 const n = 8
3973 want := make(map[string]bool, n)
3974 for i := 0; i < n; i++ {
3975 p := writeLegacySession(t, dir, fmt.Sprintf("legacy-%d.jsonl", i), "hi", time.Now())
3976 want[legacySessionTopicID(p)] = true
3977 }
3978
3979 var wg sync.WaitGroup
3980 for i := 0; i < n; i++ {
3981 wg.Add(1)
3982 go func() {
3983 defer wg.Done()
3984 migrateLegacySessionsIntoGlobalTopics(dir)
3985 }()
3986 }
3987 wg.Wait()
3988
3989 gotSet := map[string]bool{}
3990 for _, id := range loadProjectsFile().GlobalTopics {
3991 gotSet[id] = true
3992 }
3993 for id := range want {
3994 if !gotSet[id] {
3995 t.Fatalf("concurrent migration lost topic %q; GlobalTopics=%v", id, loadProjectsFile().GlobalTopics)
3996 }
3997 }
3998 }
3999
4000 func TestFindTopicSessionIndexRefreshesWhenMetaChanges(t *testing.T) {
4001 isolateDesktopUserDirs(t)
4002 dir := config.SessionDir()
4003 if err := os.MkdirAll(dir, 0o755); err != nil {
4004 t.Fatal(err)
4005 }
4006 topicID := "topic_cache_refresh"
4007 now := time.Now().UTC()
4008 first := writeTopicSessionWithPrompt(t, dir, "first.jsonl", topicID, "First", "", "first prompt", now.Add(-time.Hour))
4009
4010 if got := findTopicSession(dir, topicID); got != first {
4011 t.Fatalf("first lookup = %q, want %q", got, first)
4012 }
4013
4014 second := writeTopicSessionWithPrompt(t, dir, "second.jsonl", topicID, "Second", "", "second prompt", now)
4015 if got := findTopicSession(dir, topicID); got != second {
4016 t.Fatalf("lookup after new session = %q, want newer %q", got, second)
4017 }
4018
4019 meta, ok, err := agent.LoadBranchMeta(second)
4020 if err != nil || !ok {
4021 t.Fatalf("load second meta: ok=%v err=%v", ok, err)
4022 }
4023 meta.TopicID = "topic_cache_other"
4024 meta.UpdatedAt = now.Add(time.Hour)
4025 if err := agent.SaveBranchMetaPreserveUpdated(second, meta); err != nil {
4026 t.Fatal(err)
4027 }
4028 future := time.Now().Add(2 * time.Second)
4029 if err := os.Chtimes(agent.BranchMetaPath(second), future, future); err != nil {
4030 t.Fatal(err)
4031 }
4032
4033 if got := findTopicSession(dir, topicID); got != first {
4034 t.Fatalf("lookup after retopic = %q, want remaining %q", got, first)
4035 }
4036 if got := findTopicSession(dir, "topic_cache_other"); got != second {
4037 t.Fatalf("lookup for retopic session = %q, want %q", got, second)
4038 }
4039 }
4040
4041 func TestFindTopicSessionSkipsCleanupPending(t *testing.T) {
4042 isolateDesktopUserDirs(t)
4043 dir := config.SessionDir()
4044 if err := os.MkdirAll(dir, 0o755); err != nil {
4045 t.Fatal(err)
4046 }
4047 topicID := "topic_skip_pending"
4048 now := time.Now().UTC()
4049 normal := writeTopicSessionWithPrompt(t, dir, "normal.jsonl", topicID, "Normal", "", "normal prompt", now)
4050 pending := writeTopicSessionWithPrompt(t, dir, "pending.jsonl", topicID, "Pending", "", "pending prompt", now.Add(time.Hour))
4051
4052 if got := findTopicSession(dir, topicID); got != pending {
4053 t.Fatalf("pre-marker lookup = %q, want newest pending %q", got, pending)
4054 }
4055 if err := agent.MarkCleanupPending(pending, "delete"); err != nil {
4056 t.Fatal(err)
4057 }
4058 if got := findTopicSession(dir, topicID); got != normal {
4059 t.Fatalf("lookup with cleanup-pending newest = %q, want normal %q", got, normal)
4060 }
4061 if err := agent.MarkCleanupPending(normal, "delete"); err != nil {
4062 t.Fatal(err)
4063 }
4064 if got := findTopicSession(dir, topicID); got != "" {
4065 t.Fatalf("lookup with only cleanup-pending sessions = %q, want empty", got)
4066 }
4067 }
4068
4069 func TestOpenProjectTabSkipsCleanupPendingTopicSession(t *testing.T) {
4070 isolateDesktopUserDirs(t)
4071
4072 projectRoot := t.TempDir()
4073 app := NewApp()
4074 topic, err := app.CreateTopic("project", projectRoot, "Pending topic")
4075 if err != nil {
4076 t.Fatalf("create topic: %v", err)
4077 }
4078 dir := desktopSessionDir(projectRoot)
4079 if err := os.MkdirAll(dir, 0o755); err != nil {
4080 t.Fatal(err)
4081 }
4082 pending := writeTopicSessionWithPrompt(t, dir, "pending-topic.jsonl", topic.ID, "Pending topic", projectRoot, "pending topic prompt", time.Now())
4083 if err := agent.MarkCleanupPending(pending, "delete"); err != nil {
4084 t.Fatal(err)
4085 }
4086 if got := findTopicSession(dir, topic.ID); got != "" {
4087 t.Fatalf("topic lookup with only cleanup-pending session = %q, want empty", got)
4088 }
4089 if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != "" {
4090 t.Fatalf("target topic lookup with only cleanup-pending session = %q, want empty", got)
4091 }
4092
4093 meta, err := app.OpenProjectTab(projectRoot, topic.ID)
4094 if err != nil {
4095 t.Fatalf("open project tab: %v", err)
4096 }
4097 tab := waitForTabReady(t, app, meta.ID)
4098 if got := filepath.Clean(tab.Ctrl.SessionPath()); got == filepath.Clean(pending) {
4099 t.Fatalf("opened cleanup-pending topic session path %q", got)
4100 }
4101 for _, msg := range tab.Ctrl.History() {
4102 if msg.Content == "pending topic prompt" {
4103 t.Fatalf("opened cleanup-pending topic history at path %q: %+v", tab.Ctrl.SessionPath(), tab.Ctrl.History())
4104 }
4105 }
4106 }
4107
4108 func TestUpdateTopicSessionTitlesUsesTopicIndex(t *testing.T) {
4109 isolateDesktopUserDirs(t)
4110 dir := config.SessionDir()
4111 if err := os.MkdirAll(dir, 0o755); err != nil {
4112 t.Fatal(err)
4113 }
4114 topicID := "topic_title_index"
4115 now := time.Now().UTC()
4116 valid := writeTopicSessionWithPrompt(t, dir, "valid.jsonl", topicID, "Old", "", "hello", now)
4117 unpreviewable := filepath.Join(dir, "unpreviewable.jsonl")
4118 if err := os.WriteFile(unpreviewable, []byte("not-json\n"), 0o644); err != nil {
4119 t.Fatal(err)
4120 }
4121 if err := agent.SaveBranchMetaPreserveUpdated(unpreviewable, agent.BranchMeta{
4122 CreatedAt: now.Add(-time.Minute),
4123 UpdatedAt: now,
4124 Scope: "global",
4125 TopicID: topicID,
4126 TopicTitle: "Old",
4127 }); err != nil {
4128 t.Fatal(err)
4129 }
4130
4131 NewApp().updateTopicSessionTitles(topicID, "Renamed")
4132
4133 for _, path := range []string{valid, unpreviewable} {
4134 meta, ok, err := agent.LoadBranchMeta(path)
4135 if err != nil || !ok {
4136 t.Fatalf("load meta for %s: ok=%v err=%v", path, ok, err)
4137 }
4138 if meta.TopicTitle != "Renamed" {
4139 t.Fatalf("topic title for %s = %q, want Renamed", path, meta.TopicTitle)
4140 }
4141 }
4142 }
4143
4144 func TestEnsureTopicIndexedConcurrentRunsHaveNoLostProjectUpdates(t *testing.T) {
4145 isolateDesktopUserDirs(t)
4146
4147 projectRoot := t.TempDir()
4148 const n = 12
4149 start := make(chan struct{})
4150 var wg sync.WaitGroup
4151 for i := 0; i < n; i++ {
4152 i := i
4153 wg.Add(1)
4154 go func() {
4155 defer wg.Done()
4156 <-start
4157 topicID := fmt.Sprintf("topic_recovered_%02d", i)
4158 if err := ensureTopicIndexed("project", projectRoot, topicID, fmt.Sprintf("Recovered %02d", i), topicTitleSourceManual); err != nil {
4159 t.Errorf("ensure topic indexed: %v", err)
4160 }
4161 }()
4162 }
4163 close(start)
4164 wg.Wait()
4165
4166 nodes := NewApp().ListProjectTree()
4167 if len(nodes) != 1 {
4168 t.Fatalf("project tree len = %d, want 1: %#v", len(nodes), nodes)
4169 }
4170 got := map[string]bool{}
4171 for _, child := range nodes[0].Children {
4172 got[child.TopicID] = true
4173 }
4174 for i := 0; i < n; i++ {
4175 topicID := fmt.Sprintf("topic_recovered_%02d", i)
4176 if !got[topicID] {
4177 t.Fatalf("concurrent topic index recovery lost %q; children=%#v", topicID, nodes[0].Children)
4178 }
4179 if title := loadTopicTitle(projectRoot, topicID); title == "" {
4180 t.Fatalf("title index missing %q", topicID)
4181 }
4182 }
4183 }
4184
4185 // A freshly created empty session must not hijack the topic from the
4186 // conversation the user actually had: content-bearing sessions outrank
4187 // content-free ones regardless of updatedAt (#7305).
4188 func TestFindTopicSessionPrefersContentOverNewerEmpty(t *testing.T) {
4189 isolateDesktopUserDirs(t)
4190
4191 projectRoot := robustTempDir(t)
4192 app := NewApp()
4193 topic, err := app.CreateTopic("project", projectRoot, "")
4194 if err != nil {
4195 t.Fatalf("CreateTopic: %v", err)
4196 }
4197 dir := desktopSessionDir(projectRoot)
4198 if err := os.MkdirAll(dir, 0o755); err != nil {
4199 t.Fatalf("mkdir sessions: %v", err)
4200 }
4201
4202 contentPath := writeTopicSessionWithPrompt(t, dir, "content.jsonl", topic.ID, defaultTopicTitle, projectRoot, "real conversation", time.Now().Add(-time.Hour))
4203
4204 emptyPath := filepath.Join(dir, "empty.jsonl")
4205 if err := os.WriteFile(emptyPath, nil, 0o644); err != nil {
4206 t.Fatalf("write empty session: %v", err)
4207 }
4208 if err := agent.SaveBranchMetaPreserveUpdated(emptyPath, agent.BranchMeta{
4209 CreatedAt: time.Now(),
4210 UpdatedAt: time.Now(),
4211 Scope: "project",
4212 WorkspaceRoot: projectRoot,
4213 TopicID: topic.ID,
4214 TopicTitle: defaultTopicTitle,
4215 }); err != nil {
4216 t.Fatalf("save empty branch meta: %v", err)
4217 }
4218
4219 if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != contentPath {
4220 t.Fatalf("topic session = %q, want content-bearing %q to outrank newer empty %q", got, contentPath, emptyPath)
4221 }
4222 if got, _ := app.findTopicContentSessionForTarget("project", projectRoot, topic.ID); got != contentPath {
4223 t.Fatalf("content topic session = %q, want %q", got, contentPath)
4224 }
4225 }
4226
4226 lines GO