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