返回 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 TestCompactDoneDoesNotRepeatMaintenanceSnapshot(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 if got := m.ctrl.SessionPath(); got != active {
416 t.Fatalf("compact completion repeated snapshot and moved session path to %q, want %q", got, active)
417 }
418 if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(active); got != want {
419 t.Fatalf("lease after compact completion = %q, want unchanged %q", got, want)
420 }
421 }
422
423 func TestBranchTreeKeepsLeaseOnRecoveryPathAfterSnapshotConflict(t *testing.T) {
424 t.Setenv(agent.SessionLogSchemaEnv, "v1")
425 dir := t.TempDir()
426 active := filepath.Join(dir, "tree-active-conflict.jsonl")
427
428 m := newTestChatTUI()
429 m.width = 80
430 m.ctrl = divergedSessionController(t, dir, active)
431 m.leases = control.NewSessionLeaseKeeper()
432 t.Cleanup(m.leases.Release)
433 if err := m.leases.Rebind(active); err != nil {
434 t.Fatalf("seed active lease: %v", err)
435 }
436
437 m.showBranchTree()
438
439 recoveryPath := m.ctrl.SessionPath()
440 if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
441 t.Fatalf("session path after tree snapshot = %q, want recovery path distinct from active %q", recoveryPath, active)
442 }
443 if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
444 t.Fatalf("lease after tree snapshot = %q, want recovery path %q", got, want)
445 }
446 }
447
448 func TestShutdownMessageSnapshotsCurrentController(t *testing.T) {
449 t.Setenv(agent.SessionLogSchemaEnv, "v1")
450 dir := t.TempDir()
451 active := filepath.Join(dir, "shutdown-active-conflict.jsonl")
452
453 m := newTestChatTUI()
454 m.ctrl = divergedSessionController(t, dir, active)
455 m.leases = control.NewSessionLeaseKeeper()
456 t.Cleanup(m.leases.Release)
457 if err := m.leases.Rebind(active); err != nil {
458 t.Fatalf("seed active lease: %v", err)
459 }
460
461 next, cmd := m.Update(tuiShutdownMsg{})
462 m = next.(chatTUI)
463 if cmd == nil {
464 t.Fatal("shutdown message should return tea.Quit")
465 }
466 if msg := cmd(); msg != (tea.QuitMsg{}) {
467 t.Fatalf("shutdown command = %T, want tea.QuitMsg", msg)
468 }
469
470 recoveryPath := m.ctrl.SessionPath()
471 if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
472 t.Fatalf("session path after shutdown snapshot = %q, want recovery path distinct from active %q", recoveryPath, active)
473 }
474 if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
475 t.Fatalf("lease after shutdown snapshot = %q, want recovery path %q", got, want)
476 }
477 }
478
479 // TestBranchCompletionKeepsLeaseOnRecoveryPathAfterSnapshotConflict covers the
480 // /switch tab-completion path: listing branches snapshots the session, which
481 // can retarget the controller to a recovery branch even though no switch runs.
482 func TestBranchCompletionKeepsLeaseOnRecoveryPathAfterSnapshotConflict(t *testing.T) {
483 t.Setenv(agent.SessionLogSchemaEnv, "v1")
484 dir := t.TempDir()
485 active := filepath.Join(dir, "completion-active-conflict.jsonl")
486
487 m := newTestChatTUI()
488 m.ctrl = divergedSessionController(t, dir, active)
489 m.leases = control.NewSessionLeaseKeeper()
490 t.Cleanup(m.leases.Release)
491 if err := m.leases.Rebind(active); err != nil {
492 t.Fatalf("seed active lease: %v", err)
493 }
494
495 if _, _, ok := m.branchArgItems("/switch "); !ok {
496 t.Fatal("branchArgItems did not handle /switch completion")
497 }
498
499 recoveryPath := m.ctrl.SessionPath()
500 if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
501 t.Fatalf("session path after completion snapshot = %q, want recovery path distinct from active %q", recoveryPath, active)
502 }
503 if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
504 t.Fatalf("lease after completion snapshot = %q, want recovery path %q", got, want)
505 }
506 }
507
508 // TestModelSwitchFailureKeepsLeaseOnRecoveryPathAfterSnapshotConflict covers
509 // the rebuild-failure branch: the pre-switch snapshot can retarget the kept
510 // controller to a recovery branch, and a failed build must not leave the lease
511 // on the stale original path.
512 func TestModelSwitchFailureKeepsLeaseOnRecoveryPathAfterSnapshotConflict(t *testing.T) {
513 t.Setenv(agent.SessionLogSchemaEnv, "v1")
514 isolateUserConfig(t)
515 dir := t.TempDir()
516 active := filepath.Join(dir, "model-switch-failure-conflict.jsonl")
517
518 m := newTestChatTUI()
519 m.ctrl = divergedSessionController(t, dir, active)
520 m.modelRef = "old/old-model"
521 m.buildController = func(controllerBuildSpec, []provider.Message, string, control.SessionAPI) (*control.Controller, error) {
522 return nil, fmt.Errorf("build failed")
523 }
524 m.leases = control.NewSessionLeaseKeeper()
525 t.Cleanup(m.leases.Release)
526 if err := m.leases.Rebind(active); err != nil {
527 t.Fatalf("seed active lease: %v", err)
528 }
529
530 m.runModelSubcommand("/model deepseek-flash/deepseek-v4-flash")
531 if m.pendingModelSwitch == nil {
532 t.Fatal("runModelSubcommand did not queue a model switch")
533 }
534 next, _ := m.Update(m.pendingModelSwitch())
535 m = next.(chatTUI)
536
537 recoveryPath := m.ctrl.SessionPath()
538 if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
539 t.Fatalf("session path after failed switch = %q, want recovery path distinct from active %q", recoveryPath, active)
540 }
541 if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
542 t.Fatalf("lease after failed switch = %q, want recovery path %q", got, want)
543 }
544 }
545
546 // TestModelSwitchMovesLeaseToRecoveryPathBeforeRebuild pins the lease-before-
547 // bind order: the rebuilt controller resumes prevPath for writing inside
548 // buildController, so the lease must already guard the retargeted path when
549 // the build starts, not only after modelSwitchMsg lands.
550 func TestModelSwitchMovesLeaseToRecoveryPathBeforeRebuild(t *testing.T) {
551 t.Setenv(agent.SessionLogSchemaEnv, "v1")
552 isolateUserConfig(t)
553 dir := t.TempDir()
554 active := filepath.Join(dir, "model-switch-lease-order.jsonl")
555
556 m := newTestChatTUI()
557 m.ctrl = divergedSessionController(t, dir, active)
558 m.modelRef = "old/old-model"
559 m.leases = control.NewSessionLeaseKeeper()
560 t.Cleanup(m.leases.Release)
561 if err := m.leases.Rebind(active); err != nil {
562 t.Fatalf("seed active lease: %v", err)
563 }
564 var heldAtBuild string
565 m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
566 heldAtBuild = m.leases.HeldPath()
567 return newOwnedTestController(t, control.Options{Label: "deepseek-flash"}), nil
568 }
569
570 m.runModelSubcommand("/model deepseek-flash/deepseek-v4-flash")
571 if m.pendingModelSwitch == nil {
572 t.Fatal("runModelSubcommand did not queue a model switch")
573 }
574 m.pendingModelSwitch()
575
576 assertLeaseHeldRecoveryPathAtBuild(t, &m, active, heldAtBuild)
577 }
578
579 // TestEffortSwitchMovesLeaseToRecoveryPathBeforeRebuild covers the same
580 // lease-before-bind order for the /effort rebuild path.
581 func TestEffortSwitchMovesLeaseToRecoveryPathBeforeRebuild(t *testing.T) {
582 t.Setenv(agent.SessionLogSchemaEnv, "v1")
583 isolateUserConfig(t)
584 dir := t.TempDir()
585 active := filepath.Join(dir, "effort-switch-lease-order.jsonl")
586
587 m := newTestChatTUI()
588 m.ctrl = divergedSessionController(t, dir, active)
589 m.modelRef = "deepseek-flash/deepseek-v4-flash"
590 m.leases = control.NewSessionLeaseKeeper()
591 t.Cleanup(m.leases.Release)
592 if err := m.leases.Rebind(active); err != nil {
593 t.Fatalf("seed active lease: %v", err)
594 }
595 var heldAtBuild string
596 m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
597 heldAtBuild = m.leases.HeldPath()
598 return newOwnedTestController(t, control.Options{Label: "deepseek-flash"}), nil
599 }
600
601 cmd := m.runEffortCommand("/effort max")
602 if cmd == nil {
603 t.Fatal("runEffortCommand did not queue a rebuild")
604 }
605 cmd()
606
607 assertLeaseHeldRecoveryPathAtBuild(t, &m, active, heldAtBuild)
608 }
609
610 // TestSkillRefreshMovesLeaseToRecoveryPathBeforeRebuild covers the same
611 // lease-before-bind order for the TUI skill rebuild path.
612 func TestSkillRefreshMovesLeaseToRecoveryPathBeforeRebuild(t *testing.T) {
613 t.Setenv(agent.SessionLogSchemaEnv, "v1")
614 dir := t.TempDir()
615 active := filepath.Join(dir, "skill-refresh-lease-order.jsonl")
616
617 m := newTestChatTUI()
618 m.ctrl = divergedSessionController(t, dir, active)
619 m.modelRef = "deepseek-flash/deepseek-v4-flash"
620 m.leases = control.NewSessionLeaseKeeper()
621 t.Cleanup(m.leases.Release)
622 if err := m.leases.Rebind(active); err != nil {
623 t.Fatalf("seed active lease: %v", err)
624 }
625 var heldAtBuild string
626 m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
627 heldAtBuild = m.leases.HeldPath()
628 return newOwnedTestController(t, control.Options{Label: "deepseek-flash"}), nil
629 }
630
631 if !m.scheduleSkillSessionRefresh("skill refresh", "") {
632 t.Fatal("scheduleSkillSessionRefresh did not queue a rebuild")
633 }
634 m.pendingModelSwitch()
635
636 assertLeaseHeldRecoveryPathAtBuild(t, &m, active, heldAtBuild)
637 }
638
639 // assertLeaseHeldRecoveryPathAtBuild verifies that the snapshot retargeted the
640 // controller to a recovery branch and that the lease already guarded that
641 // branch when buildController ran.
642 func assertLeaseHeldRecoveryPathAtBuild(t *testing.T, m *chatTUI, active, heldAtBuild string) {
643 t.Helper()
644 recoveryPath := m.ctrl.SessionPath()
645 if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
646 t.Fatalf("session path after switch snapshot = %q, want recovery path distinct from active %q", recoveryPath, active)
647 }
648 if want := agent.CanonicalSessionPath(recoveryPath); heldAtBuild != want {
649 t.Fatalf("lease when build started = %q, want recovery path %q", heldAtBuild, want)
650 }
651 }
652
653 func resumeIndexForPath(t *testing.T, dir, path string) int {
654 t.Helper()
655 for i, session := range mergedResumeSessions(dir) {
656 if session.Path == path {
657 return i + 1
658 }
659 }
660 t.Fatalf("session %q not found in recent sessions", path)
661 return 0
662 }
663
664 // TestAdoptCarriedHistoryRefreshesLeadingSystemPrompt pins the fix for the
665 // bug where /model, /effort, /work-mode, and skill-toggle rebuilds carried
666 // the outgoing profile's system prompt forward: the freshly built controller
667 // already has its own leading system message for the target profile, but
668 // AdoptHistory replaces the whole history (including that message) with the
669 // carried one unless the caller splices it in first.
670 func TestAdoptCarriedHistoryRefreshesLeadingSystemPrompt(t *testing.T) {
671 fresh := newOwnedTestController(t, control.Options{
672 Executor: agent.New(nil, nil, agent.NewSession("system prompt for profile delivery"), agent.Options{}, event.Discard),
673 })
674 carry := []provider.Message{
675 {Role: provider.RoleSystem, Content: "system prompt for profile balanced"},
676 {Role: provider.RoleUser, Content: "hello"},
677 {Role: provider.RoleAssistant, Content: "hi"},
678 }
679
680 if err := adoptCarriedHistoryPreservingProfileAndGrants(fresh, carry, "", nil); err != nil {
681 t.Fatalf("adoptCarriedHistoryPreservingProfileAndGrants: %v", err)
682 }
683
684 history := fresh.History()
685 if len(history) != 3 || history[0].Role != provider.RoleSystem {
686 t.Fatalf("history = %+v, want 3 messages with a leading system message", history)
687 }
688 if got, want := history[0].Content, "system prompt for profile delivery"; got != want {
689 t.Fatalf("leading system message = %q, want %q (stale outgoing profile carried forward)", got, want)
690 }
691 if history[1].Content != "hello" || history[2].Content != "hi" {
692 t.Fatalf("history = %+v, want carried user/assistant turns preserved", history)
693 }
694 }
695
696 // TestAdoptCarriedHistoryRestoresSessionAuthorizations pins the fix for a
697 // rebuild dropping same-session "Allow for this session" tool grants and
698 // Plan-mode read-only command trust, forcing the user to re-approve
699 // something already granted this session after every /model, /effort, or
700 // /work-mode switch.
701 func TestAdoptCarriedHistoryRestoresSessionAuthorizations(t *testing.T) {
702 old := newOwnedTestController(t, control.Options{})
703 old.RestoreSessionAuthorizations(control.SessionAuthorizations{
704 Grants: []string{"bash|go test ./..."},
705 PlanModeReadOnlyCommands: []string{"go test ./..."},
706 })
707
708 fresh := newOwnedTestController(t, control.Options{
709 Executor: agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard),
710 })
711
712 if err := adoptCarriedHistoryPreservingProfileAndGrants(fresh, nil, "", old); err != nil {
713 t.Fatalf("adoptCarriedHistoryPreservingProfileAndGrants: %v", err)
714 }
715
716 got := fresh.SessionAuthorizations()
717 if len(got.Grants) != 1 || got.Grants[0] != "bash|go test ./..." {
718 t.Fatalf("restored grants = %+v, want [\"bash|go test ./...\"]", got.Grants)
719 }
720 if len(got.PlanModeReadOnlyCommands) != 1 || got.PlanModeReadOnlyCommands[0] != "go test ./..." {
721 t.Fatalf("restored plan-mode read-only commands = %+v, want [\"go test ./...\"]", got.PlanModeReadOnlyCommands)
722 }
723 }
724
725 // TestAdoptCarriedHistoryPersistsRefreshedSystemPromptToDisk pins the disk
726 // half of the splice in adoptCarriedHistoryPreservingProfileAndGrants: the
727 // refreshed leading system message must be persisted at switch time, because
728 // nothing saves again until the next turn ends — quitting right after a
729 // /model, /effort, or /work-mode switch and resuming would otherwise revive
730 // the outgoing profile's contract from disk.
731 func TestAdoptCarriedHistoryPersistsRefreshedSystemPromptToDisk(t *testing.T) {
732 dir := t.TempDir()
733 path := filepath.Join(dir, "adopt-persist.jsonl")
734
735 oldSession := agent.NewSession("system prompt for profile balanced")
736 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
737 oldSession.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
738 if err := oldSession.Save(path); err != nil {
739 t.Fatalf("save base session: %v", err)
740 }
741
742 fresh := newOwnedTestController(t, control.Options{
743 Executor: agent.New(nil, nil, agent.NewSession("system prompt for profile delivery"), agent.Options{}, event.Discard),
744 SessionDir: dir,
745 })
746 carry := []provider.Message{
747 {Role: provider.RoleSystem, Content: "system prompt for profile balanced"},
748 {Role: provider.RoleUser, Content: "hello"},
749 {Role: provider.RoleAssistant, Content: "hi"},
750 }
751
752 if err := adoptCarriedHistoryPreservingProfileAndGrants(fresh, carry, path, nil); err != nil {
753 t.Fatalf("adoptCarriedHistoryPreservingProfileAndGrants: %v", err)
754 }
755
756 loaded, err := agent.LoadSession(path)
757 if err != nil {
758 t.Fatalf("load transcript after adopt: %v", err)
759 }
760 msgs := loaded.Snapshot()
761 if len(msgs) != 3 || msgs[0].Role != provider.RoleSystem {
762 t.Fatalf("on-disk history after adopt = %+v, want 3 messages with a leading system message", msgs)
763 }
764 if got, want := msgs[0].Content, "system prompt for profile delivery"; got != want {
765 t.Fatalf("on-disk leading system message = %q, want %q (quit + resume would revive the outgoing contract)", got, want)
766 }
767 }
768
769 func TestAdoptCarriedHistoryReportsSnapshotFailure(t *testing.T) {
770 invalidPath := filepath.Join(t.TempDir(), "transcript-is-a-directory")
771 if err := os.Mkdir(invalidPath, 0o755); err != nil {
772 t.Fatalf("mkdir invalid transcript path: %v", err)
773 }
774 fresh := newOwnedTestController(t, control.Options{
775 Executor: agent.New(nil, nil, agent.NewSession("system prompt for profile delivery"), agent.Options{}, event.Discard),
776 })
777 carry := []provider.Message{
778 {Role: provider.RoleSystem, Content: "system prompt for profile balanced"},
779 {Role: provider.RoleUser, Content: "hello"},
780 }
781
782 err := adoptCarriedHistoryPreservingProfileAndGrants(fresh, carry, invalidPath, nil)
783 if err == nil || !strings.Contains(err.Error(), "snapshot after runtime switch") {
784 t.Fatalf("adopt error = %v, want snapshot failure", err)
785 }
786 }
787
787 lines GO