返回 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 "reasonix/internal/agent"
7 "reasonix/internal/config"
8 "reasonix/internal/control"
9 "reasonix/internal/event"
10 "reasonix/internal/provider"
11 "reasonix/internal/session"
12 "reasonix/internal/tool"
13 "strings"
14 "testing"
15 "time"
16 )
17
18 func carryingController(carried []provider.Message, path string) *control.Controller {
19 sess := &agent.Session{}
20 sess.Replace(carried)
21 ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
22 return control.New(control.Options{Executor: ag, SessionPath: path, Sink: event.Discard})
23 }
24
25 // TestCarriedRebuildsKeepOneSession reproduces issue #2807: a model switch or any
26 // config change rebuilds the controller and carries the conversation forward. Each
27 // rebuild must keep writing to the same file, so a run of them leaves exactly one
28 // history entry — not a new identical duplicate per rebuild.
29 func TestCarriedRebuildsKeepOneSession(t *testing.T) {
30 dir := t.TempDir()
31 path := agent.NewSessionPath(dir, "model-a")
32 ctrl := controllerWithContent(t, path)
33 if err := ctrl.Snapshot(); err != nil {
34 t.Fatal(err)
35 }
36
37 for range 5 {
38 prevPath := ctrl.SessionPath()
39 carried := ctrl.History()
40 ctrl.Close()
41
42 newPath := agent.ContinueSessionPath(prevPath, dir, "model-b")
43 ctrl = carryingController(carried, newPath)
44 if err := ctrl.Snapshot(); err != nil {
45 t.Fatal(err)
46 }
47 }
48 ctrl.Close()
49
50 infos, err := agent.ListSessions(dir)
51 if err != nil {
52 t.Fatal(err)
53 }
54 if len(infos) != 1 {
55 paths := make([]string, len(infos))
56 for i, s := range infos {
57 paths[i] = filepath.Base(s.Path)
58 }
59 t.Fatalf("after 5 carried rebuilds the history shows %d sessions, want 1: %v", len(infos), paths)
60 }
61 }
62
63 // EnsureBlankTab reuses an already-open blank tab rather than creating a second one.
64
65 func TestEnsureBlankTabReusesExistingBlankTab(t *testing.T) {
66 isolateDesktopUserDirs(t)
67
68 app := NewApp()
69 first, err := app.EnsureBlankTab("global", "")
70 if err != nil {
71 t.Fatal(err)
72 }
73 if first.SessionID == "" || first.SessionPath != "" {
74 t.Fatalf("EnsureBlankTab identity = id %q path %q", first.SessionID, first.SessionPath)
75 }
76 service := app.desktopSessionService(app.activeSessionDir())
77 if _, err := service.Query().Snapshot(t.Context(), session.SessionRef{HostID: service.HostID(), SessionID: first.SessionID}); err != nil {
78 t.Fatalf("pre-created blank v3 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 TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspaceContext(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 identity, ok := tabB.Ctrl.(control.IdentityLifecycle)
289 if !ok || !identity.UsesExclusiveSession() {
290 t.Fatalf("project B controller did not use exclusive v3 identity")
291 }
292 if ref, bound := identity.SessionRef(); !bound || strings.TrimSpace(ref.SessionID) == "" || strings.TrimSpace(tabB.Ctrl.SessionPath()) != "" {
293 t.Fatalf("project B controller identity = %+v bound=%v legacyPath=%q", ref, bound, tabB.Ctrl.SessionPath())
294 }
295 sys := systemPromptFrom(tabB.Ctrl.History())
296 if strings.Contains(sys, "Current workspace:") {
297 t.Fatalf("dynamic workspace leaked into project B system prompt:\n%s", sys)
298 }
299 ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
300 submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B context turn")
301 assertWorkspaceSessionContext(t, ctrl.History(), projectB, projectA)
302 }
303
304 func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
305 isolateDesktopUserDirs(t)
306
307 projectA := robustTempDir(t)
308 projectB := robustTempDir(t)
309 pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
310 if err != nil {
311 t.Fatalf("create project A empty session: %v", err)
312 }
313 tab := &WorkspaceTab{
314 ID: "blank-project-b",
315 Scope: "project",
316 WorkspaceRoot: projectB,
317 SessionPath: pathA,
318 }
319
320 if blankTabSessionPathHasNoContent(tab) {
321 t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
322 }
323 }
324
325 func TestForkKeepsProjectWorkspaceContext(t *testing.T) {
326 isolateDesktopUserDirsSchemaOne(t)
327
328 projectA := robustTempDir(t)
329 projectB := robustTempDir(t)
330 if err := addProject(projectA, "Project A"); err != nil {
331 t.Fatalf("add project A: %v", err)
332 }
333 if err := addProject(projectB, "Project B"); err != nil {
334 t.Fatalf("add project B: %v", err)
335 }
336
337 app := NewApp()
338 first, err := app.EnsureBlankTab("project", projectA)
339 if err != nil {
340 t.Fatalf("EnsureBlankTab(project A): %v", err)
341 }
342 waitForTabReady(t, app, first.ID)
343
344 second, err := app.EnsureBlankTab("project", projectB)
345 if err != nil {
346 t.Fatalf("EnsureBlankTab(project B): %v", err)
347 }
348 tabB := waitForTabReady(t, app, second.ID)
349 ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
350 turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
351
352 forked, err := app.Fork(turn)
353 if err != nil {
354 t.Fatalf("Fork: %v", err)
355 }
356 if forked.ID == "" || forked.ID == second.ID {
357 t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
358 }
359 forkTab := waitForTabReady(t, app, forked.ID)
360 if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
361 t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
362 }
363 if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
364 t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
365 }
366 sys := systemPromptFrom(forkTab.Ctrl.History())
367 if strings.Contains(sys, "Current workspace:") {
368 t.Fatalf("fork system prompt contains dynamic workspace:\n%s", sys)
369 }
370 forkCtrl := installStubControllerWithCurrentPrompt(t, app, forkTab)
371 submitStubTurnAndWaitForCheckpoint(t, forkCtrl, "project B after fork")
372 assertWorkspaceSessionContext(t, forkCtrl.History(), projectB, projectA)
373 }
374
375 func TestRewindReinjectsProjectWorkspaceContext(t *testing.T) {
376 isolateDesktopUserDirs(t)
377
378 projectA := robustTempDir(t)
379 projectB := robustTempDir(t)
380 if err := addProject(projectA, "Project A"); err != nil {
381 t.Fatalf("add project A: %v", err)
382 }
383 if err := addProject(projectB, "Project B"); err != nil {
384 t.Fatalf("add project B: %v", err)
385 }
386
387 app := NewApp()
388 first, err := app.EnsureBlankTab("project", projectA)
389 if err != nil {
390 t.Fatalf("EnsureBlankTab(project A): %v", err)
391 }
392 waitForTabReady(t, app, first.ID)
393
394 second, err := app.EnsureBlankTab("project", projectB)
395 if err != nil {
396 t.Fatalf("EnsureBlankTab(project B): %v", err)
397 }
398 tabB := waitForTabReady(t, app, second.ID)
399 ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
400 turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
401
402 if err := app.Rewind(turn, "conversation"); err != nil {
403 t.Fatalf("Rewind: %v", err)
404 }
405 if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
406 t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
407 }
408 sys := systemPromptFrom(tabB.Ctrl.History())
409 if strings.Contains(sys, "Current workspace:") {
410 t.Fatalf("rewound system prompt contains dynamic workspace:\n%s", sys)
411 }
412 tabB = waitForTabReady(t, app, second.ID)
413 ctrl = installStubControllerWithCurrentPrompt(t, app, tabB)
414 ctrl.SubmitUserTurn("project B after rewind", "project B after rewind")
415 waitNotRunning(t, ctrl)
416 assertWorkspaceSessionContext(t, ctrl.History(), projectB, projectA)
417 }
418
419 func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
420 t.Helper()
421 ctrl.SubmitUserTurn(input, input)
422 waitNotRunning(t, ctrl)
423
424 deadline := time.Now().Add(time.Second)
425 for {
426 checkpoints := ctrl.Checkpoints()
427 if len(checkpoints) > 0 {
428 return checkpoints[len(checkpoints)-1].Turn
429 }
430 if time.Now().After(deadline) {
431 t.Fatal("controller did not record a checkpoint")
432 }
433 time.Sleep(10 * time.Millisecond)
434 }
435 }
436
437 func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
438 isolateDesktopUserDirs(t)
439
440 projectRoot := t.TempDir()
441 app := NewApp()
442 topic, err := app.CreateTopic("project", projectRoot, "")
443 if err != nil {
444 t.Fatalf("create topic: %v", err)
445 }
446 if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
447 t.Fatalf("set stale auto title: %v", err)
448 }
449 tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
450 app.tabs[tab.ID] = tab
451 app.tabOrder = []string{tab.ID}
452 app.activeTabID = tab.ID
453
454 meta, err := app.EnsureBlankTab("project", projectRoot)
455 if err != nil {
456 t.Fatalf("EnsureBlankTab: %v", err)
457 }
458 if got := meta.TopicTitle; got != defaultTopicTitle {
459 t.Fatalf("reused auto topic title = %q, want %q", got, defaultTopicTitle)
460 }
461 if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
462 t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
463 }
464 if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
465 t.Fatalf("title source = %q, want auto", got)
466 }
467 }
468
469 func TestEnsureBlankTabPreservesReusableManualTopicTitle(t *testing.T) {
470 isolateDesktopUserDirs(t)
471
472 projectRoot := t.TempDir()
473 app := NewApp()
474 topic, err := app.CreateTopic("project", projectRoot, "Manual title")
475 if err != nil {
476 t.Fatalf("create topic: %v", err)
477 }
478 tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
479 app.tabs[tab.ID] = tab
480 app.tabOrder = []string{tab.ID}
481 app.activeTabID = tab.ID
482
483 meta, err := app.EnsureBlankTab("project", projectRoot)
484 if err != nil {
485 t.Fatalf("EnsureBlankTab: %v", err)
486 }
487 if got := meta.TopicTitle; got != "Manual title" {
488 t.Fatalf("reused manual topic title = %q, want Manual title", got)
489 }
490 if got := loadTopicTitle(projectRoot, topic.ID); got != "Manual title" {
491 t.Fatalf("stored title = %q, want Manual title", got)
492 }
493 if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
494 t.Fatalf("title source = %q, want manual", got)
495 }
496 }
497
498 func TestEnsureBlankTabKeepsActiveTabWhenTitleResetFails(t *testing.T) {
499 isolateDesktopUserDirs(t)
500
501 projectRoot := t.TempDir()
502 seedLegacyTopicBridge(t, projectRoot)
503 app := NewApp()
504 topic, err := app.CreateTopic("project", projectRoot, "")
505 if err != nil {
506 t.Fatalf("create topic: %v", err)
507 }
508 if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
509 t.Fatalf("set stale auto title: %v", err)
510 }
511 activeTab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "active-tab")
512 reusableTab := app.createTabEntryWithID("project", projectRoot, topic.ID, "reusable-tab")
513 app.tabs[activeTab.ID] = activeTab
514 app.tabs[reusableTab.ID] = reusableTab
515 app.tabOrder = []string{activeTab.ID, reusableTab.ID}
516 app.activeTabID = activeTab.ID
517
518 titlePath := topicTitlesPath(projectRoot)
519 if err := os.Remove(titlePath); err != nil {
520 t.Fatalf("remove title file: %v", err)
521 }
522 if err := os.Mkdir(titlePath, 0o755); err != nil {
523 t.Fatalf("replace title file with directory: %v", err)
524 }
525
526 if _, err := app.EnsureBlankTab("project", projectRoot); err == nil {
527 t.Fatal("EnsureBlankTab succeeded, want title reset error")
528 }
529 if got := app.activeTabID; got != activeTab.ID {
530 t.Fatalf("active tab after failed title reset = %q, want %q", got, activeTab.ID)
531 }
532 }
533
534 // EnsureBlankTab picks up an existing blank topic created in the sidebar
535 // instead of creating a fresh topic, for global scope.
536
537 func TestEnsureBlankTabOpensExistingSidebarBlankTopic(t *testing.T) {
538 isolateDesktopUserDirs(t)
539
540 app := NewApp()
541 topic, err := app.CreateTopic("global", "", "")
542 if err != nil {
543 t.Fatal(err)
544 }
545
546 meta, err := app.EnsureBlankTab("global", "")
547 if err != nil {
548 t.Fatal(err)
549 }
550 if meta.TopicID != topic.ID {
551 t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
552 }
553 if topics := loadProjectsFile().GlobalTopics; len(topics) != 1 {
554 t.Fatalf("global topics length = %d, want 1: %v", len(topics), topics)
555 }
556 }
557
558 // EnsureBlankTab picks up an existing blank topic created in the sidebar
559 // instead of creating a fresh topic, for project scope.
560
561 func TestEnsureBlankTabOpensExistingProjectSidebarBlankTopic(t *testing.T) {
562 isolateDesktopUserDirs(t)
563
564 projectRoot := t.TempDir()
565 app := NewApp()
566 topic, err := app.CreateTopic("project", projectRoot, "")
567 if err != nil {
568 t.Fatal(err)
569 }
570
571 meta, err := app.EnsureBlankTab("project", projectRoot)
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 var topics []string
579 for _, project := range loadProjectsFile().Projects {
580 if project.Root == projectRoot {
581 topics = project.Topics
582 break
583 }
584 }
585 if len(topics) != 1 {
586 t.Fatalf("project topics length = %d, want 1: %v", len(topics), topics)
587 }
588 }
589
590 func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
591 isolateDesktopUserDirs(t)
592
593 projectRoot := robustTempDir(t)
594 app := NewApp()
595 topic, err := app.CreateTopic("project", projectRoot, "")
596 if err != nil {
597 t.Fatalf("CreateTopic: %v", err)
598 }
599 dir := desktopSessionDir(projectRoot)
600 if err := os.MkdirAll(dir, 0o755); err != nil {
601 t.Fatalf("mkdir sessions: %v", err)
602 }
603 existingPath := writeTopicSession(t, dir, "existing.jsonl", topic.ID, defaultTopicTitle, projectRoot)
604 if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
605 t.Fatalf("precondition topic session = %q, want %q", got, existingPath)
606 }
607
608 meta, err := app.EnsureBlankTab("project", projectRoot)
609 if err != nil {
610 t.Fatalf("EnsureBlankTab: %v", err)
611 }
612 if meta.TopicID == topic.ID {
613 t.Fatalf("EnsureBlankTab reused topic %q even though it already has session %q", topic.ID, existingPath)
614 }
615 if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
616 t.Fatalf("existing topic session changed = %q, want %q", got, existingPath)
617 }
618 }
619
620 // EnsureBlankTab must not reuse a tombstoned topic: the reused ID would flow
621 // into ensureTopicIndexed, whose intentional prepend clears the delete
622 // tombstone and resurrects the topic the user removed.
623 func TestEnsureBlankTabDoesNotReuseTombstonedTopic(t *testing.T) {
624 isolateDesktopUserDirs(t)
625
626 // Race product on disk: deleted topic whose default title lingered in the
627 // global title map (title-only, absent from GlobalTopics, no sessions).
628 tombstonedID := "topic_tombstone_blank"
629 if err := setTopicTitle("", tombstonedID, defaultTopicTitle); err != nil {
630 t.Fatalf("set lingering title: %v", err)
631 }
632 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
633 f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
634 return true, nil
635 }); err != nil {
636 t.Fatalf("seed tombstone: %v", err)
637 }
638
639 meta, err := NewApp().EnsureBlankTab("global", "")
640 if err != nil {
641 t.Fatalf("EnsureBlankTab: %v", err)
642 }
643 if meta.TopicID == tombstonedID {
644 t.Fatalf("EnsureBlankTab reused tombstoned topic %q", meta.TopicID)
645 }
646 f := loadProjectsFile()
647 if !containsDesktopString(f.DeletedTopics, tombstonedID) {
648 t.Fatalf("deletedTopics = %#v, tombstone must survive blank-tab creation", f.DeletedTopics)
649 }
650 if containsDesktopString(f.GlobalTopics, tombstonedID) {
651 t.Fatalf("globalTopics = %#v, tombstoned topic must not be re-indexed", f.GlobalTopics)
652 }
653 }
654
655 // NewSession skips the snapshot when the current tab has no real conversation content.
656
657 func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
658 isolateDesktopUserDirs(t)
659
660 dir := t.TempDir()
661 path := agent.NewSessionPath(dir, "model-a")
662 ctrl := carryingController([]provider.Message{{Role: provider.RoleSystem, Content: "sys"}}, path)
663 t.Cleanup(ctrl.Close)
664 app := NewApp()
665 app.setTestCtrl(ctrl, "model-a")
666
667 if err := app.NewSession(); err != nil {
668 t.Fatal(err)
669 }
670 if got := ctrl.SessionPath(); got != path {
671 t.Fatalf("blank NewSession changed session path = %q, want %q", got, path)
672 }
673 }
674
675 func TestNewSessionUsesFreshTopicIdentity(t *testing.T) {
676 isolateDesktopUserDirs(t)
677
678 projectRoot := t.TempDir()
679 dir := config.SessionDir()
680 if err := os.MkdirAll(dir, 0o755); err != nil {
681 t.Fatalf("mkdir sessions: %v", err)
682 }
683 oldTopicID := "topic_old"
684 oldTopicTitle := "Old topic"
685 oldPath := writeTopicSessionWithPrompt(t, dir, "old.jsonl", oldTopicID, oldTopicTitle, projectRoot, "old prompt", time.Now().Add(-time.Hour))
686 sess := &agent.Session{}
687 sess.Replace([]provider.Message{{Role: provider.RoleUser, Content: "old prompt"}})
688 ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
689 ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, SessionPath: oldPath, Sink: event.Discard})
690
691 app := NewApp()
692 app.setTestCtrl(ctrl, "model-a")
693 tab := app.tabs["test"]
694 tab.Scope = "project"
695 tab.WorkspaceRoot = projectRoot
696 tab.TopicID = oldTopicID
697 tab.TopicTitle = oldTopicTitle
698 tab.SessionPath = oldPath
699 app.projectTreeChangedHook = func() {}
700
701 if err := app.NewSession(); err != nil {
702 t.Fatalf("NewSession: %v", err)
703 }
704 if got := tab.TopicID; got == "" || got == oldTopicID {
705 t.Fatalf("new session topic ID = %q, want fresh ID distinct from %q", got, oldTopicID)
706 }
707 if got := tab.TopicTitle; got != defaultTopicTitle {
708 t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
709 }
710 newPath := ctrl.SessionPath()
711 if newPath == "" || filepath.Clean(newPath) == filepath.Clean(oldPath) {
712 t.Fatalf("new session path = %q, want fresh path distinct from %q", newPath, oldPath)
713 }
714 if err := os.WriteFile(newPath, []byte(`{"role":"user","content":"new prompt"}`+"\n"), 0o644); err != nil {
715 t.Fatalf("write new session: %v", err)
716 }
717 if !app.maybeAutoTitleTopic(tab) {
718 t.Fatalf("new session should auto-title its fresh topic")
719 }
720
721 oldMeta, ok, err := agent.LoadBranchMeta(oldPath)
722 if err != nil || !ok {
723 t.Fatalf("load old meta: ok=%v err=%v", ok, err)
724 }
725 if oldMeta.TopicID != oldTopicID || oldMeta.TopicTitle != oldTopicTitle {
726 t.Fatalf("old session meta changed after new session auto-title: %+v", oldMeta)
727 }
728 newMeta, ok, err := agent.LoadBranchMeta(newPath)
729 if err != nil || !ok {
730 t.Fatalf("load new meta: ok=%v err=%v", ok, err)
731 }
732 if newMeta.TopicID != tab.TopicID || newMeta.TopicTitle != "new prompt" {
733 t.Fatalf("new session meta = %+v, want topic %q titled new prompt", newMeta, tab.TopicID)
734 }
735 }
736
737 func TestNewSessionKeepsFreshRuntimeWhenTopicRepairFails(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 path := agent.NewSessionPath(dir, "model-a")
745 ctrl := controllerWithContent(t, path)
746 app := NewApp()
747 app.projectTreeChangedHook = func() {}
748 app.setTestCtrl(ctrl, "model-a")
749 tab := app.tabs["test"]
750 tab.TopicID = "topic_old"
751 tab.TopicTitle = "Old topic"
752
753 // Block desktopConfigDir-backed topic-index writes without affecting the
754 // session directory, which exercises the post-NewSession repair failure path.
755 if err := os.MkdirAll(filepath.Dir(desktopConfigDir()), 0o755); err != nil {
756 t.Fatalf("mkdir desktop config parent: %v", err)
757 }
758 if err := os.WriteFile(desktopConfigDir(), []byte("not-a-directory"), 0o644); err != nil {
759 t.Fatalf("block desktop config dir: %v", err)
760 }
761
762 if err := app.NewSession(); err != nil {
763 t.Fatalf("NewSession should keep the fresh runtime even when topic repair fails: %v", err)
764 }
765 if got := tab.TopicID; got == "" || got == "topic_old" {
766 t.Fatalf("new session topic ID = %q, want fresh ID distinct from the old topic", got)
767 }
768 if got := tab.TopicTitle; got != defaultTopicTitle {
769 t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
770 }
771 if got := ctrl.SessionPath(); got == "" || filepath.Clean(got) == filepath.Clean(path) {
772 t.Fatalf("new session path = %q, want a fresh path distinct from %q", got, path)
773 }
774 }
775
775 lines GO