返回 DeepSeek-Reasonix
topic_live_runtime_test.go
根目录 / desktop / topic_live_runtime_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/control"
12 "reasonix/internal/provider"
13 "reasonix/internal/sessioncatalog"
14 )
15
16 func TestStartTopicActivationKeepsExplicitRepresentativeSeparateFromLiveSibling(t *testing.T) {
17 isolateDesktopUserDirs(t)
18 projectRoot := t.TempDir()
19 sessionDir := desktopSessionDir(projectRoot)
20 if err := os.MkdirAll(sessionDir, 0o755); err != nil {
21 t.Fatalf("mkdir sessions: %v", err)
22 }
23 oldPath := writeTopicSessionWithPrompt(t, sessionDir, "old.jsonl", "topic-b", "Topic B", projectRoot, "yesterday unsigned todos", time.Now().Add(-24*time.Hour))
24 livePath := writeTopicSessionWithPrompt(t, sessionDir, "live.jsonl", "topic-b", "Topic B", projectRoot, "today live turn", time.Now())
25
26 app := NewApp()
27 app.ctx = context.Background()
28 app.readyHook = func() {}
29 events := newActivationEventRecorder(app)
30 t.Cleanup(func() { app.shutdown(context.Background()) })
31
32 stub := &activationStubController{sessionPath: livePath}
33 live := &WorkspaceTab{
34 ID: "tab-live",
35 Scope: "project",
36 WorkspaceRoot: projectRoot,
37 TopicID: "topic-b",
38 TopicTitle: "Topic B",
39 SessionPath: livePath,
40 Ctrl: stub,
41 Label: "stub-model",
42 Ready: true,
43 ActivityStatus: topicStatusPaused,
44 disabledMCP: map[string]ServerView{},
45 }
46 live.sink = &tabEventSink{tabID: live.ID, app: app}
47 installNoopRuntimeEvents(app, live.sink)
48 if err := live.ensureSessionLease(livePath); err != nil {
49 t.Fatalf("ensureSessionLease live: %v", err)
50 }
51 app.detachedSessions[sessionRuntimeKey(livePath)] = live
52
53 ticket, err := app.StartTopicActivation(TopicActivationRequest{
54 Scope: "project",
55 WorkspaceRoot: projectRoot,
56 TopicID: "topic-b",
57 SessionPath: oldPath,
58 RequestID: "req-live",
59 })
60 if err != nil {
61 t.Fatalf("StartTopicActivation: %v", err)
62 }
63 events.waitFor(t, activationEventFor("req-live", "ready"))
64
65 app.mu.RLock()
66 tab := app.tabs[ticket.TabID]
67 app.mu.RUnlock()
68 if tab == nil {
69 t.Fatal("activated tab missing")
70 }
71 if tab.Ctrl == stub {
72 t.Fatal("explicit history selection reused the same-topic sibling controller")
73 }
74 assertActivatedSourceMapping(t, app, tab, oldPath)
75 if stub.closed.Load() {
76 t.Fatal("live controller was closed while attaching")
77 }
78 if ticket.Meta.SessionPath != "" && sessionRuntimeKey(ticket.Meta.SessionPath) != sessionRuntimeKey(oldPath) {
79 t.Fatalf("ticket session path = %q, want selected %q", ticket.Meta.SessionPath, oldPath)
80 }
81 }
82
83 func TestStartTopicActivationReadyDoesNotWaitForRebuildMutex(t *testing.T) {
84 isolateDesktopUserDirs(t)
85 projectRoot := t.TempDir()
86 sessionDir := desktopSessionDir(projectRoot)
87 if err := os.MkdirAll(sessionDir, 0o755); err != nil {
88 t.Fatalf("mkdir sessions: %v", err)
89 }
90 pathA := writeTopicSession(t, sessionDir, "a.jsonl", "topic-a", "Topic A", projectRoot)
91 writeTopicSessionWithPrompt(t, sessionDir, "b-old.jsonl", "topic-b", "Topic B", projectRoot, "old b", time.Now().Add(-time.Hour))
92 liveB := writeTopicSessionWithPrompt(t, sessionDir, "b-live.jsonl", "topic-b", "Topic B", projectRoot, "live b", time.Now())
93
94 app := NewApp()
95 app.ctx = context.Background()
96 app.readyHook = func() {}
97 events := newActivationEventRecorder(app)
98 t.Cleanup(func() { app.shutdown(context.Background()) })
99
100 stubA := &activationStubController{sessionPath: pathA}
101 tabA := &WorkspaceTab{
102 ID: "tab-a",
103 Scope: "project",
104 WorkspaceRoot: projectRoot,
105 TopicID: "topic-a",
106 TopicTitle: "Topic A",
107 SessionPath: pathA,
108 Ctrl: stubA,
109 Label: "stub-a",
110 Ready: true,
111 disabledMCP: map[string]ServerView{},
112 }
113 tabA.sink = &tabEventSink{tabID: tabA.ID, app: app}
114 installNoopRuntimeEvents(app, tabA.sink)
115 if err := tabA.ensureSessionLease(pathA); err != nil {
116 t.Fatalf("ensureSessionLease A: %v", err)
117 }
118 app.tabs[tabA.ID] = tabA
119 app.tabOrder = []string{tabA.ID}
120 app.activeTabID = tabA.ID
121
122 stubB := &activationStubController{sessionPath: liveB}
123 live := &WorkspaceTab{
124 ID: "tab-b",
125 Scope: "project",
126 WorkspaceRoot: projectRoot,
127 TopicID: "topic-b",
128 TopicTitle: "Topic B",
129 SessionPath: liveB,
130 Ctrl: stubB,
131 Label: "stub-b",
132 Ready: true,
133 disabledMCP: map[string]ServerView{},
134 }
135 live.sink = &tabEventSink{tabID: live.ID, app: app}
136 installNoopRuntimeEvents(app, live.sink)
137 if err := live.ensureSessionLease(liveB); err != nil {
138 t.Fatalf("ensureSessionLease B: %v", err)
139 }
140 app.detachedSessions[sessionRuntimeKey(liveB)] = live
141
142 app.runtimeRebuildMu.Lock()
143 defer app.runtimeRebuildMu.Unlock()
144
145 ticket, err := app.StartTopicActivation(TopicActivationRequest{
146 Scope: "project",
147 WorkspaceRoot: projectRoot,
148 TopicID: "topic-b",
149 SessionPath: liveB,
150 RequestID: "req-rebuild",
151 })
152 if err != nil {
153 t.Fatalf("StartTopicActivation: %v", err)
154 }
155 // runtimeRebuildMu remains held, so obtaining a ticket proves that activation
156 // publication does not wait for the MCP rebuild mutex.
157 deadline := time.After(400 * time.Millisecond)
158 for {
159 select {
160 case ev := <-events.ch:
161 if ev.RequestID == "req-rebuild" && ev.Phase == "ready" {
162 if sessionRuntimeKey(ticket.Meta.SessionPath) != sessionRuntimeKey(liveB) {
163 t.Fatalf("ticket path = %q, want live %q", ticket.Meta.SessionPath, liveB)
164 }
165 return
166 }
167 case <-deadline:
168 t.Fatal("ready waited for keepOnlyVisibleTab to acquire the rebuild mutex")
169 }
170 }
171 }
172
173 func TestPreferLiveSessionPathKeepsExplicitNonRepresentativeInspect(t *testing.T) {
174 live := "/sessions/live.jsonl"
175 rep := "/sessions/rep.jsonl"
176 inspect := "/sessions/inspect.jsonl"
177 if got := preferLiveSessionPath(inspect, live, rep); sessionRuntimeKey(got) != sessionRuntimeKey(inspect) {
178 t.Fatalf("inspect path = %q, want %q", got, inspect)
179 }
180 if got := preferLiveSessionPath(rep, live, rep); sessionRuntimeKey(got) != sessionRuntimeKey(live) {
181 t.Fatalf("representative path = %q, want live %q", got, live)
182 }
183 if got := preferLiveSessionPath("", live, rep); sessionRuntimeKey(got) != sessionRuntimeKey(live) {
184 t.Fatalf("empty path = %q, want live %q", got, live)
185 }
186 if got := preferLiveSessionPath(rep, "", rep); sessionRuntimeKey(got) != sessionRuntimeKey(rep) {
187 t.Fatalf("no live runtime = %q, want representative", got)
188 }
189 }
190
191 func TestPreferLiveSessionPathTreatsRepresentativeAndCanonicalAsOrdinary(t *testing.T) {
192 live := "/sessions/live.jsonl"
193 parent := "/sessions/parent.jsonl"
194 leaf := "/sessions/leaf.jsonl"
195 inspect := "/sessions/inspect.jsonl"
196 if got := preferLiveSessionPath(parent, live, parent, leaf); sessionRuntimeKey(got) != sessionRuntimeKey(live) {
197 t.Fatalf("covering parent = %q, want live %q", got, live)
198 }
199 if got := preferLiveSessionPath(leaf, live, parent, leaf); sessionRuntimeKey(got) != sessionRuntimeKey(live) {
200 t.Fatalf("canonical leaf = %q, want live %q", got, live)
201 }
202 if got := preferLiveSessionPath(inspect, live, parent, leaf); sessionRuntimeKey(got) != sessionRuntimeKey(inspect) {
203 t.Fatalf("history inspect = %q, want %q", got, inspect)
204 }
205 }
206
207 func installCoveringLeafTopicCatalog(t *testing.T, app *App) (parent, leaf, live string) {
208 t.Helper()
209 dir := desktopSessionDir(globalWorkspaceRoot())
210 if err := os.MkdirAll(dir, 0o755); err != nil {
211 t.Fatal(err)
212 }
213 q := provider.Message{Role: provider.RoleUser, Content: "question"}
214 a := provider.Message{Role: provider.RoleAssistant, Content: "answer"}
215 save := func(path, topic string, messages ...provider.Message) {
216 t.Helper()
217 session := agent.NewSession("sys")
218 for _, message := range messages {
219 session.Add(message)
220 }
221 if err := session.Save(path); err != nil {
222 t.Fatal(err)
223 }
224 if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
225 ID: agent.BranchID(path), Scope: "global", TopicID: topic, TopicTitle: "Upgraded",
226 }); err != nil {
227 t.Fatal(err)
228 }
229 }
230 parent = filepath.Join(dir, "root.jsonl")
231 leaf = filepath.Join(dir, "leaf.jsonl")
232 live = filepath.Join(dir, "live.jsonl")
233 save(parent, "conversation", q, a)
234 save(leaf, "legacy-leaf-topic", q, a,
235 provider.Message{Role: provider.RoleUser, Content: "next"},
236 provider.Message{Role: provider.RoleAssistant, Content: "done"})
237 leafSession, err := agent.LoadSession(leaf)
238 if err != nil {
239 t.Fatal(err)
240 }
241 leafDigest, err := agent.ContentDigestForMessages(leafSession.Snapshot())
242 if err != nil {
243 t.Fatal(err)
244 }
245 if err := agent.SaveBranchMetaPreserveUpdated(leaf, agent.BranchMeta{
246 ID: "leaf", Scope: "global", TopicID: "legacy-leaf-topic",
247 Recovered: true, ParentID: "root", RecoveryDepth: 1,
248 Revision: 1, ContentDigest: leafDigest, RecoveryDigest: leafDigest,
249 }); err != nil {
250 t.Fatal(err)
251 }
252 save(live, "conversation",
253 provider.Message{Role: provider.RoleUser, Content: "today live turn"},
254 provider.Message{Role: provider.RoleAssistant, Content: "paused here"})
255 installSessionCatalogForTest(t, app, dir, "global", "")
256 return parent, leaf, live
257 }
258
259 func TestResolveOpenTopicSessionPathKeepsPausedLiveOnCoveringParent(t *testing.T) {
260 isolateDesktopUserDirs(t)
261 app := NewApp()
262 parent, leaf, _ := installCoveringLeafTopicCatalog(t, app)
263 paused := &WorkspaceTab{
264 ID: "paused",
265 Scope: "global",
266 TopicID: "conversation",
267 SessionPath: parent,
268 Ctrl: &retargetRuntimeController{status: control.RuntimeStatus{}, path: parent},
269 ActivityStatus: topicStatusPaused,
270 }
271 app.detachedSessions[sessionRuntimeKey(parent)] = paused
272
273 _, resolved := app.resolveOpenTopicSessionPath("global", "", parent)
274 if resolved != parent {
275 t.Fatalf("resolve paused parent = %q, want keep live parent %q (not covering leaf %q)", resolved, parent, leaf)
276 }
277 }
278
279 func TestStartTopicActivationKeepsRecoveryContinuationSeparateFromLiveSibling(t *testing.T) {
280 isolateDesktopUserDirs(t)
281 app := NewApp()
282 app.ctx = context.Background()
283 app.readyHook = func() {}
284 events := newActivationEventRecorder(app)
285 t.Cleanup(func() { app.shutdown(context.Background()) })
286
287 parent, leaf, livePath := installCoveringLeafTopicCatalog(t, app)
288 topic, ok := app.catalogTopicRecord("global", "", "conversation")
289 if !ok {
290 t.Fatal("catalog topic missing")
291 }
292 canonical := sessioncatalog.CanonicalSessionPathForTopic(topic.Sessions, "")
293 if sessionRuntimeKey(canonical) != sessionRuntimeKey(leaf) {
294 t.Fatalf("canonical = %q, want covering leaf %q", canonical, leaf)
295 }
296 if topic.RepresentativePath != "" && sessionRuntimeKey(topic.RepresentativePath) != sessionRuntimeKey(parent) && sessionRuntimeKey(topic.RepresentativePath) != sessionRuntimeKey(leaf) {
297 t.Fatalf("representative = %q, want parent %q or covering leaf %q", topic.RepresentativePath, parent, leaf)
298 }
299
300 stub := &activationStubController{sessionPath: livePath, status: &control.RuntimeStatus{}}
301 live := &WorkspaceTab{
302 ID: "tab-live",
303 Scope: "global",
304 TopicID: "conversation",
305 TopicTitle: "Upgraded",
306 SessionPath: livePath,
307 Ctrl: stub,
308 Label: "stub-model",
309 Ready: true,
310 ActivityStatus: topicStatusPaused,
311 disabledMCP: map[string]ServerView{},
312 }
313 live.sink = &tabEventSink{tabID: live.ID, app: app}
314 installNoopRuntimeEvents(app, live.sink)
315 if err := live.ensureSessionLease(livePath); err != nil {
316 t.Fatalf("ensureSessionLease live: %v", err)
317 }
318 app.detachedSessions[sessionRuntimeKey(livePath)] = live
319
320 ticket, err := app.StartTopicActivation(TopicActivationRequest{
321 Scope: "global",
322 TopicID: "conversation",
323 SessionPath: parent,
324 RequestID: "req-covering-parent",
325 })
326 if err != nil {
327 t.Fatalf("StartTopicActivation: %v", err)
328 }
329 events.waitFor(t, activationEventFor("req-covering-parent", "ready"))
330
331 app.mu.RLock()
332 tab := app.tabs[ticket.TabID]
333 app.mu.RUnlock()
334 if tab == nil {
335 t.Fatal("activated tab missing")
336 }
337 if tab.Ctrl == stub {
338 t.Fatal("recovery parent selection reused an unrelated same-topic controller")
339 }
340 assertActivatedSourceMapping(t, app, tab, leaf)
341 if ticket.Meta.SessionPath != "" && sessionRuntimeKey(ticket.Meta.SessionPath) != sessionRuntimeKey(leaf) {
342 t.Fatalf("ticket session path = %q, want recovery leaf %q", ticket.Meta.SessionPath, leaf)
343 }
344 }
345
346 func assertActivatedSourceMapping(t *testing.T, app *App, tab *WorkspaceTab, sourcePath string) {
347 t.Helper()
348 state, err := app.workspaceRegistry().Load(t.Context())
349 if err != nil {
350 t.Fatal(err)
351 }
352 for _, mapping := range state.SourceMappings {
353 if sessionRuntimeKey(mapping.Path) == sessionRuntimeKey(sourcePath) && mapping.SessionID == tab.SessionID && tab.SessionID != "" {
354 return
355 }
356 }
357 t.Fatalf("activated session %q has no verified mapping for %q: %#v", tab.SessionID, sourcePath, state.SourceMappings)
358 }
359
359 lines GO