返回 DeepSeek-Reasonix
switch_recovery_test.go
根目录 / internal / acp / switch_recovery_test.go
1 package acp
2
3 import (
4 "context"
5 "encoding/json"
6 "io"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/control"
14 "reasonix/internal/event"
15 "reasonix/internal/provider"
16 )
17
18 // divergedACPSession writes a transcript to path whose on-disk content has
19 // diverged from the returned in-memory session, so the next Snapshot on a
20 // controller holding the stale session hits a conflict and retargets to a
21 // recovery branch.
22 func divergedACPSession(t *testing.T, path string) *agent.Session {
23 t.Helper()
24 disk := agent.NewSession("sys prompt")
25 disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
26 disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
27 disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
28 if err := disk.Save(path); err != nil {
29 t.Fatalf("save disk session: %v", err)
30 }
31
32 stale := agent.NewSession("sys prompt")
33 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
34 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
35 stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
36 return stale
37 }
38
39 // primaryRecoveryFiles filters a recovery-branch glob down to primary session
40 // transcripts, dropping the .events.jsonl / .guardian.jsonl sidecars that the
41 // *-recovery-*.jsonl pattern also matches.
42 func primaryRecoveryFiles(t *testing.T, dir string) []string {
43 t.Helper()
44 matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
45 if err != nil {
46 t.Fatalf("glob recovery branches: %v", err)
47 }
48 primary := matches[:0]
49 for _, path := range matches {
50 base := filepath.Base(path)
51 if strings.HasSuffix(base, ".events.jsonl") || strings.HasSuffix(base, ".guardian.jsonl") {
52 continue
53 }
54 primary = append(primary, path)
55 }
56 return primary
57 }
58
59 func assertACPSessionOnRecoveryPath(t *testing.T, sess *acpSession, originalPath, recoveryPath string) {
60 t.Helper()
61 if recoveryPath == "" || recoveryPath == originalPath || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
62 t.Fatalf("session path = %q, want recovery path distinct from %q", recoveryPath, originalPath)
63 }
64 sess.mu.Lock()
65 transcript := sess.transcript
66 lease := sess.lease
67 sess.mu.Unlock()
68 if transcript != recoveryPath {
69 t.Fatalf("session transcript = %q, want recovery path %q", transcript, recoveryPath)
70 }
71 if lease == nil || lease.Path() != agent.CanonicalSessionPath(recoveryPath) {
72 got := ""
73 if lease != nil {
74 got = lease.Path()
75 }
76 t.Fatalf("session lease path = %q, want recovery path %q", got, recoveryPath)
77 }
78 // The original transcript's lease must have been released by the move so
79 // another runtime can bind it.
80 orig, err := agent.TryAcquireSessionLease(originalPath)
81 if err != nil {
82 t.Fatalf("original transcript lease should be free after recovery move: %v", err)
83 }
84 orig.Release()
85 }
86
87 // TestACPRebuildSessionContinuesRecoveryPathAfterSnapshotConflict is the ACP
88 // twin of the desktop rebuild fix: when the pre-rebuild Snapshot hits a
89 // conflict and retargets the old controller to a recovery branch, the session
90 // bookkeeping must follow at commit time (sessionRecoveredHandler moves
91 // sess.transcript and the lease), and AdoptHistory must bind the replacement
92 // controller to that recovery path. A pre-snapshot capture bound the
93 // just-recovered transcript back to the original file, so every later save
94 // re-conflicted and derived yet another recovery branch.
95 func TestACPRebuildSessionContinuesRecoveryPathAfterSnapshotConflict(t *testing.T) {
96 dir := t.TempDir()
97 originalPath := filepath.Join(dir, "acp-switch-conflict.jsonl")
98 stale := divergedACPSession(t, originalPath)
99
100 sink := newUpdateSink(&fakeNotifier{}, "sess-recovery")
101 sess := &acpSession{
102 id: "sess-recovery",
103 sink: sink,
104 cwd: dir,
105 model: "fast",
106 transcript: originalPath,
107 }
108 lease, err := agent.TryAcquireSessionLease(originalPath)
109 if err != nil {
110 t.Fatalf("acquire original session lease: %v", err)
111 }
112 sess.lease = lease
113 t.Cleanup(sess.releaseSessionLease)
114
115 svc := &service{
116 factory: &configurableFactory{dir: dir},
117 sessions: map[string]*acpSession{sess.id: sess},
118 }
119 oldCtrl := control.New(control.Options{
120 Executor: agent.New(nil, nil, stale, agent.Options{}, event.Discard),
121 SessionDir: dir,
122 SessionPath: originalPath,
123 Label: "fast",
124 OnSessionRecovered: svc.sessionRecoveredHandler(sess.id),
125 })
126 sess.ctrl = oldCtrl
127
128 if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "pro"}, []sessionConfigDelta{{axis: "model", model: "pro"}}); err != nil {
129 t.Fatalf("rebuildSession: %v", err)
130 }
131 if sess.ctrl == oldCtrl {
132 t.Fatal("session controller was not replaced")
133 }
134
135 recoveryPath := sess.ctrl.SessionPath()
136 assertACPSessionOnRecoveryPath(t, sess, originalPath, recoveryPath)
137
138 // The rebuilt controller adopted the recovery file's baseline, so its next
139 // snapshot must not derive a second recovery branch.
140 if err := sess.ctrl.Snapshot(); err != nil {
141 t.Fatalf("Snapshot after rebuild: %v", err)
142 }
143 if primary := primaryRecoveryFiles(t, dir); len(primary) != 1 || primary[0] != recoveryPath {
144 t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", primary, recoveryPath)
145 }
146 }
147
148 // TestACPPersistAfterTurnMovesBookkeepingToRecoveryPath covers the autosave
149 // path: a turn-end Snapshot in persistAfterTurn that recovers onto a recovery
150 // branch must move sess.transcript and the session lease with the controller,
151 // so session/prompt reports the live file, session/delete destroys it, and the
152 // recovery transcript stays lease-guarded against other runtimes.
153 func TestACPPersistAfterTurnMovesBookkeepingToRecoveryPath(t *testing.T) {
154 dir := t.TempDir()
155 originalPath := filepath.Join(dir, "acp-autosave-conflict.jsonl")
156 stale := divergedACPSession(t, originalPath)
157
158 sink := newUpdateSink(&fakeNotifier{}, "sess-autosave")
159 sess := &acpSession{
160 id: "sess-autosave",
161 sink: sink,
162 cwd: dir,
163 model: "fast",
164 transcript: originalPath,
165 }
166 lease, err := agent.TryAcquireSessionLease(originalPath)
167 if err != nil {
168 t.Fatalf("acquire original session lease: %v", err)
169 }
170 sess.lease = lease
171 t.Cleanup(sess.releaseSessionLease)
172
173 svc := &service{
174 factory: &configurableFactory{dir: dir},
175 sessions: map[string]*acpSession{sess.id: sess},
176 }
177 ctrl := control.New(control.Options{
178 Executor: agent.New(nil, nil, stale, agent.Options{}, event.Discard),
179 SessionDir: dir,
180 SessionPath: originalPath,
181 Label: "fast",
182 OnSessionRecovered: svc.sessionRecoveredHandler(sess.id),
183 })
184 sess.ctrl = ctrl
185 t.Cleanup(ctrl.Close)
186
187 sess.persistAfterTurn("hello")
188
189 recoveryPath := ctrl.SessionPath()
190 assertACPSessionOnRecoveryPath(t, sess, originalPath, recoveryPath)
191 if primary := primaryRecoveryFiles(t, dir); len(primary) != 1 || primary[0] != recoveryPath {
192 t.Fatalf("recovery branches after autosave = %v, want only %q", primary, recoveryPath)
193 }
194 // The next turn-end autosave writes the recovery file the session now
195 // owns; it must not derive a second recovery branch.
196 sess.persistAfterTurn("again")
197 if got := ctrl.SessionPath(); got != recoveryPath {
198 t.Fatalf("controller session path after second autosave = %q, want %q", got, recoveryPath)
199 }
200 if primary := primaryRecoveryFiles(t, dir); len(primary) != 1 || primary[0] != recoveryPath {
201 t.Fatalf("recovery branches after second autosave = %v, want only %q", primary, recoveryPath)
202 }
203 }
204
205 // recoverACPSessionAndRestart drives an autosave recovery for session id in
206 // dir, then simulates a process restart: the live session's lease is released,
207 // its controller closed, and a fresh service (empty session registry, same
208 // session dir) is returned alongside the original and recovery paths.
209 func recoverACPSessionAndRestart(t *testing.T, dir, id string) (originalPath, recoveryPath string, restarted *service) {
210 t.Helper()
211 originalPath = transcriptPath(dir, id)
212 stale := divergedACPSession(t, originalPath)
213
214 svc := &service{
215 factory: &configurableFactory{dir: dir},
216 sessions: map[string]*acpSession{},
217 }
218 sess := &acpSession{
219 id: id,
220 sink: newUpdateSink(&fakeNotifier{}, id),
221 cwd: dir,
222 model: "fast",
223 title: "recovered title",
224 transcript: originalPath,
225 }
226 lease, err := agent.TryAcquireSessionLease(originalPath)
227 if err != nil {
228 t.Fatalf("acquire original session lease: %v", err)
229 }
230 sess.lease = lease
231 svc.sessions[id] = sess
232 ctrl := control.New(control.Options{
233 Executor: agent.New(nil, nil, stale, agent.Options{}, event.Discard),
234 SessionDir: dir,
235 SessionPath: originalPath,
236 Label: "fast",
237 OnSessionRecovered: svc.sessionRecoveredHandler(id),
238 })
239 sess.ctrl = ctrl
240
241 sess.persistAfterTurn("hello")
242 recoveryPath = ctrl.SessionPath()
243 assertACPSessionOnRecoveryPath(t, sess, originalPath, recoveryPath)
244
245 sess.releaseSessionLease()
246 ctrl.Close()
247 restarted = &service{
248 conn: NewConn(strings.NewReader(""), io.Discard),
249 factory: &configurableFactory{dir: dir},
250 sessions: map[string]*acpSession{},
251 }
252 return originalPath, recoveryPath, restarted
253 }
254
255 // TestACPLoadAfterRestartFollowsRecoveryTranscript covers the restart half of
256 // the recovery move: session/load and session/resume resolve the session id to
257 // the transcript the session actually lives in. Without the id-keyed redirect,
258 // a restart reopened the pre-recovery file and the user's recovered work
259 // silently vanished from ACP's view.
260 func TestACPLoadAfterRestartFollowsRecoveryTranscript(t *testing.T) {
261 dir := t.TempDir()
262 id := "sess-restart"
263 originalPath, recoveryPath, svc := recoverACPSessionAndRestart(t, dir, id)
264
265 if _, err := svc.openExistingSession(context.Background(), "session/load", id, dir, nil, false); err != nil {
266 t.Fatalf("openExistingSession after restart: %v", err)
267 }
268 loaded := svc.session(id)
269 if loaded == nil {
270 t.Fatal("session not registered after load")
271 }
272 t.Cleanup(func() {
273 loaded.releaseSessionLease()
274 loaded.ctrl.Close()
275 })
276 assertACPSessionOnRecoveryPath(t, loaded, originalPath, recoveryPath)
277 if got := loaded.ctrl.SessionPath(); got != recoveryPath {
278 t.Fatalf("loaded controller session path = %q, want recovery path %q", got, recoveryPath)
279 }
280 // The test factory's controller has no executor, so prove the content via
281 // the transcript ACP now points at: it must hold the recovered local line,
282 // not the pre-recovery disk line.
283 resumed, err := agent.LoadSession(loaded.transcript)
284 if err != nil {
285 t.Fatalf("load resolved transcript: %v", err)
286 }
287 msgs := resumed.Snapshot()
288 if len(msgs) == 0 {
289 t.Fatal("resolved transcript is empty")
290 }
291 if got := msgs[len(msgs)-1].Content; got != "local second" {
292 t.Fatalf("resolved transcript last message = %q, want recovered local transcript (%q)", got, "local second")
293 }
294 }
295
296 // TestACPDeleteAfterRestartRemovesRecoveryAndIDKeyedFiles: session/delete on a
297 // non-live recovered session must remove both the recovery transcript (the
298 // session's live file) and the id-keyed original, or the survivor resurfaces
299 // in session/list as a ghost that can never be deleted by id.
300 func TestACPDeleteAfterRestartRemovesRecoveryAndIDKeyedFiles(t *testing.T) {
301 dir := t.TempDir()
302 id := "sess-del"
303 originalPath, recoveryPath, svc := recoverACPSessionAndRestart(t, dir, id)
304
305 raw, err := json.Marshal(SessionDeleteParams{SessionID: id})
306 if err != nil {
307 t.Fatalf("marshal delete params: %v", err)
308 }
309 if _, err := svc.sessionDelete(context.Background(), raw); err != nil {
310 t.Fatalf("sessionDelete after restart: %v", err)
311 }
312 for _, path := range []string{originalPath, recoveryPath, acpMetaPath(originalPath), acpMetaPath(recoveryPath)} {
313 if _, err := os.Stat(path); !os.IsNotExist(err) {
314 t.Fatalf("%s should be removed by session/delete, stat err = %v", path, err)
315 }
316 }
317 res, err := svc.sessionList(context.Background(), nil)
318 if err != nil {
319 t.Fatalf("sessionList after delete: %v", err)
320 }
321 if sessions := res.(SessionListResult).Sessions; len(sessions) != 0 {
322 t.Fatalf("session list after delete = %#v, want empty", sessions)
323 }
324 }
325
326 // TestACPSessionListAfterRecoveryShowsSingleActiveEntry: after a recovery the
327 // id-keyed sidecar becomes a redirect, and session/list must present exactly
328 // one entry for the id, backed by the active recovery transcript's metadata
329 // (the live title), never the stale pre-recovery sidecar.
330 func TestACPSessionListAfterRecoveryShowsSingleActiveEntry(t *testing.T) {
331 dir := t.TempDir()
332 id := "sess-list"
333 _, _, svc := recoverACPSessionAndRestart(t, dir, id)
334
335 res, err := svc.sessionList(context.Background(), nil)
336 if err != nil {
337 t.Fatalf("sessionList after recovery: %v", err)
338 }
339 sessions := res.(SessionListResult).Sessions
340 if len(sessions) != 1 {
341 t.Fatalf("session list after recovery = %#v, want exactly one entry", sessions)
342 }
343 if sessions[0].SessionID != id {
344 t.Fatalf("session list entry id = %q, want %q", sessions[0].SessionID, id)
345 }
346 if sessions[0].Title != "recovered title" {
347 t.Fatalf("session list entry title = %q, want the active transcript's title %q", sessions[0].Title, "recovered title")
348 }
349 }
350
351 // profileSystemPromptFactory builds controllers whose leading system message
352 // encodes the requested runtime profile, mirroring how boot.Build appends a
353 // profile-specific contract to the system prompt (see boot/token_profile.go).
354 // It lets a test check that a controller rebuild refreshes that contract
355 // instead of carrying the outgoing profile's prompt forward.
356 type profileSystemPromptFactory struct {
357 dir string
358 }
359
360 func (f *profileSystemPromptFactory) NewSession(_ context.Context, p SessionParams) (*control.Controller, error) {
361 prompt := "system prompt for profile " + p.RuntimeProfile
362 exec := agent.New(nil, nil, agent.NewSession(prompt), agent.Options{}, event.Discard)
363 return control.New(control.Options{Executor: exec, SessionDir: f.dir, Label: p.RuntimeProfile}), nil
364 }
365
366 func (f *profileSystemPromptFactory) SessionDir() string { return f.dir }
367
368 // TestACPRebuildSessionRefreshesLeadingSystemPromptForNewProfile pins the fix
369 // for the bug where a work-mode (runtime profile) switch rebuilt the
370 // controller with the target profile's own system prompt, only for
371 // AdoptHistory to immediately overwrite it with the carried history's
372 // leading message — the outgoing profile's contract. The user-visible
373 // symptom was that the model kept following the previous profile's
374 // instructions after every switch.
375 func TestACPRebuildSessionRefreshesLeadingSystemPromptForNewProfile(t *testing.T) {
376 dir := t.TempDir()
377 path := filepath.Join(dir, "acp-profile-switch.jsonl")
378
379 oldSession := agent.NewSession("system prompt for profile balanced")
380 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
381 oldSession.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
382 if err := oldSession.Save(path); err != nil {
383 t.Fatalf("save base session: %v", err)
384 }
385
386 sink := newUpdateSink(&fakeNotifier{}, "sess-profile-switch")
387 sess := &acpSession{
388 id: "sess-profile-switch",
389 sink: sink,
390 cwd: dir,
391 model: "fast",
392 runtimeProfile: "balanced",
393 transcript: path,
394 }
395 lease, err := agent.TryAcquireSessionLease(path)
396 if err != nil {
397 t.Fatalf("acquire session lease: %v", err)
398 }
399 sess.lease = lease
400 t.Cleanup(sess.releaseSessionLease)
401
402 sess.ctrl = control.New(control.Options{
403 Executor: agent.New(nil, nil, oldSession, agent.Options{}, event.Discard),
404 SessionDir: dir,
405 SessionPath: path,
406 Label: "balanced",
407 })
408 svc := &service{
409 factory: &profileSystemPromptFactory{dir: dir},
410 sessions: map[string]*acpSession{sess.id: sess},
411 }
412
413 deltas := []sessionConfigDelta{{axis: "work_mode", runtimeProfile: "delivery"}}
414 if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{RuntimeProfile: "delivery"}, deltas); err != nil {
415 t.Fatalf("rebuildSession: %v", err)
416 }
417
418 history := sess.currentCtrl().History()
419 if len(history) == 0 || history[0].Role != provider.RoleSystem {
420 t.Fatalf("history = %+v, want a leading system message", history)
421 }
422 if got, want := history[0].Content, "system prompt for profile delivery"; got != want {
423 t.Fatalf("leading system message = %q, want %q (stale outgoing-profile contract carried forward)", got, want)
424 }
425 if len(history) != 3 || history[1].Content != "hello" || history[2].Content != "hi" {
426 t.Fatalf("history after profile switch = %+v, want carried user/assistant turns preserved", history)
427 }
428 }
429
430 // TestACPWorkModeSwitchPersistsRefreshedSystemPromptAcrossReload pins the disk
431 // half of the profile-switch fix: the refreshed leading system prompt must be
432 // persisted at switch time, because session/close never snapshots and
433 // session/load resumes the transcript exactly as saved. Before the fix the
434 // switch refreshed only the new controller's in-memory history, so a
435 // switch → close → load sequence revived the outgoing profile's contract even
436 // though the session metadata already claimed the new profile.
437 func TestACPWorkModeSwitchPersistsRefreshedSystemPromptAcrossReload(t *testing.T) {
438 dir := t.TempDir()
439 id := "sess-profile-persist"
440 path := transcriptPath(dir, id)
441
442 oldSession := agent.NewSession("system prompt for profile balanced")
443 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
444 oldSession.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
445 if err := oldSession.Save(path); err != nil {
446 t.Fatalf("save base session: %v", err)
447 }
448
449 sink := newUpdateSink(&fakeNotifier{}, id)
450 sess := &acpSession{
451 id: id,
452 sink: sink,
453 cwd: dir,
454 model: "fast",
455 runtimeProfile: "balanced",
456 transcript: path,
457 }
458 lease, err := agent.TryAcquireSessionLease(path)
459 if err != nil {
460 t.Fatalf("acquire session lease: %v", err)
461 }
462 sess.lease = lease
463 sess.ctrl = control.New(control.Options{
464 Executor: agent.New(nil, nil, oldSession, agent.Options{}, event.Discard),
465 SessionDir: dir,
466 SessionPath: path,
467 Label: "balanced",
468 })
469 svc := &service{
470 conn: NewConn(strings.NewReader(""), io.Discard),
471 factory: &profileSystemPromptFactory{dir: dir},
472 sessions: map[string]*acpSession{sess.id: sess},
473 }
474
475 deltas := []sessionConfigDelta{{axis: "work_mode", runtimeProfile: "delivery"}}
476 if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{RuntimeProfile: "delivery"}, deltas); err != nil {
477 t.Fatalf("rebuildSession: %v", err)
478 }
479
480 // The refreshed contract must be on disk as soon as the switch lands.
481 onDisk, err := agent.LoadSession(path)
482 if err != nil {
483 t.Fatalf("load transcript after switch: %v", err)
484 }
485 if msgs := onDisk.Snapshot(); len(msgs) == 0 || msgs[0].Content != "system prompt for profile delivery" {
486 t.Fatalf("on-disk leading message after switch = %+v, want the delivery profile contract", msgs)
487 }
488
489 raw, err := json.Marshal(SessionCloseParams{SessionID: id})
490 if err != nil {
491 t.Fatalf("marshal close params: %v", err)
492 }
493 if _, err := svc.sessionClose(context.Background(), raw); err != nil {
494 t.Fatalf("sessionClose: %v", err)
495 }
496
497 if _, err := svc.openExistingSession(context.Background(), "session/load", id, dir, nil, false); err != nil {
498 t.Fatalf("openExistingSession after close: %v", err)
499 }
500 loaded := svc.session(id)
501 if loaded == nil {
502 t.Fatal("session not registered after load")
503 }
504 t.Cleanup(func() {
505 loaded.releaseSessionLease()
506 loaded.currentCtrl().Close()
507 })
508
509 history := loaded.currentCtrl().History()
510 if len(history) == 0 || history[0].Role != provider.RoleSystem {
511 t.Fatalf("loaded history = %+v, want a leading system message", history)
512 }
513 if got, want := history[0].Content, "system prompt for profile delivery"; got != want {
514 t.Fatalf("leading system prompt after switch → close → load = %q, want %q (stale outgoing-profile contract revived from disk)", got, want)
515 }
516 if len(history) != 3 || history[1].Content != "hello" || history[2].Content != "hi" {
517 t.Fatalf("loaded history = %+v, want carried user/assistant turns preserved", history)
518 }
519 }
520
521 // TestACPWorkModeSwitchSnapshotFailureKeepsOutgoingController proves failure
522 // atomicity for the switch-time persistence step. If the refreshed history
523 // cannot be written, the service must return an error and leave the outgoing
524 // controller/config active instead of publishing an in-memory-only switch.
525 func TestACPWorkModeSwitchSnapshotFailureKeepsOutgoingController(t *testing.T) {
526 dir := t.TempDir()
527 invalidPath := filepath.Join(dir, "transcript-is-a-directory")
528 if err := os.Mkdir(invalidPath, 0o755); err != nil {
529 t.Fatalf("mkdir invalid transcript path: %v", err)
530 }
531
532 oldSession := agent.NewSession("system prompt for profile balanced")
533 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
534 oldSession.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
535 oldCtrl := &snapshotLockProbeController{Controller: control.New(control.Options{
536 Executor: agent.New(nil, nil, oldSession, agent.Options{}, event.Discard),
537 SessionDir: dir,
538 SessionPath: invalidPath,
539 Label: "balanced",
540 })}
541 t.Cleanup(oldCtrl.Close)
542
543 sess := &acpSession{
544 id: "sess-profile-persist-failure",
545 ctrl: oldCtrl,
546 sink: newUpdateSink(&fakeNotifier{}, "sess-profile-persist-failure"),
547 cwd: dir,
548 model: "fast",
549 runtimeProfile: "balanced",
550 transcript: invalidPath,
551 }
552 svc := &service{
553 factory: &profileSystemPromptFactory{dir: dir},
554 sessions: map[string]*acpSession{sess.id: sess},
555 }
556
557 deltas := []sessionConfigDelta{{axis: "work_mode", runtimeProfile: "delivery"}}
558 err := svc.rebuildSession(context.Background(), sess, SessionConfigState{RuntimeProfile: "delivery"}, deltas)
559 if err == nil || !strings.Contains(err.Error(), "snapshot after switch") {
560 t.Fatalf("rebuildSession error = %v, want snapshot after switch failure", err)
561 }
562 if got := sess.currentCtrl(); got != oldCtrl {
563 t.Fatalf("controller changed after persistence failure: got %T %p, want outgoing %p", got, got, oldCtrl)
564 }
565 if got := sess.runtimeProfile; got != "balanced" {
566 t.Fatalf("runtime profile = %q, want outgoing balanced after persistence failure", got)
567 }
568 }
569
569 lines GO