返回 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 // activationStubController is the minimal SessionAPI surface exercised by
213 // prune/detach/attach flows around a tab with active runtime work.
214 type activationStubController struct {
215 stubSessionAPI
216 sessionPath string
217 closed atomic.Bool
218 status *control.RuntimeStatus
219 }
220
221 func (c *activationStubController) RuntimeStatus() control.RuntimeStatus {
222 if c.status != nil {
223 return *c.status
224 }
225 return control.RuntimeStatus{Running: true}
226 }
227 func (c *activationStubController) SessionPath() string { return c.sessionPath }
228 func (c *activationStubController) SetSessionPath(p string) { c.sessionPath = p }
229 func (c *activationStubController) SessionDir() string { return "" }
230 func (c *activationStubController) Snapshot() error { return nil }
231 func (c *activationStubController) Cancel() {}
232 func (c *activationStubController) Close() { c.closed.Store(true) }
233 func (c *activationStubController) Label() string { return "stub-model" }
234 func (c *activationStubController) ReplayPendingPrompts() {}
235 func (c *activationStubController) PlanMode() bool { return false }
236 func (c *activationStubController) AutoApproveTools() bool { return false }
237 func (c *activationStubController) ToolApprovalMode() string { return "" }
238 func (c *activationStubController) Goal() string { return "" }
239 func (c *activationStubController) GoalStatus() string { return "" }
240 func (c *activationStubController) Turn() int { return 0 }
241 func (c *activationStubController) GoalRuntime() control.GoalRuntimeView {
242 return control.GoalRuntimeView{}
243 }
244 func (c *activationStubController) Todos() []evidence.TodoItem { return nil }
245 func (c *activationStubController) SnapshotForShutdown() error { return nil }
246
247 func TestActivateTopicSupersedesPendingTicketedActivation(t *testing.T) {
248 isolateDesktopUserDirs(t)
249 app := NewApp()
250 app.ctx = context.Background()
251 readyCh := make(chan struct{}, 8)
252 app.readyHook = func() { readyCh <- struct{}{} }
253 installNoopRuntimeEvents(app)
254 events := newActivationEventRecorder(app)
255 gate := newTabBuildGate(app)
256 t.Cleanup(func() {
257 gate.releaseAll()
258 app.shutdown(context.Background())
259 })
260
261 ticketA, err := app.StartTopicActivation(TopicActivationRequest{Scope: "global", TopicID: "topic-a", RequestID: "req-a"})
262 if err != nil {
263 t.Fatalf("StartTopicActivation A: %v", err)
264 }
265 if got := events.next(t); got.Phase != "starting" {
266 t.Fatalf("event = %+v, want starting", got)
267 }
268 gate.waitEntered(t, ticketA.TabID)
269 app.mu.RLock()
270 tabA := app.tabs[ticketA.TabID]
271 var tabABuildDone chan struct{}
272 if tabA != nil {
273 // Snapshot the build-done channel now: closeTabBuildDone nils the
274 // field after closing it, so reading it after the fact would block on
275 // a nil channel forever.
276 tabABuildDone = tabA.buildDone
277 }
278 app.mu.RUnlock()
279 if tabA == nil {
280 t.Fatal("tab A missing after activation start")
281 }
282
283 // A legacy ActivateTopic call supersedes the pending ticketed activation
284 // and keeps its own synchronous contract: it returns after the prune.
285 type legacyResult struct {
286 meta TabMeta
287 err error
288 }
289 legacyDone := make(chan legacyResult, 1)
290 go func() {
291 meta, err := app.ActivateTopic("global", "", "topic-b", "")
292 legacyDone <- legacyResult{meta: meta, err: err}
293 }()
294 if got := events.next(t); got != (TopicActivationEvent{RequestID: "req-a", TabID: ticketA.TabID, Phase: "cancelled"}) {
295 t.Fatalf("event = %+v, want cancelled req-a", got)
296 }
297 // The legacy prune queues the runtime-admission WRITE lock. Go's RWMutex
298 // blocks new readers behind a queued writer, so tab B's gated build may
299 // not be able to enter the gate until the prune has run — release A first
300 // (its build abandons via the superseded path), which unblocks the prune
301 // in the interleaving where B's build is still stuck behind the writer.
302 app.mu.RLock()
303 var tabBID string
304 for id := range app.tabs {
305 if id != ticketA.TabID {
306 tabBID = id
307 }
308 }
309 app.mu.RUnlock()
310 if tabBID == "" {
311 t.Fatal("legacy activation did not open tab B")
312 }
313 gate.release(ticketA.TabID)
314 gate.waitEntered(t, tabBID)
315 gate.release(tabBID)
316
317 var legacy legacyResult
318 select {
319 case legacy = <-legacyDone:
320 case <-time.After(15 * time.Second):
321 t.Fatal("legacy ActivateTopic did not return")
322 }
323 if legacy.err != nil {
324 t.Fatalf("legacy ActivateTopic: %v", legacy.err)
325 }
326 if legacy.meta.ID != tabBID {
327 t.Fatalf("legacy meta tab = %q, want %q", legacy.meta.ID, tabBID)
328 }
329 <-readyCh // B's build published and emitted agent:ready
330
331 assertTabIDs(t, app.ListTabs(), tabBID)
332 <-tabABuildDone // A's abandoned build terminated
333 flushActivationCompletions(app)
334 // The legacy path emits no activation events of its own, and the
335 // superseded completion stays silent.
336 events.drainEmpty(t)
337 }
338
339 func TestSetActiveTabSupersedesPendingPublication(t *testing.T) {
340 isolateDesktopUserDirs(t)
341 app := NewApp()
342 app.ctx = context.Background()
343 readyCh := make(chan struct{}, 8)
344 app.readyHook = func() { readyCh <- struct{}{} }
345 installNoopRuntimeEvents(app)
346 events := newActivationEventRecorder(app)
347 t.Cleanup(func() { app.shutdown(context.Background()) })
348
349 // Establish a settled visible tab through the legacy path (ungated).
350 metaB, err := app.ActivateTopic("global", "", "topic-b", "")
351 if err != nil {
352 t.Fatalf("ActivateTopic B: %v", err)
353 }
354 select {
355 case <-readyCh:
356 case <-time.After(15 * time.Second):
357 t.Fatal("tab B build did not finish")
358 }
359
360 gate := newTabBuildGate(app)
361 t.Cleanup(gate.releaseAll)
362 ticketA, err := app.StartTopicActivation(TopicActivationRequest{Scope: "global", TopicID: "topic-a", RequestID: "req-a"})
363 if err != nil {
364 t.Fatalf("StartTopicActivation A: %v", err)
365 }
366 if got := events.next(t); got.Phase != "starting" {
367 t.Fatalf("event = %+v, want starting", got)
368 }
369 gate.waitEntered(t, ticketA.TabID)
370 app.mu.RLock()
371 tabA := app.tabs[ticketA.TabID]
372 var tabABuildDone chan struct{}
373 if tabA != nil {
374 // Snapshot before the build terminates: closeTabBuildDone nils the
375 // field after closing the channel.
376 tabABuildDone = tabA.buildDone
377 }
378 app.mu.RUnlock()
379 if tabA == nil || tabABuildDone == nil {
380 t.Fatal("tab A missing or has no in-flight build")
381 }
382
383 // The user clicks tab B directly: the pending activation's publication
384 // (prune + ready) is superseded, but its build is not cancelled — tab A
385 // stays open and may legitimately become ready.
386 if err := app.SetActiveTab(metaB.ID); err != nil {
387 t.Fatalf("SetActiveTab B: %v", err)
388 }
389 if got := events.next(t); got != (TopicActivationEvent{RequestID: "req-a", TabID: ticketA.TabID, Phase: "cancelled"}) {
390 t.Fatalf("event = %+v, want cancelled req-a", got)
391 }
392 gate.release(ticketA.TabID)
393 select {
394 case <-readyCh: // A's build still completes and publishes
395 case <-time.After(15 * time.Second):
396 t.Fatal("tab A build did not finish after SetActiveTab")
397 }
398
399 app.mu.RLock()
400 tabAReady := tabA.Ready
401 tabCount := len(app.tabs)
402 active := app.activeTabID
403 app.mu.RUnlock()
404 if tabCount != 2 {
405 t.Fatalf("tab count = %d, want 2 (SetActiveTab must not prune)", tabCount)
406 }
407 if active != metaB.ID {
408 t.Fatalf("active tab = %q, want %q", active, metaB.ID)
409 }
410 if !tabAReady {
411 t.Fatal("tab A build was cancelled or unpublished by SetActiveTab")
412 }
413 <-tabABuildDone
414 flushActivationCompletions(app)
415 events.drainEmpty(t)
416 }
417
418 func TestMetaForTabFastPathCachesExpensiveFields(t *testing.T) {
419 isolateDesktopUserDirs(t)
420 setDesktopTestCredential(t, "CUSTOM_KEY", "sk-test")
421
422 cfg := config.Default()
423 cfg.DefaultModel = "custom/vision-pro"
424 cfg.Agent.VisionModel = "auto"
425 cfg.Desktop.ProviderAccess = []string{"custom"}
426 cfg.Providers = []config.ProviderEntry{{
427 Name: "custom",
428 Kind: "openai",
429 BaseURL: "https://example.invalid/v1",
430 APIKeyEnv: "CUSTOM_KEY",
431 Models: []string{"text-only", "vision-pro"},
432 VisionModels: []string{"vision-pro"},
433 }}
434 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
435 t.Fatalf("save config: %v", err)
436 }
437
438 repoRoot := t.TempDir()
439 runGitIn(t, repoRoot, "init")
440 runGitIn(t, repoRoot, "checkout", "-b", "feature/meta-cache")
441 plainRoot := t.TempDir()
442
443 app := NewApp()
444 app.ctx = context.Background()
445 app.readyHook = func() {}
446 metaEvents := make(chan TabMetaRefreshEvent, 8)
447 // NOTE: install the capture AFTER any installNoopRuntimeEvents call — that
448 // helper overwrites app.runtimeEvents.emit.
449 installNoopRuntimeEvents(app)
450 app.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
451 if name != tabMetaRefreshEventChannel || len(payload) == 0 {
452 return
453 }
454 if ev, ok := payload[0].(TabMetaRefreshEvent); ok {
455 metaEvents <- ev
456 }
457 }
458 var loads atomic.Int32
459 var blockLoad atomic.Bool
460 blockLoad.Store(true)
461 loadEntered := make(chan struct{})
462 var loadOnce sync.Once
463 releaseLoad := make(chan struct{})
464 var releaseOnce sync.Once
465 release := func() {
466 releaseOnce.Do(func() {
467 blockLoad.Store(false)
468 close(releaseLoad)
469 })
470 }
471 app.configLoadForRootHook = func(string) {
472 loads.Add(1)
473 loadOnce.Do(func() { close(loadEntered) })
474 if blockLoad.Load() {
475 <-releaseLoad
476 }
477 }
478
479 tab := &WorkspaceTab{
480 ID: "meta-tab",
481 Scope: "project",
482 WorkspaceRoot: repoRoot,
483 Label: "custom/vision-pro",
484 model: "custom/vision-pro",
485 disabledMCP: map[string]ServerView{},
486 }
487 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
488 installNoopRuntimeEvents(nil, tab.sink) // sink only — keep the capture on app.runtimeEvents
489 app.tabs[tab.ID] = tab
490 app.tabOrder = []string{tab.ID}
491 app.activeTabID = tab.ID
492 t.Cleanup(func() {
493 release()
494 app.shutdown(context.Background())
495 })
496
497 // The request path never loads config: the first MetaForTab returns empty
498 // expensive fields even while the background refresh is parked inside the
499 // config-load hook.
500 first := app.MetaForTab(tab.ID)
501 if first.GitBranch != "" || first.ImageInputEnabled {
502 t.Fatalf("first MetaForTab = branch %q image %v, want empty cached values", first.GitBranch, first.ImageInputEnabled)
503 }
504 select {
505 case <-loadEntered:
506 case <-time.After(15 * time.Second):
507 t.Fatal("background meta refresh never reached the config load")
508 }
509 if got := loads.Load(); got != 1 {
510 t.Fatalf("config loads = %d, want exactly 1 (deduped background refresh)", got)
511 }
512 release()
513
514 var refreshed TabMetaRefreshEvent
515 select {
516 case refreshed = <-metaEvents:
517 case <-time.After(15 * time.Second):
518 t.Fatal("no tab:meta event after the background refresh")
519 }
520 if refreshed.TabID != tab.ID {
521 t.Fatalf("tab:meta tab = %q, want %q", refreshed.TabID, tab.ID)
522 }
523 if refreshed.Meta.GitBranch != "feature/meta-cache" {
524 t.Fatalf("refreshed branch = %q, want feature/meta-cache", refreshed.Meta.GitBranch)
525 }
526 if !refreshed.Meta.ImageInputEnabled {
527 t.Fatal("refreshed meta should enable image input for custom/vision-pro")
528 }
529 if !refreshed.Meta.VisionFallbackEnabled {
530 t.Fatal("refreshed meta should expose the configured image-understanding fallback")
531 }
532
533 // A fresh cache serves subsequent calls without another config load.
534 second := app.MetaForTab(tab.ID)
535 if second.GitBranch != "feature/meta-cache" || !second.ImageInputEnabled {
536 t.Fatalf("cached MetaForTab = branch %q image %v", second.GitBranch, second.ImageInputEnabled)
537 }
538 if got := loads.Load(); got != 1 {
539 t.Fatalf("config loads after cache hit = %d, want 1", got)
540 }
541
542 // A root change invalidates conservatively: the other root's cached values
543 // must not be served, and the background refresh repopulates for the new
544 // root (not a git repo here, so the branch stays empty).
545 app.mu.Lock()
546 tab.WorkspaceRoot = plainRoot
547 app.mu.Unlock()
548 third := app.MetaForTab(tab.ID)
549 if third.GitBranch != "" || third.ImageInputEnabled {
550 t.Fatalf("MetaForTab after root change = branch %q image %v, want empty", third.GitBranch, third.ImageInputEnabled)
551 }
552 select {
553 case ev := <-metaEvents:
554 if ev.Meta.GitBranch != "" {
555 t.Fatalf("refreshed branch for non-repo root = %q, want empty", ev.Meta.GitBranch)
556 }
557 case <-time.After(15 * time.Second):
558 t.Fatal("no tab:meta event after the root change")
559 }
560 if got := loads.Load(); got != 2 {
561 t.Fatalf("config loads after root change = %d, want 2", got)
562 }
563 }
564
564 lines GO