返回 DeepSeek-Reasonix
session_title_consistency_test.go
根目录 / desktop / session_title_consistency_test.go
1 package main
2
3 import (
4 "os"
5 "testing"
6 "time"
7
8 "reasonix/internal/session"
9 )
10
11 // sidebarLabelForSession returns the label the project tree paints for ref.
12 func sidebarLabelForSession(t *testing.T, app *App, root string, ref session.SessionRef) string {
13 t.Helper()
14 page, err := app.unifiedProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 50})
15 if err != nil {
16 t.Fatalf("sidebar page: %v", err)
17 }
18 for _, node := range page.Items {
19 if node.Session != nil && node.Session.SessionID == ref.SessionID {
20 return node.Label
21 }
22 }
23 t.Fatalf("session %q missing from the sidebar page", ref.SessionID)
24 return ""
25 }
26
27 func canonicalTitleFixture(t *testing.T) (*App, *WorkspaceTab, *session.Runtime, session.SessionRef) {
28 t.Helper()
29 app, tab, target, _, _ := canonicalWorkspaceOpenFixture(t)
30 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: tab.SessionID}
31 // Desktop session creation seeds the presentation row with the auto
32 // default while the session log itself carries no title yet.
33 if err := app.workspaceRegistry().EnsureSessionTopic(t.Context(), ref.SessionID, "topic-A", defaultTopicTitle); err != nil {
34 t.Fatal(err)
35 }
36 if _, err := app.OpenSession(ref); err != nil {
37 t.Fatal(err)
38 }
39 return app, tab, target, ref
40 }
41
42 // An untitled session used to show its first user turn in the sidebar and the
43 // presentation default in the topicbar. Both surfaces share one projection.
44 func TestUntitledCanonicalSessionSurfacesAgree(t *testing.T) {
45 app, tab, _, ref := canonicalTitleFixture(t)
46 topicbar := app.tabMeta(tab, true).TopicTitle
47 sidebar := sidebarLabelForSession(t, app, tab.WorkspaceRoot, ref)
48 if topicbar != sidebar {
49 t.Fatalf("topicbar %q != sidebar %q", topicbar, sidebar)
50 }
51 if topicbar != "session-A" {
52 t.Fatalf("untitled session must show its first user turn, got %q", topicbar)
53 }
54 }
55
56 // A canonical rename writes only the session log. Every tab bind, including
57 // the restart rebuild that starts from the legacy topic map, must re-derive
58 // the name from that log, and the presentation row must mirror it.
59 func TestCanonicalRenameSurvivesSwitchAndRebuild(t *testing.T) {
60 app, tab, target, ref := canonicalTitleFixture(t)
61 rootA := tab.WorkspaceRoot
62 const want = "AI 生成的标题"
63 if err := app.RenameCanonicalSession(ref, want); err != nil {
64 t.Fatalf("rename: %v", err)
65 }
66 if tab.TopicTitle != want || tab.topicTitleSource != topicTitleSourceManual {
67 t.Fatalf("live tab after rename = %q/%q", tab.TopicTitle, tab.topicTitleSource)
68 }
69 state, err := app.workspaceRegistry().Load(t.Context())
70 if err != nil {
71 t.Fatal(err)
72 }
73 if got := state.Presentation[ref.SessionID].Title; got != want {
74 t.Fatalf("presentation title = %q, want write-through %q", got, want)
75 }
76
77 if _, err := app.OpenSession(target.Ref()); err != nil {
78 t.Fatalf("switch away: %v", err)
79 }
80 if _, err := app.OpenSession(ref); err != nil {
81 t.Fatalf("switch back: %v", err)
82 }
83 if got := app.tabMeta(tab, true).TopicTitle; got != want {
84 t.Fatalf("topicbar after switching back = %q, want %q", got, want)
85 }
86 if got := sidebarLabelForSession(t, app, rootA, ref); got != want {
87 t.Fatalf("sidebar after rename = %q, want %q", got, want)
88 }
89
90 // Restart: restoreOrBuildTabs recreates every local tab through
91 // createTabEntryWithID and then builds its controller. Park the visible
92 // surface on B first so A is cold, exactly like a fresh process.
93 if _, err := app.OpenSession(target.Ref()); err != nil {
94 t.Fatalf("park on B: %v", err)
95 }
96 const restoredID = "restored-tab"
97 restored := app.createTabEntryWithID("project", rootA, "topic-A", restoredID)
98 if restored.TopicTitle == want {
99 t.Fatal("fixture must start from the legacy topic map, not the log")
100 }
101 restored.SessionID = ref.SessionID
102 restored.sink = &tabEventSink{tabID: restoredID, app: app}
103 app.mu.Lock()
104 app.tabs[restoredID] = restored
105 app.tabOrder = append(app.tabOrder, restoredID)
106 app.activeTabID = restoredID
107 app.mu.Unlock()
108 app.startTabControllerBuild(restored)
109 built := waitForTabReady(t, app, restoredID)
110 if got := app.tabMeta(built, true).TopicTitle; got != want {
111 t.Fatalf("topicbar after restart rebuild = %q, want %q", got, want)
112 }
113 }
114
115 // Clearing a committed title must not leave the topicbar blank while the
116 // sidebar shows the preview: the log owns the name, the shared chain closes it.
117 func TestClearedCanonicalTitleFallsBackToPreviewEverywhere(t *testing.T) {
118 app, tab, _, ref := canonicalTitleFixture(t)
119 if err := app.RenameCanonicalSession(ref, "临时标题"); err != nil {
120 t.Fatal(err)
121 }
122 if err := app.RenameCanonicalSession(ref, ""); err != nil {
123 t.Fatal(err)
124 }
125 topicbar := app.tabMeta(tab, true).TopicTitle
126 sidebar := sidebarLabelForSession(t, app, tab.WorkspaceRoot, ref)
127 if topicbar != "session-A" || sidebar != "session-A" {
128 t.Fatalf("cleared title: topicbar %q sidebar %q, want the first user turn", topicbar, sidebar)
129 }
130 state, err := app.workspaceRegistry().Load(t.Context())
131 if err != nil {
132 t.Fatal(err)
133 }
134 if got := state.Presentation[ref.SessionID].Title; got != "" {
135 t.Fatalf("presentation title after clear = %q, want empty", got)
136 }
137 }
138
139 // Legacy sessions had the opposite asymmetry: the rename reached the sidebar
140 // row but never the open tab bound to that file.
141 func TestLegacySessionRenameProjectsIntoOpenTab(t *testing.T) {
142 isolateDesktopUserDirs(t)
143 app := NewApp()
144 t.Cleanup(app.closeSessionServices)
145 dir := desktopSessionDir(globalWorkspaceRoot())
146 if err := os.MkdirAll(dir, 0o755); err != nil {
147 t.Fatal(err)
148 }
149 const topicID = "topic_legacy_title"
150 if err := setTopicTitle("", topicID, "旧标题"); err != nil {
151 t.Fatal(err)
152 }
153 path := writeTopicSessionWithPrompt(t, dir, "legacy-title.jsonl", topicID, "旧标题", "", "legacy prompt", time.Now())
154 tab := &WorkspaceTab{
155 ID: "legacy-tab", Scope: "global", TopicID: topicID, TopicTitle: "旧标题",
156 topicTitleSource: topicTitleSourceManual, SessionPath: path, disabledMCP: map[string]ServerView{},
157 }
158 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
159 app.tabOrder = []string{tab.ID}
160 app.activeTabID = tab.ID
161
162 if err := app.RenameSession(path, "AI 标题"); err != nil {
163 t.Fatalf("rename legacy session: %v", err)
164 }
165 if tab.TopicTitle != "AI 标题" || tab.topicTitleSource != topicTitleSourceManual {
166 t.Fatalf("open legacy tab after rename = %q/%q", tab.TopicTitle, tab.topicTitleSource)
167 }
168 if err := app.RenameSession(path, ""); err != nil {
169 t.Fatalf("clear legacy session title: %v", err)
170 }
171 if tab.TopicTitle != "旧标题" {
172 t.Fatalf("cleared legacy title must fall back to the topic title, got %q", tab.TopicTitle)
173 }
174 }
175
175 lines GO