返回 DeepSeek-Reasonix
save_test.go
根目录 / internal / agent / save_test.go
1 package agent
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "reflect"
11 "runtime"
12 "strings"
13 "testing"
14 "time"
15
16 "reasonix/internal/provider"
17 "reasonix/internal/store"
18 )
19
20 // touch sets a file's mtime to t. Used by the listing-order test so it
21 // doesn't have to sleep between Saves.
22 func touch(path string, t time.Time) error {
23 return os.Chtimes(path, t, t)
24 }
25
26 func TestSaveLoadPreservesLegacyContentAndRawUserContent(t *testing.T) {
27 path := filepath.Join(t.TempDir(), "session.jsonl")
28 const raw = "fix the bug"
29 const rendered = "<reasoning-language>zh</reasoning-language>\n\nfix the bug"
30 s := NewSession("system")
31 s.Add(provider.Message{Role: provider.RoleUser, Content: rendered, RawContent: raw})
32 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
33
34 before, err := json.Marshal(provider.ModelMessages(s.Snapshot()))
35 if err != nil {
36 t.Fatalf("marshal provider messages before save: %v", err)
37 }
38 if err := s.SaveSnapshot(path); err != nil {
39 t.Fatalf("SaveSnapshot: %v", err)
40 }
41 loaded, err := LoadSession(path)
42 if err != nil {
43 t.Fatalf("LoadSession: %v", err)
44 }
45 stored := loaded.Snapshot()
46 if got := stored[1].Content; got != rendered {
47 t.Fatalf("reloaded provider content = %q, want %q", got, rendered)
48 }
49 if got := stored[1].RawContent; got != raw {
50 t.Fatalf("reloaded raw content = %q, want %q", got, raw)
51 }
52 if stored[1].ProviderContent != "" {
53 t.Fatalf("reloaded transitional provider content = %q, want empty", stored[1].ProviderContent)
54 }
55 after, err := json.Marshal(provider.ModelMessages(stored))
56 if err != nil {
57 t.Fatalf("marshal provider messages after load: %v", err)
58 }
59 if string(after) != string(before) {
60 t.Fatalf("provider request bytes changed across save/load:\nbefore: %s\nafter: %s", before, after)
61 }
62 }
63
64 func TestLoadSessionMigratesLegacyInjectedUserContentWithoutChangingProviderBytes(t *testing.T) {
65 path := filepath.Join(t.TempDir(), "session.jsonl")
66 const raw = "fix the bug"
67 const legacy = "<reasoning-language>\nVisible reasoning/thinking text preference: use Simplified Chinese.\n</reasoning-language>\n\nfix the bug"
68 s := NewSession("system")
69 s.Add(provider.Message{Role: provider.RoleUser, Content: legacy})
70 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
71 if err := s.SaveSnapshot(path); err != nil {
72 t.Fatalf("SaveSnapshot legacy fixture: %v", err)
73 }
74
75 loaded, err := LoadSession(path)
76 if err != nil {
77 t.Fatalf("LoadSession: %v", err)
78 }
79 stored := loaded.Snapshot()
80 if got := stored[1].Content; got != legacy {
81 t.Fatalf("migrated provider content = %q, want legacy bytes", got)
82 }
83 if got := stored[1].RawContent; got != raw {
84 t.Fatalf("migrated raw content = %q, want %q", got, raw)
85 }
86 if stored[1].ProviderContent != "" {
87 t.Fatalf("migrated transitional provider content = %q, want empty", stored[1].ProviderContent)
88 }
89 model := provider.ModelMessages(stored)
90 if got := model[1].Content; got != legacy {
91 t.Fatalf("provider content after migration = %q, want %q", got, legacy)
92 }
93 if !loaded.normalizedDirty {
94 t.Fatal("legacy migration must schedule a rewrite on the next save")
95 }
96 }
97
98 func TestLoadSessionMigratesTransitionalProviderContentToLegacySafeShape(t *testing.T) {
99 path := filepath.Join(t.TempDir(), "session.jsonl")
100 const raw = "fix the bug"
101 const rendered = "<reasoning-language>zh</reasoning-language>\n\nfix the bug"
102 s := NewSession("system")
103 s.Add(provider.Message{Role: provider.RoleUser, Content: raw, ProviderContent: rendered})
104 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
105 if err := s.SaveSnapshot(path); err != nil {
106 t.Fatalf("SaveSnapshot transitional fixture: %v", err)
107 }
108
109 loaded, err := LoadSession(path)
110 if err != nil {
111 t.Fatalf("LoadSession: %v", err)
112 }
113 stored := loaded.Snapshot()
114 if stored[1].Content != rendered || stored[1].RawContent != raw || stored[1].ProviderContent != "" {
115 t.Fatalf("transitional user turn not canonicalized: %+v", stored[1])
116 }
117 model := provider.ModelMessages(stored)
118 if model[1].Content != rendered || model[1].RawContent != "" || model[1].ProviderContent != "" {
119 t.Fatalf("provider model turn not canonical: %+v", model[1])
120 }
121 if !loaded.normalizedDirty {
122 t.Fatal("transitional migration must schedule a rewrite on the next save")
123 }
124 }
125
126 // TestSnapshotUpToDateFastPath locks in the #6607 switch-lag fix: a snapshot
127 // of a session that has not changed since its last save to the same path must
128 // be a pure in-memory no-op — no serialize, no digest, no disk access. The
129 // desktop snapshots defensively on every tab/session switch, and on large
130 // transcripts the redundant full-save work is seconds of UI freeze.
131 func TestSnapshotUpToDateFastPath(t *testing.T) {
132 path := filepath.Join(t.TempDir(), "session.jsonl")
133 s := NewSession("sys")
134 s.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
135 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
136 if err := s.SaveSnapshot(path); err != nil {
137 t.Fatalf("initial SaveSnapshot: %v", err)
138 }
139 if !s.snapshotUpToDate(path) {
140 t.Fatal("snapshotUpToDate = false right after a successful save")
141 }
142 if s.snapshotUpToDate(filepath.Join(t.TempDir(), "other.jsonl")) {
143 t.Fatal("snapshotUpToDate = true for a different path")
144 }
145
146 // Deterministic proof the disk is untouched: scribble on the event log and
147 // snapshot again. The fast path skips entirely, so the scribble survives; a
148 // full save would detect and repair/truncate it.
149 logPath := store.SessionEventLog(path)
150 f, err := os.OpenFile(logPath, os.O_WRONLY|os.O_APPEND, 0o644)
151 if err != nil {
152 t.Fatalf("open log: %v", err)
153 }
154 if _, err := f.Write([]byte("{torn")); err != nil {
155 t.Fatalf("scribble: %v", err)
156 }
157 f.Close()
158 before, err := os.ReadFile(logPath)
159 if err != nil {
160 t.Fatalf("read log: %v", err)
161 }
162 if err := s.SaveSnapshot(path); err != nil {
163 t.Fatalf("no-op SaveSnapshot: %v", err)
164 }
165 after, err := os.ReadFile(logPath)
166 if err != nil {
167 t.Fatalf("read log after no-op save: %v", err)
168 }
169 if string(before) != string(after) {
170 t.Fatal("no-op snapshot touched the disk — fast path did not fire")
171 }
172
173 // Any transcript change re-arms the full path, which heals the scribble
174 // and persists the new turn.
175 s.Add(provider.Message{Role: provider.RoleUser, Content: "again"})
176 if s.snapshotUpToDate(path) {
177 t.Fatal("snapshotUpToDate = true after Add")
178 }
179 if err := s.SaveSnapshot(path); err != nil {
180 t.Fatalf("SaveSnapshot after Add: %v", err)
181 }
182 if !s.snapshotUpToDate(path) {
183 t.Fatal("snapshotUpToDate = false after the follow-up save")
184 }
185 loaded, err := LoadSession(path)
186 if err != nil {
187 t.Fatalf("LoadSession: %v", err)
188 }
189 if loaded.eventLogDamaged {
190 t.Fatal("follow-up save left the log damaged")
191 }
192 if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "again" {
193 t.Fatalf("reloaded tail = %q, want %q", got, "again")
194 }
195 // A load-adopted baseline must NOT arm the fast path: the ledger can lag
196 // the transcript after an interrupted save, and the first save after a
197 // load is the one that heals it (see
198 // TestSameContentSaveHealsStaleLedgerDigest).
199 if loaded.snapshotUpToDate(path) {
200 t.Fatal("snapshotUpToDate = true for a freshly loaded session")
201 }
202
203 // A pending rewrite (compaction/rewind) also disarms the fast path.
204 s.Replace(append([]provider.Message(nil), loaded.Messages[:2]...))
205 s.IncrementRewrite()
206 if s.snapshotUpToDate(path) {
207 t.Fatal("snapshotUpToDate = true with a pending rewrite")
208 }
209 }
210
211 func TestSaveSnapshotBoundsCrossProcessFileLockWait(t *testing.T) {
212 path := filepath.Join(t.TempDir(), "session.jsonl")
213 lock, err := tryTakeSessionLockFile(store.SessionLockFile(path))
214 if err != nil {
215 t.Fatalf("take competing session lock: %v", err)
216 }
217 defer lock.Unlock()
218
219 prevWait, prevPoll := sessionFileLockWait, sessionFileLockPollInterval
220 sessionFileLockWait = 40 * time.Millisecond
221 sessionFileLockPollInterval = 5 * time.Millisecond
222 defer func() {
223 sessionFileLockWait = prevWait
224 sessionFileLockPollInterval = prevPoll
225 }()
226
227 s := NewSession("sys")
228 s.Add(provider.Message{Role: provider.RoleUser, Content: "must stay in memory"})
229 started := time.Now()
230 err = s.SaveSnapshot(path)
231 if !errors.Is(err, ErrSessionFileLockHeld) {
232 t.Fatalf("SaveSnapshot error = %v, want ErrSessionFileLockHeld", err)
233 }
234 if elapsed := time.Since(started); elapsed > time.Second {
235 t.Fatalf("SaveSnapshot waited %v for a held cross-process lock; want a bounded failure", elapsed)
236 }
237 if got := s.Snapshot(); len(got) != 2 || got[1].Content != "must stay in memory" {
238 t.Fatalf("failed save changed in-memory transcript: %+v", got)
239 }
240 }
241
242 func TestSaveSnapshotSucceedsWhenCrossProcessFileLockReleasesBeforeDeadline(t *testing.T) {
243 path := filepath.Join(t.TempDir(), "session.jsonl")
244 lock, err := tryTakeSessionLockFile(store.SessionLockFile(path))
245 if err != nil {
246 t.Fatalf("take competing session lock: %v", err)
247 }
248 released := make(chan struct{})
249 go func() {
250 time.Sleep(30 * time.Millisecond)
251 lock.Unlock()
252 close(released)
253 }()
254 t.Cleanup(func() { <-released })
255
256 prevWait, prevPoll := sessionFileLockWait, sessionFileLockPollInterval
257 sessionFileLockWait = 500 * time.Millisecond
258 sessionFileLockPollInterval = 5 * time.Millisecond
259 defer func() {
260 sessionFileLockWait = prevWait
261 sessionFileLockPollInterval = prevPoll
262 }()
263
264 s := NewSession("sys")
265 s.Add(provider.Message{Role: provider.RoleUser, Content: "save after transient lock"})
266 if err := s.SaveSnapshot(path); err != nil {
267 t.Fatalf("SaveSnapshot after transient lock: %v", err)
268 }
269 select {
270 case <-released:
271 default:
272 t.Fatal("SaveSnapshot returned before the competing lock was released")
273 }
274 loaded, err := LoadSession(path)
275 if err != nil {
276 t.Fatalf("LoadSession: %v", err)
277 }
278 if got := loaded.Snapshot(); len(got) != 2 || got[1].Content != "save after transient lock" {
279 t.Fatalf("persisted transcript = %+v", got)
280 }
281 }
282
283 func TestSaveShutdownRecoveryBranchBypassesHeldOriginalFileLock(t *testing.T) {
284 dir := t.TempDir()
285 path := filepath.Join(dir, "session.jsonl")
286 base := NewSession("sys")
287 base.Add(provider.Message{Role: provider.RoleUser, Content: "persisted"})
288 if err := base.SaveSnapshot(path); err != nil {
289 t.Fatalf("seed session: %v", err)
290 }
291
292 current, err := LoadSession(path)
293 if err != nil {
294 t.Fatalf("LoadSession: %v", err)
295 }
296 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "unsaved shutdown tail"})
297 lock, err := tryTakeSessionLockFile(store.SessionLockFile(path))
298 if err != nil {
299 t.Fatalf("take competing session lock: %v", err)
300 }
301 defer lock.Unlock()
302
303 prevWait, prevPoll := sessionFileLockWait, sessionFileLockPollInterval
304 sessionFileLockWait = 40 * time.Millisecond
305 sessionFileLockPollInterval = 5 * time.Millisecond
306 defer func() {
307 sessionFileLockWait = prevWait
308 sessionFileLockPollInterval = prevPoll
309 }()
310
311 saveErr := current.SaveSnapshot(path)
312 if !errors.Is(saveErr, ErrSessionFileLockHeld) {
313 t.Fatalf("SaveSnapshot error = %v, want ErrSessionFileLockHeld", saveErr)
314 }
315 info, err := current.SaveShutdownRecoveryBranch(RecoveryBranchOptions{
316 OriginalPath: path,
317 Reason: "shutdown session file lock timeout",
318 })
319 if err != nil {
320 t.Fatalf("SaveShutdownRecoveryBranch: %v", err)
321 }
322 if info.Path == path {
323 t.Fatalf("shutdown recovery path = original path %q", path)
324 }
325 if !info.Meta.Recovered || info.Meta.RecoveryReason != "shutdown session file lock timeout" {
326 t.Fatalf("shutdown recovery meta = %+v", info.Meta)
327 }
328 recovered, err := LoadSession(info.Path)
329 if err != nil {
330 t.Fatalf("load shutdown recovery: %v", err)
331 }
332 if got := recovered.Snapshot(); len(got) != 3 || got[2].Content != "unsaved shutdown tail" {
333 t.Fatalf("shutdown recovery transcript = %+v", got)
334 }
335 original, err := LoadSession(path)
336 if err != nil {
337 t.Fatalf("reload original session: %v", err)
338 }
339 if got := original.Snapshot(); len(got) != 2 {
340 t.Fatalf("held original transcript changed: %+v", got)
341 }
342 }
343
344 // TestRepairedSessionArmsFastPath (#6613 review P2): a session loaded with a
345 // damaged event log — or carrying a load-time normalization repair — must
346 // re-arm the snapshot no-op fast path once a successful save persists the
347 // repair. Before the fix the flags were never cleared, so a repaired session
348 // paid a full serialize + digest on every defensive snapshot until restart.
349 func TestRepairedSessionArmsFastPath(t *testing.T) {
350 path := filepath.Join(t.TempDir(), "session.jsonl")
351 sessionWithTurns(t, path, 2)
352
353 // Tear the event log so the next load marks it damaged.
354 logPath := store.SessionEventLog(path)
355 f, err := os.OpenFile(logPath, os.O_WRONLY|os.O_APPEND, 0o644)
356 if err != nil {
357 t.Fatalf("open log: %v", err)
358 }
359 if _, err := f.Write([]byte(`{"schema_version":1,"type":"ap`)); err != nil {
360 t.Fatalf("write torn tail: %v", err)
361 }
362 f.Close()
363
364 s, err := LoadSession(path)
365 if err != nil {
366 t.Fatalf("LoadSession: %v", err)
367 }
368 if !s.eventLogDamaged {
369 t.Fatal("test setup: session should load damaged")
370 }
371 if s.snapshotUpToDate(path) {
372 t.Fatal("snapshotUpToDate = true for a damaged, unsaved session")
373 }
374
375 // The healing save persists the repair; the fast path must arm afterwards.
376 s.Add(provider.Message{Role: provider.RoleUser, Content: "heal"})
377 if err := s.SaveSnapshot(path); err != nil {
378 t.Fatalf("healing SaveSnapshot: %v", err)
379 }
380 if !s.snapshotUpToDate(path) {
381 t.Fatal("snapshotUpToDate = false after the healing save — repaired session never re-arms the fast path")
382 }
383
384 // A pending normalization repair also disarms, and a successful save that
385 // lands it re-arms.
386 s.normalizedDirty = true
387 if s.snapshotUpToDate(path) {
388 t.Fatal("snapshotUpToDate = true with a pending normalization repair")
389 }
390 if err := s.SaveSnapshot(path); err != nil {
391 t.Fatalf("SaveSnapshot with normalization flag: %v", err)
392 }
393 if !s.snapshotUpToDate(path) {
394 t.Fatal("snapshotUpToDate = false after the save persisted the normalization repair")
395 }
396 }
397
398 // TestSaveLoadRoundTrip is the contract `reasonix --resume` depends on: a
399 // session written to disk reloads byte-for-byte, including tool calls and
400 // reasoning content (which the model wants to keep across resumes for cache
401 // hits on thinking-mode providers).
402 func TestSaveLoadRoundTrip(t *testing.T) {
403 s := NewSession("you are reasonix")
404 s.Add(provider.Message{Role: provider.RoleUser, Content: "find the bug"})
405 s.Add(provider.Message{
406 Role: provider.RoleAssistant,
407 Content: "Let me check.",
408 ReasoningContent: "I should look at main.go first.",
409 ToolCalls: []provider.ToolCall{{
410 ID: "call_1", Name: "read_file", Arguments: `{"path":"main.go"}`, ThoughtSignature: "gemini-signed",
411 }},
412 })
413 s.Add(provider.Message{
414 Role: provider.RoleTool, Name: "read_file", ToolCallID: "call_1",
415 Content: "package main\nfunc main() {}\n",
416 })
417 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "It's fine."})
418
419 path := filepath.Join(t.TempDir(), "s.jsonl")
420 if err := s.Save(path); err != nil {
421 t.Fatalf("Save: %v", err)
422 }
423 loaded, err := LoadSession(path)
424 if err != nil {
425 t.Fatalf("LoadSession: %v", err)
426 }
427 if got, want := len(loaded.Messages), len(s.Messages); got != want {
428 t.Fatalf("message count after round-trip = %d, want %d", got, want)
429 }
430 for i, m := range s.Messages {
431 if loaded.Messages[i].Role != m.Role {
432 t.Errorf("message %d role mismatch", i)
433 }
434 if loaded.Messages[i].Content != m.Content {
435 t.Errorf("message %d content mismatch", i)
436 }
437 if loaded.Messages[i].ReasoningContent != m.ReasoningContent {
438 t.Errorf("message %d reasoning mismatch", i)
439 }
440 if !reflect.DeepEqual(loaded.Messages[i].ToolCalls, m.ToolCalls) {
441 t.Errorf("message %d tool_calls mismatch:\n got: %#v\nwant: %#v", i, loaded.Messages[i].ToolCalls, m.ToolCalls)
442 }
443 }
444 }
445
446 func TestSavePreservesToolContentOnDisk(t *testing.T) {
447 secret := "sk-real-secret-value-123456"
448 s := NewSession("sys")
449 s.Add(provider.Message{Role: provider.RoleUser, Content: "inspect"})
450 s.Add(provider.Message{
451 Role: provider.RoleTool,
452 Name: "bash",
453 ToolCallID: "call_1",
454 Content: "DEEPSEEK_API_KEY=" + secret + "\n",
455 })
456
457 path := filepath.Join(t.TempDir(), "verbatim.jsonl")
458 if err := s.Save(path); err != nil {
459 t.Fatalf("Save: %v", err)
460 }
461 body, err := os.ReadFile(path)
462 if err != nil {
463 t.Fatalf("read saved session: %v", err)
464 }
465 if !strings.Contains(string(body), "DEEPSEEK_API_KEY="+secret) {
466 t.Fatalf("session did not persist tool content verbatim:\n%s", body)
467 }
468 }
469
470 func TestSaveProtectsVerbatimSessionEventLog(t *testing.T) {
471 if runtime.GOOS == "windows" {
472 t.Skip("Windows file ACLs are not represented by Unix permission bits")
473 }
474 s := NewSession("sys")
475 s.Add(provider.Message{Role: provider.RoleUser, Content: "inspect"})
476 s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_1", Content: "API_KEY=raw-secret"})
477 path := filepath.Join(t.TempDir(), "private.jsonl")
478 if err := s.SaveSnapshot(path); err != nil {
479 t.Fatalf("first SaveSnapshot: %v", err)
480 }
481 eventPath := store.SessionEventLog(path)
482 assertPrivateSessionFile(t, eventPath)
483
484 // Simulate an event log created by a previous release. The next append must
485 // tighten the existing inode before writing any new unredacted content.
486 if err := os.Chmod(eventPath, 0o644); err != nil {
487 t.Fatal(err)
488 }
489 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
490 if err := s.SaveSnapshot(path); err != nil {
491 t.Fatalf("append SaveSnapshot: %v", err)
492 }
493 assertPrivateSessionFile(t, eventPath)
494 }
495
496 func assertPrivateSessionFile(t *testing.T, path string) {
497 t.Helper()
498 info, err := os.Stat(path)
499 if err != nil {
500 t.Fatalf("stat %s: %v", path, err)
501 }
502 if got := info.Mode().Perm(); got != 0o600 {
503 t.Fatalf("%s mode = %04o, want 0600", path, got)
504 }
505 }
506
507 // TestSaveVerbatimRoundTripKeepsSnapshotBaselineStable pins digest consistency
508 // for byte-preserving transcripts: a loaded transcript re-saves without a
509 // rewrite or revision bump, and appending afterward remains a plain append.
510 func TestSaveVerbatimRoundTripKeepsSnapshotBaselineStable(t *testing.T) {
511 secret := "sk-real-secret-value-123456"
512 s := NewSession("sys")
513 s.Add(provider.Message{Role: provider.RoleUser, Content: "inspect"})
514 s.Add(provider.Message{
515 Role: provider.RoleTool,
516 Name: "bash",
517 ToolCallID: "call_1",
518 Content: "DEEPSEEK_API_KEY=" + secret + "\n",
519 })
520
521 path := filepath.Join(t.TempDir(), "stable.jsonl")
522 if err := s.SaveSnapshot(path); err != nil {
523 t.Fatalf("first SaveSnapshot: %v", err)
524 }
525 rev1, _, err := sessionContentRevision(path)
526 if err != nil {
527 t.Fatalf("read revision: %v", err)
528 }
529
530 loaded, err := LoadSession(path)
531 if err != nil {
532 t.Fatalf("LoadSession: %v", err)
533 }
534 if err := loaded.SaveSnapshot(path); err != nil {
535 t.Fatalf("resave of loaded session: %v", err)
536 }
537 rev2, _, err := sessionContentRevision(path)
538 if err != nil {
539 t.Fatalf("read revision after resave: %v", err)
540 }
541 if rev1 != rev2 {
542 t.Fatalf("no-op resave bumped revision %d -> %d", rev1, rev2)
543 }
544
545 // A continued conversation still append-saves cleanly on top.
546 loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
547 if err := loaded.SaveSnapshot(path); err != nil {
548 t.Fatalf("append snapshot after verbatim round-trip: %v", err)
549 }
550 reloaded, err := LoadSession(path)
551 if err != nil {
552 t.Fatalf("reload: %v", err)
553 }
554 if got := len(reloaded.Messages); got != 4 {
555 t.Fatalf("message count after append = %d, want 4", got)
556 }
557 }
558
559 func TestSaveLoadLargeMessage(t *testing.T) {
560 s := NewSession("sys")
561 s.Add(provider.Message{Role: provider.RoleUser, Content: "run it"})
562 // A bash result can exceed any line-buffer cap; Save must round-trip it.
563 big := strings.Repeat("x", 5*1024*1024)
564 s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "c1", Content: big})
565
566 path := filepath.Join(t.TempDir(), "big.jsonl")
567 if err := s.Save(path); err != nil {
568 t.Fatalf("Save: %v", err)
569 }
570 loaded, err := LoadSession(path)
571 if err != nil {
572 t.Fatalf("LoadSession of a session with a >4MiB message: %v", err)
573 }
574 if len(loaded.Messages) != 3 {
575 t.Fatalf("message count = %d, want 3", len(loaded.Messages))
576 }
577 if loaded.Messages[2].Content != big {
578 t.Errorf("large content not round-tripped (got %d bytes, want %d)", len(loaded.Messages[2].Content), len(big))
579 }
580 }
581
582 func TestSaveSnapshotRejectsStalePrefixOverwrite(t *testing.T) {
583 path := filepath.Join(t.TempDir(), "session.jsonl")
584 current := NewSession("sys")
585 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
586 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
587 current.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
588 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "two"})
589 if err := current.Save(path); err != nil {
590 t.Fatalf("Save current: %v", err)
591 }
592
593 stale := NewSession("sys")
594 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
595 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
596 if err := stale.SaveSnapshot(path); !errors.Is(err, ErrSessionSnapshotConflict) {
597 t.Fatalf("SaveSnapshot stale prefix err = %v, want ErrSessionSnapshotConflict", err)
598 }
599
600 loaded, err := LoadSession(path)
601 if err != nil {
602 t.Fatalf("LoadSession: %v", err)
603 }
604 if got := len(loaded.Messages); got != 5 {
605 t.Fatalf("message count after stale snapshot = %d, want 5", got)
606 }
607 if got := loaded.Messages[4].Content; got != "two" {
608 t.Fatalf("last message after stale snapshot = %q, want %q", got, "two")
609 }
610 }
611
612 func TestSaveSnapshotAllowsAppendFromDiskPrefix(t *testing.T) {
613 path := filepath.Join(t.TempDir(), "session.jsonl")
614 base := NewSession("sys")
615 base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
616 if err := base.Save(path); err != nil {
617 t.Fatalf("Save base: %v", err)
618 }
619
620 next := NewSession("sys")
621 next.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
622 next.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
623 if err := next.SaveSnapshot(path); err != nil {
624 t.Fatalf("SaveSnapshot append: %v", err)
625 }
626
627 loaded, err := LoadSession(path)
628 if err != nil {
629 t.Fatalf("LoadSession: %v", err)
630 }
631 if got := len(loaded.Messages); got != 3 {
632 t.Fatalf("message count after append snapshot = %d, want 3", got)
633 }
634 }
635
636 // TestSaveSnapshotAppendsAcrossInterruptedToolCallTail is the mid-turn autosave
637 // shape from the field: a snapshot lands between an assistant tool call and its
638 // still-running result, so the transcript on disk ends with a dangling call
639 // that LoadSession answers with a fabricated placeholder. The live session then
640 // records the real result and keeps going. The next snapshot is a pure append
641 // over the bytes on disk and must land as one — not collide with the
642 // placeholder, misread the turn as divergence, and fork a recovery branch.
643 func TestSaveSnapshotAppendsAcrossInterruptedToolCallTail(t *testing.T) {
644 path := filepath.Join(t.TempDir(), "session.jsonl")
645 s := NewSession("sys")
646 s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
647 s.Add(provider.Message{
648 Role: provider.RoleAssistant, Content: "Running it.",
649 ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"make"}`}},
650 })
651 if err := s.SaveSnapshot(path); err != nil {
652 t.Fatalf("mid-turn SaveSnapshot: %v", err)
653 }
654
655 s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_1", Content: "ok"})
656 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "Build passed."})
657 if err := s.SaveSnapshot(path); err != nil {
658 t.Fatalf("SaveSnapshot after tool result: %v", err)
659 }
660
661 replay, err := replaySessionEventLog(SessionEventLogPath(path))
662 if err != nil {
663 t.Fatalf("replay event log: %v", err)
664 }
665 if replay.damaged {
666 t.Fatal("event log damaged after appending over an interrupted tool tail")
667 }
668 if replay.records != 2 {
669 t.Fatalf("event log records = %d, want 2 (bootstrap replace + append)", replay.records)
670 }
671 loaded, err := LoadSession(path)
672 if err != nil {
673 t.Fatalf("LoadSession: %v", err)
674 }
675 if got := len(loaded.Messages); got != 5 {
676 t.Fatalf("message count after append snapshot = %d, want 5", got)
677 }
678 if got := loaded.Messages[3].Content; got != "ok" {
679 t.Fatalf("tool result after round-trip = %q, want %q", got, "ok")
680 }
681 if loaded.normalizedDirty {
682 t.Fatal("transcript still needs repair after appending the real tool result")
683 }
684 }
685
686 // TestSaveSnapshotAppendsAcrossPartiallyAnsweredMultiToolCallTail covers the
687 // same raw-prefix fallback when a multi-call assistant turn already has some
688 // tool results on disk. LoadSession fabricates placeholders only for the still
689 // unanswered calls; the live session later appends the real remaining results.
690 func TestSaveSnapshotAppendsAcrossPartiallyAnsweredMultiToolCallTail(t *testing.T) {
691 path := filepath.Join(t.TempDir(), "session.jsonl")
692 s := NewSession("sys")
693 s.Add(provider.Message{Role: provider.RoleUser, Content: "inspect and test"})
694 s.Add(provider.Message{
695 Role: provider.RoleAssistant, Content: "I will run a few checks.",
696 ToolCalls: []provider.ToolCall{
697 {ID: "call_read", Name: "read_file", Arguments: `{"path":"main.go"}`},
698 {ID: "call_test", Name: "bash", Arguments: `{"cmd":"go test ./..."}`},
699 {ID: "call_status", Name: "bash", Arguments: `{"cmd":"git status --short"}`},
700 },
701 })
702 s.Add(provider.Message{Role: provider.RoleTool, Name: "read_file", ToolCallID: "call_read", Content: "package main"})
703 if err := s.SaveSnapshot(path); err != nil {
704 t.Fatalf("partial multi-tool SaveSnapshot: %v", err)
705 }
706
707 loadedPartial, err := LoadSession(path)
708 if err != nil {
709 t.Fatalf("LoadSession partial: %v", err)
710 }
711 if !loadedPartial.normalizedDirty {
712 t.Fatal("partial multi-tool load should need placeholder repair")
713 }
714
715 s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_test", Content: "ok"})
716 s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_status", Content: "clean"})
717 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "All checks passed."})
718 if err := s.SaveSnapshot(path); err != nil {
719 t.Fatalf("SaveSnapshot after remaining multi-tool results: %v", err)
720 }
721
722 events := readSessionEventsForTest(t, path)
723 if len(events) != 2 || events[1].Type != sessionEventTypeAppend {
724 t.Fatalf("events after multi-tool append = %+v, want trailing append", events)
725 }
726 if events[1].MessageIndex != 4 || len(events[1].Messages) != 3 {
727 t.Fatalf("multi-tool append event index=%d len=%d, want index 4 len 3", events[1].MessageIndex, len(events[1].Messages))
728 }
729 replay, err := replaySessionEventLog(SessionEventLogPath(path))
730 if err != nil {
731 t.Fatalf("replay event log: %v", err)
732 }
733 if replay.damaged {
734 t.Fatal("event log damaged after appending remaining multi-tool results")
735 }
736 loaded, err := LoadSession(path)
737 if err != nil {
738 t.Fatalf("LoadSession final: %v", err)
739 }
740 if got := len(loaded.Messages); got != 7 {
741 t.Fatalf("message count after multi-tool append = %d, want 7", got)
742 }
743 if got := loaded.Messages[4].Content; got != "ok" {
744 t.Fatalf("second tool result after round-trip = %q, want ok", got)
745 }
746 if got := loaded.Messages[5].Content; got != "clean" {
747 t.Fatalf("third tool result after round-trip = %q, want clean", got)
748 }
749 if loaded.normalizedDirty {
750 t.Fatal("multi-tool transcript still needs repair after real results landed")
751 }
752 }
753
754 // TestSaveSnapshotUnchangedInterruptedToolCallTailIsNoOp covers the turn that
755 // stays interrupted (cancel, crash recovery with nothing new in memory):
756 // re-snapshotting the exact bytes on disk must be a no-op, not a stale-prefix
757 // conflict against the placeholder the load-time repair fabricated.
758 func TestSaveSnapshotUnchangedInterruptedToolCallTailIsNoOp(t *testing.T) {
759 path := filepath.Join(t.TempDir(), "session.jsonl")
760 s := NewSession("sys")
761 s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
762 s.Add(provider.Message{
763 Role: provider.RoleAssistant, Content: "Running it.",
764 ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"make"}`}},
765 })
766 if err := s.SaveSnapshot(path); err != nil {
767 t.Fatalf("mid-turn SaveSnapshot: %v", err)
768 }
769 logBefore, err := os.ReadFile(SessionEventLogPath(path))
770 if err != nil {
771 t.Fatalf("ReadFile event log: %v", err)
772 }
773
774 if err := s.SaveSnapshot(path); err != nil {
775 t.Fatalf("SaveSnapshot unchanged: %v", err)
776 }
777 logAfter, err := os.ReadFile(SessionEventLogPath(path))
778 if err != nil {
779 t.Fatalf("ReadFile event log after no-op snapshot: %v", err)
780 }
781 if string(logBefore) != string(logAfter) {
782 t.Fatal("no-op snapshot rewrote the event log")
783 }
784 if revision, _, err := sessionContentRevision(path); err != nil || revision != 1 {
785 t.Fatalf("revision after no-op snapshot = %d (err %v), want 1", revision, err)
786 }
787 }
788
789 // TestSaveRewriteOwnedAcrossInterruptedToolCallTail: compaction rewrites the
790 // in-memory history while the transcript on disk still ends with the dangling
791 // call a mid-turn snapshot left behind. Ownership is anchored on the raw bytes
792 // this session wrote; the placeholder fabricated on load must not revoke it.
793 func TestSaveRewriteOwnedAcrossInterruptedToolCallTail(t *testing.T) {
794 path := filepath.Join(t.TempDir(), "session.jsonl")
795 s := NewSession("sys")
796 s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
797 s.Add(provider.Message{
798 Role: provider.RoleAssistant, Content: "Running it.",
799 ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"make"}`}},
800 })
801 if err := s.SaveSnapshot(path); err != nil {
802 t.Fatalf("mid-turn SaveSnapshot: %v", err)
803 }
804
805 s.Replace([]provider.Message{
806 {Role: provider.RoleSystem, Content: "sys"},
807 {Role: provider.RoleUser, Content: "[compacted] run the build"},
808 })
809 if err := s.SaveRewrite(path); err != nil {
810 t.Fatalf("SaveRewrite after compaction: %v", err)
811 }
812
813 loaded, err := LoadSession(path)
814 if err != nil {
815 t.Fatalf("LoadSession: %v", err)
816 }
817 if got := len(loaded.Messages); got != 2 {
818 t.Fatalf("message count after owned rewrite = %d, want 2", got)
819 }
820 if got := loaded.Messages[1].Content; got != "[compacted] run the build" {
821 t.Fatalf("compacted message after round-trip = %q", got)
822 }
823 }
824
825 // TestSaveSnapshotAfterDirtyResumeKeepsEventChainReplayable: a session resumed
826 // from an interrupted tool tail carries the load-time repair in memory, so its
827 // transcript is one message longer than what the event log replays. The next
828 // snapshot must not take the append shortcut with that inflated index — the
829 // chain-broken append event would be discarded on replay, silently dropping
830 // the whole new turn from disk. It must fall back to a full rewrite that
831 // persists the repair and the new turn together.
832 func TestSaveSnapshotAfterDirtyResumeKeepsEventChainReplayable(t *testing.T) {
833 path := filepath.Join(t.TempDir(), "session.jsonl")
834 s := NewSession("sys")
835 s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
836 s.Add(provider.Message{
837 Role: provider.RoleAssistant, Content: "Running it.",
838 ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"make"}`}},
839 })
840 if err := s.SaveSnapshot(path); err != nil {
841 t.Fatalf("mid-turn SaveSnapshot: %v", err)
842 }
843
844 // Crash + reopen: the resume load answers the dangling call with a
845 // placeholder, then the user runs another turn.
846 resumed, err := LoadSession(path)
847 if err != nil {
848 t.Fatalf("LoadSession resume: %v", err)
849 }
850 if !resumed.normalizedDirty {
851 t.Fatal("resume load should carry a pending repair for the dangling call")
852 }
853 resumed.Add(provider.Message{Role: provider.RoleUser, Content: "try again"})
854 resumed.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
855 if err := resumed.SaveSnapshot(path); err != nil {
856 t.Fatalf("SaveSnapshot after dirty resume: %v", err)
857 }
858
859 replay, err := replaySessionEventLog(SessionEventLogPath(path))
860 if err != nil {
861 t.Fatalf("replay event log: %v", err)
862 }
863 if replay.damaged {
864 t.Fatal("event log chain broken by the post-resume snapshot")
865 }
866 loaded, err := LoadSession(path)
867 if err != nil {
868 t.Fatalf("LoadSession reload: %v", err)
869 }
870 if got := len(loaded.Messages); got != 6 {
871 t.Fatalf("message count after post-resume snapshot = %d, want 6", got)
872 }
873 if got := loaded.Messages[3].ToolCallID; got != "call_1" {
874 t.Fatalf("placeholder tool result not persisted; message 3 tool_call_id = %q", got)
875 }
876 if got := loaded.Messages[5].Content; got != "done" {
877 t.Fatalf("new turn after round-trip = %q, want %q", got, "done")
878 }
879 }
880
881 // TestSaveSnapshotAfterDirtyResumeWithTruncatedToolArgsPersistsRepair covers a
882 // same-length load-time repair: truncated tool-call JSON is fixed in memory, but
883 // appending against the repaired view would leave the broken arguments on disk.
884 // The snapshot must rewrite so the repair and new turn persist together.
885 func TestSaveSnapshotAfterDirtyResumeWithTruncatedToolArgsPersistsRepair(t *testing.T) {
886 path := filepath.Join(t.TempDir(), "session.jsonl")
887 s := NewSession("sys")
888 s.Add(provider.Message{Role: provider.RoleUser, Content: "run tests"})
889 s.Add(provider.Message{
890 Role: provider.RoleAssistant,
891 Content: "Running tests.",
892 ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"go test ./...`}},
893 })
894 s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_1", Content: "ok"})
895 if err := s.SaveSnapshot(path); err != nil {
896 t.Fatalf("SaveSnapshot with truncated args: %v", err)
897 }
898
899 resumed, err := LoadSession(path)
900 if err != nil {
901 t.Fatalf("LoadSession resume: %v", err)
902 }
903 if !resumed.normalizedDirty {
904 t.Fatal("resume load should carry a pending repair for truncated tool arguments")
905 }
906 const repairedArgs = `{"cmd":"go test ./..."}`
907 if got := resumed.Messages[2].ToolCalls[0].Arguments; got != repairedArgs {
908 t.Fatalf("repaired args on resume = %q, want %q", got, repairedArgs)
909 }
910
911 resumed.Add(provider.Message{Role: provider.RoleUser, Content: "summarize"})
912 resumed.Add(provider.Message{Role: provider.RoleAssistant, Content: "Tests passed."})
913 if err := resumed.SaveSnapshot(path); err != nil {
914 t.Fatalf("SaveSnapshot after truncated-args resume: %v", err)
915 }
916
917 events := readSessionEventsForTest(t, path)
918 if len(events) != 2 || events[1].Type != sessionEventTypeReplace || events[1].Reason != "snapshot" {
919 t.Fatalf("events after truncated-args repair = %+v, want trailing snapshot replace", events)
920 }
921 replay, err := replaySessionEventLog(SessionEventLogPath(path))
922 if err != nil {
923 t.Fatalf("replay event log: %v", err)
924 }
925 if replay.damaged {
926 t.Fatal("event log damaged after truncated-args repair rewrite")
927 }
928 loaded, err := LoadSession(path)
929 if err != nil {
930 t.Fatalf("LoadSession reload: %v", err)
931 }
932 if loaded.normalizedDirty {
933 t.Fatal("truncated-args repair was not persisted")
934 }
935 if got := loaded.Messages[2].ToolCalls[0].Arguments; got != repairedArgs {
936 t.Fatalf("persisted args = %q, want %q", got, repairedArgs)
937 }
938 if got := loaded.Messages[5].Content; got != "Tests passed." {
939 t.Fatalf("new turn after round-trip = %q, want %q", got, "Tests passed.")
940 }
941 }
942
943 // TestSaveRecoveryBranchNotNeededWhenRawTranscriptCoversSnapshot: the
944 // recovery-needed check must also judge coverage against the pre-repair bytes.
945 // Here the stored transcript equals the snapshot exactly, but normalization
946 // backfills an empty tool-call name on load; that repair must not make the
947 // disk look like it fails to cover the snapshot and fork a pointless recovery.
948 func TestSaveRecoveryBranchNotNeededWhenRawTranscriptCoversSnapshot(t *testing.T) {
949 path := filepath.Join(t.TempDir(), "session.jsonl")
950 s := NewSession("sys")
951 s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
952 s.Add(provider.Message{
953 Role: provider.RoleAssistant, Content: "Running it.",
954 ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "", Arguments: `{"cmd":"make"}`}},
955 })
956 s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_1", Content: "ok"})
957 if err := s.Save(path); err != nil {
958 t.Fatalf("Save: %v", err)
959 }
960
961 _, err := s.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
962 if !errors.Is(err, ErrSessionRecoveryNotNeeded) {
963 t.Fatalf("SaveRecoveryBranch err = %v, want ErrSessionRecoveryNotNeeded", err)
964 }
965 }
966
967 func TestSaveSnapshotAppendsWithoutReplacingPrefixFile(t *testing.T) {
968 path := filepath.Join(t.TempDir(), "session.jsonl")
969 base := NewSession("sys")
970 base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
971 if err := base.Save(path); err != nil {
972 t.Fatalf("Save base: %v", err)
973 }
974 before, err := os.Stat(path)
975 if err != nil {
976 t.Fatalf("Stat before append: %v", err)
977 }
978
979 next := NewSession("sys")
980 next.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
981 next.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
982 if err := next.SaveSnapshot(path); err != nil {
983 t.Fatalf("SaveSnapshot append: %v", err)
984 }
985 after, err := os.Stat(path)
986 if err != nil {
987 t.Fatalf("Stat after append: %v", err)
988 }
989 if !os.SameFile(before, after) {
990 t.Fatal("SaveSnapshot replaced the session file; want append-in-place for disk-prefix snapshots")
991 }
992 }
993
994 func TestSaveSnapshotAppendsToEventLogWithoutChangingCheckpoint(t *testing.T) {
995 path := filepath.Join(t.TempDir(), "session.jsonl")
996 base := NewSession("sys")
997 base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
998 if err := base.SaveSnapshot(path); err != nil {
999 t.Fatalf("SaveSnapshot base: %v", err)
1000 }
1001 checkpointBefore, err := os.ReadFile(path)
1002 if err != nil {
1003 t.Fatalf("ReadFile checkpoint before append: %v", err)
1004 }
1005
1006 next, err := LoadSession(path)
1007 if err != nil {
1008 t.Fatalf("LoadSession base: %v", err)
1009 }
1010 next.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1011 if err := next.SaveSnapshot(path); err != nil {
1012 t.Fatalf("SaveSnapshot append: %v", err)
1013 }
1014
1015 checkpointAfter, err := os.ReadFile(path)
1016 if err != nil {
1017 t.Fatalf("ReadFile checkpoint after append: %v", err)
1018 }
1019 if string(checkpointAfter) != string(checkpointBefore) {
1020 t.Fatalf("checkpoint changed after append-only snapshot:\nbefore=%s\nafter=%s", checkpointBefore, checkpointAfter)
1021 }
1022 events := readSessionEventsForTest(t, path)
1023 if len(events) != 2 {
1024 t.Fatalf("event count = %d, want replace + append", len(events))
1025 }
1026 if events[0].Type != sessionEventTypeReplace || events[1].Type != sessionEventTypeAppend {
1027 t.Fatalf("event types = %q, %q; want replace, append", events[0].Type, events[1].Type)
1028 }
1029 if events[1].MessageIndex != 2 || len(events[1].Messages) != 1 || events[1].Messages[0].Content != "one" {
1030 t.Fatalf("append event = %+v, want assistant suffix at index 2", events[1])
1031 }
1032 loaded, err := LoadSession(path)
1033 if err != nil {
1034 t.Fatalf("LoadSession after append: %v", err)
1035 }
1036 if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "one" {
1037 t.Fatalf("loaded tail = %q, want one", got)
1038 }
1039 }
1040
1041 func TestSaveRewriteAppendsReplaceEventAndRefreshesCheckpoint(t *testing.T) {
1042 path := filepath.Join(t.TempDir(), "session.jsonl")
1043 base := NewSession("sys")
1044 base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1045 base.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1046 if err := base.SaveSnapshot(path); err != nil {
1047 t.Fatalf("SaveSnapshot base: %v", err)
1048 }
1049
1050 loaded, err := LoadSession(path)
1051 if err != nil {
1052 t.Fatalf("LoadSession base: %v", err)
1053 }
1054 loaded.Replace([]provider.Message{
1055 {Role: provider.RoleSystem, Content: "sys"},
1056 {Role: provider.RoleUser, Content: "rewound"},
1057 })
1058 if err := loaded.SaveRewrite(path); err != nil {
1059 t.Fatalf("SaveRewrite: %v", err)
1060 }
1061 // Rewrites refresh the compatibility checkpoint so direct .jsonl readers
1062 // and older binaries stay bounded-stale instead of frozen at first save.
1063 anchor, err := loadSessionMessagesFromJSONL(path)
1064 if err != nil {
1065 t.Fatalf("read checkpoint after rewrite: %v", err)
1066 }
1067 if len(anchor) != 2 || anchor[1].Content != "rewound" {
1068 t.Fatalf("checkpoint after rewrite = %+v, want refreshed rewound transcript", anchor)
1069 }
1070 events := readSessionEventsForTest(t, path)
1071 if len(events) != 2 || events[1].Type != sessionEventTypeReplace || events[1].Reason != "rewrite" {
1072 t.Fatalf("events after rewrite = %+v, want trailing rewrite replace", events)
1073 }
1074 reloaded, err := LoadSession(path)
1075 if err != nil {
1076 t.Fatalf("LoadSession after rewrite: %v", err)
1077 }
1078 if len(reloaded.Messages) != 2 || reloaded.Messages[1].Content != "rewound" {
1079 t.Fatalf("replayed rewrite messages = %+v", reloaded.Messages)
1080 }
1081 }
1082
1083 func TestSaveSnapshotMigratesLegacyJSONLToEventLog(t *testing.T) {
1084 path := filepath.Join(t.TempDir(), "legacy.jsonl")
1085 if err := os.WriteFile(path, []byte(`{"role":"system","content":"sys"}`+"\n"+`{"role":"user","content":"legacy"}`+"\n"), 0o644); err != nil {
1086 t.Fatalf("write legacy jsonl: %v", err)
1087 }
1088 loaded, err := LoadSession(path)
1089 if err != nil {
1090 t.Fatalf("LoadSession legacy: %v", err)
1091 }
1092 loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "migrated"})
1093 if err := loaded.SaveSnapshot(path); err != nil {
1094 t.Fatalf("SaveSnapshot legacy append: %v", err)
1095 }
1096 events := readSessionEventsForTest(t, path)
1097 if len(events) != 1 || events[0].Type != sessionEventTypeReplace {
1098 t.Fatalf("legacy migration events = %+v, want one replace seed", events)
1099 }
1100 reloaded, err := LoadSession(path)
1101 if err != nil {
1102 t.Fatalf("LoadSession migrated: %v", err)
1103 }
1104 if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "migrated" {
1105 t.Fatalf("migrated tail = %q, want migrated", got)
1106 }
1107 if _, err := os.Stat(SessionEventIndexPath(path)); err != nil {
1108 t.Fatalf("event index missing: %v", err)
1109 }
1110 }
1111
1112 func TestSaveSnapshotAllowsAppendAfterSystemPromptRefresh(t *testing.T) {
1113 path := filepath.Join(t.TempDir(), "session.jsonl")
1114 base := NewSession("old sys")
1115 base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1116 if err := base.Save(path); err != nil {
1117 t.Fatalf("Save base: %v", err)
1118 }
1119
1120 next := NewSession("new sys")
1121 next.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1122 next.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1123 if err := next.SaveSnapshot(path); err != nil {
1124 t.Fatalf("SaveSnapshot after system refresh: %v", err)
1125 }
1126
1127 loaded, err := LoadSession(path)
1128 if err != nil {
1129 t.Fatalf("LoadSession: %v", err)
1130 }
1131 if got := len(loaded.Messages); got != 3 {
1132 t.Fatalf("message count after system refresh append = %d, want 3", got)
1133 }
1134 if got := loaded.Messages[0].Content; got != "new sys" {
1135 t.Fatalf("system prompt after refresh = %q, want %q", got, "new sys")
1136 }
1137 }
1138
1139 func TestSaveSnapshotRecordsRevisionAndMetaUpdatesPreserveIt(t *testing.T) {
1140 path := filepath.Join(t.TempDir(), "session.jsonl")
1141 base := NewSession("sys")
1142 base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1143 if err := base.SaveSnapshot(path); err != nil {
1144 t.Fatalf("SaveSnapshot base: %v", err)
1145 }
1146 meta, ok, err := LoadBranchMeta(path)
1147 if err != nil || !ok {
1148 t.Fatalf("LoadBranchMeta base ok=%v err=%v", ok, err)
1149 }
1150 if meta.Revision != 1 || meta.ContentDigest == "" || meta.WriterID == "" {
1151 t.Fatalf("base persistence meta = %+v, want revision/digest/writer", meta)
1152 }
1153
1154 if err := UpdateSessionMeta(path, "model-a", "first", 1, true); err != nil {
1155 t.Fatalf("UpdateSessionMeta: %v", err)
1156 }
1157 refreshed, ok, err := LoadBranchMeta(path)
1158 if err != nil || !ok {
1159 t.Fatalf("LoadBranchMeta refreshed ok=%v err=%v", ok, err)
1160 }
1161 if refreshed.Revision != meta.Revision || refreshed.ContentDigest != meta.ContentDigest || refreshed.WriterID != meta.WriterID {
1162 t.Fatalf("listing meta update changed persistence fields: before=%+v after=%+v", meta, refreshed)
1163 }
1164
1165 loaded, err := LoadSession(path)
1166 if err != nil {
1167 t.Fatalf("LoadSession: %v", err)
1168 }
1169 loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1170 if err := loaded.SaveSnapshot(path); err != nil {
1171 t.Fatalf("SaveSnapshot append: %v", err)
1172 }
1173 advanced, ok, err := LoadBranchMeta(path)
1174 if err != nil || !ok {
1175 t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
1176 }
1177 if advanced.Revision != refreshed.Revision+1 {
1178 t.Fatalf("revision after append = %d, want %d", advanced.Revision, refreshed.Revision+1)
1179 }
1180 if advanced.ContentDigest == refreshed.ContentDigest {
1181 t.Fatalf("content digest did not change after append: %q", advanced.ContentDigest)
1182 }
1183 }
1184
1185 func TestSaveSnapshotSameContentSkipsRevisionBump(t *testing.T) {
1186 path := filepath.Join(t.TempDir(), "session.jsonl")
1187 s := NewSession("sys")
1188 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1189 if err := s.SaveSnapshot(path); err != nil {
1190 t.Fatalf("SaveSnapshot base: %v", err)
1191 }
1192 before, ok, err := LoadBranchMeta(path)
1193 if err != nil || !ok {
1194 t.Fatalf("LoadBranchMeta base ok=%v err=%v", ok, err)
1195 }
1196
1197 if err := s.SaveSnapshot(path); err != nil {
1198 t.Fatalf("SaveSnapshot same content: %v", err)
1199 }
1200 after, ok, err := LoadBranchMeta(path)
1201 if err != nil || !ok {
1202 t.Fatalf("LoadBranchMeta after no-op ok=%v err=%v", ok, err)
1203 }
1204 if after.Revision != before.Revision || after.ContentDigest != before.ContentDigest || after.WriterID != before.WriterID {
1205 t.Fatalf("same-content snapshot changed persistence meta: before=%+v after=%+v", before, after)
1206 }
1207
1208 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1209 if err := s.SaveSnapshot(path); err != nil {
1210 t.Fatalf("SaveSnapshot append after no-op: %v", err)
1211 }
1212 advanced, ok, err := LoadBranchMeta(path)
1213 if err != nil || !ok {
1214 t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
1215 }
1216 if advanced.Revision != before.Revision+1 {
1217 t.Fatalf("revision after append = %d, want %d", advanced.Revision, before.Revision+1)
1218 }
1219 }
1220
1221 func TestSaveSnapshotSameContentByOtherRuntimeKeepsClonedBaselineWritable(t *testing.T) {
1222 path := filepath.Join(t.TempDir(), "session.jsonl")
1223 s := NewSession("sys")
1224 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1225 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1226 if err := s.Save(path); err != nil {
1227 t.Fatalf("Save base: %v", err)
1228 }
1229
1230 loaded, err := LoadSession(path)
1231 if err != nil {
1232 t.Fatalf("LoadSession: %v", err)
1233 }
1234 resumed, ok := loaded.CloneWithMessagesIfCompatible(loaded.Snapshot())
1235 if !ok {
1236 t.Fatal("expected compatible clone")
1237 }
1238
1239 // Another runtime autosaves the identical transcript (e.g. a shutdown
1240 // snapshot of an idle tab). It must not bump the revision, or the resumed
1241 // clone's baseline goes stale and its next append is misread as a
1242 // stale-runtime conflict.
1243 other, err := LoadSession(path)
1244 if err != nil {
1245 t.Fatalf("LoadSession other: %v", err)
1246 }
1247 if err := other.SaveSnapshot(path); err != nil {
1248 t.Fatalf("SaveSnapshot other same content: %v", err)
1249 }
1250
1251 resumed.Add(provider.Message{Role: provider.RoleUser, Content: "next"})
1252 if err := resumed.SaveSnapshot(path); err != nil {
1253 t.Fatalf("SaveSnapshot append after same-content autosave elsewhere: %v", err)
1254 }
1255 reloaded, err := LoadSession(path)
1256 if err != nil {
1257 t.Fatalf("LoadSession appended: %v", err)
1258 }
1259 if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "next" {
1260 t.Fatalf("tail after append = %q, want next", got)
1261 }
1262 if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1263 t.Fatalf("recovery branches after append = %v err=%v, want none", matches, err)
1264 }
1265 }
1266
1267 func TestSaveSnapshotAllowsExactAppendFromStaleRevisionBaseline(t *testing.T) {
1268 path := filepath.Join(t.TempDir(), "session.jsonl")
1269 s := NewSession("sys")
1270 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1271 if err := s.SaveSnapshot(path); err != nil {
1272 t.Fatalf("SaveSnapshot base: %v", err)
1273 }
1274 staleBaseline := s.persistState(path)
1275
1276 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1277 if err := s.SaveSnapshot(path); err != nil {
1278 t.Fatalf("SaveSnapshot prefix append: %v", err)
1279 }
1280 prefixMeta, ok, err := LoadBranchMeta(path)
1281 if err != nil || !ok {
1282 t.Fatalf("LoadBranchMeta prefix ok=%v err=%v", ok, err)
1283 }
1284 if prefixMeta.Revision == staleBaseline.revision {
1285 t.Fatalf("prefix revision did not advance: %d", prefixMeta.Revision)
1286 }
1287
1288 s.Add(provider.Message{Role: provider.RoleUser, Content: "two"})
1289 s.setPersistedBaseline(path, staleBaseline.digest, staleBaseline.version, staleBaseline.revision, true, true, 0)
1290 if err := s.SaveSnapshot(path); err != nil {
1291 t.Fatalf("SaveSnapshot exact append from stale revision baseline: %v", err)
1292 }
1293
1294 loaded, err := LoadSession(path)
1295 if err != nil {
1296 t.Fatalf("LoadSession appended: %v", err)
1297 }
1298 if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "two" {
1299 t.Fatalf("tail after stale-baseline append = %q, want two", got)
1300 }
1301 advancedMeta, ok, err := LoadBranchMeta(path)
1302 if err != nil || !ok {
1303 t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
1304 }
1305 if advancedMeta.Revision != prefixMeta.Revision+1 {
1306 t.Fatalf("revision after stale-baseline append = %d, want %d", advancedMeta.Revision, prefixMeta.Revision+1)
1307 }
1308 if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1309 t.Fatalf("recovery branches after stale-baseline append = %v err=%v, want none", matches, err)
1310 }
1311 }
1312
1313 func TestSaveSnapshotAllowsCompatibleSystemAppendFromStaleRevisionBaseline(t *testing.T) {
1314 path := filepath.Join(t.TempDir(), "session.jsonl")
1315 s := NewSession("sys v1")
1316 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1317 if err := s.SaveSnapshot(path); err != nil {
1318 t.Fatalf("SaveSnapshot base: %v", err)
1319 }
1320 staleBaseline := s.persistState(path)
1321
1322 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1323 if err := s.SaveSnapshot(path); err != nil {
1324 t.Fatalf("SaveSnapshot prefix append: %v", err)
1325 }
1326 prefixMeta, ok, err := LoadBranchMeta(path)
1327 if err != nil || !ok {
1328 t.Fatalf("LoadBranchMeta prefix ok=%v err=%v", ok, err)
1329 }
1330
1331 // A resume swapped the system prompt, then the turn appended a message —
1332 // while the persistence baseline still points at the first save.
1333 msgs := s.Snapshot()
1334 msgs[0] = provider.Message{Role: provider.RoleSystem, Content: "sys v2"}
1335 msgs = append(msgs, provider.Message{Role: provider.RoleUser, Content: "two"})
1336 s.Replace(msgs)
1337 s.setPersistedBaseline(path, staleBaseline.digest, staleBaseline.version, staleBaseline.revision, true, true, 0)
1338 if err := s.SaveSnapshot(path); err != nil {
1339 t.Fatalf("SaveSnapshot compatible-system append from stale baseline: %v", err)
1340 }
1341
1342 loaded, err := LoadSession(path)
1343 if err != nil {
1344 t.Fatalf("LoadSession appended: %v", err)
1345 }
1346 if got := loaded.Messages[0].Content; got != "sys v2" {
1347 t.Fatalf("system after compatible append = %q, want sys v2", got)
1348 }
1349 if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "two" {
1350 t.Fatalf("tail after compatible append = %q, want two", got)
1351 }
1352 advancedMeta, ok, err := LoadBranchMeta(path)
1353 if err != nil || !ok {
1354 t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
1355 }
1356 if advancedMeta.Revision != prefixMeta.Revision+1 {
1357 t.Fatalf("revision after compatible append = %d, want %d", advancedMeta.Revision, prefixMeta.Revision+1)
1358 }
1359 if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1360 t.Fatalf("recovery branches after compatible append = %v err=%v, want none", matches, err)
1361 }
1362 }
1363
1364 func TestSaveSnapshotRefusesStaleBaselineAppendOverRewoundTranscript(t *testing.T) {
1365 path := filepath.Join(t.TempDir(), "session.jsonl")
1366 s := NewSession("sys")
1367 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1368 if err := s.SaveSnapshot(path); err != nil {
1369 t.Fatalf("SaveSnapshot base: %v", err)
1370 }
1371 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1372 if err := s.SaveSnapshot(path); err != nil {
1373 t.Fatalf("SaveSnapshot extend: %v", err)
1374 }
1375
1376 // Another runtime rewinds the transcript below this session's baseline
1377 // (e.g. a cancelled turn truncated the partial assistant reply).
1378 other, err := LoadSession(path)
1379 if err != nil {
1380 t.Fatalf("LoadSession other: %v", err)
1381 }
1382 other.Replace(other.Snapshot()[:2])
1383 if err := other.SaveRewrite(path); err != nil {
1384 t.Fatalf("SaveRewrite rewind: %v", err)
1385 }
1386
1387 // Appending from the stale baseline would resurrect the rewound suffix.
1388 s.Add(provider.Message{Role: provider.RoleUser, Content: "two"})
1389 if err := s.SaveSnapshot(path); !errors.Is(err, ErrSessionSnapshotConflict) {
1390 t.Fatalf("SaveSnapshot over rewound transcript err = %v, want ErrSessionSnapshotConflict", err)
1391 }
1392 loaded, err := LoadSession(path)
1393 if err != nil {
1394 t.Fatalf("LoadSession after refused append: %v", err)
1395 }
1396 if got := len(loaded.Messages); got != 2 {
1397 t.Fatalf("messages after refused append = %d, want rewound 2", got)
1398 }
1399 }
1400
1401 func TestSaveRewriteAllowsOwnedRewriteAfterLedgerReset(t *testing.T) {
1402 path := filepath.Join(t.TempDir(), "session.jsonl")
1403 s := NewSession("sys")
1404 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1405 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "partial turn"})
1406 if err := s.SaveSnapshot(path); err != nil {
1407 t.Fatalf("SaveSnapshot base: %v", err)
1408 }
1409 // The meta sidecar (revision ledger) is lost — e.g. swept by a cleanup
1410 // that deleted session-adjacent files. The transcript itself is intact.
1411 if err := os.Remove(BranchMetaPath(path)); err != nil {
1412 t.Fatalf("remove sidecar: %v", err)
1413 }
1414
1415 // A cancelled turn strips the partial reply and flushes via SaveRewrite.
1416 msgs := s.Snapshot()
1417 s.Replace(msgs[:len(msgs)-1])
1418 if err := s.SaveRewrite(path); err != nil {
1419 t.Fatalf("SaveRewrite after ledger reset: %v", err)
1420 }
1421
1422 loaded, err := LoadSession(path)
1423 if err != nil {
1424 t.Fatalf("LoadSession truncated: %v", err)
1425 }
1426 if got := len(loaded.Messages); got != 2 {
1427 t.Fatalf("messages after owned rewrite = %d, want 2", got)
1428 }
1429 if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "first" {
1430 t.Fatalf("tail after owned rewrite = %q, want first", got)
1431 }
1432 if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1433 t.Fatalf("recovery branches after owned rewrite = %v err=%v, want none", matches, err)
1434 }
1435 }
1436
1437 func TestSaveSnapshotStillPersistsNormalizedRepair(t *testing.T) {
1438 path := filepath.Join(t.TempDir(), "session.jsonl")
1439 mal := NewSession("sys")
1440 mal.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1441 // Unanswered tool call: LoadSession backfills a placeholder result, so the
1442 // loaded history digests equal to itself while the on-disk bytes differ.
1443 mal.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "tool-1", Name: "read_file", Arguments: "{}"}}})
1444 mal.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
1445 if err := mal.Save(path); err != nil {
1446 t.Fatalf("Save malformed: %v", err)
1447 }
1448 base, ok, err := LoadBranchMeta(path)
1449 if err != nil || !ok {
1450 t.Fatalf("LoadBranchMeta base ok=%v err=%v", ok, err)
1451 }
1452
1453 loaded, err := LoadSession(path)
1454 if err != nil {
1455 t.Fatalf("LoadSession: %v", err)
1456 }
1457 if !loaded.normalizedDirty {
1458 t.Fatal("fixture did not trigger a load-time repair; adjust the malformed history")
1459 }
1460 if err := loaded.SaveSnapshot(path); err != nil {
1461 t.Fatalf("SaveSnapshot repaired history: %v", err)
1462 }
1463 repaired, ok, err := LoadBranchMeta(path)
1464 if err != nil || !ok {
1465 t.Fatalf("LoadBranchMeta repaired ok=%v err=%v", ok, err)
1466 }
1467 if repaired.Revision != base.Revision+1 {
1468 t.Fatalf("revision after repair save = %d, want %d", repaired.Revision, base.Revision+1)
1469 }
1470 reloaded, err := LoadSession(path)
1471 if err != nil {
1472 t.Fatalf("LoadSession repaired: %v", err)
1473 }
1474 if reloaded.normalizedDirty {
1475 t.Fatal("repair did not persist: reloaded session is still normalized-dirty")
1476 }
1477
1478 // With the repair on disk, the same snapshot is now a true no-op.
1479 if err := reloaded.SaveSnapshot(path); err != nil {
1480 t.Fatalf("SaveSnapshot post-repair: %v", err)
1481 }
1482 final, ok, err := LoadBranchMeta(path)
1483 if err != nil || !ok {
1484 t.Fatalf("LoadBranchMeta final ok=%v err=%v", ok, err)
1485 }
1486 if final.Revision != repaired.Revision {
1487 t.Fatalf("post-repair no-op bumped revision: %d, want %d", final.Revision, repaired.Revision)
1488 }
1489 }
1490
1491 func TestSaveSnapshotRejectsStalePrefixAfterSystemPromptRefresh(t *testing.T) {
1492 path := filepath.Join(t.TempDir(), "session.jsonl")
1493 current := NewSession("new sys")
1494 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1495 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1496 current.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
1497 if err := current.Save(path); err != nil {
1498 t.Fatalf("Save current: %v", err)
1499 }
1500
1501 stale := NewSession("old sys")
1502 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1503 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1504 if err := stale.SaveSnapshot(path); !errors.Is(err, ErrSessionSnapshotConflict) {
1505 t.Fatalf("SaveSnapshot stale after system refresh err = %v, want ErrSessionSnapshotConflict", err)
1506 }
1507
1508 loaded, err := LoadSession(path)
1509 if err != nil {
1510 t.Fatalf("LoadSession: %v", err)
1511 }
1512 if got := len(loaded.Messages); got != 4 {
1513 t.Fatalf("message count after stale system refresh snapshot = %d, want 4", got)
1514 }
1515 if got := loaded.Messages[3].Content; got != "second" {
1516 t.Fatalf("last message after stale system refresh snapshot = %q, want %q", got, "second")
1517 }
1518 }
1519
1520 func TestSaveRewriteAllowsRewriteOverSameContentForeignStamp(t *testing.T) {
1521 path := filepath.Join(t.TempDir(), "session.jsonl")
1522 base := NewSession("sys")
1523 base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1524 base.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1525 if err := base.Save(path); err != nil {
1526 t.Fatalf("Save base: %v", err)
1527 }
1528
1529 stale, err := LoadSession(path)
1530 if err != nil {
1531 t.Fatalf("LoadSession stale: %v", err)
1532 }
1533 // Another runtime healed the ledger over identical content: the revision
1534 // advanced under a foreign writer id, but the recorded digest still
1535 // describes the exact bytes this session loaded and owns.
1536 meta, ok, err := LoadBranchMeta(path)
1537 if err != nil || !ok {
1538 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
1539 }
1540 meta.Revision++
1541 meta.WriterID = "other-writer"
1542 if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
1543 t.Fatalf("bump revision: %v", err)
1544 }
1545
1546 stale.Replace([]provider.Message{
1547 {Role: provider.RoleSystem, Content: "sys"},
1548 {Role: provider.RoleUser, Content: "summarized first"},
1549 })
1550 if err := stale.SaveRewrite(path); err != nil {
1551 t.Fatalf("SaveRewrite over same-content stamp: %v", err)
1552 }
1553
1554 loaded, err := LoadSession(path)
1555 if err != nil {
1556 t.Fatalf("LoadSession: %v", err)
1557 }
1558 if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "summarized first" {
1559 t.Fatalf("tail after owned rewrite = %q, want summarized first", got)
1560 }
1561 advanced, ok, err := LoadBranchMeta(path)
1562 if err != nil || !ok {
1563 t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
1564 }
1565 if advanced.Revision != meta.Revision+1 {
1566 t.Fatalf("revision after rewrite = %d, want %d", advanced.Revision, meta.Revision+1)
1567 }
1568 if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1569 t.Fatalf("recovery branches after owned rewrite = %v err=%v, want none", matches, err)
1570 }
1571 }
1572
1573 func TestSaveRewriteRejectsForeignStampForUnattributedBytes(t *testing.T) {
1574 path := filepath.Join(t.TempDir(), "session.jsonl")
1575 base := NewSession("sys")
1576 base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1577 base.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1578 if err := base.Save(path); err != nil {
1579 t.Fatalf("Save base: %v", err)
1580 }
1581
1582 stale, err := LoadSession(path)
1583 if err != nil {
1584 t.Fatalf("LoadSession stale: %v", err)
1585 }
1586 // A foreign stamp whose digest disagrees with the on-disk transcript is
1587 // the aftermath of a save whose bytes and record split — the transcript
1588 // cannot be attributed, so the rewrite must fall to the conflict path.
1589 meta, ok, err := LoadBranchMeta(path)
1590 if err != nil || !ok {
1591 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
1592 }
1593 meta.Revision++
1594 meta.WriterID = "other-writer"
1595 meta.ContentDigest = "0000000000000000000000000000000000000000000000000000000000000000"
1596 if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
1597 t.Fatalf("stamp foreign digest: %v", err)
1598 }
1599
1600 stale.Replace([]provider.Message{
1601 {Role: provider.RoleSystem, Content: "sys"},
1602 {Role: provider.RoleUser, Content: "summarized first"},
1603 })
1604 err = stale.SaveRewrite(path)
1605 if !errors.Is(err, ErrSessionSnapshotConflict) {
1606 t.Fatalf("SaveRewrite unattributed stamp err = %v, want ErrSessionSnapshotConflict", err)
1607 }
1608 var conflict *SessionSnapshotConflictError
1609 if !errors.As(err, &conflict) || conflict.Kind != SessionSnapshotConflictDiverged {
1610 t.Fatalf("conflict = %+v, want diverged revision conflict", conflict)
1611 }
1612 if conflict.BaseRevision != meta.Revision-1 || conflict.DiskRevision != meta.Revision {
1613 t.Fatalf("conflict revisions = base %d disk %d, want %d/%d",
1614 conflict.BaseRevision, conflict.DiskRevision, meta.Revision-1, meta.Revision)
1615 }
1616
1617 loaded, err := LoadSession(path)
1618 if err != nil {
1619 t.Fatalf("LoadSession: %v", err)
1620 }
1621 if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "one" {
1622 t.Fatalf("tail after rejected rewrite = %q, want one", got)
1623 }
1624 }
1625
1626 func TestSaveSnapshotAllowsOwnedNonPrefixRewrite(t *testing.T) {
1627 path := filepath.Join(t.TempDir(), "session.jsonl")
1628 s := NewSession("sys")
1629 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1630 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1631 if err := s.Save(path); err != nil {
1632 t.Fatalf("Save base: %v", err)
1633 }
1634
1635 s.Replace([]provider.Message{
1636 {Role: provider.RoleSystem, Content: "sys"},
1637 {Role: provider.RoleUser, Content: "summarized first"},
1638 })
1639 // The persisted digest, revision, and ledger digest still describe the
1640 // exact bytes this Session wrote, so a non-prefix snapshot may safely use
1641 // the full-rewrite path without creating a recovery branch.
1642 if err := s.SaveSnapshot(path); err != nil {
1643 t.Fatalf("SaveSnapshot owned non-prefix rewrite: %v", err)
1644 }
1645
1646 loaded, err := LoadSession(path)
1647 if err != nil {
1648 t.Fatalf("LoadSession: %v", err)
1649 }
1650 if got := len(loaded.Messages); got != 2 {
1651 t.Fatalf("message count after accepted snapshot rewrite = %d, want 2", got)
1652 }
1653 if got := loaded.Messages[1].Content; got != "summarized first" {
1654 t.Fatalf("rewritten content = %q, want %q", got, "summarized first")
1655 }
1656 }
1657
1658 func TestSaveSnapshotRejectsInterruptedForeignWriteAtSameRevision(t *testing.T) {
1659 path := filepath.Join(t.TempDir(), "session.jsonl")
1660 base := NewSession("sys")
1661 base.Add(provider.Message{Role: provider.RoleUser, Content: "base"})
1662 if err := base.Save(path); err != nil {
1663 t.Fatalf("Save base: %v", err)
1664 }
1665
1666 stale, err := LoadSession(path)
1667 if err != nil {
1668 t.Fatalf("LoadSession stale: %v", err)
1669 }
1670 revision, _, err := sessionContentRevision(path)
1671 if err != nil {
1672 t.Fatalf("sessionContentRevision: %v", err)
1673 }
1674
1675 foreignMessages := append(stale.Snapshot(),
1676 provider.Message{Role: provider.RoleAssistant, Content: "foreign writer tail"})
1677 foreignDigest, err := digestSessionMessages(foreignMessages)
1678 if err != nil {
1679 t.Fatalf("digest foreign messages: %v", err)
1680 }
1681 if err := appendSessionReplaceEvent(path, foreignMessages, foreignDigest, revision, "snapshot"); err != nil {
1682 t.Fatalf("append interrupted foreign event: %v", err)
1683 }
1684 if err := writeSessionMessages(path, foreignMessages); err != nil {
1685 t.Fatalf("write interrupted foreign checkpoint: %v", err)
1686 }
1687 // Simulate a crash before recordSessionContentRevision: the transcript and
1688 // event log changed, but the revision still equals stale's baseline.
1689
1690 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "stale writer tail"})
1691 err = stale.SaveSnapshot(path)
1692 if !errors.Is(err, ErrSessionSnapshotConflict) {
1693 t.Fatalf("SaveSnapshot err = %v, want ErrSessionSnapshotConflict", err)
1694 }
1695
1696 loaded, err := LoadSession(path)
1697 if err != nil {
1698 t.Fatalf("LoadSession final: %v", err)
1699 }
1700 if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "foreign writer tail" {
1701 t.Fatalf("foreign tail after rejected snapshot = %q, want preserved", got)
1702 }
1703 }
1704
1705 func TestSaveRewriteAllowsOwnedNonPrefixRewrite(t *testing.T) {
1706 path := filepath.Join(t.TempDir(), "session.jsonl")
1707 s := NewSession("sys")
1708 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1709 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1710 if err := s.Save(path); err != nil {
1711 t.Fatalf("Save base: %v", err)
1712 }
1713
1714 s.Replace([]provider.Message{
1715 {Role: provider.RoleSystem, Content: "sys"},
1716 {Role: provider.RoleUser, Content: "summarized first"},
1717 })
1718 if err := s.SaveRewrite(path); err != nil {
1719 t.Fatalf("SaveRewrite owned rewrite: %v", err)
1720 }
1721
1722 loaded, err := LoadSession(path)
1723 if err != nil {
1724 t.Fatalf("LoadSession: %v", err)
1725 }
1726 if got := len(loaded.Messages); got != 2 {
1727 t.Fatalf("message count after rewrite = %d, want 2", got)
1728 }
1729 if got := loaded.Messages[1].Content; got != "summarized first" {
1730 t.Fatalf("rewritten content = %q, want %q", got, "summarized first")
1731 }
1732 }
1733
1734 func TestCloneWithMessagesPreservesRewriteBaseline(t *testing.T) {
1735 path := filepath.Join(t.TempDir(), "session.jsonl")
1736 s := NewSession("old sys")
1737 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1738 s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "tool-1", Name: "read_file", Arguments: "{}"}}})
1739 s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "tool-1", Name: "read_file", Content: strings.Repeat("detail ", 100)})
1740 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
1741 if err := s.Save(path); err != nil {
1742 t.Fatalf("Save base: %v", err)
1743 }
1744
1745 loaded, err := LoadSession(path)
1746 if err != nil {
1747 t.Fatalf("LoadSession: %v", err)
1748 }
1749 msgs := loaded.Snapshot()
1750 msgs[0].Content = "new sys"
1751 msgs[3].Content = "[elided tool result]"
1752 resumed := loaded.CloneWithMessages(msgs)
1753 if err := resumed.SaveRewrite(path); err != nil {
1754 t.Fatalf("SaveRewrite cloned resume rewrite: %v", err)
1755 }
1756
1757 reloaded, err := LoadSession(path)
1758 if err != nil {
1759 t.Fatalf("LoadSession rewritten: %v", err)
1760 }
1761 if got := reloaded.Messages[0].Content; got != "new sys" {
1762 t.Fatalf("system prompt after rewrite = %q, want new sys", got)
1763 }
1764 if got := reloaded.Messages[3].Content; got != "[elided tool result]" {
1765 t.Fatalf("tool result after rewrite = %q, want elided", got)
1766 }
1767 }
1768
1769 func TestCloneWithMessagesIfCompatibleRejectsHistoryChanges(t *testing.T) {
1770 path := filepath.Join(t.TempDir(), "session.jsonl")
1771 s := NewSession("old sys")
1772 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1773 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1774 if err := s.Save(path); err != nil {
1775 t.Fatalf("Save base: %v", err)
1776 }
1777
1778 loaded, err := LoadSession(path)
1779 if err != nil {
1780 t.Fatalf("LoadSession: %v", err)
1781 }
1782 systemOnly := loaded.Snapshot()
1783 systemOnly[0].Content = "new sys"
1784 if _, ok := loaded.CloneWithMessagesIfCompatible(systemOnly); !ok {
1785 t.Fatal("system-only change should be compatible")
1786 }
1787
1788 changed := loaded.Snapshot()
1789 changed[2].Content = "rewritten assistant"
1790 if _, ok := loaded.CloneWithMessagesIfCompatible(changed); ok {
1791 t.Fatal("non-system history change should not preserve baseline")
1792 }
1793 }
1794
1795 func TestSaveRewriteRejectsStalePrefixOverwrite(t *testing.T) {
1796 path := filepath.Join(t.TempDir(), "session.jsonl")
1797 current := NewSession("sys")
1798 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1799 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1800 current.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
1801 if err := current.Save(path); err != nil {
1802 t.Fatalf("Save current: %v", err)
1803 }
1804
1805 stale := NewSession("sys")
1806 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1807 if err := stale.SaveRewrite(path); !errors.Is(err, ErrSessionSnapshotConflict) {
1808 t.Fatalf("SaveRewrite stale err = %v, want ErrSessionSnapshotConflict", err)
1809 }
1810
1811 loaded, err := LoadSession(path)
1812 if err != nil {
1813 t.Fatalf("LoadSession: %v", err)
1814 }
1815 if got := len(loaded.Messages); got != 4 {
1816 t.Fatalf("message count after stale rewrite = %d, want 4", got)
1817 }
1818 if got := loaded.Messages[3].Content; got != "second" {
1819 t.Fatalf("last message after stale rewrite = %q, want %q", got, "second")
1820 }
1821 }
1822
1823 func TestSaveRecoveryBranchPersistsDivergedSnapshot(t *testing.T) {
1824 path := filepath.Join(t.TempDir(), "session.jsonl")
1825 current := NewSession("sys")
1826 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1827 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1828 current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
1829 if err := current.Save(path); err != nil {
1830 t.Fatalf("Save current: %v", err)
1831 }
1832
1833 stale := NewSession("sys")
1834 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1835 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1836 stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
1837 if err := stale.SaveSnapshot(path); !errors.Is(err, ErrSessionSnapshotConflict) {
1838 t.Fatalf("SaveSnapshot stale err = %v, want ErrSessionSnapshotConflict", err)
1839 }
1840
1841 info, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
1842 if err != nil {
1843 t.Fatalf("SaveRecoveryBranch: %v", err)
1844 }
1845 if info.Path == "" || info.Path == path {
1846 t.Fatalf("recovery path = %q, want distinct path", info.Path)
1847 }
1848 if info.Turns != 2 || info.Preview != "first" {
1849 t.Fatalf("recovery preview/turns = %q/%d, want first/2", info.Preview, info.Turns)
1850 }
1851 recovered, err := LoadSession(info.Path)
1852 if err != nil {
1853 t.Fatalf("LoadSession recovery: %v", err)
1854 }
1855 if got := recovered.Messages[len(recovered.Messages)-1].Content; got != "local second" {
1856 t.Fatalf("recovery tail = %q, want local second", got)
1857 }
1858 original, err := LoadSession(path)
1859 if err != nil {
1860 t.Fatalf("LoadSession original: %v", err)
1861 }
1862 if got := original.Messages[len(original.Messages)-1].Content; got != "disk second" {
1863 t.Fatalf("original tail = %q, want disk second", got)
1864 }
1865 meta, ok, err := LoadBranchMeta(info.Path)
1866 if err != nil || !ok {
1867 t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
1868 }
1869 if !meta.Recovered || meta.ParentID != BranchID(path) || meta.Name != RecoveryBranchDefaultName {
1870 t.Fatalf("recovery meta = %+v, want recovered parent/name", meta)
1871 }
1872 if meta.RecoveryDigest == "" || meta.SchemaVersion != BranchMetaCountsVersion {
1873 t.Fatalf("recovery digest/schema = %q/%d", meta.RecoveryDigest, meta.SchemaVersion)
1874 }
1875 if meta.Revision != 1 || meta.ContentDigest != meta.RecoveryDigest || meta.WriterID == "" {
1876 t.Fatalf("recovery persistence meta = %+v, want revision/content digest/writer", meta)
1877 }
1878 }
1879
1880 func TestSaveRecoveryBranchSkipsPureStalePrefix(t *testing.T) {
1881 path := filepath.Join(t.TempDir(), "session.jsonl")
1882 current := NewSession("sys")
1883 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1884 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1885 current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
1886 if err := current.Save(path); err != nil {
1887 t.Fatalf("Save current: %v", err)
1888 }
1889
1890 stale := NewSession("sys")
1891 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1892 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1893 if _, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path}); !errors.Is(err, ErrSessionRecoveryNotNeeded) {
1894 t.Fatalf("SaveRecoveryBranch stale prefix err = %v, want ErrSessionRecoveryNotNeeded", err)
1895 }
1896 }
1897
1898 func divergedSessionPair(t *testing.T, dir, name string) (string, *Session) {
1899 t.Helper()
1900 path := filepath.Join(dir, name)
1901 current := NewSession("sys")
1902 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1903 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1904 current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
1905 if err := current.Save(path); err != nil {
1906 t.Fatalf("Save current: %v", err)
1907 }
1908 stale := NewSession("sys")
1909 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1910 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1911 stale.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name})
1912 return path, stale
1913 }
1914
1915 func stampRecoveryMeta(t *testing.T, path string, depth int) {
1916 t.Helper()
1917 meta, ok, err := LoadBranchMeta(path)
1918 if err != nil || !ok {
1919 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
1920 }
1921 meta.Recovered = true
1922 meta.RecoveryDepth = depth
1923 if err := SaveBranchMeta(path, meta); err != nil {
1924 t.Fatalf("SaveBranchMeta: %v", err)
1925 }
1926 }
1927
1928 func TestSaveRecoveryBranchStampsAndCapsChainDepth(t *testing.T) {
1929 dir := t.TempDir()
1930
1931 // Forking from a normal session stamps depth 1.
1932 path, stale := divergedSessionPair(t, dir, "session.jsonl")
1933 info, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
1934 if err != nil {
1935 t.Fatalf("SaveRecoveryBranch: %v", err)
1936 }
1937 if info.Meta.RecoveryDepth != 1 {
1938 t.Fatalf("first fork depth = %d, want 1", info.Meta.RecoveryDepth)
1939 }
1940
1941 // Forking from a recovery branch increments the chain depth.
1942 deeper, staleDeeper := divergedSessionPair(t, dir, "deeper.jsonl")
1943 stampRecoveryMeta(t, deeper, 1)
1944 info, err = staleDeeper.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: deeper})
1945 if err != nil {
1946 t.Fatalf("SaveRecoveryBranch from depth 1: %v", err)
1947 }
1948 if info.Meta.RecoveryDepth != 2 {
1949 t.Fatalf("nested fork depth = %d, want 2", info.Meta.RecoveryDepth)
1950 }
1951
1952 // A legacy recovery meta without the depth field counts as depth 1.
1953 legacy, staleLegacy := divergedSessionPair(t, dir, "legacy.jsonl")
1954 stampRecoveryMeta(t, legacy, 0)
1955 info, err = staleLegacy.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: legacy})
1956 if err != nil {
1957 t.Fatalf("SaveRecoveryBranch from legacy recovery: %v", err)
1958 }
1959 if info.Meta.RecoveryDepth != 2 {
1960 t.Fatalf("legacy nested fork depth = %d, want 2", info.Meta.RecoveryDepth)
1961 }
1962
1963 // A parent at the cap refuses to fork deeper.
1964 capped, staleCapped := divergedSessionPair(t, dir, "capped.jsonl")
1965 stampRecoveryMeta(t, capped, SessionRecoveryMaxDepth)
1966 if _, err := staleCapped.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: capped}); !errors.Is(err, ErrSessionRecoveryDepthExceeded) {
1967 t.Fatalf("SaveRecoveryBranch at cap err = %v, want ErrSessionRecoveryDepthExceeded", err)
1968 }
1969 forks, err := filepath.Glob(filepath.Join(dir, "capped-recovery-*.jsonl"))
1970 if err != nil {
1971 t.Fatalf("glob: %v", err)
1972 }
1973 if len(forks) != 0 {
1974 t.Fatalf("capped parent still forked: %v", forks)
1975 }
1976 }
1977
1978 func TestSaveRecoveryBranchDedupesByDigest(t *testing.T) {
1979 path := filepath.Join(t.TempDir(), "session.jsonl")
1980 current := NewSession("sys")
1981 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1982 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
1983 if err := current.Save(path); err != nil {
1984 t.Fatalf("Save current: %v", err)
1985 }
1986
1987 stale := NewSession("sys")
1988 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1989 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
1990 first, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
1991 if err != nil {
1992 t.Fatalf("first SaveRecoveryBranch: %v", err)
1993 }
1994 second, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
1995 if err != nil {
1996 t.Fatalf("second SaveRecoveryBranch: %v", err)
1997 }
1998 if second.Path != first.Path || !second.Existing {
1999 t.Fatalf("second recovery = %+v, want existing same path %q", second, first.Path)
2000 }
2001 }
2002
2003 func TestSaveRecoveryBranchCompactsLongParentFilename(t *testing.T) {
2004 dir := t.TempDir()
2005 parentID := strings.Repeat("longparent-", 22)
2006 path := filepath.Join(dir, parentID+".jsonl")
2007 current := NewSession("sys")
2008 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2009 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
2010 if err := current.Save(path); err != nil {
2011 t.Fatalf("Save current: %v", err)
2012 }
2013
2014 stale := NewSession("sys")
2015 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2016 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
2017 info, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
2018 if err != nil {
2019 t.Fatalf("SaveRecoveryBranch: %v", err)
2020 }
2021 base := filepath.Base(info.Path)
2022 if len(base) > 140 {
2023 t.Fatalf("recovery basename length = %d (%q), want bounded", len(base), base)
2024 }
2025 for _, suffix := range []string{".lock", ".lease.lock", ".lease.json", ".meta"} {
2026 if len(base+suffix) > 255 {
2027 t.Fatalf("recovery sidecar basename %q length = %d, want <= 255", base+suffix, len(base+suffix))
2028 }
2029 }
2030 meta, ok, err := LoadBranchMeta(info.Path)
2031 if err != nil || !ok {
2032 t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
2033 }
2034 if meta.ParentID != BranchID(path) {
2035 t.Fatalf("recovery parent = %q, want original branch id", meta.ParentID)
2036 }
2037 }
2038
2039 func TestSaveRecoveryBranchDoesNotCascadeRecoveryFilename(t *testing.T) {
2040 dir := t.TempDir()
2041 path := filepath.Join(dir, "session.jsonl")
2042 current := NewSession("sys")
2043 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2044 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
2045 if err := current.Save(path); err != nil {
2046 t.Fatalf("Save current: %v", err)
2047 }
2048
2049 local := NewSession("sys")
2050 local.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2051 local.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
2052 first, err := local.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
2053 if err != nil {
2054 t.Fatalf("first SaveRecoveryBranch: %v", err)
2055 }
2056
2057 recoveryDisk, err := LoadSession(first.Path)
2058 if err != nil {
2059 t.Fatalf("LoadSession recovery: %v", err)
2060 }
2061 recoveryDisk.Add(provider.Message{Role: provider.RoleUser, Content: "disk follow-up"})
2062 if err := recoveryDisk.SaveSnapshot(first.Path); err != nil {
2063 t.Fatalf("SaveSnapshot recovery disk: %v", err)
2064 }
2065 recoveryLocal := NewSession("sys")
2066 recoveryLocal.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2067 recoveryLocal.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
2068 recoveryLocal.Add(provider.Message{Role: provider.RoleUser, Content: "local follow-up"})
2069
2070 second, err := recoveryLocal.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: first.Path})
2071 if err != nil {
2072 t.Fatalf("second SaveRecoveryBranch: %v", err)
2073 }
2074 base := filepath.Base(second.Path)
2075 if count := strings.Count(base, "-recovery-"); count != 1 {
2076 t.Fatalf("recovery basename = %q, contains %d recovery markers, want 1", base, count)
2077 }
2078 if len(base) > 140 {
2079 t.Fatalf("recovery basename length = %d (%q), want bounded", len(base), base)
2080 }
2081 }
2082
2083 // TestSaveSnapshotSameRevisionAllowsOwnedNonPrefixAppend reproduces the scenario from
2084 // #6948: a recovery branch whose snapshot saves systematically diverged because
2085 // checkSnapshotWrite's byte-level prefix comparison failed on messages carrying
2086 // local-only metadata (LocalOnly + interrupted_turn) that survived JSON round-trip
2087 // with subtle differences. The persisted digest proves that the disk still holds
2088 // this Session's baseline, so the save proceeds as a full rewrite instead of
2089 // forking another recovery branch.
2090 func TestSaveSnapshotSameRevisionAllowsOwnedNonPrefixAppend(t *testing.T) {
2091 dir := t.TempDir()
2092 path := filepath.Join(dir, "session.jsonl")
2093
2094 // Simulate a mid-turn snapshot that captured an interrupted tool turn.
2095 // The LocalOnly + interrupted_turn metadata is the byte-level difference
2096 // that makes the normalized disk view diverge from the in-memory snapshot.
2097 s0 := NewSession("sys")
2098 s0.Add(provider.Message{Role: provider.RoleUser, Content: "edit file"})
2099 s0.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok", ToolCalls: []provider.ToolCall{
2100 {ID: "call_1", Name: "edit", Arguments: `{"file":"f","old":"a","new":"b"}`},
2101 }})
2102 s0.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "call_1", Name: "edit",
2103 Content: "edited f (+1 -1)", WorkDurationMs: 1234})
2104 // Simulate interrupted turn: mid-turn snapshot with LocalOnly recovery placeholder.
2105 s0.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "call_2", Name: "bash",
2106 LocalOnly: true, WorkDurationMs: 0,
2107 InterruptedTurn: &provider.InterruptedTurnRecovery{
2108 Pending: true, CompletedTools: []provider.InterruptedToolSummary{
2109 {ID: "call_1", Name: "edit", Added: 1, Removed: 1},
2110 },
2111 },
2112 })
2113 if err := s0.Save(path); err != nil {
2114 t.Fatalf("Save base: %v", err)
2115 }
2116
2117 // Simulate recovery: load the saved transcript into a new session, then
2118 // add more messages. Every subsequent SaveSnapshot must succeed without a
2119 // diverged conflict while the persisted digest still proves ownership.
2120 for turn := 0; turn < 5; turn++ {
2121 s, err := LoadSession(path)
2122 if err != nil {
2123 t.Fatalf("LoadSession turn %d: %v", turn, err)
2124 }
2125 s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("msg %d", turn)})
2126 s.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("reply %d", turn)})
2127 if err := s.SaveSnapshot(path); err != nil {
2128 t.Fatalf("SaveSnapshot turn %d: %v", turn, err)
2129 }
2130 }
2131
2132 // Final transcript must contain all turns.
2133 final, err := LoadSession(path)
2134 if err != nil {
2135 t.Fatalf("LoadSession final: %v", err)
2136 }
2137 got := len(final.Messages)
2138 // base: sys + user + asst(tc) + tool + LocalOnly = 5, + 5*2 = 15
2139 if got < 10 {
2140 t.Fatalf("final message count = %d, want >= 10", got)
2141 }
2142 }
2143
2144 func TestReconcileSessionSidecarsRemovesUnlockedArtifacts(t *testing.T) {
2145 dir := t.TempDir()
2146 path := filepath.Join(dir, "session.jsonl")
2147 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
2148 t.Fatal(err)
2149 }
2150 for _, sidecar := range []string{path + ".lock", path + ".lease.lock", path + ".lease.json"} {
2151 if err := os.WriteFile(sidecar, []byte("{}\n"), 0o644); err != nil {
2152 t.Fatalf("write %s: %v", sidecar, err)
2153 }
2154 }
2155
2156 if err := ReconcileSessionSidecars(dir); err != nil {
2157 t.Fatalf("ReconcileSessionSidecars: %v", err)
2158 }
2159 if _, err := os.Stat(path); err != nil {
2160 t.Fatalf("session transcript removed: %v", err)
2161 }
2162 for _, sidecar := range []string{path + ".lock", path + ".lease.lock", path + ".lease.json"} {
2163 if _, err := os.Stat(sidecar); !os.IsNotExist(err) {
2164 t.Fatalf("%s exists after sidecar cleanup (err=%v)", sidecar, err)
2165 }
2166 }
2167 }
2168
2169 func TestReconcileSessionSidecarsKeepsLiveLocks(t *testing.T) {
2170 dir := t.TempDir()
2171 path := filepath.Join(dir, "session.jsonl")
2172 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
2173 t.Fatal(err)
2174 }
2175 unlock, err := lockSessionFile(path)
2176 if err != nil {
2177 t.Fatalf("lockSessionFile: %v", err)
2178 }
2179 defer unlock()
2180 lease, err := TryAcquireSessionLease(path)
2181 if err != nil {
2182 t.Fatalf("TryAcquireSessionLease: %v", err)
2183 }
2184 defer lease.Release()
2185
2186 if err := ReconcileSessionSidecars(dir); err != nil {
2187 t.Fatalf("ReconcileSessionSidecars: %v", err)
2188 }
2189 for _, sidecar := range []string{path + ".lock", path + ".lease.lock", path + ".lease.json"} {
2190 if _, err := os.Stat(sidecar); err != nil {
2191 t.Fatalf("%s missing while lock is live: %v", sidecar, err)
2192 }
2193 }
2194 }
2195
2196 // TestReconcileSessionSidecarsKeepsFlockOnlyLocks proves the file lock alone
2197 // protects a writer from cleanup: CLI-style savers hold the .lock flock while
2198 // writing without ever taking a session lease.
2199 func TestReconcileSessionSidecarsKeepsFlockOnlyLocks(t *testing.T) {
2200 dir := t.TempDir()
2201 path := filepath.Join(dir, "session.jsonl")
2202 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
2203 t.Fatal(err)
2204 }
2205 unlock, err := lockSessionFile(path)
2206 if err != nil {
2207 t.Fatalf("lockSessionFile: %v", err)
2208 }
2209 if err := ReconcileSessionSidecars(dir); err != nil {
2210 t.Fatalf("ReconcileSessionSidecars: %v", err)
2211 }
2212 if _, err := os.Stat(path + ".lock"); err != nil {
2213 t.Fatalf(".lock removed while a lock-only writer holds it: %v", err)
2214 }
2215 unlock()
2216 if err := ReconcileSessionSidecars(dir); err != nil {
2217 t.Fatalf("ReconcileSessionSidecars after unlock: %v", err)
2218 }
2219 if _, err := os.Stat(path + ".lock"); !os.IsNotExist(err) {
2220 t.Fatalf(".lock survived cleanup after release (err=%v)", err)
2221 }
2222 }
2223
2224 // TestReconcileSessionSidecarsRenamesOverlongSessionFiles covers the
2225 // migration for transcripts left behind by the unbounded recovery cascade
2226 // (#5923): names so long their lock/lease sidecars could not be created. The
2227 // conversation bytes must survive under a bounded name, branch meta must move
2228 // with its ID rewritten, and children must be re-parented onto the new ID.
2229 func TestReconcileSessionSidecarsRenamesOverlongSessionFiles(t *testing.T) {
2230 dir := t.TempDir()
2231 longID := strings.Repeat("p", 240) // 246-byte basename: .lock fits, .lease.lock does not
2232 hugeID := strings.Repeat("q", 248) // 254-byte basename: no sidecar fits at all
2233 oldLong := filepath.Join(dir, longID+".jsonl")
2234 oldHuge := filepath.Join(dir, hugeID+".jsonl")
2235 content := `{"role":"system","content":"sys"}` + "\n" + `{"role":"user","content":"hello"}` + "\n"
2236 for _, p := range []string{oldLong, oldHuge} {
2237 if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
2238 t.Fatal(err)
2239 }
2240 }
2241 if err := os.WriteFile(oldLong+".lock", []byte("{}\n"), 0o644); err != nil {
2242 t.Fatal(err)
2243 }
2244 if err := SaveBranchMeta(oldLong, BranchMeta{Name: "长会话", ParentID: "root-branch"}); err != nil {
2245 t.Fatalf("SaveBranchMeta: %v", err)
2246 }
2247 childPath := filepath.Join(dir, "child.jsonl")
2248 if err := os.WriteFile(childPath, []byte(content), 0o644); err != nil {
2249 t.Fatal(err)
2250 }
2251 if err := SaveBranchMeta(childPath, BranchMeta{Name: "child", ParentID: longID}); err != nil {
2252 t.Fatalf("SaveBranchMeta child: %v", err)
2253 }
2254
2255 if err := ReconcileSessionSidecars(dir); err != nil {
2256 t.Fatalf("ReconcileSessionSidecars: %v", err)
2257 }
2258
2259 for _, gone := range []string{oldLong, oldHuge, oldLong + ".lock", oldLong + ".meta"} {
2260 if _, err := os.Stat(gone); !os.IsNotExist(err) {
2261 t.Fatalf("%s still present after rename (err=%v)", filepath.Base(gone), err)
2262 }
2263 }
2264 newLongID := recoveryParentStem(longID)
2265 newLong := filepath.Join(dir, newLongID+".jsonl")
2266 newHuge := filepath.Join(dir, recoveryParentStem(hugeID)+".jsonl")
2267 for _, p := range []string{newLong, newHuge} {
2268 if base := filepath.Base(p); len(base) > maxSessionBasenameBytes {
2269 t.Fatalf("renamed basename %q length %d exceeds bound %d", base, len(base), maxSessionBasenameBytes)
2270 }
2271 b, err := os.ReadFile(p)
2272 if err != nil {
2273 t.Fatalf("read renamed transcript: %v", err)
2274 }
2275 if string(b) != content {
2276 t.Fatalf("transcript content changed by rename: %q", b)
2277 }
2278 }
2279 meta, ok, err := LoadBranchMeta(newLong)
2280 if err != nil || !ok {
2281 t.Fatalf("LoadBranchMeta renamed ok=%v err=%v", ok, err)
2282 }
2283 if meta.ID != newLongID {
2284 t.Fatalf("migrated meta ID = %q, want %q", meta.ID, newLongID)
2285 }
2286 if meta.Name != "长会话" || meta.ParentID != "root-branch" {
2287 t.Fatalf("migrated meta lost fields: %+v", meta)
2288 }
2289 childMeta, ok, err := LoadBranchMeta(childPath)
2290 if err != nil || !ok {
2291 t.Fatalf("LoadBranchMeta child ok=%v err=%v", ok, err)
2292 }
2293 if childMeta.ParentID != newLongID {
2294 t.Fatalf("child ParentID = %q, want re-parented %q", childMeta.ParentID, newLongID)
2295 }
2296
2297 before, err := filepath.Glob(filepath.Join(dir, "*"))
2298 if err != nil {
2299 t.Fatal(err)
2300 }
2301 if err := ReconcileSessionSidecars(dir); err != nil {
2302 t.Fatalf("ReconcileSessionSidecars rerun: %v", err)
2303 }
2304 after, err := filepath.Glob(filepath.Join(dir, "*"))
2305 if err != nil {
2306 t.Fatal(err)
2307 }
2308 if len(before) != len(after) {
2309 t.Fatalf("rerun changed the directory: before=%d entries, after=%d", len(before), len(after))
2310 }
2311 }
2312
2313 // TestReconcileOverlongRenameStillReparentsWhenSidecarMigrationFails pins the
2314 // point-of-no-return contract: once the transcript rename lands, the mapping
2315 // must be committed — children re-parented, error surfaced as a warning —
2316 // because the old name is gone and no later run can reconstruct it.
2317 func TestReconcileOverlongRenameStillReparentsWhenSidecarMigrationFails(t *testing.T) {
2318 dir := t.TempDir()
2319 longID := strings.Repeat("m", 240)
2320 oldPath := filepath.Join(dir, longID+".jsonl")
2321 content := `{"role":"user","content":"hello"}` + "\n"
2322 if err := os.WriteFile(oldPath, []byte(content), 0o644); err != nil {
2323 t.Fatal(err)
2324 }
2325 if err := SaveBranchMeta(oldPath, BranchMeta{Name: "keep", ParentID: "root"}); err != nil {
2326 t.Fatalf("SaveBranchMeta: %v", err)
2327 }
2328 childPath := filepath.Join(dir, "child.jsonl")
2329 if err := os.WriteFile(childPath, []byte(content), 0o644); err != nil {
2330 t.Fatal(err)
2331 }
2332 if err := SaveBranchMeta(childPath, BranchMeta{Name: "child", ParentID: longID}); err != nil {
2333 t.Fatalf("SaveBranchMeta child: %v", err)
2334 }
2335
2336 newID := recoveryParentStem(longID)
2337 newPath := filepath.Join(dir, newID+".jsonl")
2338 // Sabotage the meta migration: its destination path is a directory.
2339 if err := os.Mkdir(newPath+".meta", 0o755); err != nil {
2340 t.Fatal(err)
2341 }
2342
2343 if err := ReconcileSessionSidecars(dir); err == nil {
2344 t.Fatal("expected the sabotaged meta migration to surface an error")
2345 }
2346 if _, err := os.Stat(newPath); err != nil {
2347 t.Fatalf("renamed transcript missing after partial failure: %v", err)
2348 }
2349 if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
2350 t.Fatalf("old transcript still present (err=%v)", err)
2351 }
2352 childMeta, ok, err := LoadBranchMeta(childPath)
2353 if err != nil || !ok {
2354 t.Fatalf("LoadBranchMeta child ok=%v err=%v", ok, err)
2355 }
2356 if childMeta.ParentID != newID {
2357 t.Fatalf("child ParentID = %q, want %q despite sidecar failure", childMeta.ParentID, newID)
2358 }
2359 // The old meta stays behind as the durable copy of the un-migrated fields.
2360 if _, err := os.Stat(oldPath + ".meta"); err != nil {
2361 t.Fatalf("old meta lost though its migration failed: %v", err)
2362 }
2363 }
2364
2365 // TestListSessionsOrdersByMTime makes sure the picker shows the most
2366 // recently used conversation first — that's what users reach for when they
2367 // hit `reasonix --continue`.
2368 func TestListSessionsOrdersByMTime(t *testing.T) {
2369 dir := t.TempDir()
2370 // Write two sessions with explicit mtimes so the order is deterministic.
2371 for _, name := range []string{"a.jsonl", "b.jsonl"} {
2372 s := NewSession("")
2373 s.Add(provider.Message{Role: provider.RoleUser, Content: "preview for " + name})
2374 if err := s.Save(filepath.Join(dir, name)); err != nil {
2375 t.Fatal(err)
2376 }
2377 }
2378 oldT := time.Now().Add(-1 * time.Hour)
2379 newT := time.Now()
2380 if err := touch(filepath.Join(dir, "a.jsonl"), oldT); err != nil {
2381 t.Fatal(err)
2382 }
2383 if err := touch(filepath.Join(dir, "b.jsonl"), newT); err != nil {
2384 t.Fatal(err)
2385 }
2386
2387 got, err := ListSessions(dir)
2388 if err != nil {
2389 t.Fatal(err)
2390 }
2391 if len(got) != 2 {
2392 t.Fatalf("len = %d, want 2", len(got))
2393 }
2394 if !strings.HasSuffix(got[0].Path, "b.jsonl") {
2395 t.Errorf("first entry = %s, want the newer 'b.jsonl'", got[0].Path)
2396 }
2397 if got[0].Turns != 1 || got[0].Preview != "preview for b.jsonl" {
2398 t.Errorf("preview/turns wrong on newest: turns=%d preview=%q", got[0].Turns, got[0].Preview)
2399 }
2400 }
2401
2402 func TestListSessionsIncludesCustomTitle(t *testing.T) {
2403 dir := t.TempDir()
2404 path := filepath.Join(dir, "named.jsonl")
2405 s := NewSession("")
2406 s.Add(provider.Message{Role: provider.RoleUser, Content: "first user prompt"})
2407 if err := s.Save(path); err != nil {
2408 t.Fatal(err)
2409 }
2410 if err := SaveBranchMetaPreserveUpdated(path, BranchMeta{
2411 TopicTitle: "Topic title",
2412 CustomTitle: "Custom session title",
2413 Preview: "first user prompt",
2414 Turns: 1,
2415 SchemaVersion: BranchMetaCountsVersion,
2416 }); err != nil {
2417 t.Fatalf("SaveBranchMetaPreserveUpdated: %v", err)
2418 }
2419
2420 got, err := ListSessions(dir)
2421 if err != nil {
2422 t.Fatal(err)
2423 }
2424 if len(got) != 1 {
2425 t.Fatalf("len = %d, want 1", len(got))
2426 }
2427 if got[0].CustomTitle != "Custom session title" {
2428 t.Fatalf("custom title = %q, want Custom session title", got[0].CustomTitle)
2429 }
2430 if got[0].TopicTitle != "Topic title" {
2431 t.Fatalf("topic title = %q, want Topic title", got[0].TopicTitle)
2432 }
2433 }
2434
2435 func TestListSessionsSkipsCleanupPending(t *testing.T) {
2436 dir := t.TempDir()
2437 path := filepath.Join(dir, "pending.jsonl")
2438 s := NewSession("")
2439 s.Add(provider.Message{Role: provider.RoleUser, Content: "preview"})
2440 if err := s.Save(path); err != nil {
2441 t.Fatal(err)
2442 }
2443 if err := MarkCleanupPending(path, "delete"); err != nil {
2444 t.Fatal(err)
2445 }
2446 if !IsCleanupPending(path) {
2447 t.Fatal("session should be marked cleanup-pending")
2448 }
2449
2450 got, err := ListSessions(dir)
2451 if err != nil {
2452 t.Fatal(err)
2453 }
2454 if len(got) != 0 {
2455 t.Fatalf("cleanup-pending session should be hidden, got %+v", got)
2456 }
2457
2458 if err := ClearCleanupPending(path); err != nil {
2459 t.Fatal(err)
2460 }
2461 got, err = ListSessions(dir)
2462 if err != nil {
2463 t.Fatal(err)
2464 }
2465 if len(got) != 1 || got[0].Path != path {
2466 t.Fatalf("session should be visible after clearing marker, got %+v", got)
2467 }
2468 }
2469
2470 func TestListSessionsOrdersByLastActivityMeta(t *testing.T) {
2471 dir := t.TempDir()
2472 aPath := filepath.Join(dir, "a.jsonl")
2473 bPath := filepath.Join(dir, "b.jsonl")
2474 for _, path := range []string{aPath, bPath} {
2475 s := NewSession("")
2476 s.Add(provider.Message{Role: provider.RoleUser, Content: "preview for " + filepath.Base(path)})
2477 if err := s.Save(path); err != nil {
2478 t.Fatal(err)
2479 }
2480 }
2481
2482 now := time.Now().UTC()
2483 olderActivity := now.Add(-2 * time.Hour)
2484 newerActivity := now.Add(-1 * time.Hour)
2485 writeBranchMeta(t, aPath, now.Add(-24*time.Hour), newerActivity)
2486 writeBranchMeta(t, bPath, now.Add(-24*time.Hour), olderActivity)
2487 if err := touch(aPath, now.Add(-3*time.Hour)); err != nil {
2488 t.Fatal(err)
2489 }
2490 if err := touch(bPath, now); err != nil {
2491 t.Fatal(err)
2492 }
2493
2494 got, err := ListSessions(dir)
2495 if err != nil {
2496 t.Fatal(err)
2497 }
2498 if len(got) != 2 {
2499 t.Fatalf("len = %d, want 2", len(got))
2500 }
2501 if got[0].Path != aPath {
2502 t.Fatalf("first entry = %s, want activity-newer a.jsonl despite older file mtime", got[0].Path)
2503 }
2504 if !got[0].LastActivityAt.Equal(newerActivity) || !got[0].ModTime.Equal(newerActivity) {
2505 t.Fatalf("activity fields = %s / %s, want %s", got[0].LastActivityAt, got[0].ModTime, newerActivity)
2506 }
2507 }
2508
2509 func TestListSessionOrderIncludesEmptySessionsWithoutPreviewScan(t *testing.T) {
2510 dir := t.TempDir()
2511 emptyPath := filepath.Join(dir, "empty.jsonl")
2512 realPath := filepath.Join(dir, "real.jsonl")
2513 if err := os.WriteFile(emptyPath, nil, 0o644); err != nil {
2514 t.Fatal(err)
2515 }
2516 s := NewSession("")
2517 s.Add(provider.Message{Role: provider.RoleUser, Content: "real prompt"})
2518 if err := s.Save(realPath); err != nil {
2519 t.Fatal(err)
2520 }
2521
2522 now := time.Now().UTC()
2523 writeBranchMeta(t, emptyPath, now, now.Add(time.Hour))
2524 writeBranchMeta(t, realPath, now, now)
2525
2526 ordered, err := ListSessionOrder(dir)
2527 if err != nil {
2528 t.Fatal(err)
2529 }
2530 if len(ordered) != 2 {
2531 t.Fatalf("lightweight order len = %d, want 2", len(ordered))
2532 }
2533 if ordered[0].Path != emptyPath {
2534 t.Fatalf("lightweight order first = %s, want newer empty session %s", ordered[0].Path, emptyPath)
2535 }
2536
2537 listed, err := ListSessions(dir)
2538 if err != nil {
2539 t.Fatal(err)
2540 }
2541 if len(listed) != 1 || listed[0].Path != realPath {
2542 t.Fatalf("ListSessions = %+v, want only the non-empty real session", listed)
2543 }
2544 }
2545
2546 func TestSessionListingsExposeRecoveryMetadata(t *testing.T) {
2547 dir := t.TempDir()
2548 path := filepath.Join(dir, "recovered.jsonl")
2549 s := NewSession("")
2550 s.Add(provider.Message{Role: provider.RoleUser, Content: "continued recovery"})
2551 if err := s.Save(path); err != nil {
2552 t.Fatal(err)
2553 }
2554
2555 meta, ok, err := LoadBranchMeta(path)
2556 if err != nil || !ok {
2557 t.Fatalf("LoadBranchMeta: ok=%v err=%v", ok, err)
2558 }
2559 meta.Recovered = true
2560 meta.RecoveryDigest = strings.Repeat("a", 64)
2561 meta.ParentID = "parent"
2562 if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
2563 t.Fatal(err)
2564 }
2565
2566 ordered, err := ListSessionOrder(dir)
2567 if err != nil {
2568 t.Fatal(err)
2569 }
2570 if len(ordered) != 1 {
2571 t.Fatalf("ListSessionOrder len = %d, want 1", len(ordered))
2572 }
2573 if ordered[0].RecoveryDigest != meta.RecoveryDigest || ordered[0].ParentID != meta.ParentID {
2574 t.Fatalf("ordered recovery metadata = digest:%q parent:%q, want digest:%q parent:%q", ordered[0].RecoveryDigest, ordered[0].ParentID, meta.RecoveryDigest, meta.ParentID)
2575 }
2576
2577 listed, err := ListSessions(dir)
2578 if err != nil {
2579 t.Fatal(err)
2580 }
2581 if len(listed) != 1 {
2582 t.Fatalf("ListSessions len = %d, want 1", len(listed))
2583 }
2584 if listed[0].RecoveryDigest != meta.RecoveryDigest || listed[0].ParentID != meta.ParentID {
2585 t.Fatalf("listed recovery metadata = digest:%q parent:%q, want digest:%q parent:%q", listed[0].RecoveryDigest, listed[0].ParentID, meta.RecoveryDigest, meta.ParentID)
2586 }
2587 }
2588
2589 func writeBranchMeta(t *testing.T, path string, createdAt, updatedAt time.Time) {
2590 t.Helper()
2591 meta := BranchMeta{
2592 ID: BranchID(path),
2593 CreatedAt: createdAt,
2594 UpdatedAt: updatedAt,
2595 }
2596 b, err := json.Marshal(meta)
2597 if err != nil {
2598 t.Fatal(err)
2599 }
2600 if err := os.WriteFile(BranchMetaPath(path), append(b, '\n'), 0o644); err != nil {
2601 t.Fatal(err)
2602 }
2603 }
2604
2605 func TestContinueSessionPathReusesPriorFile(t *testing.T) {
2606 prev := filepath.Join("sessions", "20260602-120000.000000000-deepseek.jsonl")
2607 if got := ContinueSessionPath(prev, "sessions", "other-model"); got != prev {
2608 t.Fatalf("carried conversation should keep its file %q, got %q", prev, got)
2609 }
2610 }
2611
2612 func TestContinueSessionPathMintsFreshWhenNoPrior(t *testing.T) {
2613 dir := t.TempDir()
2614 got := ContinueSessionPath("", dir, "deepseek")
2615 if filepath.Dir(got) != dir || !strings.HasSuffix(got, ".jsonl") {
2616 t.Fatalf("fresh path = %q, want a .jsonl under %q", got, dir)
2617 }
2618 }
2619
2620 func TestContinueSessionPathNoPersistence(t *testing.T) {
2621 if got := ContinueSessionPath("", "", "deepseek"); got != "" {
2622 t.Fatalf("no session dir should disable persistence, got %q", got)
2623 }
2624 }
2625
2626 // TestListSessionsMissingDir returns nil + no error so callers can fall
2627 // through to a fresh session without special-casing.
2628 func TestListSessionsMissingDir(t *testing.T) {
2629 got, err := ListSessions(filepath.Join(t.TempDir(), "never-created"))
2630 if err != nil || got != nil {
2631 t.Errorf("missing dir = %v / %v, want nil/nil", got, err)
2632 }
2633 }
2634
2635 func readSessionEventsForTest(t *testing.T, path string) []sessionEventRecord {
2636 t.Helper()
2637 f, err := os.Open(SessionEventLogPath(path))
2638 if err != nil {
2639 t.Fatalf("open event log: %v", err)
2640 }
2641 defer f.Close()
2642 dec := json.NewDecoder(f)
2643 var out []sessionEventRecord
2644 for {
2645 var rec sessionEventRecord
2646 if err := dec.Decode(&rec); err != nil {
2647 if errors.Is(err, io.EOF) {
2648 break
2649 }
2650 t.Fatalf("decode event log: %v", err)
2651 }
2652 out = append(out, rec)
2653 }
2654 return out
2655 }
2656
2657 // TestReconcileOverlongMigratesDiagnosticSidecars pins two halves of one fix:
2658 // overlong-name reconciliation must skip .events.jsonl / .conflicts.jsonl
2659 // sidecars instead of renaming each into a fake session with fabricated meta,
2660 // and the owning transcript's rename must carry those sidecars along instead
2661 // of orphaning them under the retired stem.
2662 func TestReconcileOverlongMigratesDiagnosticSidecars(t *testing.T) {
2663 dir := t.TempDir()
2664 // 236-byte transcript name: past the 224 reconcile bound, while its
2665 // .events.jsonl (243) and .conflicts.jsonl (246) still fit under 255 —
2666 // exactly the window where the old suffix filter mistook them for
2667 // overlong sessions.
2668 id := strings.Repeat("s", 230)
2669 oldPath := filepath.Join(dir, id+".jsonl")
2670 content := `{"role":"system","content":"sys"}` + "\n" + `{"role":"user","content":"hello"}` + "\n"
2671 if err := os.WriteFile(oldPath, []byte(content), 0o644); err != nil {
2672 t.Fatal(err)
2673 }
2674 if err := os.WriteFile(store.SessionEventLog(oldPath),
2675 []byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"hello"}]}`+"\n"), 0o644); err != nil {
2676 t.Fatal(err)
2677 }
2678 if err := os.WriteFile(store.SessionConflictLog(oldPath),
2679 []byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644); err != nil {
2680 t.Fatal(err)
2681 }
2682 if err := os.WriteFile(store.SessionRecoveryState(oldPath),
2683 []byte(`{"tasks":{"root":{"phase":"diagnosing"}}}`+"\n"), 0o600); err != nil {
2684 t.Fatal(err)
2685 }
2686 // The salvage sidecar holds raw session bytes; orphaning it under the
2687 // retired stem would leave unreachable transcript content behind (#6613
2688 // review follow-up).
2689 if err := os.WriteFile(store.SessionEventLogDamaged(oldPath),
2690 []byte(`{"damaged_tail":true}`+"\ntorn bytes\n"), 0o644); err != nil {
2691 t.Fatal(err)
2692 }
2693
2694 if err := ReconcileSessionSidecars(dir); err != nil {
2695 t.Fatalf("ReconcileSessionSidecars: %v", err)
2696 }
2697
2698 newPath := filepath.Join(dir, recoveryParentStem(id)+".jsonl")
2699 if _, err := os.Stat(newPath); err != nil {
2700 t.Fatalf("renamed transcript missing: %v", err)
2701 }
2702 if _, err := os.Stat(store.SessionEventLog(newPath)); err != nil {
2703 t.Fatalf("event log not migrated with the rename: %v", err)
2704 }
2705 if _, err := os.Stat(store.SessionEventLogDamaged(newPath)); err != nil {
2706 t.Fatalf("damaged salvage sidecar not migrated with the rename: %v", err)
2707 }
2708 if _, err := os.Stat(store.SessionConflictLog(newPath)); err != nil {
2709 t.Fatalf("conflict log not migrated with the rename: %v", err)
2710 }
2711 if _, err := os.Stat(store.SessionRecoveryState(newPath)); err != nil {
2712 t.Fatalf("recovery state not migrated with the rename: %v", err)
2713 }
2714 for _, gone := range []string{oldPath, store.SessionEventLog(oldPath), store.SessionEventLogDamaged(oldPath), store.SessionConflictLog(oldPath), store.SessionRecoveryState(oldPath)} {
2715 if _, err := os.Stat(gone); !os.IsNotExist(err) {
2716 t.Fatalf("%s still present under the retired stem (err=%v)", filepath.Base(gone), err)
2717 }
2718 }
2719 entries, err := os.ReadDir(dir)
2720 if err != nil {
2721 t.Fatal(err)
2722 }
2723 for _, e := range entries {
2724 if store.IsSessionTranscriptName(e.Name()) && e.Name() != filepath.Base(newPath) {
2725 t.Fatalf("reconcile fabricated an extra session from a sidecar: %s", e.Name())
2726 }
2727 }
2728 }
2729
2729 lines GO