| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "reasonix/internal/agent" |
| 17 | "reasonix/internal/config" |
| 18 | "reasonix/internal/control" |
| 19 | "reasonix/internal/provider" |
| 20 | "reasonix/internal/session" |
| 21 | "reasonix/internal/sessioncatalog" |
| 22 | ) |
| 23 | |
| 24 | type desktopSessionTitleProvider struct { |
| 25 | started chan struct{} |
| 26 | chunks chan provider.Chunk |
| 27 | request provider.Request |
| 28 | // budget is what was left of the model round trip's deadline when the |
| 29 | // request reached the provider, and streamedAt when that happened. |
| 30 | budget time.Duration |
| 31 | streamedAt time.Time |
| 32 | } |
| 33 | |
| 34 | func (p *desktopSessionTitleProvider) Name() string { return "desktop-session-title" } |
| 35 | |
| 36 | func (p *desktopSessionTitleProvider) Stream(ctx context.Context, request provider.Request) (<-chan provider.Chunk, error) { |
| 37 | p.request = request |
| 38 | p.streamedAt = time.Now() |
| 39 | if deadline, ok := ctx.Deadline(); ok { |
| 40 | p.budget = time.Until(deadline) |
| 41 | } |
| 42 | if p.started != nil { |
| 43 | close(p.started) |
| 44 | } |
| 45 | return p.chunks, nil |
| 46 | } |
| 47 | |
| 48 | func TestAIRenameCanonicalSessionUsesDurableHistoryInsteadOfEmptyLegacyFile(t *testing.T) { |
| 49 | for _, identity := range []string{"topic", "session-id", "session-route"} { |
| 50 | t.Run(identity, func(t *testing.T) { |
| 51 | app, ctrl, runtime, prov, path := newCanonicalTitleFixture(t) |
| 52 | appendSessionTestMessage(t, runtime, "host", provider.Message{ID: "host", Role: provider.RoleUser, Origin: provider.MessageOriginHost, Content: "hidden host policy"}) |
| 53 | appendSessionTestMessage(t, runtime, "user", provider.Message{ID: "user", Role: provider.RoleUser, Origin: provider.MessageOriginUser, Content: "wrapped model input", RawContent: "帮我制作扫雷游戏"}) |
| 54 | // Long tool work pushes the authored turn outside the recent window. |
| 55 | for i := range 110 { |
| 56 | id := fmt.Sprintf("tool-%d", i) |
| 57 | appendSessionTestMessage(t, runtime, id, provider.Message{ID: id, Role: provider.RoleTool, Content: "tool output"}) |
| 58 | } |
| 59 | // Compaction changes model context without erasing the UI transcript. |
| 60 | if _, err := runtime.Session().AppendBatch(t.Context(), "compact", []session.Event{{Kind: "model/context-replace", Payload: []byte(`{"messages":[{"role":"assistant","content":"compacted summary"}],"reason":"compaction"}`)}}); err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | key := "topic-canonical" |
| 64 | if identity != "topic" { |
| 65 | app.tabs["test"].TopicID = "" |
| 66 | key = runtime.Ref().SessionID |
| 67 | if identity == "session-route" { |
| 68 | key = sessionRoute(key) |
| 69 | } |
| 70 | } |
| 71 | title, err := app.AIRenameSession(key) |
| 72 | if err != nil || title != "制作扫雷游戏" { |
| 73 | t.Fatalf("AIRenameSession = %q, %v", title, err) |
| 74 | } |
| 75 | if got, err := ctrl.SessionService().Query().Stat(t.Context(), runtime.Ref()); err != nil || got.Title != title { |
| 76 | t.Fatalf("canonical title = %+v, %v", got, err) |
| 77 | } |
| 78 | if got := prov.request.Messages; len(got) != 2 || got[1].Content != "帮我制作扫雷游戏" { |
| 79 | t.Fatalf("title prompt = %+v", got) |
| 80 | } |
| 81 | if bytes, err := os.ReadFile(path); err != nil || len(bytes) != 0 { |
| 82 | t.Fatalf("legacy file changed: length=%d, err=%v", len(bytes), err) |
| 83 | } |
| 84 | if meta, ok, err := agent.LoadBranchMeta(path); err != nil || (ok && meta.CustomTitle != "") { |
| 85 | t.Fatalf("canonical rename wrote legacy title: %+v, %v", meta, err) |
| 86 | } |
| 87 | if identity == "topic" { |
| 88 | if got := loadTopicTitle("", key); got == title || app.tabs["test"].TopicTitle != title { |
| 89 | t.Fatalf("sidebar title = %q, runtime title = %q", got, app.tabs["test"].TopicTitle) |
| 90 | } |
| 91 | } |
| 92 | }) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | // The model round trip owns the AI-title budget: control bounds that call at |
| 97 | // sessionTitleTimeout, measured from the call. A second host-level deadline |
| 98 | // over the whole operation would spend part of it on durable preparation — |
| 99 | // the snapshot, the flush and the history projection TitleMessages builds — |
| 100 | // so a long conversation on a slow host hands the model a short budget and |
| 101 | // eventually loses an already generated title to a deadline that belongs to |
| 102 | // the provider, reported as the opaque operation_failed. |
| 103 | func TestAISessionTitleBudgetBelongsToTheModelRoundTrip(t *testing.T) { |
| 104 | // control.sessionTitleTimeout. The host must hand over all of it. |
| 105 | const modelRoundTripBudget = 30 * time.Second |
| 106 | app, _, runtime, prov, _ := newCanonicalTitleFixture(t) |
| 107 | appendSessionTestMessage(t, runtime, "user", provider.Message{ |
| 108 | ID: "user", Role: provider.RoleUser, Origin: provider.MessageOriginUser, |
| 109 | Content: "wrapped model input", RawContent: "帮我制作扫雷游戏", |
| 110 | }) |
| 111 | // Enough durable history that its first projection build is measurable. |
| 112 | for i := range 60 { |
| 113 | id := fmt.Sprintf("tool-%d", i) |
| 114 | appendSessionTestMessage(t, runtime, id, provider.Message{ID: id, Role: provider.RoleTool, Content: "tool output"}) |
| 115 | } |
| 116 | |
| 117 | started := time.Now() |
| 118 | title, err := app.AIRenameSession("topic-canonical") |
| 119 | if err != nil || title != "制作扫雷游戏" { |
| 120 | t.Fatalf("AIRenameSession = %q, %v", title, err) |
| 121 | } |
| 122 | |
| 123 | if prov.streamedAt.IsZero() { |
| 124 | t.Fatal("the model was never asked for a title") |
| 125 | } |
| 126 | if prov.budget < modelRoundTripBudget-time.Second { |
| 127 | t.Fatalf("durable preparation of %v left the model only %v of its %v budget", |
| 128 | prov.streamedAt.Sub(started), prov.budget, modelRoundTripBudget) |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | func TestAIRenameDoesNotRenameSiblingCreatedDuringGeneration(t *testing.T) { |
| 133 | app, _, runtime, prov, _ := newCanonicalTitleFixture(t) |
| 134 | appendSessionTestMessage(t, runtime, "user", provider.Message{ID: "user", Role: provider.RoleUser, Content: "rename only A"}) |
| 135 | prov.started, prov.chunks = make(chan struct{}), make(chan provider.Chunk, 2) |
| 136 | done := make(chan error, 1) |
| 137 | go func() { _, err := app.AIRenameSession(sessionRoute(runtime.Ref().SessionID)); done <- err }() |
| 138 | select { |
| 139 | case <-prov.started: |
| 140 | case err := <-done: |
| 141 | t.Fatalf("early result: %v", err) |
| 142 | case <-time.After(10 * time.Second): |
| 143 | t.Fatal("provider did not start") |
| 144 | } |
| 145 | app.mu.Lock() |
| 146 | app.tabs["sibling"] = &WorkspaceTab{ID: "sibling", Scope: "global", TopicID: "topic-canonical", TopicTitle: "B unchanged", SessionID: "sibling"} |
| 147 | app.mu.Unlock() |
| 148 | prov.chunks <- provider.Chunk{Type: provider.ChunkText, Text: "A changed"} |
| 149 | prov.chunks <- provider.Chunk{Type: provider.ChunkDone} |
| 150 | close(prov.chunks) |
| 151 | if err := <-done; err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | if app.tabs["sibling"].TopicTitle != "B unchanged" { |
| 155 | t.Fatal("late AI rename changed sibling title") |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | func TestAIRenameCanonicalSessionDoesNotRequireTargetTab(t *testing.T) { |
| 160 | app, _, runtime, prov, path := newCanonicalTitleFixture(t) |
| 161 | appendSessionTestMessage(t, runtime, "user", provider.Message{ |
| 162 | ID: "user", Role: provider.RoleUser, Origin: provider.MessageOriginUser, Content: "rename a cold sidebar session", |
| 163 | }) |
| 164 | if _, err := runtime.Session().Flush(t.Context()); err != nil { |
| 165 | t.Fatal(err) |
| 166 | } |
| 167 | workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "global", "") |
| 168 | if err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspaceID, runtime.Ref().SessionID, ""); err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | if err := app.workspaceRegistry().EnsureSessionTopic(t.Context(), runtime.Ref().SessionID, "cold-topic", "Cold topic"); err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | |
| 178 | // Keep another conversation active only as the provider host. The target |
| 179 | // has no tab/controller binding and must be resolved from durable identity. |
| 180 | generator := newDesktopSessionTitleController(filepath.Dir(path), path, prov) |
| 181 | t.Cleanup(generator.Close) |
| 182 | app.mu.Lock() |
| 183 | app.tabs["test"].Ctrl = generator |
| 184 | app.tabs["test"].TopicID = "active-other-topic" |
| 185 | app.tabs["test"].SessionID = "active-other-session" |
| 186 | app.mu.Unlock() |
| 187 | |
| 188 | title, err := app.AIRenameSession(sessionRoute(runtime.Ref().SessionID)) |
| 189 | if err != nil || title != "制作扫雷游戏" { |
| 190 | t.Fatalf("AIRenameSession = %q, %v", title, err) |
| 191 | } |
| 192 | if app.tabs["test"].TopicID != "active-other-topic" || app.tabs["test"].SessionID != "active-other-session" { |
| 193 | t.Fatal("AI rename navigated away from the active conversation") |
| 194 | } |
| 195 | info, err := app.desktopSessionService("").Query().Stat(t.Context(), runtime.Ref()) |
| 196 | if err != nil || info.Title != title { |
| 197 | t.Fatalf("cold target title = %+v, %v", info, err) |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | func TestAIRenameCanonicalEmptySessionReturnsProductError(t *testing.T) { |
| 202 | app, _, _, _, _ := newCanonicalTitleFixture(t) |
| 203 | if _, err := app.AIRenameSession("topic-canonical"); err == nil || !strings.Contains(err.Error(), "session_operation:no_messages:") { |
| 204 | t.Fatalf("empty session error = %v", err) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | func TestAIRenameSessionDeduplicatesSameTarget(t *testing.T) { |
| 209 | app, _, runtime, prov, _ := newCanonicalTitleFixture(t) |
| 210 | appendSessionTestMessage(t, runtime, "user", provider.Message{ |
| 211 | ID: "user", Role: provider.RoleUser, Origin: provider.MessageOriginUser, Content: "deduplicate this rename", |
| 212 | }) |
| 213 | prov.started = make(chan struct{}) |
| 214 | prov.chunks = make(chan provider.Chunk, 2) |
| 215 | first := make(chan error, 1) |
| 216 | go func() { |
| 217 | _, err := app.AIRenameSession("topic-canonical") |
| 218 | first <- err |
| 219 | }() |
| 220 | <-prov.started |
| 221 | if _, err := app.AIRenameSession("topic-canonical"); err == nil || !strings.Contains(err.Error(), "session_operation:operation_busy:") { |
| 222 | t.Fatalf("duplicate rename error = %v", err) |
| 223 | } |
| 224 | prov.chunks <- provider.Chunk{Type: provider.ChunkText, Text: "Deduplicated title"} |
| 225 | prov.chunks <- provider.Chunk{Type: provider.ChunkDone} |
| 226 | close(prov.chunks) |
| 227 | if err := <-first; err != nil { |
| 228 | t.Fatalf("first rename: %v", err) |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | func TestAISessionTitleOldFinallyCannotClearNewOperation(t *testing.T) { |
| 233 | app := NewApp() |
| 234 | _, oldCancel := context.WithCancelCause(context.Background()) |
| 235 | defer oldCancel(context.Canceled) |
| 236 | _, newCancel := context.WithCancelCause(context.Background()) |
| 237 | defer newCancel(context.Canceled) |
| 238 | const key = "path:/session" |
| 239 | app.aiSessionTitleInFlight[key] = aiSessionTitleOperation{ID: "old", Cancel: oldCancel} |
| 240 | app.cancelAISessionTitle(key) |
| 241 | app.aiSessionTitleInFlight[key] = aiSessionTitleOperation{ID: "new", Cancel: newCancel} |
| 242 | app.finishAISessionTitle(key, "old") |
| 243 | if got := app.aiSessionTitleInFlight[key].ID; got != "new" { |
| 244 | t.Fatalf("old finally cleared operation %q, want new", got) |
| 245 | } |
| 246 | app.finishAISessionTitle(key, "new") |
| 247 | if _, ok := app.aiSessionTitleInFlight[key]; ok { |
| 248 | t.Fatal("owning finally did not clear completed operation") |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | func TestInvalidateAuxiliaryProviderOperationsCancelsAndFencesRequests(t *testing.T) { |
| 253 | app := NewApp() |
| 254 | ctx, cancel := context.WithCancelCause(context.Background()) |
| 255 | t.Cleanup(func() { cancel(context.Canceled) }) |
| 256 | app.aiSessionTitleInFlight["path:/cold"] = aiSessionTitleOperation{ID: "old", Cancel: cancel} |
| 257 | before := app.auxiliaryProviderGeneration.Load() |
| 258 | |
| 259 | app.invalidateAuxiliaryProviderOperations() |
| 260 | |
| 261 | if app.auxiliaryProviderGeneration.Load() != before+1 { |
| 262 | t.Fatalf("auxiliary provider generation did not advance") |
| 263 | } |
| 264 | if len(app.aiSessionTitleInFlight) != 0 { |
| 265 | t.Fatalf("invalidated operations remain in flight: %+v", app.aiSessionTitleInFlight) |
| 266 | } |
| 267 | var operationErr *SessionOperationError |
| 268 | if !errors.As(context.Cause(ctx), &operationErr) || operationErr.Code != "provider_unavailable" { |
| 269 | t.Fatalf("cancellation cause = %v, want provider_unavailable", context.Cause(ctx)) |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | func newCanonicalTitleFixture(t *testing.T) (*App, *control.Controller, *session.Runtime, *desktopSessionTitleProvider, string) { |
| 274 | t.Helper() |
| 275 | isolateDesktopUserDirs(t) |
| 276 | dir := t.TempDir() |
| 277 | app := NewApp() |
| 278 | t.Cleanup(app.closeSessionServices) |
| 279 | service := app.desktopSessionService(dir) |
| 280 | runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "canonical-title", CWD: globalWorkspaceRoot()}) |
| 281 | if err != nil { |
| 282 | t.Fatal(err) |
| 283 | } |
| 284 | path := agent.NewSessionPath(dir, "legacy-empty") |
| 285 | if err := os.WriteFile(path, nil, 0600); err != nil { |
| 286 | t.Fatal(err) |
| 287 | } |
| 288 | chunks := make(chan provider.Chunk, 2) |
| 289 | chunks <- provider.Chunk{Type: provider.ChunkText, Text: "制作扫雷游戏"} |
| 290 | chunks <- provider.Chunk{Type: provider.ChunkDone} |
| 291 | close(chunks) |
| 292 | prov := &desktopSessionTitleProvider{chunks: chunks} |
| 293 | // The cold path uses real config/resolver assembly and a disposable HTTP |
| 294 | // provider, not a controller borrowed from another conversation. |
| 295 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 296 | if r.Method != http.MethodPost { |
| 297 | http.NotFound(w, r) |
| 298 | return |
| 299 | } |
| 300 | if prov.started != nil { |
| 301 | close(prov.started) |
| 302 | } |
| 303 | w.Header().Set("Content-Type", "text/event-stream") |
| 304 | for { |
| 305 | select { |
| 306 | case <-r.Context().Done(): |
| 307 | return |
| 308 | case chunk, ok := <-prov.chunks: |
| 309 | if !ok || chunk.Type == provider.ChunkDone { |
| 310 | fmt.Fprint(w, "data: [DONE]\n\n") |
| 311 | return |
| 312 | } |
| 313 | body, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": map[string]string{"content": chunk.Text}}}}) |
| 314 | fmt.Fprintf(w, "data: %s\n\n", body) |
| 315 | w.(http.Flusher).Flush() |
| 316 | } |
| 317 | } |
| 318 | })) |
| 319 | t.Cleanup(server.Close) |
| 320 | cfg := config.LoadForEdit(config.UserConfigPath()) |
| 321 | cfg.DefaultModel = "test/title-model" |
| 322 | cfg.Providers = []config.ProviderEntry{{Name: "test", Kind: "openai", Model: "title-model", BaseURL: server.URL, NoProxy: true}} |
| 323 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 324 | t.Fatal(err) |
| 325 | } |
| 326 | workspace, err := app.ensureDesktopWorkspace(t.Context(), "global", "") |
| 327 | if err != nil { |
| 328 | t.Fatal(err) |
| 329 | } |
| 330 | if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspace, runtime.Ref().SessionID, ""); err != nil { |
| 331 | t.Fatal(err) |
| 332 | } |
| 333 | if err := app.workspaceRegistry().EnsureSessionTopic(t.Context(), runtime.Ref().SessionID, "topic-canonical", ""); err != nil { |
| 334 | t.Fatal(err) |
| 335 | } |
| 336 | ctrl := control.New(control.Options{ |
| 337 | SessionDir: dir, SessionPath: path, ModelRef: "test/title-model", |
| 338 | SessionService: service, SessionRuntime: runtime, ExclusiveSession: true, |
| 339 | ProviderResolver: &provider.StaticResolver{Descriptors: []provider.Descriptor{{Ref: "test/title-model"}}, Providers: map[string]provider.Provider{"test/title-model": prov}}, |
| 340 | }) |
| 341 | t.Cleanup(ctrl.Close) |
| 342 | installDesktopSessionTitleTab(app, ctrl, "topic-canonical", path) |
| 343 | app.tabs["test"].SessionID = runtime.Ref().SessionID |
| 344 | return app, ctrl, runtime, prov, path |
| 345 | } |
| 346 | |
| 347 | func TestAIRenameCanonicalSessionPreservesManualRenameAndSurvivesTabClose(t *testing.T) { |
| 348 | for _, change := range []string{"manual-title", "manual-topic", "binding"} { |
| 349 | t.Run(change, func(t *testing.T) { |
| 350 | app, ctrl, runtime, prov, _ := newCanonicalTitleFixture(t) |
| 351 | appendSessionTestMessage(t, runtime, "user", provider.Message{ID: "user", Role: provider.RoleUser, Content: "rename this session"}) |
| 352 | prov.started = make(chan struct{}) |
| 353 | prov.chunks = make(chan provider.Chunk, 2) |
| 354 | result := make(chan error, 1) |
| 355 | go func() { _, err := app.AIRenameSession("topic-canonical"); result <- err }() |
| 356 | select { |
| 357 | case <-prov.started: |
| 358 | case err := <-result: |
| 359 | t.Fatalf("rename stopped before provider: %v", err) |
| 360 | case <-time.After(10 * time.Second): |
| 361 | t.Fatal("provider did not start") |
| 362 | } |
| 363 | switch change { |
| 364 | case "manual-title": |
| 365 | if err := app.RenameSession(sessionRoute(runtime.Ref().SessionID), "manual title"); err != nil { |
| 366 | t.Fatal(err) |
| 367 | } |
| 368 | case "manual-topic": |
| 369 | if err := app.RenameTopic("topic-canonical", "manual topic title"); err != nil { |
| 370 | t.Fatal(err) |
| 371 | } |
| 372 | default: |
| 373 | app.mu.Lock() |
| 374 | app.tabs["test"].Ctrl = nil |
| 375 | app.mu.Unlock() |
| 376 | } |
| 377 | prov.chunks <- provider.Chunk{Type: provider.ChunkText, Text: "stale AI title"} |
| 378 | prov.chunks <- provider.Chunk{Type: provider.ChunkDone} |
| 379 | close(prov.chunks) |
| 380 | resultErr := <-result |
| 381 | if change == "binding" { |
| 382 | if resultErr != nil { |
| 383 | t.Fatalf("tab close cancelled persistent rename: %v", resultErr) |
| 384 | } |
| 385 | } else if resultErr == nil { |
| 386 | t.Fatal("manual title change did not reject stale AI completion") |
| 387 | } |
| 388 | info, err := ctrl.SessionService().Query().Stat(t.Context(), runtime.Ref()) |
| 389 | if err != nil || |
| 390 | (change != "binding" && info.Title == "stale AI title") || |
| 391 | (change == "binding" && info.Title != "stale AI title") || |
| 392 | (change == "manual-title" && info.Title != "manual title") { |
| 393 | t.Fatalf("title = %+v, %v", info, err) |
| 394 | } |
| 395 | if change == "manual-topic" && info.Title != "manual topic title" { |
| 396 | t.Fatal("manual sidebar title was overwritten") |
| 397 | } |
| 398 | }) |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | func TestAIRenameSessionReadFailureIsNotEmptyHistory(t *testing.T) { |
| 403 | isolateDesktopUserDirs(t) |
| 404 | dir := t.TempDir() |
| 405 | path := agent.NewSessionPath(dir, "unreadable") |
| 406 | if err := os.WriteFile(path, []byte("invalid JSON\n"), 0600); err != nil { |
| 407 | t.Fatal(err) |
| 408 | } |
| 409 | ctrl := newDesktopSessionTitleController(dir, path, &desktopSessionTitleProvider{}) |
| 410 | defer ctrl.Close() |
| 411 | app := NewApp() |
| 412 | installDesktopSessionTitleTab(app, ctrl, "topic-error", path) |
| 413 | if _, err := app.AIRenameSession("topic-error"); err == nil || !strings.Contains(err.Error(), "session_operation:operation_failed:") || strings.Contains(err.Error(), dir) { |
| 414 | t.Fatalf("read error = %v", err) |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | func newDesktopSessionTitleController(dir, path string, prov provider.Provider) *control.Controller { |
| 419 | return control.New(control.Options{ |
| 420 | SessionDir: dir, |
| 421 | SessionPath: path, |
| 422 | ModelRef: "test/title-model", |
| 423 | ProviderResolver: &provider.StaticResolver{ |
| 424 | Descriptors: []provider.Descriptor{{Ref: "test/title-model"}}, |
| 425 | Providers: map[string]provider.Provider{"test/title-model": prov}, |
| 426 | }, |
| 427 | }) |
| 428 | } |
| 429 | |
| 430 | func installDesktopSessionTitleTab(app *App, ctrl *control.Controller, topicID, path string) { |
| 431 | app.setTestCtrl(ctrl, "test/title-model") |
| 432 | app.mu.Lock() |
| 433 | tab := app.tabs["test"] |
| 434 | tab.TopicID = topicID |
| 435 | tab.SessionPath = path |
| 436 | app.mu.Unlock() |
| 437 | } |
| 438 | |
| 439 | func TestAIRenameSessionWritesCanonicalAndLegacyTitles(t *testing.T) { |
| 440 | isolateDesktopUserDirs(t) |
| 441 | dir := t.TempDir() |
| 442 | path := agent.NewSessionPath(dir, "title-test") |
| 443 | writeHistoryTestSession(t, path, "debug the login redirect loop") |
| 444 | chunks := make(chan provider.Chunk, 2) |
| 445 | chunks <- provider.Chunk{Type: provider.ChunkText, Text: `"Debug login redirect loop"`} |
| 446 | chunks <- provider.Chunk{Type: provider.ChunkDone} |
| 447 | close(chunks) |
| 448 | ctrl := newDesktopSessionTitleController(dir, path, &desktopSessionTitleProvider{chunks: chunks}) |
| 449 | app := NewApp() |
| 450 | installDesktopSessionTitleTab(app, ctrl, "topic-login", path) |
| 451 | defer ctrl.Close() |
| 452 | |
| 453 | title, err := app.AIRenameSession("topic-login") |
| 454 | if err != nil { |
| 455 | t.Fatalf("AIRenameSession: %v", err) |
| 456 | } |
| 457 | if title != "Debug login redirect loop" { |
| 458 | t.Fatalf("title = %q", title) |
| 459 | } |
| 460 | meta, ok, err := agent.LoadBranchMeta(path) |
| 461 | if err != nil || !ok || meta.CustomTitle != title { |
| 462 | t.Fatalf("meta = %+v, ok=%v, err=%v", meta, ok, err) |
| 463 | } |
| 464 | if got := loadSessionTitles(dir)[filepath.Base(path)]; got != title { |
| 465 | t.Fatalf("legacy title = %q", got) |
| 466 | } |
| 467 | } |
| 468 | |
| 469 | func TestAIRenameSessionRejectsStaleProviderCompletion(t *testing.T) { |
| 470 | isolateDesktopUserDirs(t) |
| 471 | dir := t.TempDir() |
| 472 | first := agent.NewSessionPath(dir, "first") |
| 473 | second := agent.NewSessionPath(dir, "second") |
| 474 | writeHistoryTestSession(t, first, "first conversation") |
| 475 | writeHistoryTestSession(t, second, "second conversation") |
| 476 | started := make(chan struct{}) |
| 477 | chunks := make(chan provider.Chunk, 2) |
| 478 | ctrl := newDesktopSessionTitleController(dir, first, &desktopSessionTitleProvider{started: started, chunks: chunks}) |
| 479 | app := NewApp() |
| 480 | installDesktopSessionTitleTab(app, ctrl, "topic-race", first) |
| 481 | defer ctrl.Close() |
| 482 | |
| 483 | result := make(chan error, 1) |
| 484 | go func() { |
| 485 | _, err := app.AIRenameSession("topic-race") |
| 486 | result <- err |
| 487 | }() |
| 488 | <-started |
| 489 | ctrl.SetSessionPath(second) |
| 490 | app.mu.Lock() |
| 491 | app.tabs["test"].SessionPath = second |
| 492 | app.mu.Unlock() |
| 493 | chunks <- provider.Chunk{Type: provider.ChunkText, Text: "Stale title"} |
| 494 | chunks <- provider.Chunk{Type: provider.ChunkDone} |
| 495 | close(chunks) |
| 496 | |
| 497 | if err := <-result; err == nil || !strings.Contains(err.Error(), "session_operation:target_changed:") { |
| 498 | t.Fatalf("stale completion error = %v", err) |
| 499 | } |
| 500 | for _, path := range []string{first, second} { |
| 501 | meta, ok, err := agent.LoadBranchMeta(path) |
| 502 | if err != nil { |
| 503 | t.Fatal(err) |
| 504 | } |
| 505 | if ok && meta.CustomTitle != "" { |
| 506 | t.Fatalf("stale completion renamed %s to %q", path, meta.CustomTitle) |
| 507 | } |
| 508 | } |
| 509 | } |
| 510 | |
| 511 | func TestAIRenameSessionRejectsCompletionAfterManualRename(t *testing.T) { |
| 512 | isolateDesktopUserDirs(t) |
| 513 | dir := t.TempDir() |
| 514 | path := agent.NewSessionPath(dir, "manual-wins") |
| 515 | writeHistoryTestSession(t, path, "original conversation") |
| 516 | started := make(chan struct{}) |
| 517 | chunks := make(chan provider.Chunk, 2) |
| 518 | ctrl := newDesktopSessionTitleController(dir, path, &desktopSessionTitleProvider{started: started, chunks: chunks}) |
| 519 | app := NewApp() |
| 520 | installDesktopSessionTitleTab(app, ctrl, "topic-manual", path) |
| 521 | defer ctrl.Close() |
| 522 | |
| 523 | result := make(chan error, 1) |
| 524 | go func() { |
| 525 | _, err := app.AIRenameSession("topic-manual") |
| 526 | result <- err |
| 527 | }() |
| 528 | <-started |
| 529 | if err := app.RenameSession(path, "Newer manual title"); err != nil { |
| 530 | t.Fatal(err) |
| 531 | } |
| 532 | chunks <- provider.Chunk{Type: provider.ChunkText, Text: "Stale AI title"} |
| 533 | chunks <- provider.Chunk{Type: provider.ChunkDone} |
| 534 | close(chunks) |
| 535 | |
| 536 | if err := <-result; err == nil || !strings.Contains(err.Error(), "title changed") { |
| 537 | t.Fatalf("stale AI completion error = %v", err) |
| 538 | } |
| 539 | meta, ok, err := agent.LoadBranchMeta(path) |
| 540 | if err != nil || !ok || meta.CustomTitle != "Newer manual title" { |
| 541 | t.Fatalf("manual title was overwritten: meta=%+v ok=%v err=%v", meta, ok, err) |
| 542 | } |
| 543 | } |
| 544 | |
| 545 | func TestDelayedTitleCallbackProjectsCurrentCanonicalTitle(t *testing.T) { |
| 546 | isolateDesktopUserDirs(t) |
| 547 | dir := t.TempDir() |
| 548 | path := agent.NewSessionPath(dir, "projection-race") |
| 549 | writeHistoryTestSession(t, path, "original conversation") |
| 550 | app := NewApp() |
| 551 | |
| 552 | if err := agent.RenameSession(path, "AI title"); err != nil { |
| 553 | t.Fatal(err) |
| 554 | } |
| 555 | if err := agent.RenameSession(path, "Newer manual title"); err != nil { |
| 556 | t.Fatal(err) |
| 557 | } |
| 558 | if err := app.onSessionTitleChanged(dir, path, "Newer manual title"); err != nil { |
| 559 | t.Fatal(err) |
| 560 | } |
| 561 | // Simulate the older AI callback resuming after the newer manual callback. |
| 562 | if err := app.onSessionTitleChanged(dir, path, "AI title"); err != nil { |
| 563 | t.Fatal(err) |
| 564 | } |
| 565 | |
| 566 | meta, ok, err := agent.LoadBranchMeta(path) |
| 567 | if err != nil || !ok || meta.CustomTitle != "Newer manual title" { |
| 568 | t.Fatalf("canonical title = %+v, ok=%v, err=%v", meta, ok, err) |
| 569 | } |
| 570 | if got := loadSessionTitles(dir)[filepath.Base(path)]; got != "Newer manual title" { |
| 571 | t.Fatalf("legacy projection = %q, want canonical newer title", got) |
| 572 | } |
| 573 | } |
| 574 | |
| 575 | func TestIndependentSessionTitleOverridesSharedTopicTitle(t *testing.T) { |
| 576 | isolateDesktopUserDirs(t) |
| 577 | dir := t.TempDir() |
| 578 | topicID := "metadata-title" |
| 579 | path := writeTopicSessionWithPrompt(t, dir, "metadata-title.jsonl", topicID, "Original topic", "", "first prompt", time.Now()) |
| 580 | if err := ensureTopicIndexed("global", "", topicID, "Original topic", topicTitleSourceManual); err != nil { |
| 581 | t.Fatal(err) |
| 582 | } |
| 583 | app := NewApp() |
| 584 | installSessionCatalogForTest(t, app, dir, "global", "") |
| 585 | catalog := app.sessionCatalog.Load() |
| 586 | if err := app.syncSessionCatalogMetadata(context.Background(), catalog); err != nil { |
| 587 | t.Fatal(err) |
| 588 | } |
| 589 | if err := agent.RenameSession(path, "AI session title"); err != nil { |
| 590 | t.Fatal(err) |
| 591 | } |
| 592 | if err := catalog.IndexSessionPath(context.Background(), sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}, path); err != nil { |
| 593 | t.Fatal(err) |
| 594 | } |
| 595 | if err := app.syncSessionCatalogMetadata(context.Background(), catalog); err != nil { |
| 596 | t.Fatal(err) |
| 597 | } |
| 598 | page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50}) |
| 599 | if err != nil { |
| 600 | t.Fatal(err) |
| 601 | } |
| 602 | if len(page.Items) != 1 || page.Items[0].Label != "AI session title" { |
| 603 | t.Fatalf("independent session title was not projected: %+v", page.Items) |
| 604 | } |
| 605 | if meta, ok, err := agent.LoadBranchMeta(path); err != nil || !ok || meta.CustomTitle != "AI session title" { |
| 606 | t.Fatalf("independent session title was not preserved: meta=%+v ok=%v err=%v", meta, ok, err) |
| 607 | } |
| 608 | } |
| 609 | |
| 610 | func TestSessionTitleTranscriptAndPreviewAreBounded(t *testing.T) { |
| 611 | transcript := sessionTitleTranscript([]string{ |
| 612 | strings.Repeat("a", aiSessionTitleMaxTurnRunes+10), "second", "third", "ignored", |
| 613 | }) |
| 614 | parts := strings.Split(transcript, "\n\n") |
| 615 | if len(parts) != aiSessionTitleMaxTurns || len([]rune(parts[0])) != aiSessionTitleMaxTurnRunes { |
| 616 | t.Fatalf("parts = %d first runes = %d", len(parts), len([]rune(parts[0]))) |
| 617 | } |
| 618 | records := []sessioncatalog.SessionRecord{{Path: "/sessions/a.jsonl", Preview: "full first-message preview"}} |
| 619 | if got := topicSessionPreview(records, "/sessions/a.jsonl"); got != "full first-message preview" { |
| 620 | t.Fatalf("preview = %q", got) |
| 621 | } |
| 622 | } |
| 623 | |
| 624 | func TestControllerForTopicPrefersActiveAndRejectsAmbiguousBackgroundTabs(t *testing.T) { |
| 625 | app := NewApp() |
| 626 | ctrlA := control.New(control.Options{}) |
| 627 | ctrlB := control.New(control.Options{}) |
| 628 | defer ctrlA.Close() |
| 629 | defer ctrlB.Close() |
| 630 | app.tabs = map[string]*WorkspaceTab{ |
| 631 | "a": {ID: "a", TopicID: "shared", Ctrl: ctrlA, Ready: true}, |
| 632 | "b": {ID: "b", TopicID: "shared", Ctrl: ctrlB, Ready: true}, |
| 633 | } |
| 634 | app.activeTabID = "b" |
| 635 | if got := app.controllerForTopic("shared"); got != ctrlB { |
| 636 | t.Fatalf("active controller = %p, want %p", got, ctrlB) |
| 637 | } |
| 638 | app.activeTabID = "other" |
| 639 | if got := app.controllerForTopic("shared"); got != nil { |
| 640 | t.Fatalf("ambiguous background controller = %p, want nil", got) |
| 641 | } |
| 642 | } |
| 643 |