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