返回 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 chime-dedupe contract:
19 // model/effort rebuilds emit runtime:rebuilt; deprecated SetTokenMode does not.
20 func TestRuntimeRebuildsEmitRuntimeRebuiltForTab(t *testing.T) {
21 isolateDesktopUserDirs(t)
22 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
23 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
24
25 cfg := config.Default()
26 cfg.DefaultModel = "old/old-model"
27 cfg.Desktop.ProviderAccess = []string{"old", "new"}
28 cfg.Providers = []config.ProviderEntry{
29 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
30 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "deepseek-v4-pro", APIKeyEnv: "NEW_MODEL_KEY"},
31 }
32 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
33 t.Fatalf("save config: %v", err)
34 }
35
36 dir := config.SessionDir()
37 if err := os.MkdirAll(dir, 0o755); err != nil {
38 t.Fatalf("mkdir session dir: %v", err)
39 }
40 sess := agent.NewSession("sys")
41 sess.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
42 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
43 path := filepath.Join(dir, "rebuild-events.jsonl")
44 ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
45
46 app := NewApp()
47 app.ctx = context.Background()
48 // emitReady calls the Wails runtime directly; the ready hook keeps the
49 // workspace-reconcile path (which SetEffortForTab can take) off the real
50 // event bridge, which log.Fatals on a plain Background context.
51 app.readyHook = func() {}
52
53 var mu sync.Mutex
54 var rebuilt []string
55 // The App-level queue must stay silent: ordering against the tab's agent
56 // events only holds when the notice rides the tab sink's own queue, so a
57 // notice showing up here means the routing regressed to the fallback.
58 app.runtimeEvents.emit = func(_ context.Context, name string, _ ...any) {
59 if name == "runtime:rebuilt" {
60 mu.Lock()
61 rebuilt = append(rebuilt, "VIA-APP-QUEUE")
62 mu.Unlock()
63 }
64 }
65 sinkEmit := func(_ context.Context, name string, payload ...any) {
66 if name != "runtime:rebuilt" {
67 return
68 }
69 tabID := ""
70 if len(payload) > 0 {
71 tabID, _ = payload[0].(string)
72 }
73 mu.Lock()
74 rebuilt = append(rebuilt, tabID)
75 mu.Unlock()
76 }
77
78 tab := &WorkspaceTab{
79 ID: "tab_rebuild_events",
80 Scope: "global",
81 WorkspaceRoot: globalTabWorkspaceRoot(),
82 Ready: true,
83 model: "old/old-model",
84 Ctrl: ctrl,
85 sink: &tabEventSink{tabID: "tab_rebuild_events", app: app, ctx: context.Background()},
86 disabledMCP: map[string]ServerView{},
87 }
88 tab.sink.runtimeEvents.emit = sinkEmit
89 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
90 app.tabOrder = []string{tab.ID}
91 app.activeTabID = tab.ID
92 t.Cleanup(func() {
93 if tab.Ctrl != nil {
94 tab.Ctrl.Close()
95 }
96 })
97
98 waitCount := func(want int, step string) {
99 t.Helper()
100 deadline := time.Now().Add(5 * time.Second)
101 for time.Now().Before(deadline) {
102 mu.Lock()
103 n := len(rebuilt)
104 mu.Unlock()
105 if n >= want {
106 return
107 }
108 time.Sleep(10 * time.Millisecond)
109 }
110 mu.Lock()
111 defer mu.Unlock()
112 t.Fatalf("after %s: runtime:rebuilt events = %v, want %d", step, rebuilt, want)
113 }
114
115 if err := app.SetModelForTab(tab.ID, "new/deepseek-v4-pro"); err != nil {
116 t.Fatalf("SetModelForTab: %v", err)
117 }
118 waitCount(1, "model switch")
119
120 if err := app.SetEffortForTab(tab.ID, "high"); err != nil {
121 t.Fatalf("SetEffortForTab: %v", err)
122 }
123 waitCount(2, "effort switch")
124
125 if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
126 t.Fatalf("SetTokenModeForTab: %v", err)
127 }
128 // Give a real rebuild event time to arrive if the no-op regresses.
129 time.Sleep(50 * time.Millisecond)
130 mu.Lock()
131 if len(rebuilt) != 2 {
132 t.Fatalf("after SetTokenModeForTab: runtime:rebuilt events = %v, want 2 (no rebuild)", rebuilt)
133 }
134 for i, id := range rebuilt {
135 if id == "VIA-APP-QUEUE" {
136 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)
137 }
138 if id != tab.ID {
139 t.Fatalf("event %d carried tab id %q, want %q (full: %v)", i, id, tab.ID, rebuilt)
140 }
141 }
142 mu.Unlock()
143 }
144
145 // TestRuntimeReattachFencesPendingAskBeforeReplay pins the detached-runtime
146 // handoff order. A transferred controller keeps its pending ask, but the
147 // frontend must learn the transferred epoch before that ask reaches it.
148 func TestRuntimeReattachFencesPendingAskBeforeReplay(t *testing.T) {
149 type emittedEvent struct {
150 name string
151 payload []any
152 }
153 emitted := make(chan emittedEvent, 4)
154 sink := &tabEventSink{
155 tabID: "tab-reattach",
156 ctx: context.Background(),
157 runtimeEpoch: "runtime-new",
158 }
159 sink.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
160 emitted <- emittedEvent{name: name, payload: payload}
161 }
162
163 ctrl := control.New(control.Options{Sink: sink})
164 ctx, cancel := context.WithCancel(t.Context())
165 done := make(chan struct{})
166 go func() {
167 defer close(done)
168 _, _ = ctrl.Ask(ctx, []event.AskQuestion{{ID: "choice", Prompt: "Pick one"}})
169 }()
170 t.Cleanup(func() {
171 cancel()
172 <-done
173 ctrl.Close()
174 })
175
176 select {
177 case initial := <-emitted:
178 if initial.name != eventChannel {
179 t.Fatalf("initial event = %q, want %q", initial.name, eventChannel)
180 }
181 case <-time.After(2 * time.Second):
182 t.Fatal("timed out waiting for initial ask")
183 }
184
185 app := NewApp()
186 tab := &WorkspaceTab{ID: "tab-reattach", Ctrl: ctrl, sink: sink, Ready: true}
187 app.replayPendingPromptsAfterRuntimeAttach(tab.ID, sink, ctrl, "runtime-new")
188
189 var got []emittedEvent
190 for len(got) < 2 {
191 select {
192 case next := <-emitted:
193 got = append(got, next)
194 case <-time.After(2 * time.Second):
195 t.Fatalf("timed out waiting for reattach events; got %+v", got)
196 }
197 }
198 if got[0].name != "runtime:rebuilt" || got[1].name != eventChannel {
199 t.Fatalf("reattach event order = [%s, %s], want [runtime:rebuilt, %s]", got[0].name, got[1].name, eventChannel)
200 }
201 if len(got[0].payload) < 2 || got[0].payload[0] != tab.ID || got[0].payload[1] != "runtime-new" {
202 t.Fatalf("runtime:rebuilt payload = %#v", got[0].payload)
203 }
204 }
205
205 lines GO