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