返回 DeepSeek-Reasonix
runtime_rebuilt_event_test.go
根目录 / desktop / runtime_rebuilt_event_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "sync"
8 "testing"
9 "time"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/config"
13 "reasonix/internal/control"
14 "reasonix/internal/event"
15 "reasonix/internal/provider"
16 )
17
18 // TestRuntimeRebuildsEmitRuntimeRebuiltForTab pins the frontend contract the
19 // prompt-chime dedupe depends on: every in-place controller replacement must
20 // announce itself. Model, effort, and token-mode switches rebuild the tab's
21 // controller WITHOUT an agent:ready — the rebuilt controller restarts its
22 // approval/ask id counter at "1", so without runtime:rebuilt the frontend's
23 // id-keyed chime dedupe mutes the first prompt after a switch.
24 func TestRuntimeRebuildsEmitRuntimeRebuiltForTab(t *testing.T) {
25 isolateDesktopUserDirs(t)
26 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
27 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
28
29 cfg := config.Default()
30 cfg.DefaultModel = "old/old-model"
31 cfg.Desktop.ProviderAccess = []string{"old", "new"}
32 cfg.Providers = []config.ProviderEntry{
33 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
34 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "deepseek-v4-pro", APIKeyEnv: "NEW_MODEL_KEY"},
35 }
36 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
37 t.Fatalf("save config: %v", err)
38 }
39
40 dir := config.SessionDir()
41 if err := os.MkdirAll(dir, 0o755); err != nil {
42 t.Fatalf("mkdir session dir: %v", err)
43 }
44 sess := agent.NewSession("sys")
45 sess.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
46 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
47 path := filepath.Join(dir, "rebuild-events.jsonl")
48 ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
49
50 app := NewApp()
51 app.ctx = context.Background()
52 // emitReady calls the Wails runtime directly; the ready hook keeps the
53 // workspace-reconcile path (which SetEffortForTab can take) off the real
54 // event bridge, which log.Fatals on a plain Background context.
55 app.readyHook = func() {}
56
57 var mu sync.Mutex
58 var rebuilt []string
59 // The App-level queue must stay silent: ordering against the tab's agent
60 // events only holds when the notice rides the tab sink's own queue, so a
61 // notice showing up here means the routing regressed to the fallback.
62 app.runtimeEvents.emit = func(_ context.Context, name string, _ ...interface{}) {
63 if name == "runtime:rebuilt" {
64 mu.Lock()
65 rebuilt = append(rebuilt, "VIA-APP-QUEUE")
66 mu.Unlock()
67 }
68 }
69 sinkEmit := func(_ context.Context, name string, payload ...interface{}) {
70 if name != "runtime:rebuilt" {
71 return
72 }
73 tabID := ""
74 if len(payload) > 0 {
75 tabID, _ = payload[0].(string)
76 }
77 mu.Lock()
78 rebuilt = append(rebuilt, tabID)
79 mu.Unlock()
80 }
81
82 tab := &WorkspaceTab{
83 ID: "tab_rebuild_events",
84 Scope: "global",
85 WorkspaceRoot: globalTabWorkspaceRoot(),
86 Ready: true,
87 model: "old/old-model",
88 Ctrl: ctrl,
89 sink: &tabEventSink{tabID: "tab_rebuild_events", app: app, ctx: context.Background()},
90 disabledMCP: map[string]ServerView{},
91 }
92 tab.sink.runtimeEvents.emit = sinkEmit
93 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
94 app.tabOrder = []string{tab.ID}
95 app.activeTabID = tab.ID
96 t.Cleanup(func() {
97 if tab.Ctrl != nil {
98 tab.Ctrl.Close()
99 }
100 })
101
102 waitCount := func(want int, step string) {
103 t.Helper()
104 deadline := time.Now().Add(5 * time.Second)
105 for time.Now().Before(deadline) {
106 mu.Lock()
107 n := len(rebuilt)
108 mu.Unlock()
109 if n >= want {
110 return
111 }
112 time.Sleep(10 * time.Millisecond)
113 }
114 mu.Lock()
115 defer mu.Unlock()
116 t.Fatalf("after %s: runtime:rebuilt events = %v, want %d", step, rebuilt, want)
117 }
118
119 if err := app.SetModelForTab(tab.ID, "new/deepseek-v4-pro"); err != nil {
120 t.Fatalf("SetModelForTab: %v", err)
121 }
122 waitCount(1, "model switch")
123
124 if err := app.SetEffortForTab(tab.ID, "high"); err != nil {
125 t.Fatalf("SetEffortForTab: %v", err)
126 }
127 waitCount(2, "effort switch")
128
129 if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
130 t.Fatalf("SetTokenModeForTab: %v", err)
131 }
132 waitCount(3, "token-mode switch")
133
134 mu.Lock()
135 defer mu.Unlock()
136 for i, id := range rebuilt {
137 if id == "VIA-APP-QUEUE" {
138 t.Fatalf("event %d took the App-level fallback queue; it must ride the tab sink queue so it orders before the rebuilt controller's agent events (full: %v)", i, rebuilt)
139 }
140 if id != tab.ID {
141 t.Fatalf("event %d carried tab id %q, want %q (full: %v)", i, id, tab.ID, rebuilt)
142 }
143 }
144 }
145
145 lines GO