| 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 |