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