返回 DeepSeek-Reasonix
session_ai_title_test.go
根目录 / desktop / session_ai_title_test.go
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
643 lines GO