返回 DeepSeek-Reasonix
app_session_dedup_test.go
根目录 / desktop / app_session_dedup_test.go
1 package main
2
3 import (
4 "os"
5 "path/filepath"
6 "strconv"
7 "strings"
8 "testing"
9 "time"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/config"
13 "reasonix/internal/control"
14 "reasonix/internal/event"
15 "reasonix/internal/provider"
16 "reasonix/internal/tool"
17 )
18
19 func carryingController(carried []provider.Message, path string) *control.Controller {
20 sess := &agent.Session{}
21 sess.Replace(carried)
22 ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
23 return control.New(control.Options{Executor: ag, SessionPath: path, Sink: event.Discard})
24 }
25
26 // TestCarriedRebuildsKeepOneSession reproduces issue #2807: a model switch or any
27 // config change rebuilds the controller and carries the conversation forward. Each
28 // rebuild must keep writing to the same file, so a run of them leaves exactly one
29 // history entry — not a new identical duplicate per rebuild.
30 func TestCarriedRebuildsKeepOneSession(t *testing.T) {
31 dir := t.TempDir()
32 path := agent.NewSessionPath(dir, "model-a")
33 ctrl := controllerWithContent(t, path)
34 if err := ctrl.Snapshot(); err != nil {
35 t.Fatal(err)
36 }
37
38 for i := 0; i < 5; i++ {
39 prevPath := ctrl.SessionPath()
40 carried := ctrl.History()
41 ctrl.Close()
42
43 newPath := agent.ContinueSessionPath(prevPath, dir, "model-b")
44 ctrl = carryingController(carried, newPath)
45 if err := ctrl.Snapshot(); err != nil {
46 t.Fatal(err)
47 }
48 }
49 ctrl.Close()
50
51 infos, err := agent.ListSessions(dir)
52 if err != nil {
53 t.Fatal(err)
54 }
55 if len(infos) != 1 {
56 paths := make([]string, len(infos))
57 for i, s := range infos {
58 paths[i] = filepath.Base(s.Path)
59 }
60 t.Fatalf("after 5 carried rebuilds the history shows %d sessions, want 1: %v", len(infos), paths)
61 }
62 }
63
64 // EnsureBlankTab reuses an already-open blank tab rather than creating a second one.
65
66 func TestEnsureBlankTabReusesExistingBlankTab(t *testing.T) {
67 isolateDesktopUserDirs(t)
68
69 app := NewApp()
70 first, err := app.EnsureBlankTab("global", "")
71 if err != nil {
72 t.Fatal(err)
73 }
74 if first.SessionPath == "" {
75 t.Fatal("EnsureBlankTab should pre-create a session path for immediate deletion")
76 }
77 if _, err := os.Stat(first.SessionPath); err != nil {
78 t.Fatalf("pre-created blank session should exist: %v", err)
79 }
80 second, err := app.EnsureBlankTab("global", "")
81 if err != nil {
82 t.Fatal(err)
83 }
84 if second.ID != first.ID {
85 t.Fatalf("EnsureBlankTab created duplicate blank tab: first=%q second=%q", first.ID, second.ID)
86 }
87 if tabs := app.ListTabs(); len(tabs) != 1 {
88 t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
89 }
90 }
91
92 func TestEnsureBlankTabReusesPrecreatedBlankBeforeControllerReady(t *testing.T) {
93 isolateDesktopUserDirs(t)
94
95 globalRoot := globalWorkspaceRoot()
96 if err := os.MkdirAll(globalRoot, 0o755); err != nil {
97 t.Fatal(err)
98 }
99 sessionPath := agent.NewSessionPath(desktopSessionDir(globalRoot), "")
100 if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
101 t.Fatal(err)
102 }
103 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
104 t.Fatal(err)
105 }
106
107 app := NewApp()
108 topic, err := app.CreateTopic("global", "", "")
109 if err != nil {
110 t.Fatalf("create topic: %v", err)
111 }
112 app.tabs["blank"] = &WorkspaceTab{
113 ID: "blank",
114 Scope: "global",
115 WorkspaceRoot: globalRoot,
116 TopicID: topic.ID,
117 TopicTitle: defaultTopicTitle,
118 SessionPath: sessionPath,
119 disabledMCP: map[string]ServerView{},
120 }
121 app.tabOrder = []string{"blank"}
122 app.activeTabID = "blank"
123
124 meta, err := app.EnsureBlankTab("global", "")
125 if err != nil {
126 t.Fatalf("EnsureBlankTab: %v", err)
127 }
128 if meta.ID != "blank" {
129 t.Fatalf("EnsureBlankTab created duplicate blank tab %q, want existing pre-created blank", meta.ID)
130 }
131 }
132
133 func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
134 isolateDesktopUserDirs(t)
135
136 app := NewApp()
137 topic, err := app.CreateTopic("global", "", "")
138 if err != nil {
139 t.Fatalf("create topic: %v", err)
140 }
141 globalRoot := globalWorkspaceRoot()
142 dir := desktopSessionDir(globalRoot)
143 if err := os.MkdirAll(dir, 0o755); err != nil {
144 t.Fatalf("mkdir sessions: %v", err)
145 }
146 stubPath := filepath.Join(dir, "empty-stub.jsonl")
147 if err := os.WriteFile(stubPath, nil, 0o644); err != nil {
148 t.Fatalf("write empty stub: %v", err)
149 }
150 now := time.Now()
151 if err := agent.SaveBranchMetaPreserveUpdated(stubPath, agent.BranchMeta{
152 CreatedAt: now.Add(-time.Minute),
153 UpdatedAt: now,
154 Scope: "global",
155 WorkspaceRoot: globalRoot,
156 TopicID: topic.ID,
157 TopicTitle: defaultTopicTitle,
158 }); err != nil {
159 t.Fatalf("save branch meta: %v", err)
160 }
161
162 meta, err := app.EnsureBlankTab("global", "")
163 if err != nil {
164 t.Fatalf("EnsureBlankTab: %v", err)
165 }
166 if meta.TopicID != topic.ID {
167 t.Fatalf("EnsureBlankTab topic = %q, want reused empty topic %q", meta.TopicID, topic.ID)
168 }
169 }
170
171 func TestEnsureBlankTabStoresCreatedAt(t *testing.T) {
172 isolateDesktopUserDirs(t)
173
174 app := NewApp()
175 before := time.Now().UnixMilli()
176 meta, err := app.EnsureBlankTab("global", "")
177 after := time.Now().UnixMilli()
178 if err != nil {
179 t.Fatalf("EnsureBlankTab: %v", err)
180 }
181
182 createdAt := loadTopicCreatedAt("", meta.TopicID)
183 if createdAt < before || createdAt > after {
184 t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
185 }
186
187 nodes := app.ListProjectTree()
188 if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
189 t.Fatalf("project tree = %#v, want Global with one topic", nodes)
190 }
191 if got := nodes[0].Children[0].CreatedAt; got != createdAt {
192 t.Fatalf("project tree createdAt = %d, want %d", got, createdAt)
193 }
194 }
195
196 func TestEnsureBlankTabRepairsMissingCreatedAtForReusedTopic(t *testing.T) {
197 isolateDesktopUserDirs(t)
198
199 const topicID = "topic_20260704-104018_deadbeef"
200 if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
201 t.Fatalf("set topic title: %v", err)
202 }
203 if err := prependTopicInProjectsFile("", topicID, false); err != nil {
204 t.Fatalf("prepend topic: %v", err)
205 }
206 if got := loadTopicCreatedAt("", topicID); got != 0 {
207 t.Fatalf("createdAt before reuse = %d, want 0", got)
208 }
209
210 app := NewApp()
211 meta, err := app.EnsureBlankTab("global", "")
212 if err != nil {
213 t.Fatalf("EnsureBlankTab: %v", err)
214 }
215 if meta.TopicID != topicID {
216 t.Fatalf("EnsureBlankTab topic = %q, want reused topic %q", meta.TopicID, topicID)
217 }
218
219 expected := time.Date(2026, 7, 4, 10, 40, 18, 0, time.UTC).UnixMilli()
220 if got := loadTopicCreatedAt("", topicID); got != expected {
221 t.Fatalf("repaired createdAt = %d, want %d", got, expected)
222 }
223 }
224
225 // EnsureBlankTab reuses an already-open project-scoped blank tab.
226
227 func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
228 isolateDesktopUserDirs(t)
229
230 projectRoot := t.TempDir()
231 app := NewApp()
232 first, err := app.EnsureBlankTab("project", projectRoot)
233 if err != nil {
234 t.Fatal(err)
235 }
236 second, err := app.EnsureBlankTab("project", projectRoot)
237 if err != nil {
238 t.Fatal(err)
239 }
240 if second.ID != first.ID {
241 t.Fatalf("EnsureBlankTab created duplicate project blank tab: first=%q second=%q", first.ID, second.ID)
242 }
243 if tabs := app.ListTabs(); len(tabs) != 1 {
244 t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
245 }
246 }
247
248 func TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspacePrompt(t *testing.T) {
249 isolateDesktopUserDirs(t)
250
251 projectA := robustTempDir(t)
252 projectB := robustTempDir(t)
253 if err := addProject(projectA, "Project A"); err != nil {
254 t.Fatalf("add project A: %v", err)
255 }
256 if err := addProject(projectB, "Project B"); err != nil {
257 t.Fatalf("add project B: %v", err)
258 }
259
260 app := NewApp()
261 first, err := app.EnsureBlankTab("project", projectA)
262 if err != nil {
263 t.Fatalf("EnsureBlankTab(project A): %v", err)
264 }
265 tabA := waitForTabReady(t, app, first.ID)
266 if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
267 t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
268 }
269
270 second, err := app.EnsureBlankTab("project", projectB)
271 if err != nil {
272 t.Fatalf("EnsureBlankTab(project B): %v", err)
273 }
274 if second.ID == first.ID {
275 t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
276 }
277 tabB := waitForTabReady(t, app, second.ID)
278
279 if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
280 t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
281 }
282 if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
283 t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
284 }
285 if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
286 t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
287 }
288 if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
289 t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
290 }
291 sys := systemPromptFrom(tabB.Ctrl.History())
292 if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
293 t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
294 }
295 if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
296 t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
297 }
298 }
299
300 func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
301 isolateDesktopUserDirs(t)
302
303 projectA := robustTempDir(t)
304 projectB := robustTempDir(t)
305 pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
306 if err != nil {
307 t.Fatalf("create project A empty session: %v", err)
308 }
309 tab := &WorkspaceTab{
310 ID: "blank-project-b",
311 Scope: "project",
312 WorkspaceRoot: projectB,
313 SessionPath: pathA,
314 }
315
316 if blankTabSessionPathHasNoContent(tab) {
317 t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
318 }
319 }
320
321 func TestForkKeepsProjectWorkspacePrompt(t *testing.T) {
322 isolateDesktopUserDirs(t)
323
324 projectA := robustTempDir(t)
325 projectB := robustTempDir(t)
326 if err := addProject(projectA, "Project A"); err != nil {
327 t.Fatalf("add project A: %v", err)
328 }
329 if err := addProject(projectB, "Project B"); err != nil {
330 t.Fatalf("add project B: %v", err)
331 }
332
333 app := NewApp()
334 first, err := app.EnsureBlankTab("project", projectA)
335 if err != nil {
336 t.Fatalf("EnsureBlankTab(project A): %v", err)
337 }
338 waitForTabReady(t, app, first.ID)
339
340 second, err := app.EnsureBlankTab("project", projectB)
341 if err != nil {
342 t.Fatalf("EnsureBlankTab(project B): %v", err)
343 }
344 tabB := waitForTabReady(t, app, second.ID)
345 ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
346 turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
347
348 forked, err := app.Fork(turn)
349 if err != nil {
350 t.Fatalf("Fork: %v", err)
351 }
352 if forked.ID == "" || forked.ID == second.ID {
353 t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
354 }
355 forkTab := waitForTabReady(t, app, forked.ID)
356 if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
357 t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
358 }
359 if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
360 t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
361 }
362 sys := systemPromptFrom(forkTab.Ctrl.History())
363 if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
364 t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
365 }
366 if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
367 t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
368 }
369 }
370
371 func TestRewindKeepsProjectWorkspacePrompt(t *testing.T) {
372 isolateDesktopUserDirs(t)
373
374 projectA := robustTempDir(t)
375 projectB := robustTempDir(t)
376 if err := addProject(projectA, "Project A"); err != nil {
377 t.Fatalf("add project A: %v", err)
378 }
379 if err := addProject(projectB, "Project B"); err != nil {
380 t.Fatalf("add project B: %v", err)
381 }
382
383 app := NewApp()
384 first, err := app.EnsureBlankTab("project", projectA)
385 if err != nil {
386 t.Fatalf("EnsureBlankTab(project A): %v", err)
387 }
388 waitForTabReady(t, app, first.ID)
389
390 second, err := app.EnsureBlankTab("project", projectB)
391 if err != nil {
392 t.Fatalf("EnsureBlankTab(project B): %v", err)
393 }
394 tabB := waitForTabReady(t, app, second.ID)
395 ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
396 turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
397
398 if err := app.Rewind(turn, "conversation"); err != nil {
399 t.Fatalf("Rewind: %v", err)
400 }
401 if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
402 t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
403 }
404 sys := systemPromptFrom(tabB.Ctrl.History())
405 if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
406 t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
407 }
408 if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
409 t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
410 }
411 }
412
413 func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
414 t.Helper()
415 if tab == nil || tab.Ctrl == nil {
416 t.Fatal("tab controller is required")
417 }
418 sys := systemPromptFrom(tab.Ctrl.History())
419 if strings.TrimSpace(sys) == "" {
420 t.Fatal("tab controller did not expose a system prompt")
421 }
422 sessionDir := tab.Ctrl.SessionDir()
423 sessionPath := tab.Ctrl.SessionPath()
424 workspaceRoot := tab.Ctrl.WorkspaceRoot()
425 label := tab.Ctrl.Label()
426 tab.Ctrl.Close()
427
428 sess := agent.NewSession(sys)
429 ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
430 ctrl := control.New(control.Options{
431 Runner: ag,
432 Executor: ag,
433 SessionDir: sessionDir,
434 SessionPath: sessionPath,
435 WorkspaceRoot: workspaceRoot,
436 Label: label,
437 SystemPrompt: sys,
438 Sink: event.Discard,
439 })
440 tab.Ctrl = ctrl
441 app.bindControllerDisplayRecorder(ctrl)
442 return ctrl
443 }
444
445 func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
446 t.Helper()
447 ctrl.SubmitUserTurn(input, input)
448 waitNotRunning(t, ctrl)
449
450 deadline := time.Now().Add(time.Second)
451 for {
452 checkpoints := ctrl.Checkpoints()
453 if len(checkpoints) > 0 {
454 return checkpoints[len(checkpoints)-1].Turn
455 }
456 if time.Now().After(deadline) {
457 t.Fatal("controller did not record a checkpoint")
458 }
459 time.Sleep(10 * time.Millisecond)
460 }
461 }
462
463 func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
464 isolateDesktopUserDirs(t)
465
466 projectRoot := t.TempDir()
467 app := NewApp()
468 topic, err := app.CreateTopic("project", projectRoot, "")
469 if err != nil {
470 t.Fatalf("create topic: %v", err)
471 }
472 if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
473 t.Fatalf("set stale auto title: %v", err)
474 }
475 tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
476 app.tabs[tab.ID] = tab
477 app.tabOrder = []string{tab.ID}
478 app.activeTabID = tab.ID
479
480 meta, err := app.EnsureBlankTab("project", projectRoot)
481 if err != nil {
482 t.Fatalf("EnsureBlankTab: %v", err)
483 }
484 if got := meta.TopicTitle; got != defaultTopicTitle {
485 t.Fatalf("reused auto topic title = %q, want %q", got, defaultTopicTitle)
486 }
487 if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
488 t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
489 }
490 if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
491 t.Fatalf("title source = %q, want auto", got)
492 }
493 }
494
495 func TestEnsureBlankTabPreservesReusableManualTopicTitle(t *testing.T) {
496 isolateDesktopUserDirs(t)
497
498 projectRoot := t.TempDir()
499 app := NewApp()
500 topic, err := app.CreateTopic("project", projectRoot, "Manual title")
501 if err != nil {
502 t.Fatalf("create topic: %v", err)
503 }
504 tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
505 app.tabs[tab.ID] = tab
506 app.tabOrder = []string{tab.ID}
507 app.activeTabID = tab.ID
508
509 meta, err := app.EnsureBlankTab("project", projectRoot)
510 if err != nil {
511 t.Fatalf("EnsureBlankTab: %v", err)
512 }
513 if got := meta.TopicTitle; got != "Manual title" {
514 t.Fatalf("reused manual topic title = %q, want Manual title", got)
515 }
516 if got := loadTopicTitle(projectRoot, topic.ID); got != "Manual title" {
517 t.Fatalf("stored title = %q, want Manual title", got)
518 }
519 if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
520 t.Fatalf("title source = %q, want manual", got)
521 }
522 }
523
524 func TestEnsureBlankTabKeepsActiveTabWhenTitleResetFails(t *testing.T) {
525 isolateDesktopUserDirs(t)
526
527 projectRoot := t.TempDir()
528 app := NewApp()
529 topic, err := app.CreateTopic("project", projectRoot, "")
530 if err != nil {
531 t.Fatalf("create topic: %v", err)
532 }
533 if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
534 t.Fatalf("set stale auto title: %v", err)
535 }
536 activeTab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "active-tab")
537 reusableTab := app.createTabEntryWithID("project", projectRoot, topic.ID, "reusable-tab")
538 app.tabs[activeTab.ID] = activeTab
539 app.tabs[reusableTab.ID] = reusableTab
540 app.tabOrder = []string{activeTab.ID, reusableTab.ID}
541 app.activeTabID = activeTab.ID
542
543 titlePath := topicTitlesPath(projectRoot)
544 if err := os.Remove(titlePath); err != nil {
545 t.Fatalf("remove title file: %v", err)
546 }
547 if err := os.Mkdir(titlePath, 0o755); err != nil {
548 t.Fatalf("replace title file with directory: %v", err)
549 }
550
551 if _, err := app.EnsureBlankTab("project", projectRoot); err == nil {
552 t.Fatal("EnsureBlankTab succeeded, want title reset error")
553 }
554 if got := app.activeTabID; got != activeTab.ID {
555 t.Fatalf("active tab after failed title reset = %q, want %q", got, activeTab.ID)
556 }
557 }
558
559 // EnsureBlankTab picks up an existing blank topic created in the sidebar
560 // instead of creating a fresh topic, for global scope.
561
562 func TestEnsureBlankTabOpensExistingSidebarBlankTopic(t *testing.T) {
563 isolateDesktopUserDirs(t)
564
565 app := NewApp()
566 topic, err := app.CreateTopic("global", "", "")
567 if err != nil {
568 t.Fatal(err)
569 }
570
571 meta, err := app.EnsureBlankTab("global", "")
572 if err != nil {
573 t.Fatal(err)
574 }
575 if meta.TopicID != topic.ID {
576 t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
577 }
578 if topics := loadProjectsFile().GlobalTopics; len(topics) != 1 {
579 t.Fatalf("global topics length = %d, want 1: %v", len(topics), topics)
580 }
581 }
582
583 // EnsureBlankTab picks up an existing blank topic created in the sidebar
584 // instead of creating a fresh topic, for project scope.
585
586 func TestEnsureBlankTabOpensExistingProjectSidebarBlankTopic(t *testing.T) {
587 isolateDesktopUserDirs(t)
588
589 projectRoot := t.TempDir()
590 app := NewApp()
591 topic, err := app.CreateTopic("project", projectRoot, "")
592 if err != nil {
593 t.Fatal(err)
594 }
595
596 meta, err := app.EnsureBlankTab("project", projectRoot)
597 if err != nil {
598 t.Fatal(err)
599 }
600 if meta.TopicID != topic.ID {
601 t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
602 }
603 var topics []string
604 for _, project := range loadProjectsFile().Projects {
605 if project.Root == projectRoot {
606 topics = project.Topics
607 break
608 }
609 }
610 if len(topics) != 1 {
611 t.Fatalf("project topics length = %d, want 1: %v", len(topics), topics)
612 }
613 }
614
615 func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
616 isolateDesktopUserDirs(t)
617
618 projectRoot := robustTempDir(t)
619 app := NewApp()
620 topic, err := app.CreateTopic("project", projectRoot, "")
621 if err != nil {
622 t.Fatalf("CreateTopic: %v", err)
623 }
624 dir := desktopSessionDir(projectRoot)
625 if err := os.MkdirAll(dir, 0o755); err != nil {
626 t.Fatalf("mkdir sessions: %v", err)
627 }
628 existingPath := writeTopicSession(t, dir, "existing.jsonl", topic.ID, defaultTopicTitle, projectRoot)
629 if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
630 t.Fatalf("precondition topic session = %q, want %q", got, existingPath)
631 }
632
633 meta, err := app.EnsureBlankTab("project", projectRoot)
634 if err != nil {
635 t.Fatalf("EnsureBlankTab: %v", err)
636 }
637 if meta.TopicID == topic.ID {
638 t.Fatalf("EnsureBlankTab reused topic %q even though it already has session %q", topic.ID, existingPath)
639 }
640 if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
641 t.Fatalf("existing topic session changed = %q, want %q", got, existingPath)
642 }
643 }
644
645 // EnsureBlankTab must not reuse a tombstoned topic: the reused ID would flow
646 // into ensureTopicIndexed, whose intentional prepend clears the delete
647 // tombstone and resurrects the topic the user removed.
648 func TestEnsureBlankTabDoesNotReuseTombstonedTopic(t *testing.T) {
649 isolateDesktopUserDirs(t)
650
651 // Race product on disk: deleted topic whose default title lingered in the
652 // global title map (title-only, absent from GlobalTopics, no sessions).
653 tombstonedID := "topic_tombstone_blank"
654 if err := setTopicTitle("", tombstonedID, defaultTopicTitle); err != nil {
655 t.Fatalf("set lingering title: %v", err)
656 }
657 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
658 f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
659 return true, nil
660 }); err != nil {
661 t.Fatalf("seed tombstone: %v", err)
662 }
663
664 meta, err := NewApp().EnsureBlankTab("global", "")
665 if err != nil {
666 t.Fatalf("EnsureBlankTab: %v", err)
667 }
668 if meta.TopicID == tombstonedID {
669 t.Fatalf("EnsureBlankTab reused tombstoned topic %q", meta.TopicID)
670 }
671 f := loadProjectsFile()
672 if !containsDesktopString(f.DeletedTopics, tombstonedID) {
673 t.Fatalf("deletedTopics = %#v, tombstone must survive blank-tab creation", f.DeletedTopics)
674 }
675 if containsDesktopString(f.GlobalTopics, tombstonedID) {
676 t.Fatalf("globalTopics = %#v, tombstoned topic must not be re-indexed", f.GlobalTopics)
677 }
678 }
679
680 // NewSession skips the snapshot when the current tab has no real conversation content.
681
682 func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
683 isolateDesktopUserDirs(t)
684
685 dir := t.TempDir()
686 path := agent.NewSessionPath(dir, "model-a")
687 ctrl := carryingController([]provider.Message{{Role: provider.RoleSystem, Content: "sys"}}, path)
688 app := NewApp()
689 app.setTestCtrl(ctrl, "model-a")
690
691 if err := app.NewSession(); err != nil {
692 t.Fatal(err)
693 }
694 if got := ctrl.SessionPath(); got != path {
695 t.Fatalf("blank NewSession changed session path = %q, want %q", got, path)
696 }
697 }
698
699 func TestNewSessionUsesFreshTopicIdentity(t *testing.T) {
700 isolateDesktopUserDirs(t)
701
702 projectRoot := t.TempDir()
703 dir := config.SessionDir()
704 if err := os.MkdirAll(dir, 0o755); err != nil {
705 t.Fatalf("mkdir sessions: %v", err)
706 }
707 oldTopicID := "topic_old"
708 oldTopicTitle := "Old topic"
709 oldPath := writeTopicSessionWithPrompt(t, dir, "old.jsonl", oldTopicID, oldTopicTitle, projectRoot, "old prompt", time.Now().Add(-time.Hour))
710 sess := &agent.Session{}
711 sess.Replace([]provider.Message{{Role: provider.RoleUser, Content: "old prompt"}})
712 ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
713 ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, SessionPath: oldPath, Sink: event.Discard})
714
715 app := NewApp()
716 app.setTestCtrl(ctrl, "model-a")
717 tab := app.tabs["test"]
718 tab.Scope = "project"
719 tab.WorkspaceRoot = projectRoot
720 tab.TopicID = oldTopicID
721 tab.TopicTitle = oldTopicTitle
722 tab.SessionPath = oldPath
723 app.projectTreeChangedHook = func() {}
724
725 if err := app.NewSession(); err != nil {
726 t.Fatalf("NewSession: %v", err)
727 }
728 if got := tab.TopicID; got == "" || got == oldTopicID {
729 t.Fatalf("new session topic ID = %q, want fresh ID distinct from %q", got, oldTopicID)
730 }
731 if got := tab.TopicTitle; got != defaultTopicTitle {
732 t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
733 }
734 newPath := ctrl.SessionPath()
735 if newPath == "" || filepath.Clean(newPath) == filepath.Clean(oldPath) {
736 t.Fatalf("new session path = %q, want fresh path distinct from %q", newPath, oldPath)
737 }
738 if err := os.WriteFile(newPath, []byte(`{"role":"user","content":"new prompt"}`+"\n"), 0o644); err != nil {
739 t.Fatalf("write new session: %v", err)
740 }
741 if !app.maybeAutoTitleTopic(tab) {
742 t.Fatalf("new session should auto-title its fresh topic")
743 }
744
745 oldMeta, ok, err := agent.LoadBranchMeta(oldPath)
746 if err != nil || !ok {
747 t.Fatalf("load old meta: ok=%v err=%v", ok, err)
748 }
749 if oldMeta.TopicID != oldTopicID || oldMeta.TopicTitle != oldTopicTitle {
750 t.Fatalf("old session meta changed after new session auto-title: %+v", oldMeta)
751 }
752 newMeta, ok, err := agent.LoadBranchMeta(newPath)
753 if err != nil || !ok {
754 t.Fatalf("load new meta: ok=%v err=%v", ok, err)
755 }
756 if newMeta.TopicID != tab.TopicID || newMeta.TopicTitle != "new prompt" {
757 t.Fatalf("new session meta = %+v, want topic %q titled new prompt", newMeta, tab.TopicID)
758 }
759 }
760
761 func TestNewSessionKeepsFreshRuntimeWhenTopicRepairFails(t *testing.T) {
762 isolateDesktopUserDirs(t)
763
764 dir := config.SessionDir()
765 if err := os.MkdirAll(dir, 0o755); err != nil {
766 t.Fatalf("mkdir sessions: %v", err)
767 }
768 path := agent.NewSessionPath(dir, "model-a")
769 ctrl := controllerWithContent(t, path)
770 app := NewApp()
771 app.projectTreeChangedHook = func() {}
772 app.setTestCtrl(ctrl, "model-a")
773 tab := app.tabs["test"]
774 tab.TopicID = "topic_old"
775 tab.TopicTitle = "Old topic"
776
777 // Block desktopConfigDir-backed topic-index writes without affecting the
778 // session directory, which exercises the post-NewSession repair failure path.
779 if err := os.MkdirAll(filepath.Dir(desktopConfigDir()), 0o755); err != nil {
780 t.Fatalf("mkdir desktop config parent: %v", err)
781 }
782 if err := os.WriteFile(desktopConfigDir(), []byte("not-a-directory"), 0o644); err != nil {
783 t.Fatalf("block desktop config dir: %v", err)
784 }
785
786 if err := app.NewSession(); err != nil {
787 t.Fatalf("NewSession should keep the fresh runtime even when topic repair fails: %v", err)
788 }
789 if got := tab.TopicID; got == "" || got == "topic_old" {
790 t.Fatalf("new session topic ID = %q, want fresh ID distinct from the old topic", got)
791 }
792 if got := tab.TopicTitle; got != defaultTopicTitle {
793 t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
794 }
795 if got := ctrl.SessionPath(); got == "" || filepath.Clean(got) == filepath.Clean(path) {
796 t.Fatalf("new session path = %q, want a fresh path distinct from %q", got, path)
797 }
798 }
799
799 lines GO