返回 DeepSeek-Reasonix
session_meta_ledger_test.go
根目录 / internal / agent / session_meta_ledger_test.go
1 package agent
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sync"
10 "sync/atomic"
11 "testing"
12 "time"
13
14 "reasonix/internal/provider"
15 )
16
17 // shrinkMetaReadBackoffs keeps corrupt-sidecar tests fast. Only the pacing of
18 // the torn-read retries changes; the retry-then-fail semantics stay intact.
19 func shrinkMetaReadBackoffs(t *testing.T) {
20 t.Helper()
21 old := branchMetaReadBackoffs
22 branchMetaReadBackoffs = []time.Duration{time.Millisecond, time.Millisecond, time.Millisecond}
23 t.Cleanup(func() { branchMetaReadBackoffs = old })
24 }
25
26 func assertNoRecoveryBranches(t *testing.T, sessionPath string) {
27 t.Helper()
28 matches, err := filepath.Glob(filepath.Join(filepath.Dir(sessionPath), "*-recovery-*.jsonl"))
29 if err != nil {
30 t.Fatalf("glob recovery branches: %v", err)
31 }
32 if len(matches) != 0 {
33 t.Fatalf("unexpected recovery branches: %v", matches)
34 }
35 }
36
37 // A meta sidecar that exists but cannot be parsed must fail the save instead
38 // of being silently treated as revision 0 — the desync that used to fork
39 // bogus recovery branches. The unreadable ledger must also survive untouched
40 // so a healed read can pick up the real revision.
41 func TestSaveFailsClosedOnCorruptMetaLedger(t *testing.T) {
42 shrinkMetaReadBackoffs(t)
43 path := filepath.Join(t.TempDir(), "session.jsonl")
44 metaPath := BranchMetaPath(path)
45 s := NewSession("sys")
46 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
47 if err := s.SaveSnapshot(path); err != nil {
48 t.Fatalf("SaveSnapshot base: %v", err)
49 }
50 good, err := os.ReadFile(metaPath)
51 if err != nil {
52 t.Fatalf("read good meta: %v", err)
53 }
54 corrupt := []byte("{ torn json")
55 if err := os.WriteFile(metaPath, corrupt, 0o644); err != nil {
56 t.Fatalf("corrupt meta: %v", err)
57 }
58
59 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
60 saveErr := s.SaveSnapshot(path)
61 if saveErr == nil {
62 t.Fatal("SaveSnapshot with corrupt meta ledger succeeded, want error")
63 }
64 if errors.Is(saveErr, ErrSessionSnapshotConflict) {
65 t.Fatalf("SaveSnapshot with corrupt meta misread as conflict: %v", saveErr)
66 }
67 assertNoRecoveryBranches(t, path)
68 if b, err := os.ReadFile(metaPath); err != nil || string(b) != string(corrupt) {
69 t.Fatalf("failed save rewrote the unreadable ledger: err=%v content=%q", err, b)
70 }
71 onDisk, err := LoadSession(path)
72 if err != nil {
73 t.Fatalf("LoadSession after failed save: %v", err)
74 }
75 if got := len(onDisk.Messages); got != 2 {
76 t.Fatalf("failed save changed transcript: %d messages, want 2", got)
77 }
78
79 // Once the sidecar reads cleanly again the same session saves normally.
80 if err := os.WriteFile(metaPath, good, 0o644); err != nil {
81 t.Fatalf("restore meta: %v", err)
82 }
83 if err := s.SaveSnapshot(path); err != nil {
84 t.Fatalf("SaveSnapshot after meta repair: %v", err)
85 }
86 meta, ok, err := LoadBranchMeta(path)
87 if err != nil || !ok {
88 t.Fatalf("LoadBranchMeta after repair ok=%v err=%v", ok, err)
89 }
90 if meta.Revision != 2 {
91 t.Fatalf("revision after repaired save = %d, want 2", meta.Revision)
92 }
93 }
94
95 // An unreadable meta sidecar at load time must not stop the session from
96 // opening, and the revision-0 placeholder must not arm the CAS check: once
97 // the sidecar reads cleanly again, appends save without a bogus conflict.
98 func TestLoadSessionWithUnreadableMetaStillOpensAndAppends(t *testing.T) {
99 shrinkMetaReadBackoffs(t)
100 path := filepath.Join(t.TempDir(), "session.jsonl")
101 metaPath := BranchMetaPath(path)
102 seed := NewSession("sys")
103 seed.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
104 seed.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
105 if err := seed.SaveSnapshot(path); err != nil {
106 t.Fatalf("SaveSnapshot seed: %v", err)
107 }
108 good, err := os.ReadFile(metaPath)
109 if err != nil {
110 t.Fatalf("read good meta: %v", err)
111 }
112 if err := os.WriteFile(metaPath, []byte("{ torn json"), 0o644); err != nil {
113 t.Fatalf("corrupt meta: %v", err)
114 }
115
116 loaded, err := LoadSession(path)
117 if err != nil {
118 t.Fatalf("LoadSession with unreadable meta: %v", err)
119 }
120 if got := len(loaded.Messages); got != 3 {
121 t.Fatalf("loaded %d messages, want 3", got)
122 }
123 if !loaded.persisted.ok {
124 t.Fatal("load must still establish a persistence baseline")
125 }
126 if loaded.persisted.revisionKnown {
127 t.Fatal("baseline built without a readable ledger must be revision-unknown")
128 }
129
130 // The tear heals (another runtime's write completes / the corruption was
131 // transient): the on-disk revision is an honest 1 against this runtime's
132 // unknown baseline. The append must not be misread as a stale runtime.
133 if err := os.WriteFile(metaPath, good, 0o644); err != nil {
134 t.Fatalf("restore meta: %v", err)
135 }
136 loaded.Add(provider.Message{Role: provider.RoleUser, Content: "next"})
137 if err := loaded.SaveSnapshot(path); err != nil {
138 t.Fatalf("SaveSnapshot append after unreadable-meta load: %v", err)
139 }
140 assertNoRecoveryBranches(t, path)
141 meta, ok, err := LoadBranchMeta(path)
142 if err != nil || !ok {
143 t.Fatalf("LoadBranchMeta after append ok=%v err=%v", ok, err)
144 }
145 if meta.Revision != 2 {
146 t.Fatalf("revision after append = %d, want 2", meta.Revision)
147 }
148 if !loaded.persisted.revisionKnown {
149 t.Fatal("successful save must re-learn the revision baseline")
150 }
151
152 // With the baseline re-learned, revision CAS is armed again.
153 loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
154 if err := loaded.SaveSnapshot(path); err != nil {
155 t.Fatalf("SaveSnapshot second append: %v", err)
156 }
157 }
158
159 // Compaction-style rewrites from a revision-unknown baseline must fall back to
160 // digest+version ownership instead of failing the revision equality check.
161 func TestSaveRewriteWithUnreadableMetaBaselineOwnsByDigest(t *testing.T) {
162 shrinkMetaReadBackoffs(t)
163 path := filepath.Join(t.TempDir(), "session.jsonl")
164 metaPath := BranchMetaPath(path)
165 seed := NewSession("sys")
166 seed.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
167 seed.Add(provider.Message{Role: provider.RoleAssistant, Content: "a long detailed answer"})
168 if err := seed.SaveSnapshot(path); err != nil {
169 t.Fatalf("SaveSnapshot seed: %v", err)
170 }
171 good, err := os.ReadFile(metaPath)
172 if err != nil {
173 t.Fatalf("read good meta: %v", err)
174 }
175 if err := os.WriteFile(metaPath, []byte("{ torn json"), 0o644); err != nil {
176 t.Fatalf("corrupt meta: %v", err)
177 }
178 loaded, err := LoadSession(path)
179 if err != nil {
180 t.Fatalf("LoadSession with unreadable meta: %v", err)
181 }
182 if err := os.WriteFile(metaPath, good, 0o644); err != nil {
183 t.Fatalf("restore meta: %v", err)
184 }
185
186 rewritten := loaded.Snapshot()
187 rewritten[len(rewritten)-1].Content = "compacted"
188 loaded.Replace(rewritten)
189 if err := loaded.SaveRewrite(path); err != nil {
190 t.Fatalf("SaveRewrite from unknown-revision baseline: %v", err)
191 }
192 assertNoRecoveryBranches(t, path)
193 meta, ok, err := LoadBranchMeta(path)
194 if err != nil || !ok {
195 t.Fatalf("LoadBranchMeta after rewrite ok=%v err=%v", ok, err)
196 }
197 if meta.Revision != 2 {
198 t.Fatalf("revision after rewrite = %d, want 2", meta.Revision)
199 }
200 reloaded, err := LoadSession(path)
201 if err != nil {
202 t.Fatalf("LoadSession after rewrite: %v", err)
203 }
204 if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "compacted" {
205 t.Fatalf("rewrite tail = %q, want compacted", got)
206 }
207 }
208
209 // A missing sidecar is not damage: it is the legitimate revision-0 state of a
210 // session that never recorded one, and must keep arming the CAS baseline.
211 func TestMissingMetaRemainsKnownZeroRevisionBaseline(t *testing.T) {
212 path := filepath.Join(t.TempDir(), "session.jsonl")
213 s := NewSession("sys")
214 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
215 if err := s.SaveSnapshot(path); err != nil {
216 t.Fatalf("SaveSnapshot base: %v", err)
217 }
218 if err := os.Remove(BranchMetaPath(path)); err != nil {
219 t.Fatalf("remove meta: %v", err)
220 }
221
222 loaded, err := LoadSession(path)
223 if err != nil {
224 t.Fatalf("LoadSession without meta: %v", err)
225 }
226 if !loaded.persisted.ok || !loaded.persisted.revisionKnown || loaded.persisted.revision != 0 {
227 t.Fatalf("missing meta baseline = %+v, want known revision 0", loaded.persisted)
228 }
229 loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
230 if err := loaded.SaveSnapshot(path); err != nil {
231 t.Fatalf("SaveSnapshot append without meta: %v", err)
232 }
233 assertNoRecoveryBranches(t, path)
234 meta, ok, err := LoadBranchMeta(path)
235 if err != nil || !ok {
236 t.Fatalf("LoadBranchMeta recreated ok=%v err=%v", ok, err)
237 }
238 if meta.Revision != 1 {
239 t.Fatalf("recreated revision = %d, want 1", meta.Revision)
240 }
241 }
242
243 // Losing the event index (a listing accelerator, never read by LoadSession)
244 // must not fail a save whose transcript and revision already landed, and must
245 // not leave the baseline behind disk where the next save reads as a conflict.
246 func TestSaveSnapshotToleratesEventIndexWriteFailure(t *testing.T) {
247 path := filepath.Join(t.TempDir(), "session.jsonl")
248 // Squat a directory on the index path so every index write must fail.
249 if err := os.MkdirAll(SessionEventIndexPath(path), 0o755); err != nil {
250 t.Fatalf("pre-create index dir: %v", err)
251 }
252 s := NewSession("sys")
253 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
254 if err := s.SaveSnapshot(path); err != nil {
255 t.Fatalf("SaveSnapshot with failing event index = %v, want nil", err)
256 }
257 first, ok, err := LoadBranchMeta(path)
258 if err != nil || !ok {
259 t.Fatalf("LoadBranchMeta first ok=%v err=%v", ok, err)
260 }
261 if first.Revision != 1 {
262 t.Fatalf("first revision = %d, want 1", first.Revision)
263 }
264
265 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
266 if err := s.SaveSnapshot(path); err != nil {
267 t.Fatalf("second SaveSnapshot = %v, want nil (baseline must advance despite index failure)", err)
268 }
269 second, ok, err := LoadBranchMeta(path)
270 if err != nil || !ok {
271 t.Fatalf("LoadBranchMeta second ok=%v err=%v", ok, err)
272 }
273 if second.Revision != 2 {
274 t.Fatalf("second revision = %d, want 2", second.Revision)
275 }
276 assertNoRecoveryBranches(t, path)
277 loaded, err := LoadSession(path)
278 if err != nil {
279 t.Fatalf("LoadSession: %v", err)
280 }
281 if got := len(loaded.Messages); got != 3 {
282 t.Fatalf("loaded %d messages, want 3", got)
283 }
284 if info, err := os.Stat(SessionEventIndexPath(path)); err != nil || !info.IsDir() {
285 t.Fatalf("fixture broke: index path no longer a directory (err=%v)", err)
286 }
287 }
288
289 // Meta-only writers (rename, model stamp) racing content saves must never
290 // roll the revision ledger backwards or manufacture conflicts.
291 func TestSessionMetaConcurrentWritersKeepRevisionMonotonic(t *testing.T) {
292 path := filepath.Join(t.TempDir(), "session.jsonl")
293 s := NewSession("sys")
294 s.Add(provider.Message{Role: provider.RoleUser, Content: "turn 0"})
295 if err := s.SaveSnapshot(path); err != nil {
296 t.Fatalf("SaveSnapshot seed: %v", err)
297 }
298 if err := RenameSession(path, "seed-title"); err != nil {
299 t.Fatalf("RenameSession seed: %v", err)
300 }
301 if err := SetBranchModelPreserveUpdated(path, "prov/model-seed"); err != nil {
302 t.Fatalf("SetBranchModelPreserveUpdated seed: %v", err)
303 }
304
305 const saves = 25
306 stop := make(chan struct{})
307 var stopOnce sync.Once
308 stopAll := func() { stopOnce.Do(func() { close(stop) }) }
309 var wg sync.WaitGroup
310 defer func() {
311 stopAll()
312 wg.Wait()
313 }()
314 metaErrCh := make(chan error, 2)
315 var regressed atomic.Bool
316
317 wg.Add(1)
318 go func() {
319 defer wg.Done()
320 for i := 0; ; i++ {
321 select {
322 case <-stop:
323 return
324 default:
325 }
326 if err := RenameSession(path, fmt.Sprintf("title-%d", i)); err != nil {
327 select {
328 case metaErrCh <- err:
329 default:
330 }
331 return
332 }
333 if err := SetBranchModelPreserveUpdated(path, fmt.Sprintf("prov/model-%d", i)); err != nil {
334 select {
335 case metaErrCh <- err:
336 default:
337 }
338 return
339 }
340 }
341 }()
342 wg.Add(1)
343 go func() {
344 defer wg.Done()
345 last := int64(0)
346 for {
347 select {
348 case <-stop:
349 return
350 default:
351 }
352 if meta, ok, err := LoadBranchMeta(path); err == nil && ok {
353 if meta.Revision < last {
354 regressed.Store(true)
355 return
356 }
357 last = meta.Revision
358 }
359 }
360 }()
361
362 for i := 1; i <= saves; i++ {
363 s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("turn %d", i)})
364 if err := s.SaveSnapshot(path); err != nil {
365 t.Fatalf("SaveSnapshot %d under meta-writer hammer: %v", i, err)
366 }
367 }
368 stopAll()
369 wg.Wait()
370 close(metaErrCh)
371 for err := range metaErrCh {
372 t.Fatalf("meta writer error: %v", err)
373 }
374 if regressed.Load() {
375 t.Fatal("meta revision regressed while meta-only writers raced saves")
376 }
377
378 meta, ok, err := LoadBranchMeta(path)
379 if err != nil || !ok {
380 t.Fatalf("LoadBranchMeta final ok=%v err=%v", ok, err)
381 }
382 if want := int64(saves + 1); meta.Revision != want {
383 t.Fatalf("final revision = %d, want %d", meta.Revision, want)
384 }
385 digest, err := digestSessionMessages(s.Snapshot())
386 if err != nil {
387 t.Fatalf("digest final content: %v", err)
388 }
389 if meta.ContentDigest != digestString(digest) {
390 t.Fatalf("final content digest = %q, want %q", meta.ContentDigest, digestString(digest))
391 }
392 if meta.CustomTitle == "" || meta.Model == "" {
393 t.Fatalf("meta-only fields lost under hammer: %+v", meta)
394 }
395 assertNoRecoveryBranches(t, path)
396 }
397
398 // A saver that blocks on the save lock must persist whatever the session
399 // holds when it finally enters the critical section, not a stale capture from
400 // when it was scheduled — the out-of-order landing that used to surface as
401 // "session changed on disk" adoptions between the turn-end snapshot, periodic
402 // autosave, and shutdown snapshot.
403 func TestSaveSnapshotCapturesContentUnderSaveLock(t *testing.T) {
404 path := filepath.Join(t.TempDir(), "session.jsonl")
405 s := NewSession("sys")
406 s.Add(provider.Message{Role: provider.RoleUser, Content: "turn 0"})
407 if err := s.SaveSnapshot(path); err != nil {
408 t.Fatalf("SaveSnapshot seed: %v", err)
409 }
410
411 unlock := lockSessionSavePath(path)
412 done := make(chan error, 1)
413 go func() { done <- s.SaveSnapshot(path) }()
414 // Let the saver reach the save lock, then grow the session while it waits.
415 time.Sleep(50 * time.Millisecond)
416 s.Add(provider.Message{Role: provider.RoleUser, Content: "added while saver waited"})
417 unlock()
418 if err := <-done; err != nil {
419 t.Fatalf("SaveSnapshot after lock release: %v", err)
420 }
421
422 loaded, err := LoadSession(path)
423 if err != nil {
424 t.Fatalf("LoadSession: %v", err)
425 }
426 if got := len(loaded.Messages); got != 3 {
427 t.Fatalf("saved %d messages, want 3 (snapshot must be captured under the save lock)", got)
428 }
429 assertNoRecoveryBranches(t, path)
430 }
431
432 // Multiple in-process savers of one session (turn-end snapshot, periodic
433 // autosave, shutdown snapshot) must never conflict with each other: the
434 // snapshot is captured under the save lock, so a stale pre-lock capture can
435 // no longer land after a newer one.
436 func TestConcurrentSnapshotSaversNeverConflict(t *testing.T) {
437 path := filepath.Join(t.TempDir(), "session.jsonl")
438 s := NewSession("sys")
439 s.Add(provider.Message{Role: provider.RoleUser, Content: "turn 0"})
440 if err := s.SaveSnapshot(path); err != nil {
441 t.Fatalf("SaveSnapshot seed: %v", err)
442 }
443
444 const adds = 30
445 stop := make(chan struct{})
446 errCh := make(chan error, 64)
447 var wg sync.WaitGroup
448 for i := 0; i < 2; i++ {
449 wg.Add(1)
450 go func() {
451 defer wg.Done()
452 for {
453 if err := s.SaveSnapshot(path); err != nil {
454 select {
455 case errCh <- err:
456 default:
457 }
458 }
459 select {
460 case <-stop:
461 return
462 default:
463 }
464 }
465 }()
466 }
467 for i := 1; i <= adds; i++ {
468 s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("turn %d", i)})
469 time.Sleep(time.Millisecond)
470 }
471 close(stop)
472 wg.Wait()
473 close(errCh)
474 for err := range errCh {
475 if errors.Is(err, ErrSessionSnapshotConflict) {
476 t.Fatalf("concurrent snapshot savers conflicted: %v", err)
477 }
478 t.Errorf("concurrent snapshot saver error: %v", err)
479 }
480
481 if err := s.SaveSnapshot(path); err != nil {
482 t.Fatalf("final SaveSnapshot: %v", err)
483 }
484 loaded, err := LoadSession(path)
485 if err != nil {
486 t.Fatalf("LoadSession: %v", err)
487 }
488 if got, want := len(loaded.Messages), adds+2; got != want {
489 t.Fatalf("final message count = %d, want %d", got, want)
490 }
491 assertNoRecoveryBranches(t, path)
492 }
493
494 // A save that lands its transcript bytes and then fails to record the
495 // revision (fail-closed record, or a crash between the two writes) leaves the
496 // ledger describing older content. The next save of the same snapshot takes
497 // the up-to-date path and must heal the ledger — record the revision and
498 // digest the interrupted save deferred — instead of skipping it forever.
499 func TestSameContentSaveHealsStaleLedgerDigest(t *testing.T) {
500 path := filepath.Join(t.TempDir(), "session.jsonl")
501 metaPath := BranchMetaPath(path)
502 s := NewSession("sys")
503 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
504 if err := s.SaveSnapshot(path); err != nil {
505 t.Fatalf("SaveSnapshot base: %v", err)
506 }
507 staleMeta, err := os.ReadFile(metaPath)
508 if err != nil {
509 t.Fatalf("read base meta: %v", err)
510 }
511
512 // Land new content, then rewind the sidecar to the pre-append ledger:
513 // the exact on-disk aftermath of an append whose revision record failed.
514 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
515 if err := s.SaveSnapshot(path); err != nil {
516 t.Fatalf("SaveSnapshot append: %v", err)
517 }
518 if err := os.WriteFile(metaPath, staleMeta, 0o644); err != nil {
519 t.Fatalf("rewind meta: %v", err)
520 }
521
522 // Any runtime resuming this file now pairs the landed transcript with the
523 // stale revision — the post-crash shape.
524 loaded, err := LoadSession(path)
525 if err != nil {
526 t.Fatalf("LoadSession on stale ledger: %v", err)
527 }
528 if err := loaded.SaveSnapshot(path); err != nil {
529 t.Fatalf("same-content SaveSnapshot: %v", err)
530 }
531 meta, ok, err := LoadBranchMeta(path)
532 if err != nil || !ok {
533 t.Fatalf("LoadBranchMeta after heal ok=%v err=%v", ok, err)
534 }
535 if meta.Revision != 2 {
536 t.Fatalf("healed revision = %d, want 2", meta.Revision)
537 }
538 digest, err := digestSessionMessages(loaded.Snapshot())
539 if err != nil {
540 t.Fatalf("digest current messages: %v", err)
541 }
542 if meta.ContentDigest != digestString(digest) {
543 t.Fatalf("healed digest = %s, want %s", meta.ContentDigest, digestString(digest))
544 }
545 assertNoRecoveryBranches(t, path)
546
547 // The healed baseline keeps working: the next append saves cleanly.
548 loaded.Add(provider.Message{Role: provider.RoleUser, Content: "two"})
549 if err := loaded.SaveSnapshot(path); err != nil {
550 t.Fatalf("append after heal: %v", err)
551 }
552 after, _, err := LoadBranchMeta(path)
553 if err != nil {
554 t.Fatalf("LoadBranchMeta after append: %v", err)
555 }
556 if after.Revision != 3 {
557 t.Fatalf("revision after post-heal append = %d, want 3", after.Revision)
558 }
559 assertNoRecoveryBranches(t, path)
560 }
561
562 // The surviving in-process saver — whose baseline never advanced because the
563 // failed save returned before markPersisted — heals through the same
564 // up-to-date path on its autosave retry of the identical snapshot.
565 func TestSameContentRetryHealsLedgerForSurvivingSaver(t *testing.T) {
566 path := filepath.Join(t.TempDir(), "session.jsonl")
567 metaPath := BranchMetaPath(path)
568 s := NewSession("sys")
569 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
570 if err := s.SaveSnapshot(path); err != nil {
571 t.Fatalf("SaveSnapshot base: %v", err)
572 }
573 staleMeta, err := os.ReadFile(metaPath)
574 if err != nil {
575 t.Fatalf("read base meta: %v", err)
576 }
577 baseline := s.persistState(path)
578
579 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
580 if err := s.SaveSnapshot(path); err != nil {
581 t.Fatalf("SaveSnapshot append: %v", err)
582 }
583 // Rewind the sidecar and the in-memory baseline to the mid-save failure
584 // state: bytes landed, record failed, markPersisted never ran.
585 if err := os.WriteFile(metaPath, staleMeta, 0o644); err != nil {
586 t.Fatalf("rewind meta: %v", err)
587 }
588 s.setPersistedBaseline(path, baseline.digest, baseline.version, baseline.revision, true, true, 0)
589
590 if err := s.SaveSnapshot(path); err != nil {
591 t.Fatalf("autosave retry: %v", err)
592 }
593 meta, ok, err := LoadBranchMeta(path)
594 if err != nil || !ok {
595 t.Fatalf("LoadBranchMeta after heal ok=%v err=%v", ok, err)
596 }
597 if meta.Revision != 2 {
598 t.Fatalf("healed revision = %d, want 2", meta.Revision)
599 }
600 assertNoRecoveryBranches(t, path)
601 }
602
603 // A legacy sidecar that predates content digests is not a stale ledger: the
604 // up-to-date path must leave it untouched rather than bump a revision other
605 // runtimes still hold as their baseline.
606 func TestUpToDateSaveLeavesDigestlessLegacyMetaAlone(t *testing.T) {
607 path := filepath.Join(t.TempDir(), "session.jsonl")
608 metaPath := BranchMetaPath(path)
609 s := NewSession("sys")
610 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
611 if err := s.SaveSnapshot(path); err != nil {
612 t.Fatalf("SaveSnapshot base: %v", err)
613 }
614 // Strip the digest by editing the raw sidecar: the save helpers back-fill
615 // an empty digest from the existing meta, exactly like real legacy files
616 // acquired one only through a content-bearing save.
617 raw, err := os.ReadFile(metaPath)
618 if err != nil {
619 t.Fatalf("read meta: %v", err)
620 }
621 var fields map[string]any
622 if err := json.Unmarshal(raw, &fields); err != nil {
623 t.Fatalf("decode meta: %v", err)
624 }
625 delete(fields, "content_digest")
626 stripped, err := json.Marshal(fields)
627 if err != nil {
628 t.Fatalf("encode meta: %v", err)
629 }
630 if err := os.WriteFile(metaPath, stripped, 0o644); err != nil {
631 t.Fatalf("write legacy meta: %v", err)
632 }
633
634 loaded, err := LoadSession(path)
635 if err != nil {
636 t.Fatalf("LoadSession legacy meta: %v", err)
637 }
638 if err := loaded.SaveSnapshot(path); err != nil {
639 t.Fatalf("same-content SaveSnapshot: %v", err)
640 }
641 meta, ok, err := LoadBranchMeta(path)
642 if err != nil || !ok {
643 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
644 }
645 if meta.Revision != 1 {
646 t.Fatalf("legacy revision = %d, want 1 (no gratuitous bump)", meta.Revision)
647 }
648 if meta.ContentDigest != "" {
649 t.Fatalf("legacy digest = %q, want empty (untouched)", meta.ContentDigest)
650 }
651 assertNoRecoveryBranches(t, path)
652 }
653
653 lines GO