返回 DeepSeek-Reasonix
switch_recovery_test.go
根目录 / internal / serve / switch_recovery_test.go
1 package serve
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9 "time"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/control"
13 "reasonix/internal/event"
14 "reasonix/internal/provider"
15 "reasonix/internal/tool"
16 )
17
18 type switchAskProvider struct {
19 turn int
20 }
21
22 func (*switchAskProvider) Name() string { return "switch-ask" }
23
24 func (p *switchAskProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
25 ch := make(chan provider.Chunk, 2)
26 if p.turn == 0 {
27 ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
28 ID: "ask-after-switch",
29 Name: "ask",
30 Arguments: `{"questions":[{"header":"Direction","question":"Which path?","options":[{"label":"A"},{"label":"B"}]}]}`,
31 }}
32 } else {
33 ch <- provider.Chunk{Type: provider.ChunkText, Text: "done"}
34 }
35 p.turn++
36 ch <- provider.Chunk{Type: provider.ChunkDone}
37 close(ch)
38 return ch, nil
39 }
40
41 func TestSwitchModelKeepsAskInteractive(t *testing.T) {
42 t.Setenv("REASONIX_HOME", t.TempDir())
43 dir := t.TempDir()
44
45 bc := NewBroadcaster()
46 old := control.New(control.Options{
47 Executor: agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard),
48 SessionDir: dir,
49 Label: "old",
50 Sink: bc,
51 })
52 old.EnableInteractiveApproval()
53
54 askCh := make(chan event.Ask, 1)
55 s := &Server{ctrl: old, bc: bc}
56 s.buildController = func(_ context.Context, _ string) (*control.Controller, error) {
57 reg := tool.NewRegistry()
58 reg.Add(agent.NewAskTool())
59 exec := agent.New(&switchAskProvider{}, reg, agent.NewSession("sys"), agent.Options{}, event.Discard)
60 return control.New(control.Options{
61 Executor: exec,
62 SessionDir: dir,
63 Label: "new",
64 Sink: event.FuncSink(func(e event.Event) {
65 if e.Kind == event.AskRequest {
66 askCh <- e.Ask
67 }
68 }),
69 }), nil
70 }
71
72 if err := s.switchModel(context.Background(), "next-model"); err != nil {
73 t.Fatalf("switchModel: %v", err)
74 }
75
76 newCtrl := s.ctl().(*control.Controller)
77 runDone := make(chan error, 1)
78 go func() { runDone <- newCtrl.Executor().Run(context.Background(), "ask the user") }()
79
80 select {
81 case ask := <-askCh:
82 newCtrl.AnswerQuestion(ask.ID, []event.AskAnswer{{QuestionID: "q1", Selected: []string{"A"}}})
83 case err := <-runDone:
84 t.Fatalf("ask tool returned without an ask_request after model switch: %v", err)
85 case <-time.After(2 * time.Second):
86 t.Fatal("ask tool did not emit ask_request after model switch")
87 }
88
89 select {
90 case err := <-runDone:
91 if err != nil {
92 t.Fatalf("run after answering ask_request: %v", err)
93 }
94 case <-time.After(2 * time.Second):
95 t.Fatal("run stayed blocked after answering ask_request")
96 }
97 }
98
99 // primarySessionFiles filters a recovery-branch glob down to primary session
100 // transcripts, dropping the .events.jsonl / .guardian.jsonl sidecars that the
101 // *-recovery-*.jsonl pattern also matches.
102 func primarySessionFiles(paths []string) []string {
103 out := make([]string, 0, len(paths))
104 for _, path := range paths {
105 base := filepath.Base(path)
106 if strings.HasSuffix(base, ".jsonl") &&
107 !strings.HasSuffix(base, ".events.jsonl") &&
108 !strings.HasSuffix(base, ".guardian.jsonl") {
109 out = append(out, path)
110 }
111 }
112 return out
113 }
114
115 // TestSwitchModelContinuesRecoveryPathAfterSnapshotConflict is the serve twin
116 // of the desktop rebuild fix: when the pre-switch Snapshot hits a conflict and
117 // retargets the old controller to a recovery branch, the rebuilt controller
118 // must continue on that recovery path. Capturing prevPath before Snapshot
119 // bound the just-recovered transcript back to the original file, so every
120 // later save re-conflicted and derived yet another recovery branch.
121 func TestSwitchModelContinuesRecoveryPathAfterSnapshotConflict(t *testing.T) {
122 t.Setenv("REASONIX_HOME", t.TempDir())
123 dir := t.TempDir()
124 originalPath := filepath.Join(dir, "switch-conflict.jsonl")
125
126 disk := agent.NewSession("sys prompt")
127 disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
128 disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
129 disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
130 if err := disk.Save(originalPath); err != nil {
131 t.Fatalf("save disk session: %v", err)
132 }
133
134 stale := agent.NewSession("sys prompt")
135 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
136 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
137 stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
138
139 bc := NewBroadcaster()
140 old := control.New(control.Options{
141 Executor: agent.New(nil, nil, stale, agent.Options{}, event.Discard),
142 SessionDir: dir,
143 SessionPath: originalPath,
144 Label: "old",
145 Sink: bc,
146 })
147 s := &Server{ctrl: old, bc: bc}
148 leases := control.NewSessionLeaseKeeper()
149 t.Cleanup(leases.Release)
150 if err := leases.Rebind(originalPath); err != nil {
151 t.Fatalf("seed original lease: %v", err)
152 }
153 s.SetSessionLeases(leases)
154
155 var built *control.Controller
156 s.buildController = func(_ context.Context, _ string) (*control.Controller, error) {
157 built = control.New(control.Options{
158 Executor: agent.New(nil, nil, agent.NewSession("sys prompt"), agent.Options{}, event.Discard),
159 SessionDir: dir,
160 Label: "new",
161 Sink: bc,
162 })
163 return built, nil
164 }
165
166 if err := s.switchModel(context.Background(), "next-model"); err != nil {
167 t.Fatalf("switchModel: %v", err)
168 }
169
170 recoveryPath := built.SessionPath()
171 if recoveryPath == "" || recoveryPath == originalPath || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
172 t.Fatalf("switched session path = %q, want recovery path distinct from %q", recoveryPath, originalPath)
173 }
174 if s.ctl() != built {
175 t.Fatal("switchModel did not publish the rebuilt controller")
176 }
177 if got, want := leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
178 t.Fatalf("lease after pre-switch recovery = %q, want %q", got, want)
179 }
180
181 matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
182 if err != nil {
183 t.Fatalf("glob recovery branches: %v", err)
184 }
185 matches = primarySessionFiles(matches)
186 if len(matches) != 1 || matches[0] != recoveryPath {
187 t.Fatalf("recovery branches after switch = %v, want only %q", matches, recoveryPath)
188 }
189
190 // The rebuilt controller adopted the recovery file's baseline, so its next
191 // snapshot must not derive a second recovery branch.
192 if err := built.Snapshot(); err != nil {
193 t.Fatalf("Snapshot after switch: %v", err)
194 }
195 matches, err = filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
196 if err != nil {
197 t.Fatalf("glob recovery branches after snapshot: %v", err)
198 }
199 matches = primarySessionFiles(matches)
200 if len(matches) != 1 || matches[0] != recoveryPath {
201 t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath)
202 }
203
204 // A later ordinary autosave on the rebuilt controller must use the same
205 // ownership callback. Force another divergence after the switch and verify
206 // the keeper follows the second recovery before the controller commits it.
207 diskAfterSwitch, err := agent.LoadSession(recoveryPath)
208 if err != nil {
209 t.Fatalf("load recovery transcript for external change: %v", err)
210 }
211 diskAfterSwitch.Add(provider.Message{Role: provider.RoleUser, Content: "disk third"})
212 if err := diskAfterSwitch.Save(recoveryPath); err != nil {
213 t.Fatalf("save external recovery transcript change: %v", err)
214 }
215 built.Executor().Session().Add(provider.Message{Role: provider.RoleUser, Content: "local third"})
216 if err := built.Snapshot(); err != nil {
217 t.Fatalf("Snapshot rebuilt controller after divergence: %v", err)
218 }
219 secondRecoveryPath := built.SessionPath()
220 if secondRecoveryPath == recoveryPath || !strings.Contains(filepath.Base(secondRecoveryPath), "-recovery-") {
221 t.Fatalf("rebuilt controller path = %q, want recovery path distinct from %q", secondRecoveryPath, recoveryPath)
222 }
223 if got, want := leases.HeldPath(), agent.CanonicalSessionPath(secondRecoveryPath); got != want {
224 t.Fatalf("lease after rebuilt-controller recovery = %q, want %q", got, want)
225 }
226 }
227
228 // TestSwitchModelRefreshesLeadingSystemPrompt pins the fix for the bug where
229 // switchModel rebuilt the controller with the target model/profile's own
230 // system prompt, only for AdoptHistory to immediately overwrite it with the
231 // carried history's leading message — the outgoing controller's system
232 // prompt. The user-visible symptom was that the model kept following the
233 // previous system prompt after every /model switch.
234 func TestSwitchModelRefreshesLeadingSystemPrompt(t *testing.T) {
235 t.Setenv("REASONIX_HOME", t.TempDir())
236 dir := t.TempDir()
237
238 oldSession := agent.NewSession("old system prompt")
239 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
240 oldSession.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
241
242 bc := NewBroadcaster()
243 old := control.New(control.Options{
244 Executor: agent.New(nil, nil, oldSession, agent.Options{}, event.Discard),
245 SessionDir: dir,
246 Label: "old",
247 Sink: bc,
248 })
249 s := &Server{ctrl: old, bc: bc}
250 s.buildController = func(_ context.Context, _ string) (*control.Controller, error) {
251 return control.New(control.Options{
252 Executor: agent.New(nil, nil, agent.NewSession("new system prompt"), agent.Options{}, event.Discard),
253 SessionDir: dir,
254 Label: "new",
255 Sink: bc,
256 }), nil
257 }
258
259 if err := s.switchModel(context.Background(), "next-model"); err != nil {
260 t.Fatalf("switchModel: %v", err)
261 }
262
263 history := s.ctl().History()
264 if len(history) != 3 || history[0].Role != provider.RoleSystem {
265 t.Fatalf("history = %+v, want a leading system message", history)
266 }
267 if got, want := history[0].Content, "new system prompt"; got != want {
268 t.Fatalf("leading system message = %q, want %q (stale outgoing prompt carried forward)", got, want)
269 }
270 if history[1].Content != "hello" || history[2].Content != "hi" {
271 t.Fatalf("history after switch = %+v, want carried user/assistant turns preserved", history)
272 }
273 }
274
275 // TestSwitchModelRestoresSessionAuthorizations pins the fix for switchModel
276 // dropping same-session "Allow for this session" tool grants and Plan-mode
277 // read-only command trust on every /model switch, forcing the user to
278 // re-approve something already granted this session.
279 func TestSwitchModelRestoresSessionAuthorizations(t *testing.T) {
280 t.Setenv("REASONIX_HOME", t.TempDir())
281 dir := t.TempDir()
282
283 bc := NewBroadcaster()
284 old := control.New(control.Options{
285 Executor: agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard),
286 SessionDir: dir,
287 Label: "old",
288 Sink: bc,
289 })
290 old.RestoreSessionAuthorizations(control.SessionAuthorizations{
291 Grants: []string{"bash|go test ./..."},
292 PlanModeReadOnlyCommands: []string{"go test ./..."},
293 })
294
295 s := &Server{ctrl: old, bc: bc}
296 s.buildController = func(_ context.Context, _ string) (*control.Controller, error) {
297 return control.New(control.Options{
298 Executor: agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard),
299 SessionDir: dir,
300 Label: "new",
301 Sink: bc,
302 }), nil
303 }
304
305 if err := s.switchModel(context.Background(), "next-model"); err != nil {
306 t.Fatalf("switchModel: %v", err)
307 }
308
309 newCtrl, ok := s.ctl().(*control.Controller)
310 if !ok {
311 t.Fatalf("s.ctl() = %T, want *control.Controller", s.ctl())
312 }
313 got := newCtrl.SessionAuthorizations()
314 if len(got.Grants) != 1 || got.Grants[0] != "bash|go test ./..." {
315 t.Fatalf("restored grants = %+v, want [\"bash|go test ./...\"]", got.Grants)
316 }
317 if len(got.PlanModeReadOnlyCommands) != 1 || got.PlanModeReadOnlyCommands[0] != "go test ./..." {
318 t.Fatalf("restored plan-mode read-only commands = %+v, want [\"go test ./...\"]", got.PlanModeReadOnlyCommands)
319 }
320 }
321
322 // TestSwitchModelPersistsRefreshedSystemPromptToDisk pins the disk half of the
323 // system-prompt splice: switchModel refreshes the leading system message in
324 // the new controller's memory, and nothing snapshots an idle session again, so
325 // the switch itself must persist the adopted history or a restart + /resume
326 // revives the outgoing controller's contract from disk.
327 func TestSwitchModelPersistsRefreshedSystemPromptToDisk(t *testing.T) {
328 t.Setenv("REASONIX_HOME", t.TempDir())
329 dir := t.TempDir()
330 path := filepath.Join(dir, "switch-persist.jsonl")
331
332 oldSession := agent.NewSession("old system prompt")
333 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
334 oldSession.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
335 if err := oldSession.Save(path); err != nil {
336 t.Fatalf("save base session: %v", err)
337 }
338
339 bc := NewBroadcaster()
340 old := control.New(control.Options{
341 Executor: agent.New(nil, nil, oldSession, agent.Options{}, event.Discard),
342 SessionDir: dir,
343 SessionPath: path,
344 Label: "old",
345 Sink: bc,
346 })
347 s := &Server{ctrl: old, bc: bc}
348 s.buildController = func(_ context.Context, _ string) (*control.Controller, error) {
349 return control.New(control.Options{
350 Executor: agent.New(nil, nil, agent.NewSession("new system prompt"), agent.Options{}, event.Discard),
351 SessionDir: dir,
352 Label: "new",
353 Sink: bc,
354 }), nil
355 }
356
357 if err := s.switchModel(context.Background(), "next-model"); err != nil {
358 t.Fatalf("switchModel: %v", err)
359 }
360
361 loaded, err := agent.LoadSession(s.ctl().SessionPath())
362 if err != nil {
363 t.Fatalf("load transcript after switch: %v", err)
364 }
365 msgs := loaded.Snapshot()
366 if len(msgs) != 3 || msgs[0].Role != provider.RoleSystem {
367 t.Fatalf("on-disk history after switch = %+v, want 3 messages with a leading system message", msgs)
368 }
369 if got, want := msgs[0].Content, "new system prompt"; got != want {
370 t.Fatalf("on-disk leading system message = %q, want %q (a restart would revive the outgoing contract)", got, want)
371 }
372 }
373
374 func TestSwitchModelSnapshotFailureKeepsOldController(t *testing.T) {
375 t.Setenv("REASONIX_HOME", t.TempDir())
376 invalidSessionDir := filepath.Join(t.TempDir(), "session-dir-is-a-file")
377 if err := os.WriteFile(invalidSessionDir, []byte("not a directory"), 0o644); err != nil {
378 t.Fatalf("write invalid session dir: %v", err)
379 }
380
381 oldSession := agent.NewSession("old system prompt")
382 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
383 bc := NewBroadcaster()
384 old := control.New(control.Options{
385 Executor: agent.New(nil, nil, oldSession, agent.Options{}, event.Discard),
386 Label: "old",
387 Sink: bc,
388 })
389 t.Cleanup(old.Close)
390 s := &Server{ctrl: old, bc: bc}
391 s.buildController = func(_ context.Context, _ string) (*control.Controller, error) {
392 return control.New(control.Options{
393 Executor: agent.New(nil, nil, agent.NewSession("new system prompt"), agent.Options{}, event.Discard),
394 SessionDir: invalidSessionDir,
395 Label: "new",
396 Sink: bc,
397 }), nil
398 }
399
400 err := s.switchModel(context.Background(), "next-model")
401 if err == nil || !strings.Contains(err.Error(), "snapshot adopted history") {
402 t.Fatalf("switchModel error = %v, want snapshot adopted history failure", err)
403 }
404 if got := s.ctl(); got != old {
405 t.Fatalf("active controller changed after persistence failure: got %T %p, want outgoing %p", got, got, old)
406 }
407 }
408
408 lines GO