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