返回 DeepSeek-Reasonix
switch_recovery_test.go
根目录 / internal / cli / switch_recovery_test.go
1 package cli
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 tea "charm.land/bubbletea/v2"
14
15 "reasonix/internal/agent"
16 "reasonix/internal/config"
17 "reasonix/internal/control"
18 "reasonix/internal/event"
19 "reasonix/internal/jobs"
20 "reasonix/internal/provider"
21 )
22
23 func chatTUIWithRunningBackgroundJob(t *testing.T) chatTUI {
24 t.Helper()
25 manager := jobs.NewManager(event.Discard)
26 ctrl := control.New(control.Options{Jobs: manager})
27 t.Cleanup(ctrl.Close)
28 manager.Start("task", "running", func(ctx context.Context, _ io.Writer) (string, error) {
29 <-ctx.Done()
30 return "", ctx.Err()
31 })
32 m := newTestChatTUI()
33 m.ctrl = ctrl
34 m.modelRef = "deepseek-flash/deepseek-v4-flash"
35 m.runtimeProfile = "full"
36 m.buildController = func(controllerBuildSpec, []provider.Message, string, control.SessionAPI) (*control.Controller, error) {
37 t.Fatal("runtime switch built a replacement while a background job was running")
38 return nil, nil
39 }
40 return m
41 }
42
43 func TestRuntimeSwitchesRejectRunningBackgroundJobs(t *testing.T) {
44 t.Run("model", func(t *testing.T) {
45 m := chatTUIWithRunningBackgroundJob(t)
46 m.runModelSubcommand("/model deepseek-chat/deepseek-chat")
47 if m.pendingModelSwitch != nil {
48 t.Fatal("model switch queued a rebuild while a background job was running")
49 }
50 })
51
52 t.Run("effort", func(t *testing.T) {
53 isolateUserConfig(t)
54 m := chatTUIWithRunningBackgroundJob(t)
55 if cmd := m.runEffortCommand("/effort max"); cmd != nil {
56 t.Fatal("effort switch queued a rebuild while a background job was running")
57 }
58 })
59
60 t.Run("skill refresh", func(t *testing.T) {
61 m := chatTUIWithRunningBackgroundJob(t)
62 if m.scheduleSkillSessionRefresh("skill refresh", "") {
63 t.Fatal("skill refresh queued a rebuild while a background job was running")
64 }
65 })
66
67 t.Run("work mode", func(t *testing.T) {
68 m := chatTUIWithRunningBackgroundJob(t)
69 if cmd := m.runWorkModeCommand("/work-mode delivery"); cmd != nil {
70 t.Fatal("work-mode switch queued a rebuild while a background job was running")
71 }
72 })
73
74 t.Run("language", func(t *testing.T) {
75 isolateUserConfig(t)
76 m := chatTUIWithRunningBackgroundJob(t)
77 if cmd := m.runLanguageSubcommand("/language zh"); cmd != nil {
78 t.Fatal("language switch queued a rebuild while a background job was running")
79 }
80 if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
81 t.Fatalf("blocked language switch wrote config, stat err=%v", err)
82 }
83 })
84
85 t.Run("currency", func(t *testing.T) {
86 isolateUserConfig(t)
87 m := chatTUIWithRunningBackgroundJob(t)
88 if cmd := m.runCurrencySubcommand("/currency CNY"); cmd != nil {
89 t.Fatal("currency switch queued a rebuild while a background job was running")
90 }
91 if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
92 t.Fatalf("blocked currency switch wrote config, stat err=%v", err)
93 }
94 })
95 }
96
97 // divergedSessionController builds a controller whose in-memory transcript has
98 // diverged from what path holds on disk, so its next Snapshot hits a conflict
99 // and retargets the controller to a recovery branch.
100 func divergedSessionController(t *testing.T, dir, path string) *control.Controller {
101 return divergedSessionControllerWithRecovery(t, dir, path, nil)
102 }
103
104 func divergedSessionControllerWithRecovery(t *testing.T, dir, path string, onRecovered func(control.SessionRecoveryInfo) error) *control.Controller {
105 t.Helper()
106 disk := agent.NewSession("sys prompt")
107 disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
108 disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
109 disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
110 if err := disk.Save(path); err != nil {
111 t.Fatalf("save disk session: %v", err)
112 }
113
114 stale := agent.NewSession("sys prompt")
115 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
116 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
117 stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
118 return control.New(control.Options{
119 Executor: agent.New(nil, nil, stale, agent.Options{}, event.Discard),
120 SessionDir: dir,
121 SessionPath: path,
122 Label: "deepseek-flash",
123 OnSessionRecovered: onRecovered,
124 })
125 }
126
127 func TestSessionRecoveryCallbackMovesLeaseBeforeControllerCommit(t *testing.T) {
128 dir := t.TempDir()
129 originalPath := filepath.Join(dir, "turn-end-conflict.jsonl")
130 leases := control.NewSessionLeaseKeeper()
131 t.Cleanup(leases.Release)
132 if err := leases.Rebind(originalPath); err != nil {
133 t.Fatalf("seed original lease: %v", err)
134 }
135 ctrl := divergedSessionControllerWithRecovery(t, dir, originalPath, cliSessionRecoveredHandler(leases))
136 t.Cleanup(ctrl.Close)
137
138 if err := ctrl.Snapshot(); err != nil {
139 t.Fatalf("Snapshot: %v", err)
140 }
141 recoveryPath := ctrl.SessionPath()
142 if recoveryPath == "" || recoveryPath == originalPath || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
143 t.Fatalf("controller path = %q, want recovery path distinct from %q", recoveryPath, originalPath)
144 }
145 if got, want := leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
146 t.Fatalf("lease after recovery callback = %q, want %q", got, want)
147 }
148 if probe, err := agent.TryAcquireSessionLease(originalPath); err != nil {
149 t.Fatalf("original lease was not released after recovery: %v", err)
150 } else {
151 probe.Release()
152 }
153 if probe, err := agent.TryAcquireSessionLease(recoveryPath); !errors.Is(err, agent.ErrSessionLeaseHeld) {
154 if probe != nil {
155 probe.Release()
156 }
157 t.Fatalf("recovery path was not guarded after callback: %v", err)
158 }
159 }
160
161 func TestSessionRecoveryCallbackFailureKeepsOriginalLeaseAndPath(t *testing.T) {
162 dir := t.TempDir()
163 originalPath := filepath.Join(dir, "held-recovery-conflict.jsonl")
164 leases := control.NewSessionLeaseKeeper()
165 t.Cleanup(leases.Release)
166 if err := leases.Rebind(originalPath); err != nil {
167 t.Fatalf("seed original lease: %v", err)
168 }
169 handler := cliSessionRecoveredHandler(leases)
170 var heldRecovery *agent.SessionLease
171 ctrl := divergedSessionControllerWithRecovery(t, dir, originalPath, func(info control.SessionRecoveryInfo) error {
172 var err error
173 heldRecovery, err = agent.TryAcquireSessionLease(info.RecoveryPath)
174 if err != nil {
175 return fmt.Errorf("hold recovery path for test: %w", err)
176 }
177 return handler(info)
178 })
179 t.Cleanup(ctrl.Close)
180 t.Cleanup(func() {
181 if heldRecovery != nil {
182 heldRecovery.Release()
183 }
184 })
185
186 err := ctrl.Snapshot()
187 if err == nil {
188 t.Fatal("Snapshot succeeded while recovery path lease was held")
189 }
190 if strings.Contains(err.Error(), dir) || strings.Contains(err.Error(), filepath.Base(originalPath)) {
191 t.Fatalf("recovery bind error exposed a local path: %q", err)
192 }
193 if !strings.Contains(err.Error(), "session is in use") {
194 t.Fatalf("recovery bind error = %q, want sanitized lease refusal", err)
195 }
196 if got := ctrl.SessionPath(); got != originalPath {
197 t.Fatalf("controller path after failed callback = %q, want original %q", got, originalPath)
198 }
199 if got, want := leases.HeldPath(), agent.CanonicalSessionPath(originalPath); got != want {
200 t.Fatalf("lease after failed callback = %q, want original %q", got, want)
201 }
202 }
203
204 // TestModelSwitchCarriesRecoveryPathAfterSnapshotConflict is the TUI /model
205 // twin of the desktop rebuild fix: when the pre-switch Snapshot retargets the
206 // controller to a recovery branch, the resume path handed to buildController
207 // must be that recovery path. A pre-snapshot capture bound the just-recovered
208 // transcript back to the original file, re-conflicting on every later save.
209 func TestModelSwitchCarriesRecoveryPathAfterSnapshotConflict(t *testing.T) {
210 isolateUserConfig(t)
211 dir := t.TempDir()
212 originalPath := filepath.Join(dir, "model-switch-conflict.jsonl")
213
214 m := newTestChatTUI()
215 m.ctrl = divergedSessionController(t, dir, originalPath)
216 m.modelRef = "old/old-model"
217 var gotResumePath string
218 m.buildController = func(_ controllerBuildSpec, _ []provider.Message, resumePath string, _ control.SessionAPI) (*control.Controller, error) {
219 gotResumePath = resumePath
220 return control.New(control.Options{Label: "deepseek-flash"}), nil
221 }
222
223 m.runModelSubcommand("/model deepseek-flash/deepseek-v4-flash")
224 if m.pendingModelSwitch == nil {
225 t.Fatal("runModelSubcommand did not queue a model switch")
226 }
227 m.pendingModelSwitch()
228
229 if gotResumePath == "" || gotResumePath == originalPath || !strings.Contains(filepath.Base(gotResumePath), "-recovery-") {
230 t.Fatalf("resume path = %q, want recovery path distinct from %q", gotResumePath, originalPath)
231 }
232 if got := m.ctrl.SessionPath(); got != gotResumePath {
233 t.Fatalf("old controller session path = %q, want recovery path %q", got, gotResumePath)
234 }
235 }
236
237 // TestEffortSwitchCarriesRecoveryPathAfterSnapshotConflict covers the same
238 // contract for the TUI /effort rebuild path.
239 func TestEffortSwitchCarriesRecoveryPathAfterSnapshotConflict(t *testing.T) {
240 isolateUserConfig(t)
241 dir := t.TempDir()
242 originalPath := filepath.Join(dir, "effort-switch-conflict.jsonl")
243
244 m := newTestChatTUI()
245 m.ctrl = divergedSessionController(t, dir, originalPath)
246 m.modelRef = "deepseek-flash/deepseek-v4-flash"
247 var gotResumePath string
248 m.buildController = func(_ controllerBuildSpec, _ []provider.Message, resumePath string, _ control.SessionAPI) (*control.Controller, error) {
249 gotResumePath = resumePath
250 return control.New(control.Options{Label: "deepseek-flash"}), nil
251 }
252
253 cmd := m.runEffortCommand("/effort max")
254 if cmd == nil {
255 t.Fatal("runEffortCommand did not queue a rebuild")
256 }
257 cmd()
258
259 if gotResumePath == "" || gotResumePath == originalPath || !strings.Contains(filepath.Base(gotResumePath), "-recovery-") {
260 t.Fatalf("resume path = %q, want recovery path distinct from %q", gotResumePath, originalPath)
261 }
262 if got := m.ctrl.SessionPath(); got != gotResumePath {
263 t.Fatalf("old controller session path = %q, want recovery path %q", got, gotResumePath)
264 }
265 }
266
267 // TestSkillRefreshCarriesRecoveryPathAfterSnapshotConflict covers the TUI skill
268 // rebuild path, which also snapshots then rebuilds the controller in place.
269 func TestSkillRefreshCarriesRecoveryPathAfterSnapshotConflict(t *testing.T) {
270 dir := t.TempDir()
271 originalPath := filepath.Join(dir, "skill-refresh-conflict.jsonl")
272
273 m := newTestChatTUI()
274 m.ctrl = divergedSessionController(t, dir, originalPath)
275 m.modelRef = "deepseek-flash/deepseek-v4-flash"
276 var gotResumePath string
277 m.buildController = func(_ controllerBuildSpec, _ []provider.Message, resumePath string, _ control.SessionAPI) (*control.Controller, error) {
278 gotResumePath = resumePath
279 return control.New(control.Options{Label: "deepseek-flash"}), nil
280 }
281
282 if !m.scheduleSkillSessionRefresh("skill refresh", "") {
283 t.Fatal("scheduleSkillSessionRefresh did not queue a rebuild")
284 }
285 m.pendingModelSwitch()
286
287 if gotResumePath == "" || gotResumePath == originalPath || !strings.Contains(filepath.Base(gotResumePath), "-recovery-") {
288 t.Fatalf("resume path = %q, want recovery path distinct from %q", gotResumePath, originalPath)
289 }
290 if got := m.ctrl.SessionPath(); got != gotResumePath {
291 t.Fatalf("old controller session path = %q, want recovery path %q", got, gotResumePath)
292 }
293 }
294
295 func TestWorkModeSwitchCarriesRecoveryPathAndMovesLeaseBeforeRebuild(t *testing.T) {
296 dir := t.TempDir()
297 originalPath := filepath.Join(dir, "work-mode-conflict.jsonl")
298
299 m := newTestChatTUI()
300 m.ctrl = divergedSessionController(t, dir, originalPath)
301 m.modelRef = "deepseek-flash/deepseek-v4-flash"
302 m.runtimeProfile = "full"
303 m.leases = control.NewSessionLeaseKeeper()
304 t.Cleanup(m.leases.Release)
305 if err := m.leases.Rebind(originalPath); err != nil {
306 t.Fatalf("seed active lease: %v", err)
307 }
308 var gotResumePath, heldAtBuild string
309 m.buildController = func(_ controllerBuildSpec, _ []provider.Message, resumePath string, _ control.SessionAPI) (*control.Controller, error) {
310 gotResumePath = resumePath
311 heldAtBuild = m.leases.HeldPath()
312 return control.New(control.Options{Label: "deepseek-flash"}), nil
313 }
314
315 cmd := m.runWorkModeCommand("/work-mode delivery")
316 if cmd == nil {
317 t.Fatal("work-mode switch did not queue a rebuild")
318 }
319 cmd()
320
321 if gotResumePath == "" || gotResumePath == originalPath || !strings.Contains(filepath.Base(gotResumePath), "-recovery-") {
322 t.Fatalf("resume path = %q, want recovery path distinct from %q", gotResumePath, originalPath)
323 }
324 if got, want := heldAtBuild, agent.CanonicalSessionPath(gotResumePath); got != want {
325 t.Fatalf("lease at build = %q, want recovery path %q", got, want)
326 }
327 }
328
329 func TestResumeCommandKeepsLeaseOnRecoveryPathWhenTargetHeld(t *testing.T) {
330 dir := t.TempDir()
331 active := filepath.Join(dir, "resume-active-conflict.jsonl")
332 target := filepath.Join(dir, "resume-target.jsonl")
333 saveTestSession(t, target, "target session")
334
335 m := newTestChatTUI()
336 m.width = 80
337 m.ctrl = divergedSessionController(t, dir, active)
338 m.leases = control.NewSessionLeaseKeeper()
339 t.Cleanup(m.leases.Release)
340 if err := m.leases.Rebind(active); err != nil {
341 t.Fatalf("seed active lease: %v", err)
342 }
343 holdSessionLease(t, target)
344
345 m.runResumeCommand(fmt.Sprintf("/resume %d", resumeIndexForPath(t, dir, target)))
346
347 recoveryPath := m.ctrl.SessionPath()
348 if recoveryPath == "" || recoveryPath == active || recoveryPath == target || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
349 t.Fatalf("session path after refused resume = %q, want recovery path distinct from active %q and target %q", recoveryPath, active, target)
350 }
351 if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
352 t.Fatalf("lease after refused resume = %q, want recovery path %q", got, want)
353 }
354 }
355
356 func TestResumePickerKeepsLeaseOnRecoveryPathWhenTargetHeld(t *testing.T) {
357 dir := t.TempDir()
358 active := filepath.Join(dir, "resume-picker-active-conflict.jsonl")
359 target := filepath.Join(dir, "resume-picker-target.jsonl")
360 saveTestSession(t, target, "target session")
361
362 m := newTestChatTUI()
363 m.ctrl = divergedSessionController(t, dir, active)
364 m.resumePick = &resumePicker{sessions: []agent.SessionInfo{{Path: target}}, sel: 0}
365 m.leases = control.NewSessionLeaseKeeper()
366 t.Cleanup(m.leases.Release)
367 if err := m.leases.Rebind(active); err != nil {
368 t.Fatalf("seed active lease: %v", err)
369 }
370 holdSessionLease(t, target)
371
372 next, _ := m.applyResumePick()
373 m = next.(chatTUI)
374
375 recoveryPath := m.ctrl.SessionPath()
376 if recoveryPath == "" || recoveryPath == active || recoveryPath == target || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
377 t.Fatalf("session path after refused picker resume = %q, want recovery path distinct from active %q and target %q", recoveryPath, active, target)
378 }
379 if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
380 t.Fatalf("lease after refused picker resume = %q, want recovery path %q", got, want)
381 }
382 }
383
384 func TestCompactDoneKeepsLeaseOnRecoveryPathAfterSnapshotConflict(t *testing.T) {
385 dir := t.TempDir()
386 active := filepath.Join(dir, "compact-active-conflict.jsonl")
387
388 m := newTestChatTUI()
389 m.ctrl = divergedSessionController(t, dir, active)
390 m.leases = control.NewSessionLeaseKeeper()
391 t.Cleanup(m.leases.Release)
392 if err := m.leases.Rebind(active); err != nil {
393 t.Fatalf("seed active lease: %v", err)
394 }
395
396 next, _ := m.Update(compactDoneMsg{})
397 m = next.(chatTUI)
398
399 recoveryPath := m.ctrl.SessionPath()
400 if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
401 t.Fatalf("session path after compact snapshot = %q, want recovery path distinct from active %q", recoveryPath, active)
402 }
403 if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
404 t.Fatalf("lease after compact snapshot = %q, want recovery path %q", got, want)
405 }
406 }
407
408 func TestBranchTreeKeepsLeaseOnRecoveryPathAfterSnapshotConflict(t *testing.T) {
409 dir := t.TempDir()
410 active := filepath.Join(dir, "tree-active-conflict.jsonl")
411
412 m := newTestChatTUI()
413 m.width = 80
414 m.ctrl = divergedSessionController(t, dir, active)
415 m.leases = control.NewSessionLeaseKeeper()
416 t.Cleanup(m.leases.Release)
417 if err := m.leases.Rebind(active); err != nil {
418 t.Fatalf("seed active lease: %v", err)
419 }
420
421 m.showBranchTree()
422
423 recoveryPath := m.ctrl.SessionPath()
424 if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
425 t.Fatalf("session path after tree snapshot = %q, want recovery path distinct from active %q", recoveryPath, active)
426 }
427 if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
428 t.Fatalf("lease after tree snapshot = %q, want recovery path %q", got, want)
429 }
430 }
431
432 func TestShutdownMessageSnapshotsCurrentController(t *testing.T) {
433 dir := t.TempDir()
434 active := filepath.Join(dir, "shutdown-active-conflict.jsonl")
435
436 m := newTestChatTUI()
437 m.ctrl = divergedSessionController(t, dir, active)
438 m.leases = control.NewSessionLeaseKeeper()
439 t.Cleanup(m.leases.Release)
440 if err := m.leases.Rebind(active); err != nil {
441 t.Fatalf("seed active lease: %v", err)
442 }
443
444 next, cmd := m.Update(tuiShutdownMsg{})
445 m = next.(chatTUI)
446 if cmd == nil {
447 t.Fatal("shutdown message should return tea.Quit")
448 }
449 if msg := cmd(); msg != (tea.QuitMsg{}) {
450 t.Fatalf("shutdown command = %T, want tea.QuitMsg", msg)
451 }
452
453 recoveryPath := m.ctrl.SessionPath()
454 if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
455 t.Fatalf("session path after shutdown snapshot = %q, want recovery path distinct from active %q", recoveryPath, active)
456 }
457 if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
458 t.Fatalf("lease after shutdown snapshot = %q, want recovery path %q", got, want)
459 }
460 }
461
462 // TestBranchCompletionKeepsLeaseOnRecoveryPathAfterSnapshotConflict covers the
463 // /switch tab-completion path: listing branches snapshots the session, which
464 // can retarget the controller to a recovery branch even though no switch runs.
465 func TestBranchCompletionKeepsLeaseOnRecoveryPathAfterSnapshotConflict(t *testing.T) {
466 dir := t.TempDir()
467 active := filepath.Join(dir, "completion-active-conflict.jsonl")
468
469 m := newTestChatTUI()
470 m.ctrl = divergedSessionController(t, dir, active)
471 m.leases = control.NewSessionLeaseKeeper()
472 t.Cleanup(m.leases.Release)
473 if err := m.leases.Rebind(active); err != nil {
474 t.Fatalf("seed active lease: %v", err)
475 }
476
477 if _, _, ok := m.branchArgItems("/switch "); !ok {
478 t.Fatal("branchArgItems did not handle /switch completion")
479 }
480
481 recoveryPath := m.ctrl.SessionPath()
482 if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
483 t.Fatalf("session path after completion snapshot = %q, want recovery path distinct from active %q", recoveryPath, active)
484 }
485 if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
486 t.Fatalf("lease after completion snapshot = %q, want recovery path %q", got, want)
487 }
488 }
489
490 // TestModelSwitchFailureKeepsLeaseOnRecoveryPathAfterSnapshotConflict covers
491 // the rebuild-failure branch: the pre-switch snapshot can retarget the kept
492 // controller to a recovery branch, and a failed build must not leave the lease
493 // on the stale original path.
494 func TestModelSwitchFailureKeepsLeaseOnRecoveryPathAfterSnapshotConflict(t *testing.T) {
495 isolateUserConfig(t)
496 dir := t.TempDir()
497 active := filepath.Join(dir, "model-switch-failure-conflict.jsonl")
498
499 m := newTestChatTUI()
500 m.ctrl = divergedSessionController(t, dir, active)
501 m.modelRef = "old/old-model"
502 m.buildController = func(controllerBuildSpec, []provider.Message, string, control.SessionAPI) (*control.Controller, error) {
503 return nil, fmt.Errorf("build failed")
504 }
505 m.leases = control.NewSessionLeaseKeeper()
506 t.Cleanup(m.leases.Release)
507 if err := m.leases.Rebind(active); err != nil {
508 t.Fatalf("seed active lease: %v", err)
509 }
510
511 m.runModelSubcommand("/model deepseek-flash/deepseek-v4-flash")
512 if m.pendingModelSwitch == nil {
513 t.Fatal("runModelSubcommand did not queue a model switch")
514 }
515 next, _ := m.Update(m.pendingModelSwitch())
516 m = next.(chatTUI)
517
518 recoveryPath := m.ctrl.SessionPath()
519 if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
520 t.Fatalf("session path after failed switch = %q, want recovery path distinct from active %q", recoveryPath, active)
521 }
522 if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
523 t.Fatalf("lease after failed switch = %q, want recovery path %q", got, want)
524 }
525 }
526
527 // TestModelSwitchMovesLeaseToRecoveryPathBeforeRebuild pins the lease-before-
528 // bind order: the rebuilt controller resumes prevPath for writing inside
529 // buildController, so the lease must already guard the retargeted path when
530 // the build starts, not only after modelSwitchMsg lands.
531 func TestModelSwitchMovesLeaseToRecoveryPathBeforeRebuild(t *testing.T) {
532 isolateUserConfig(t)
533 dir := t.TempDir()
534 active := filepath.Join(dir, "model-switch-lease-order.jsonl")
535
536 m := newTestChatTUI()
537 m.ctrl = divergedSessionController(t, dir, active)
538 m.modelRef = "old/old-model"
539 m.leases = control.NewSessionLeaseKeeper()
540 t.Cleanup(m.leases.Release)
541 if err := m.leases.Rebind(active); err != nil {
542 t.Fatalf("seed active lease: %v", err)
543 }
544 var heldAtBuild string
545 m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
546 heldAtBuild = m.leases.HeldPath()
547 return control.New(control.Options{Label: "deepseek-flash"}), nil
548 }
549
550 m.runModelSubcommand("/model deepseek-flash/deepseek-v4-flash")
551 if m.pendingModelSwitch == nil {
552 t.Fatal("runModelSubcommand did not queue a model switch")
553 }
554 m.pendingModelSwitch()
555
556 assertLeaseHeldRecoveryPathAtBuild(t, &m, active, heldAtBuild)
557 }
558
559 // TestEffortSwitchMovesLeaseToRecoveryPathBeforeRebuild covers the same
560 // lease-before-bind order for the /effort rebuild path.
561 func TestEffortSwitchMovesLeaseToRecoveryPathBeforeRebuild(t *testing.T) {
562 isolateUserConfig(t)
563 dir := t.TempDir()
564 active := filepath.Join(dir, "effort-switch-lease-order.jsonl")
565
566 m := newTestChatTUI()
567 m.ctrl = divergedSessionController(t, dir, active)
568 m.modelRef = "deepseek-flash/deepseek-v4-flash"
569 m.leases = control.NewSessionLeaseKeeper()
570 t.Cleanup(m.leases.Release)
571 if err := m.leases.Rebind(active); err != nil {
572 t.Fatalf("seed active lease: %v", err)
573 }
574 var heldAtBuild string
575 m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
576 heldAtBuild = m.leases.HeldPath()
577 return control.New(control.Options{Label: "deepseek-flash"}), nil
578 }
579
580 cmd := m.runEffortCommand("/effort max")
581 if cmd == nil {
582 t.Fatal("runEffortCommand did not queue a rebuild")
583 }
584 cmd()
585
586 assertLeaseHeldRecoveryPathAtBuild(t, &m, active, heldAtBuild)
587 }
588
589 // TestSkillRefreshMovesLeaseToRecoveryPathBeforeRebuild covers the same
590 // lease-before-bind order for the TUI skill rebuild path.
591 func TestSkillRefreshMovesLeaseToRecoveryPathBeforeRebuild(t *testing.T) {
592 dir := t.TempDir()
593 active := filepath.Join(dir, "skill-refresh-lease-order.jsonl")
594
595 m := newTestChatTUI()
596 m.ctrl = divergedSessionController(t, dir, active)
597 m.modelRef = "deepseek-flash/deepseek-v4-flash"
598 m.leases = control.NewSessionLeaseKeeper()
599 t.Cleanup(m.leases.Release)
600 if err := m.leases.Rebind(active); err != nil {
601 t.Fatalf("seed active lease: %v", err)
602 }
603 var heldAtBuild string
604 m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
605 heldAtBuild = m.leases.HeldPath()
606 return control.New(control.Options{Label: "deepseek-flash"}), nil
607 }
608
609 if !m.scheduleSkillSessionRefresh("skill refresh", "") {
610 t.Fatal("scheduleSkillSessionRefresh did not queue a rebuild")
611 }
612 m.pendingModelSwitch()
613
614 assertLeaseHeldRecoveryPathAtBuild(t, &m, active, heldAtBuild)
615 }
616
617 // assertLeaseHeldRecoveryPathAtBuild verifies that the snapshot retargeted the
618 // controller to a recovery branch and that the lease already guarded that
619 // branch when buildController ran.
620 func assertLeaseHeldRecoveryPathAtBuild(t *testing.T, m *chatTUI, active, heldAtBuild string) {
621 t.Helper()
622 recoveryPath := m.ctrl.SessionPath()
623 if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
624 t.Fatalf("session path after switch snapshot = %q, want recovery path distinct from active %q", recoveryPath, active)
625 }
626 if want := agent.CanonicalSessionPath(recoveryPath); heldAtBuild != want {
627 t.Fatalf("lease when build started = %q, want recovery path %q", heldAtBuild, want)
628 }
629 }
630
631 func resumeIndexForPath(t *testing.T, dir, path string) int {
632 t.Helper()
633 for i, session := range recentSessions(dir) {
634 if session.Path == path {
635 return i + 1
636 }
637 }
638 t.Fatalf("session %q not found in recent sessions", path)
639 return 0
640 }
641
642 // TestAdoptCarriedHistoryRefreshesLeadingSystemPrompt pins the fix for the
643 // bug where /model, /effort, /work-mode, and skill-toggle rebuilds carried
644 // the outgoing profile's system prompt forward: the freshly built controller
645 // already has its own leading system message for the target profile, but
646 // AdoptHistory replaces the whole history (including that message) with the
647 // carried one unless the caller splices it in first.
648 func TestAdoptCarriedHistoryRefreshesLeadingSystemPrompt(t *testing.T) {
649 fresh := control.New(control.Options{
650 Executor: agent.New(nil, nil, agent.NewSession("system prompt for profile delivery"), agent.Options{}, event.Discard),
651 })
652 carry := []provider.Message{
653 {Role: provider.RoleSystem, Content: "system prompt for profile balanced"},
654 {Role: provider.RoleUser, Content: "hello"},
655 {Role: provider.RoleAssistant, Content: "hi"},
656 }
657
658 if err := adoptCarriedHistoryPreservingProfileAndGrants(fresh, carry, "", nil); err != nil {
659 t.Fatalf("adoptCarriedHistoryPreservingProfileAndGrants: %v", err)
660 }
661
662 history := fresh.History()
663 if len(history) != 3 || history[0].Role != provider.RoleSystem {
664 t.Fatalf("history = %+v, want 3 messages with a leading system message", history)
665 }
666 if got, want := history[0].Content, "system prompt for profile delivery"; got != want {
667 t.Fatalf("leading system message = %q, want %q (stale outgoing profile carried forward)", got, want)
668 }
669 if history[1].Content != "hello" || history[2].Content != "hi" {
670 t.Fatalf("history = %+v, want carried user/assistant turns preserved", history)
671 }
672 }
673
674 // TestAdoptCarriedHistoryRestoresSessionAuthorizations pins the fix for a
675 // rebuild dropping same-session "Allow for this session" tool grants and
676 // Plan-mode read-only command trust, forcing the user to re-approve
677 // something already granted this session after every /model, /effort, or
678 // /work-mode switch.
679 func TestAdoptCarriedHistoryRestoresSessionAuthorizations(t *testing.T) {
680 old := control.New(control.Options{})
681 old.RestoreSessionAuthorizations(control.SessionAuthorizations{
682 Grants: []string{"bash|go test ./..."},
683 PlanModeReadOnlyCommands: []string{"go test ./..."},
684 })
685
686 fresh := control.New(control.Options{
687 Executor: agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard),
688 })
689
690 if err := adoptCarriedHistoryPreservingProfileAndGrants(fresh, nil, "", old); err != nil {
691 t.Fatalf("adoptCarriedHistoryPreservingProfileAndGrants: %v", err)
692 }
693
694 got := fresh.SessionAuthorizations()
695 if len(got.Grants) != 1 || got.Grants[0] != "bash|go test ./..." {
696 t.Fatalf("restored grants = %+v, want [\"bash|go test ./...\"]", got.Grants)
697 }
698 if len(got.PlanModeReadOnlyCommands) != 1 || got.PlanModeReadOnlyCommands[0] != "go test ./..." {
699 t.Fatalf("restored plan-mode read-only commands = %+v, want [\"go test ./...\"]", got.PlanModeReadOnlyCommands)
700 }
701 }
702
703 // TestAdoptCarriedHistoryPersistsRefreshedSystemPromptToDisk pins the disk
704 // half of the splice in adoptCarriedHistoryPreservingProfileAndGrants: the
705 // refreshed leading system message must be persisted at switch time, because
706 // nothing saves again until the next turn ends — quitting right after a
707 // /model, /effort, or /work-mode switch and resuming would otherwise revive
708 // the outgoing profile's contract from disk.
709 func TestAdoptCarriedHistoryPersistsRefreshedSystemPromptToDisk(t *testing.T) {
710 dir := t.TempDir()
711 path := filepath.Join(dir, "adopt-persist.jsonl")
712
713 oldSession := agent.NewSession("system prompt for profile balanced")
714 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
715 oldSession.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
716 if err := oldSession.Save(path); err != nil {
717 t.Fatalf("save base session: %v", err)
718 }
719
720 fresh := control.New(control.Options{
721 Executor: agent.New(nil, nil, agent.NewSession("system prompt for profile delivery"), agent.Options{}, event.Discard),
722 SessionDir: dir,
723 })
724 carry := []provider.Message{
725 {Role: provider.RoleSystem, Content: "system prompt for profile balanced"},
726 {Role: provider.RoleUser, Content: "hello"},
727 {Role: provider.RoleAssistant, Content: "hi"},
728 }
729
730 if err := adoptCarriedHistoryPreservingProfileAndGrants(fresh, carry, path, nil); err != nil {
731 t.Fatalf("adoptCarriedHistoryPreservingProfileAndGrants: %v", err)
732 }
733
734 loaded, err := agent.LoadSession(path)
735 if err != nil {
736 t.Fatalf("load transcript after adopt: %v", err)
737 }
738 msgs := loaded.Snapshot()
739 if len(msgs) != 3 || msgs[0].Role != provider.RoleSystem {
740 t.Fatalf("on-disk history after adopt = %+v, want 3 messages with a leading system message", msgs)
741 }
742 if got, want := msgs[0].Content, "system prompt for profile delivery"; got != want {
743 t.Fatalf("on-disk leading system message = %q, want %q (quit + resume would revive the outgoing contract)", got, want)
744 }
745 }
746
747 func TestAdoptCarriedHistoryReportsSnapshotFailure(t *testing.T) {
748 invalidPath := filepath.Join(t.TempDir(), "transcript-is-a-directory")
749 if err := os.Mkdir(invalidPath, 0o755); err != nil {
750 t.Fatalf("mkdir invalid transcript path: %v", err)
751 }
752 fresh := control.New(control.Options{
753 Executor: agent.New(nil, nil, agent.NewSession("system prompt for profile delivery"), agent.Options{}, event.Discard),
754 })
755 carry := []provider.Message{
756 {Role: provider.RoleSystem, Content: "system prompt for profile balanced"},
757 {Role: provider.RoleUser, Content: "hello"},
758 }
759
760 err := adoptCarriedHistoryPreservingProfileAndGrants(fresh, carry, invalidPath, nil)
761 if err == nil || !strings.Contains(err.Error(), "snapshot after runtime switch") {
762 t.Fatalf("adopt error = %v, want snapshot failure", err)
763 }
764 }
765
765 lines GO