返回 DeepSeek-Reasonix
topic_activation_test.go
根目录 / desktop / topic_activation_test.go
1 package main
2
3 import (
4 "context"
5 "sync"
6 "sync/atomic"
7 "testing"
8 "time"
9
10 "reasonix/internal/config"
11 "reasonix/internal/control"
12 "reasonix/internal/evidence"
13 )
14
15 // activationEventRecorder captures "topic:activation" events through the
16 // synchronous test hook, in emission order.
17 type activationEventRecorder struct {
18 ch chan TopicActivationEvent
19 }
20
21 func newActivationEventRecorder(app *App) *activationEventRecorder {
22 r := &activationEventRecorder{ch: make(chan TopicActivationEvent, 64)}
23 app.activationEventHook = func(ev TopicActivationEvent) { r.ch <- ev }
24 return r
25 }
26
27 // next returns the next event, failing the test if none arrives.
28 func (r *activationEventRecorder) next(t *testing.T) TopicActivationEvent {
29 t.Helper()
30 select {
31 case ev := <-r.ch:
32 return ev
33 case <-time.After(15 * time.Second):
34 t.Fatal("timed out waiting for a topic activation event")
35 return TopicActivationEvent{}
36 }
37 }
38
39 // waitFor drains events until pred matches and returns that event.
40 func (r *activationEventRecorder) waitFor(t *testing.T, pred func(TopicActivationEvent) bool) TopicActivationEvent {
41 t.Helper()
42 deadline := time.After(15 * time.Second)
43 for {
44 select {
45 case ev := <-r.ch:
46 if pred(ev) {
47 return ev
48 }
49 case <-deadline:
50 t.Fatal("timed out waiting for the expected topic activation event")
51 return TopicActivationEvent{}
52 }
53 }
54 }
55
56 // drainEmpty asserts no further events are pending.
57 func (r *activationEventRecorder) drainEmpty(t *testing.T) {
58 t.Helper()
59 for {
60 select {
61 case ev := <-r.ch:
62 t.Fatalf("unexpected extra topic activation event: %+v", ev)
63 default:
64 return
65 }
66 }
67 }
68
69 // tabBuildGate blocks every tab controller build at its entry until the test
70 // releases that tab, letting activation tests force out-of-order build
71 // completion without sleeps.
72 type tabBuildGate struct {
73 mu sync.Mutex
74 gates map[string]chan struct{}
75 entered chan string
76 }
77
78 func newTabBuildGate(app *App) *tabBuildGate {
79 g := &tabBuildGate{
80 gates: map[string]chan struct{}{},
81 entered: make(chan string, 64),
82 }
83 app.tabBuildStartHook = func(tabID string) {
84 g.mu.Lock()
85 ch := g.gates[tabID]
86 if ch == nil {
87 ch = make(chan struct{})
88 g.gates[tabID] = ch
89 }
90 g.mu.Unlock()
91 g.entered <- tabID
92 <-ch
93 }
94 return g
95 }
96
97 // waitEntered fails the test unless a build for tabID reaches the gate.
98 func (g *tabBuildGate) waitEntered(t *testing.T, tabID string) {
99 t.Helper()
100 deadline := time.After(15 * time.Second)
101 for {
102 select {
103 case got := <-g.entered:
104 if got == tabID {
105 return
106 }
107 case <-deadline:
108 t.Fatalf("timed out waiting for tab %q build to reach the gate", tabID)
109 }
110 }
111 }
112
113 func (g *tabBuildGate) release(tabID string) {
114 g.mu.Lock()
115 defer g.mu.Unlock()
116 if ch := g.gates[tabID]; ch != nil {
117 close(ch)
118 delete(g.gates, tabID)
119 }
120 }
121
122 // releaseAll unblocks every gated build; safe to call from test cleanup.
123 func (g *tabBuildGate) releaseAll() {
124 g.mu.Lock()
125 defer g.mu.Unlock()
126 for id, ch := range g.gates {
127 close(ch)
128 delete(g.gates, id)
129 }
130 }
131
132 func activationEventFor(requestID, phase string) func(TopicActivationEvent) bool {
133 return func(ev TopicActivationEvent) bool {
134 return ev.RequestID == requestID && ev.Phase == phase
135 }
136 }
137
138 // flushActivationCompletions gives superseded completion goroutines a
139 // deterministic sync point: they must pass singleSurfaceMu before doing
140 // anything observable, so round-tripping the mutex behind them (blocked
141 // Lockers queue FIFO) proves their guarded-off generation check has run.
142 func flushActivationCompletions(app *App) {
143 done := make(chan struct{})
144 go func() {
145 app.singleSurfaceMu.Lock()
146 defer app.singleSurfaceMu.Unlock()
147 close(done)
148 }()
149 <-done
150 }
151
152 func TestStartTopicActivationSyncBuildAndReuseFastPath(t *testing.T) {
153 isolateDesktopUserDirs(t)
154 app := NewApp() // a.ctx == nil: builds run synchronously inside the call
155 app.readyHook = func() {}
156 installNoopRuntimeEvents(app)
157 events := newActivationEventRecorder(app)
158 t.Cleanup(func() { app.shutdown(context.Background()) })
159
160 ticket, err := app.StartTopicActivation(TopicActivationRequest{Scope: "global", TopicID: "topic-sync", RequestID: "req-1"})
161 if err != nil {
162 t.Fatalf("StartTopicActivation: %v", err)
163 }
164 if got := events.next(t); got.Phase != "starting" || got.RequestID != "req-1" {
165 t.Fatalf("event = %+v, want starting req-1", got)
166 }
167 ready := events.waitFor(t, activationEventFor("req-1", "ready"))
168 if ready.TabID != ticket.TabID {
169 t.Fatalf("ready tab = %q, want %q", ready.TabID, ticket.TabID)
170 }
171
172 app.mu.RLock()
173 tab := app.tabs[ticket.TabID]
174 var ctrlAfterFirst control.SessionAPI
175 if tab != nil {
176 ctrlAfterFirst = tab.Ctrl
177 }
178 app.mu.RUnlock()
179 if tab == nil || ctrlAfterFirst == nil {
180 t.Fatal("tab missing or controller not built after ready")
181 }
182
183 // Reuse/fast path: reactivating the same topic reuses the tab, starts no
184 // new build, and still emits exactly one ready after pruning.
185 ticket2, err := app.StartTopicActivation(TopicActivationRequest{Scope: "global", TopicID: "topic-sync", RequestID: "req-2"})
186 if err != nil {
187 t.Fatalf("StartTopicActivation reuse: %v", err)
188 }
189 if ticket2.TabID != ticket.TabID {
190 t.Fatalf("reuse ticket tab = %q, want reused %q", ticket2.TabID, ticket.TabID)
191 }
192 if got := events.next(t); got.Phase != "starting" || got.RequestID != "req-2" {
193 t.Fatalf("event = %+v, want starting req-2", got)
194 }
195 events.waitFor(t, activationEventFor("req-2", "ready"))
196 flushActivationCompletions(app)
197 events.drainEmpty(t)
198
199 app.mu.RLock()
200 sameTab := app.tabs[ticket.TabID] == tab
201 sameCtrl := tab.Ctrl == ctrlAfterFirst
202 tabCount := len(app.tabs)
203 app.mu.RUnlock()
204 if !sameTab || !sameCtrl {
205 t.Fatal("reuse activation rebuilt or replaced the tab/controller")
206 }
207 if tabCount != 1 {
208 t.Fatalf("tab count = %d, want 1", tabCount)
209 }
210 }
211
212 func TestStartTopicActivationReplacesRemoteSurface(t *testing.T) {
213 isolateDesktopUserDirs(t)
214 app := NewApp()
215 app.ctx = context.Background()
216 app.readyHook = func() {}
217 installNoopRuntimeEvents(app)
218 events := newActivationEventRecorder(app)
219 gate := newTabBuildGate(app)
220 t.Cleanup(func() { gate.releaseAll(); app.shutdown(context.Background()) })
221
222 remoteCtx, cancelRemote := context.WithCancel(context.Background())
223 app.remoteTabMu.Lock()
224 app.remoteTabs = map[string]*remoteTab{
225 "remote": {id: "remote", ref: RemoteTabRef{HostID: "box", Workspace: "/work"}, state: "disconnected", cancel: cancelRemote},
226 }
227 app.remoteTabLayout = remoteTabLayoutState{activeID: "remote", order: []string{"remote"}, stripOrder: []string{"remote"}}
228 app.remoteTabMu.Unlock()
229
230 ticket, err := app.StartTopicActivation(TopicActivationRequest{Scope: "global", TopicID: "local-after-remote", RequestID: "local-after-remote"})
231 if err != nil {
232 t.Fatal(err)
233 }
234 // The tab build stays held: the local tab must be selected without it.
235 gate.waitEntered(t, ticket.TabID)
236 if tabs := app.ListTabs(); len(tabs) == 0 {
237 t.Fatalf("tabs immediately after local ticket = %+v, want active %q", tabs, ticket.TabID)
238 } else {
239 active := ""
240 for _, tab := range tabs {
241 if tab.Active {
242 active = tab.ID
243 }
244 }
245 if active != ticket.TabID {
246 t.Fatalf("tabs immediately after local ticket = %+v, want active %q", tabs, ticket.TabID)
247 }
248 }
249 gate.release(ticket.TabID)
250 events.waitFor(t, activationEventFor(ticket.RequestID, "ready"))
251 flushActivationCompletions(app)
252 if tabs := app.ListTabs(); len(tabs) != 1 || tabs[0].ID != ticket.TabID || !tabs[0].Active {
253 t.Fatalf("tabs after local activation = %+v, want only active local %q", tabs, ticket.TabID)
254 }
255 select {
256 case <-remoteCtx.Done():
257 default:
258 t.Fatal("remote tab pump was not cancelled after switching to local")
259 }
260 }
261
262 // activationStubController is the minimal SessionAPI surface exercised by
263 // prune/detach/attach flows around a tab with active runtime work.
264 type activationStubController struct {
265 stubSessionAPI
266 sessionPath string
267 closed atomic.Bool
268 status *control.RuntimeStatus
269 }
270
271 func (c *activationStubController) RuntimeStatus() control.RuntimeStatus {
272 if c.status != nil {
273 return *c.status
274 }
275 return control.RuntimeStatus{Running: true}
276 }
277 func (c *activationStubController) SessionPath() string { return c.sessionPath }
278 func (c *activationStubController) SetSessionPath(p string) { c.sessionPath = p }
279 func (c *activationStubController) SessionDir() string { return "" }
280 func (c *activationStubController) Snapshot() error { return nil }
281 func (c *activationStubController) Cancel() {}
282 func (c *activationStubController) Close() { c.closed.Store(true) }
283 func (c *activationStubController) Label() string { return "stub-model" }
284 func (c *activationStubController) ReplayPendingPrompts() {}
285 func (c *activationStubController) PlanMode() bool { return false }
286 func (c *activationStubController) AutoApproveTools() bool { return false }
287 func (c *activationStubController) ToolApprovalMode() string { return "" }
288 func (c *activationStubController) Goal() string { return "" }
289 func (c *activationStubController) GoalStatus() string { return "" }
290 func (c *activationStubController) Turn() int { return 0 }
291 func (c *activationStubController) GoalRuntime() control.GoalRuntimeView {
292 return control.GoalRuntimeView{}
293 }
294 func (c *activationStubController) Todos() []evidence.TodoItem { return nil }
295 func (c *activationStubController) SnapshotForShutdown() error { return nil }
296
297 func TestActivateTopicSupersedesPendingTicketedActivation(t *testing.T) {
298 isolateDesktopUserDirs(t)
299 app := NewApp()
300 app.ctx = context.Background()
301 readyCh := make(chan struct{}, 8)
302 app.readyHook = func() { readyCh <- struct{}{} }
303 installNoopRuntimeEvents(app)
304 events := newActivationEventRecorder(app)
305 gate := newTabBuildGate(app)
306 t.Cleanup(func() {
307 gate.releaseAll()
308 app.shutdown(context.Background())
309 })
310
311 ticketA, err := app.StartTopicActivation(TopicActivationRequest{Scope: "global", TopicID: "topic-a", RequestID: "req-a"})
312 if err != nil {
313 t.Fatalf("StartTopicActivation A: %v", err)
314 }
315 if got := events.next(t); got.Phase != "starting" {
316 t.Fatalf("event = %+v, want starting", got)
317 }
318 gate.waitEntered(t, ticketA.TabID)
319 app.mu.RLock()
320 tabA := app.tabs[ticketA.TabID]
321 var tabABuildDone chan struct{}
322 if tabA != nil {
323 // Snapshot the build-done channel now: closeTabBuildDone nils the
324 // field after closing it, so reading it after the fact would block on
325 // a nil channel forever.
326 tabABuildDone = tabA.buildDone
327 }
328 app.mu.RUnlock()
329 if tabA == nil {
330 t.Fatal("tab A missing after activation start")
331 }
332
333 // A legacy ActivateTopic call supersedes the pending ticketed activation
334 // and keeps its own synchronous contract: it returns after the prune.
335 type legacyResult struct {
336 meta TabMeta
337 err error
338 }
339 legacyDone := make(chan legacyResult, 1)
340 go func() {
341 meta, err := app.ActivateTopic("global", "", "topic-b", "")
342 legacyDone <- legacyResult{meta: meta, err: err}
343 }()
344 if got := events.next(t); got != (TopicActivationEvent{RequestID: "req-a", TabID: ticketA.TabID, Phase: "cancelled"}) {
345 t.Fatalf("event = %+v, want cancelled req-a", got)
346 }
347 // The legacy prune queues the runtime-admission WRITE lock. Go's RWMutex
348 // blocks new readers behind a queued writer, so tab B's gated build may
349 // not be able to enter the gate until the prune has run — release A first
350 // (its build abandons via the superseded path), which unblocks the prune
351 // in the interleaving where B's build is still stuck behind the writer.
352 app.mu.RLock()
353 var tabBID string
354 for id := range app.tabs {
355 if id != ticketA.TabID {
356 tabBID = id
357 }
358 }
359 app.mu.RUnlock()
360 if tabBID == "" {
361 t.Fatal("legacy activation did not open tab B")
362 }
363 gate.release(ticketA.TabID)
364 gate.waitEntered(t, tabBID)
365 gate.release(tabBID)
366
367 var legacy legacyResult
368 select {
369 case legacy = <-legacyDone:
370 case <-time.After(15 * time.Second):
371 t.Fatal("legacy ActivateTopic did not return")
372 }
373 if legacy.err != nil {
374 t.Fatalf("legacy ActivateTopic: %v", legacy.err)
375 }
376 if legacy.meta.ID != tabBID {
377 t.Fatalf("legacy meta tab = %q, want %q", legacy.meta.ID, tabBID)
378 }
379 <-readyCh // B's build published and emitted agent:ready
380
381 assertTabIDs(t, app.ListTabs(), tabBID)
382 <-tabABuildDone // A's abandoned build terminated
383 flushActivationCompletions(app)
384 // The legacy path emits no activation events of its own, and the
385 // superseded completion stays silent.
386 events.drainEmpty(t)
387 }
388
389 func TestSetActiveTabSupersedesPendingPublication(t *testing.T) {
390 isolateDesktopUserDirs(t)
391 app := NewApp()
392 app.ctx = context.Background()
393 readyCh := make(chan struct{}, 8)
394 app.readyHook = func() { readyCh <- struct{}{} }
395 installNoopRuntimeEvents(app)
396 events := newActivationEventRecorder(app)
397 t.Cleanup(func() { app.shutdown(context.Background()) })
398
399 // Establish a settled visible tab through the legacy path (ungated).
400 metaB, err := app.ActivateTopic("global", "", "topic-b", "")
401 if err != nil {
402 t.Fatalf("ActivateTopic B: %v", err)
403 }
404 select {
405 case <-readyCh:
406 case <-time.After(15 * time.Second):
407 t.Fatal("tab B build did not finish")
408 }
409
410 gate := newTabBuildGate(app)
411 t.Cleanup(gate.releaseAll)
412 ticketA, err := app.StartTopicActivation(TopicActivationRequest{Scope: "global", TopicID: "topic-a", RequestID: "req-a"})
413 if err != nil {
414 t.Fatalf("StartTopicActivation A: %v", err)
415 }
416 if got := events.next(t); got.Phase != "starting" {
417 t.Fatalf("event = %+v, want starting", got)
418 }
419 gate.waitEntered(t, ticketA.TabID)
420 app.mu.RLock()
421 tabA := app.tabs[ticketA.TabID]
422 var tabABuildDone chan struct{}
423 if tabA != nil {
424 // Snapshot before the build terminates: closeTabBuildDone nils the
425 // field after closing the channel.
426 tabABuildDone = tabA.buildDone
427 }
428 app.mu.RUnlock()
429 if tabA == nil || tabABuildDone == nil {
430 t.Fatal("tab A missing or has no in-flight build")
431 }
432
433 // The user clicks tab B directly: the pending activation's publication
434 // (prune + ready) is superseded, but its build is not cancelled — tab A
435 // stays open and may legitimately become ready.
436 if err := app.SetActiveTab(metaB.ID); err != nil {
437 t.Fatalf("SetActiveTab B: %v", err)
438 }
439 if got := events.next(t); got != (TopicActivationEvent{RequestID: "req-a", TabID: ticketA.TabID, Phase: "cancelled"}) {
440 t.Fatalf("event = %+v, want cancelled req-a", got)
441 }
442 gate.release(ticketA.TabID)
443 select {
444 case <-readyCh: // A's build still completes and publishes
445 case <-time.After(15 * time.Second):
446 t.Fatal("tab A build did not finish after SetActiveTab")
447 }
448
449 app.mu.RLock()
450 tabAReady := tabA.Ready
451 tabCount := len(app.tabs)
452 active := app.activeTabID
453 app.mu.RUnlock()
454 if tabCount != 2 {
455 t.Fatalf("tab count = %d, want 2 (SetActiveTab must not prune)", tabCount)
456 }
457 if active != metaB.ID {
458 t.Fatalf("active tab = %q, want %q", active, metaB.ID)
459 }
460 if !tabAReady {
461 t.Fatal("tab A build was cancelled or unpublished by SetActiveTab")
462 }
463 <-tabABuildDone
464 flushActivationCompletions(app)
465 events.drainEmpty(t)
466 }
467
468 func TestMetaForTabFastPathCachesExpensiveFields(t *testing.T) {
469 isolateDesktopUserDirs(t)
470 setDesktopTestCredential(t, "CUSTOM_KEY", "sk-test")
471
472 cfg := config.Default()
473 cfg.DefaultModel = "custom/vision-pro"
474 cfg.Agent.VisionModel = "auto"
475 cfg.Desktop.ProviderAccess = []string{"custom"}
476 cfg.Providers = []config.ProviderEntry{{
477 Name: "custom",
478 Kind: "openai",
479 BaseURL: "https://example.invalid/v1",
480 APIKeyEnv: "CUSTOM_KEY",
481 Models: []string{"text-only", "vision-pro"},
482 VisionModels: []string{"vision-pro"},
483 }}
484 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
485 t.Fatalf("save config: %v", err)
486 }
487
488 repoRoot := t.TempDir()
489 runGitIn(t, repoRoot, "init")
490 runGitIn(t, repoRoot, "checkout", "-b", "feature/meta-cache")
491 plainRoot := t.TempDir()
492
493 app := NewApp()
494 app.ctx = context.Background()
495 app.readyHook = func() {}
496 metaEvents := make(chan TabMetaRefreshEvent, 8)
497 // NOTE: install the capture AFTER any installNoopRuntimeEvents call — that
498 // helper overwrites app.runtimeEvents.emit.
499 installNoopRuntimeEvents(app)
500 app.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
501 if name != tabMetaRefreshEventChannel || len(payload) == 0 {
502 return
503 }
504 if ev, ok := payload[0].(TabMetaRefreshEvent); ok {
505 metaEvents <- ev
506 }
507 }
508 var loads atomic.Int32
509 var blockLoad atomic.Bool
510 blockLoad.Store(true)
511 loadEntered := make(chan struct{})
512 var loadOnce sync.Once
513 releaseLoad := make(chan struct{})
514 var releaseOnce sync.Once
515 release := func() {
516 releaseOnce.Do(func() {
517 blockLoad.Store(false)
518 close(releaseLoad)
519 })
520 }
521 app.configLoadForRootHook = func(string) {
522 loads.Add(1)
523 loadOnce.Do(func() { close(loadEntered) })
524 if blockLoad.Load() {
525 <-releaseLoad
526 }
527 }
528
529 tab := &WorkspaceTab{
530 ID: "meta-tab",
531 Scope: "project",
532 WorkspaceRoot: repoRoot,
533 Label: "custom/vision-pro",
534 model: "custom/vision-pro",
535 disabledMCP: map[string]ServerView{},
536 }
537 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
538 installNoopRuntimeEvents(nil, tab.sink) // sink only — keep the capture on app.runtimeEvents
539 app.tabs[tab.ID] = tab
540 app.tabOrder = []string{tab.ID}
541 app.activeTabID = tab.ID
542 t.Cleanup(func() {
543 release()
544 app.shutdown(context.Background())
545 })
546
547 // The request path never loads config: the first MetaForTab returns empty
548 // expensive fields even while the background refresh is parked inside the
549 // config-load hook.
550 first := app.MetaForTab(tab.ID)
551 if first.GitBranch != "" || first.ImageInputEnabled {
552 t.Fatalf("first MetaForTab = branch %q image %v, want empty cached values", first.GitBranch, first.ImageInputEnabled)
553 }
554 select {
555 case <-loadEntered:
556 case <-time.After(15 * time.Second):
557 t.Fatal("background meta refresh never reached the config load")
558 }
559 if got := loads.Load(); got != 1 {
560 t.Fatalf("config loads = %d, want exactly 1 (deduped background refresh)", got)
561 }
562 release()
563
564 var refreshed TabMetaRefreshEvent
565 select {
566 case refreshed = <-metaEvents:
567 case <-time.After(15 * time.Second):
568 t.Fatal("no tab:meta event after the background refresh")
569 }
570 if refreshed.TabID != tab.ID {
571 t.Fatalf("tab:meta tab = %q, want %q", refreshed.TabID, tab.ID)
572 }
573 if refreshed.Meta.GitBranch != "feature/meta-cache" {
574 t.Fatalf("refreshed branch = %q, want feature/meta-cache", refreshed.Meta.GitBranch)
575 }
576 if !refreshed.Meta.ImageInputEnabled {
577 t.Fatal("refreshed meta should enable image input for custom/vision-pro")
578 }
579 if !refreshed.Meta.VisionFallbackEnabled {
580 t.Fatal("refreshed meta should expose the configured image-understanding fallback")
581 }
582
583 // A fresh cache serves subsequent calls without another config load.
584 second := app.MetaForTab(tab.ID)
585 if second.GitBranch != "feature/meta-cache" || !second.ImageInputEnabled {
586 t.Fatalf("cached MetaForTab = branch %q image %v", second.GitBranch, second.ImageInputEnabled)
587 }
588 if got := loads.Load(); got != 1 {
589 t.Fatalf("config loads after cache hit = %d, want 1", got)
590 }
591
592 // A root change invalidates conservatively: the other root's cached values
593 // must not be served, and the background refresh repopulates for the new
594 // root (not a git repo here, so the branch stays empty).
595 app.mu.Lock()
596 tab.WorkspaceRoot = plainRoot
597 app.mu.Unlock()
598 third := app.MetaForTab(tab.ID)
599 if third.GitBranch != "" || third.ImageInputEnabled {
600 t.Fatalf("MetaForTab after root change = branch %q image %v, want empty", third.GitBranch, third.ImageInputEnabled)
601 }
602 select {
603 case ev := <-metaEvents:
604 if ev.Meta.GitBranch != "" {
605 t.Fatalf("refreshed branch for non-repo root = %q, want empty", ev.Meta.GitBranch)
606 }
607 case <-time.After(15 * time.Second):
608 t.Fatal("no tab:meta event after the root change")
609 }
610 if got := loads.Load(); got != 2 {
611 t.Fatalf("config loads after root change = %d, want 2", got)
612 }
613 }
614
614 lines GO