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