返回 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 TestSaveSnapshotMigratesLegacyJSONLToEventLog(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 events := readSessionEventsForTest(t, path)
1142 if len(events) != 1 || events[0].Type != sessionEventTypeReplace {
1143 t.Fatalf("legacy migration events = %+v, want one replace seed", events)
1144 }
1145 reloaded, err := LoadSession(path)
1146 if err != nil {
1147 t.Fatalf("LoadSession migrated: %v", err)
1148 }
1149 if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "migrated" {
1150 t.Fatalf("migrated tail = %q, want migrated", got)
1151 }
1152 if _, err := os.Stat(SessionEventIndexPath(path)); err != nil {
1153 t.Fatalf("event index missing: %v", err)
1154 }
1155 }
1156
1157 func TestSaveSnapshotAllowsAppendAfterSystemPromptRefresh(t *testing.T) {
1158 path := filepath.Join(t.TempDir(), "session.jsonl")
1159 base := NewSession("old sys")
1160 base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1161 if err := base.Save(path); err != nil {
1162 t.Fatalf("Save base: %v", err)
1163 }
1164
1165 next := NewSession("new sys")
1166 next.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1167 next.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1168 if err := next.SaveSnapshot(path); err != nil {
1169 t.Fatalf("SaveSnapshot after system refresh: %v", err)
1170 }
1171
1172 loaded, err := LoadSession(path)
1173 if err != nil {
1174 t.Fatalf("LoadSession: %v", err)
1175 }
1176 if got := len(loaded.Messages); got != 3 {
1177 t.Fatalf("message count after system refresh append = %d, want 3", got)
1178 }
1179 if got := loaded.Messages[0].Content; got != "new sys" {
1180 t.Fatalf("system prompt after refresh = %q, want %q", got, "new sys")
1181 }
1182 }
1183
1184 func TestSaveSnapshotRecordsRevisionAndMetaUpdatesPreserveIt(t *testing.T) {
1185 path := filepath.Join(t.TempDir(), "session.jsonl")
1186 base := NewSession("sys")
1187 base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1188 if err := base.SaveSnapshot(path); err != nil {
1189 t.Fatalf("SaveSnapshot base: %v", err)
1190 }
1191 meta, ok, err := LoadBranchMeta(path)
1192 if err != nil || !ok {
1193 t.Fatalf("LoadBranchMeta base ok=%v err=%v", ok, err)
1194 }
1195 if meta.Revision != 1 || meta.ContentDigest == "" || meta.WriterID == "" {
1196 t.Fatalf("base persistence meta = %+v, want revision/digest/writer", meta)
1197 }
1198
1199 if err := UpdateSessionMeta(path, "model-a", "first", 1, true); err != nil {
1200 t.Fatalf("UpdateSessionMeta: %v", err)
1201 }
1202 refreshed, ok, err := LoadBranchMeta(path)
1203 if err != nil || !ok {
1204 t.Fatalf("LoadBranchMeta refreshed ok=%v err=%v", ok, err)
1205 }
1206 if refreshed.Revision != meta.Revision || refreshed.ContentDigest != meta.ContentDigest || refreshed.WriterID != meta.WriterID {
1207 t.Fatalf("listing meta update changed persistence fields: before=%+v after=%+v", meta, refreshed)
1208 }
1209
1210 loaded, err := LoadSession(path)
1211 if err != nil {
1212 t.Fatalf("LoadSession: %v", err)
1213 }
1214 loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1215 if err := loaded.SaveSnapshot(path); err != nil {
1216 t.Fatalf("SaveSnapshot append: %v", err)
1217 }
1218 advanced, ok, err := LoadBranchMeta(path)
1219 if err != nil || !ok {
1220 t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
1221 }
1222 if advanced.Revision != refreshed.Revision+1 {
1223 t.Fatalf("revision after append = %d, want %d", advanced.Revision, refreshed.Revision+1)
1224 }
1225 if advanced.ContentDigest == refreshed.ContentDigest {
1226 t.Fatalf("content digest did not change after append: %q", advanced.ContentDigest)
1227 }
1228 }
1229
1230 func TestSaveSnapshotSameContentSkipsRevisionBump(t *testing.T) {
1231 path := filepath.Join(t.TempDir(), "session.jsonl")
1232 s := NewSession("sys")
1233 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1234 if err := s.SaveSnapshot(path); err != nil {
1235 t.Fatalf("SaveSnapshot base: %v", err)
1236 }
1237 before, ok, err := LoadBranchMeta(path)
1238 if err != nil || !ok {
1239 t.Fatalf("LoadBranchMeta base ok=%v err=%v", ok, err)
1240 }
1241
1242 if err := s.SaveSnapshot(path); err != nil {
1243 t.Fatalf("SaveSnapshot same content: %v", err)
1244 }
1245 after, ok, err := LoadBranchMeta(path)
1246 if err != nil || !ok {
1247 t.Fatalf("LoadBranchMeta after no-op ok=%v err=%v", ok, err)
1248 }
1249 if after.Revision != before.Revision || after.ContentDigest != before.ContentDigest || after.WriterID != before.WriterID {
1250 t.Fatalf("same-content snapshot changed persistence meta: before=%+v after=%+v", before, after)
1251 }
1252
1253 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1254 if err := s.SaveSnapshot(path); err != nil {
1255 t.Fatalf("SaveSnapshot append after no-op: %v", err)
1256 }
1257 advanced, ok, err := LoadBranchMeta(path)
1258 if err != nil || !ok {
1259 t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
1260 }
1261 if advanced.Revision != before.Revision+1 {
1262 t.Fatalf("revision after append = %d, want %d", advanced.Revision, before.Revision+1)
1263 }
1264 }
1265
1266 func TestSaveSnapshotSameContentByOtherRuntimeKeepsClonedBaselineWritable(t *testing.T) {
1267 path := filepath.Join(t.TempDir(), "session.jsonl")
1268 s := NewSession("sys")
1269 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1270 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1271 if err := s.Save(path); err != nil {
1272 t.Fatalf("Save base: %v", err)
1273 }
1274
1275 loaded, err := LoadSession(path)
1276 if err != nil {
1277 t.Fatalf("LoadSession: %v", err)
1278 }
1279 resumed, ok := loaded.CloneWithMessagesIfCompatible(loaded.Snapshot())
1280 if !ok {
1281 t.Fatal("expected compatible clone")
1282 }
1283
1284 // Another runtime autosaves the identical transcript (e.g. a shutdown
1285 // snapshot of an idle tab). It must not bump the revision, or the resumed
1286 // clone's baseline goes stale and its next append is misread as a
1287 // stale-runtime conflict.
1288 other, err := LoadSession(path)
1289 if err != nil {
1290 t.Fatalf("LoadSession other: %v", err)
1291 }
1292 if err := other.SaveSnapshot(path); err != nil {
1293 t.Fatalf("SaveSnapshot other same content: %v", err)
1294 }
1295
1296 resumed.Add(provider.Message{Role: provider.RoleUser, Content: "next"})
1297 if err := resumed.SaveSnapshot(path); err != nil {
1298 t.Fatalf("SaveSnapshot append after same-content autosave elsewhere: %v", err)
1299 }
1300 reloaded, err := LoadSession(path)
1301 if err != nil {
1302 t.Fatalf("LoadSession appended: %v", err)
1303 }
1304 if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "next" {
1305 t.Fatalf("tail after append = %q, want next", got)
1306 }
1307 if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1308 t.Fatalf("recovery branches after append = %v err=%v, want none", matches, err)
1309 }
1310 }
1311
1312 func TestSaveSnapshotAllowsExactAppendFromStaleRevisionBaseline(t *testing.T) {
1313 path := filepath.Join(t.TempDir(), "session.jsonl")
1314 s := NewSession("sys")
1315 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1316 if err := s.SaveSnapshot(path); err != nil {
1317 t.Fatalf("SaveSnapshot base: %v", err)
1318 }
1319 staleBaseline := s.persistState(path)
1320
1321 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1322 if err := s.SaveSnapshot(path); err != nil {
1323 t.Fatalf("SaveSnapshot prefix append: %v", err)
1324 }
1325 prefixMeta, ok, err := LoadBranchMeta(path)
1326 if err != nil || !ok {
1327 t.Fatalf("LoadBranchMeta prefix ok=%v err=%v", ok, err)
1328 }
1329 if prefixMeta.Revision == staleBaseline.revision {
1330 t.Fatalf("prefix revision did not advance: %d", prefixMeta.Revision)
1331 }
1332
1333 s.Add(provider.Message{Role: provider.RoleUser, Content: "two"})
1334 s.setPersistedBaseline(path, staleBaseline.digest, staleBaseline.version, staleBaseline.revision, true, true, 0, nil)
1335 if err := s.SaveSnapshot(path); err != nil {
1336 t.Fatalf("SaveSnapshot exact append from stale revision baseline: %v", err)
1337 }
1338
1339 loaded, err := LoadSession(path)
1340 if err != nil {
1341 t.Fatalf("LoadSession appended: %v", err)
1342 }
1343 if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "two" {
1344 t.Fatalf("tail after stale-baseline append = %q, want two", got)
1345 }
1346 advancedMeta, ok, err := LoadBranchMeta(path)
1347 if err != nil || !ok {
1348 t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
1349 }
1350 if advancedMeta.Revision != prefixMeta.Revision+1 {
1351 t.Fatalf("revision after stale-baseline append = %d, want %d", advancedMeta.Revision, prefixMeta.Revision+1)
1352 }
1353 if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1354 t.Fatalf("recovery branches after stale-baseline append = %v err=%v, want none", matches, err)
1355 }
1356 }
1357
1358 func TestSaveSnapshotAllowsCompatibleSystemAppendFromStaleRevisionBaseline(t *testing.T) {
1359 path := filepath.Join(t.TempDir(), "session.jsonl")
1360 s := NewSession("sys v1")
1361 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1362 if err := s.SaveSnapshot(path); err != nil {
1363 t.Fatalf("SaveSnapshot base: %v", err)
1364 }
1365 staleBaseline := s.persistState(path)
1366
1367 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1368 if err := s.SaveSnapshot(path); err != nil {
1369 t.Fatalf("SaveSnapshot prefix append: %v", err)
1370 }
1371 prefixMeta, ok, err := LoadBranchMeta(path)
1372 if err != nil || !ok {
1373 t.Fatalf("LoadBranchMeta prefix ok=%v err=%v", ok, err)
1374 }
1375
1376 // A resume swapped the system prompt, then the turn appended a message —
1377 // while the persistence baseline still points at the first save.
1378 msgs := s.Snapshot()
1379 msgs[0] = provider.Message{Role: provider.RoleSystem, Content: "sys v2"}
1380 msgs = append(msgs, provider.Message{Role: provider.RoleUser, Content: "two"})
1381 s.Replace(msgs)
1382 s.setPersistedBaseline(path, staleBaseline.digest, staleBaseline.version, staleBaseline.revision, true, true, 0, nil)
1383 if err := s.SaveSnapshot(path); err != nil {
1384 t.Fatalf("SaveSnapshot compatible-system append from stale baseline: %v", err)
1385 }
1386
1387 loaded, err := LoadSession(path)
1388 if err != nil {
1389 t.Fatalf("LoadSession appended: %v", err)
1390 }
1391 if got := loaded.Messages[0].Content; got != "sys v2" {
1392 t.Fatalf("system after compatible append = %q, want sys v2", got)
1393 }
1394 if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "two" {
1395 t.Fatalf("tail after compatible append = %q, want two", got)
1396 }
1397 advancedMeta, ok, err := LoadBranchMeta(path)
1398 if err != nil || !ok {
1399 t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
1400 }
1401 if advancedMeta.Revision != prefixMeta.Revision+1 {
1402 t.Fatalf("revision after compatible append = %d, want %d", advancedMeta.Revision, prefixMeta.Revision+1)
1403 }
1404 if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1405 t.Fatalf("recovery branches after compatible append = %v err=%v, want none", matches, err)
1406 }
1407 }
1408
1409 func TestSaveSnapshotRefusesStaleBaselineAppendOverRewoundTranscript(t *testing.T) {
1410 path := schemaOneSessionPath(t, "session.jsonl")
1411 s := NewSession("sys")
1412 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1413 if err := s.SaveSnapshot(path); err != nil {
1414 t.Fatalf("SaveSnapshot base: %v", err)
1415 }
1416 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1417 if err := s.SaveSnapshot(path); err != nil {
1418 t.Fatalf("SaveSnapshot extend: %v", err)
1419 }
1420
1421 // Another runtime rewinds the transcript below this session's baseline
1422 // (e.g. a cancelled turn truncated the partial assistant reply).
1423 other, err := LoadSession(path)
1424 if err != nil {
1425 t.Fatalf("LoadSession other: %v", err)
1426 }
1427 other.Replace(other.Snapshot()[:2])
1428 if err := other.SaveRewrite(path); err != nil {
1429 t.Fatalf("SaveRewrite rewind: %v", err)
1430 }
1431
1432 // Appending from the stale baseline would resurrect the rewound suffix.
1433 s.Add(provider.Message{Role: provider.RoleUser, Content: "two"})
1434 if err := s.SaveSnapshot(path); !errors.Is(err, ErrSessionSnapshotConflict) {
1435 t.Fatalf("SaveSnapshot over rewound transcript err = %v, want ErrSessionSnapshotConflict", err)
1436 }
1437 loaded, err := LoadSession(path)
1438 if err != nil {
1439 t.Fatalf("LoadSession after refused append: %v", err)
1440 }
1441 if got := len(loaded.Messages); got != 2 {
1442 t.Fatalf("messages after refused append = %d, want rewound 2", got)
1443 }
1444 }
1445
1446 func TestSaveRewriteAllowsOwnedRewriteAfterLedgerReset(t *testing.T) {
1447 path := schemaOneSessionPath(t, "session.jsonl")
1448 s := NewSession("sys")
1449 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1450 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "partial turn"})
1451 if err := s.SaveSnapshot(path); err != nil {
1452 t.Fatalf("SaveSnapshot base: %v", err)
1453 }
1454 // The meta sidecar (revision ledger) is lost — e.g. swept by a cleanup
1455 // that deleted session-adjacent files. The transcript itself is intact.
1456 if err := os.Remove(BranchMetaPath(path)); err != nil {
1457 t.Fatalf("remove sidecar: %v", err)
1458 }
1459
1460 // A cancelled turn strips the partial reply and flushes via SaveRewrite.
1461 msgs := s.Snapshot()
1462 s.Replace(msgs[:len(msgs)-1])
1463 if err := s.SaveRewrite(path); err != nil {
1464 t.Fatalf("SaveRewrite after ledger reset: %v", err)
1465 }
1466
1467 loaded, err := LoadSession(path)
1468 if err != nil {
1469 t.Fatalf("LoadSession truncated: %v", err)
1470 }
1471 if got := len(loaded.Messages); got != 2 {
1472 t.Fatalf("messages after owned rewrite = %d, want 2", got)
1473 }
1474 if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "first" {
1475 t.Fatalf("tail after owned rewrite = %q, want first", got)
1476 }
1477 if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1478 t.Fatalf("recovery branches after owned rewrite = %v err=%v, want none", matches, err)
1479 }
1480 }
1481
1482 func TestSaveSnapshotStillPersistsNormalizedRepair(t *testing.T) {
1483 path := filepath.Join(t.TempDir(), "session.jsonl")
1484 mal := NewSession("sys")
1485 mal.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1486 // Unanswered tool call: LoadSession backfills a placeholder result, so the
1487 // loaded history digests equal to itself while the on-disk bytes differ.
1488 mal.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "tool-1", Name: "read_file", Arguments: "{}"}}})
1489 mal.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
1490 if err := mal.Save(path); err != nil {
1491 t.Fatalf("Save malformed: %v", err)
1492 }
1493 base, ok, err := LoadBranchMeta(path)
1494 if err != nil || !ok {
1495 t.Fatalf("LoadBranchMeta base ok=%v err=%v", ok, err)
1496 }
1497
1498 loaded, err := LoadSession(path)
1499 if err != nil {
1500 t.Fatalf("LoadSession: %v", err)
1501 }
1502 if !loaded.normalizedDirty {
1503 t.Fatal("fixture did not trigger a load-time repair; adjust the malformed history")
1504 }
1505 if err := loaded.SaveSnapshot(path); err != nil {
1506 t.Fatalf("SaveSnapshot repaired history: %v", err)
1507 }
1508 repaired, ok, err := LoadBranchMeta(path)
1509 if err != nil || !ok {
1510 t.Fatalf("LoadBranchMeta repaired ok=%v err=%v", ok, err)
1511 }
1512 if repaired.Revision != base.Revision+1 {
1513 t.Fatalf("revision after repair save = %d, want %d", repaired.Revision, base.Revision+1)
1514 }
1515 reloaded, err := LoadSession(path)
1516 if err != nil {
1517 t.Fatalf("LoadSession repaired: %v", err)
1518 }
1519 if reloaded.normalizedDirty {
1520 t.Fatal("repair did not persist: reloaded session is still normalized-dirty")
1521 }
1522
1523 // With the repair on disk, the same snapshot is now a true no-op.
1524 if err := reloaded.SaveSnapshot(path); err != nil {
1525 t.Fatalf("SaveSnapshot post-repair: %v", err)
1526 }
1527 final, ok, err := LoadBranchMeta(path)
1528 if err != nil || !ok {
1529 t.Fatalf("LoadBranchMeta final ok=%v err=%v", ok, err)
1530 }
1531 if final.Revision != repaired.Revision {
1532 t.Fatalf("post-repair no-op bumped revision: %d, want %d", final.Revision, repaired.Revision)
1533 }
1534 }
1535
1536 func TestSaveSnapshotRejectsStalePrefixAfterSystemPromptRefresh(t *testing.T) {
1537 path := schemaOneSessionPath(t, "session.jsonl")
1538 current := NewSession("new sys")
1539 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1540 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1541 current.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
1542 if err := current.Save(path); err != nil {
1543 t.Fatalf("Save current: %v", err)
1544 }
1545
1546 stale := NewSession("old sys")
1547 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1548 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1549 if err := stale.SaveSnapshot(path); !errors.Is(err, ErrSessionSnapshotConflict) {
1550 t.Fatalf("SaveSnapshot stale after system refresh err = %v, want ErrSessionSnapshotConflict", err)
1551 }
1552
1553 loaded, err := LoadSession(path)
1554 if err != nil {
1555 t.Fatalf("LoadSession: %v", err)
1556 }
1557 if got := len(loaded.Messages); got != 4 {
1558 t.Fatalf("message count after stale system refresh snapshot = %d, want 4", got)
1559 }
1560 if got := loaded.Messages[3].Content; got != "second" {
1561 t.Fatalf("last message after stale system refresh snapshot = %q, want %q", got, "second")
1562 }
1563 }
1564
1565 func TestSaveRewriteAllowsRewriteOverSameContentForeignStamp(t *testing.T) {
1566 path := filepath.Join(t.TempDir(), "session.jsonl")
1567 base := NewSession("sys")
1568 base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1569 base.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1570 if err := base.Save(path); err != nil {
1571 t.Fatalf("Save base: %v", err)
1572 }
1573
1574 stale, err := LoadSession(path)
1575 if err != nil {
1576 t.Fatalf("LoadSession stale: %v", err)
1577 }
1578 // Another runtime healed the ledger over identical content: the revision
1579 // advanced under a foreign writer id, but the recorded digest still
1580 // describes the exact bytes this session loaded and owns.
1581 meta, ok, err := LoadBranchMeta(path)
1582 if err != nil || !ok {
1583 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
1584 }
1585 meta.Revision++
1586 meta.WriterID = "other-writer"
1587 if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
1588 t.Fatalf("bump revision: %v", err)
1589 }
1590
1591 stale.Replace([]provider.Message{
1592 {Role: provider.RoleSystem, Content: "sys"},
1593 {Role: provider.RoleUser, Content: "summarized first"},
1594 })
1595 if err := stale.SaveRewrite(path); err != nil {
1596 t.Fatalf("SaveRewrite over same-content stamp: %v", err)
1597 }
1598
1599 loaded, err := LoadSession(path)
1600 if err != nil {
1601 t.Fatalf("LoadSession: %v", err)
1602 }
1603 if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "summarized first" {
1604 t.Fatalf("tail after owned rewrite = %q, want summarized first", got)
1605 }
1606 advanced, ok, err := LoadBranchMeta(path)
1607 if err != nil || !ok {
1608 t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
1609 }
1610 if advanced.Revision != meta.Revision+1 {
1611 t.Fatalf("revision after rewrite = %d, want %d", advanced.Revision, meta.Revision+1)
1612 }
1613 if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1614 t.Fatalf("recovery branches after owned rewrite = %v err=%v, want none", matches, err)
1615 }
1616 }
1617
1618 func TestSaveRewriteRejectsForeignStampForUnattributedBytes(t *testing.T) {
1619 path := schemaOneSessionPath(t, "session.jsonl")
1620 base := NewSession("sys")
1621 base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1622 base.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1623 if err := base.Save(path); err != nil {
1624 t.Fatalf("Save base: %v", err)
1625 }
1626
1627 stale, err := LoadSession(path)
1628 if err != nil {
1629 t.Fatalf("LoadSession stale: %v", err)
1630 }
1631 // A foreign stamp whose digest disagrees with the on-disk transcript is
1632 // the aftermath of a save whose bytes and record split — the transcript
1633 // cannot be attributed, so the rewrite must fall to the conflict path.
1634 meta, ok, err := LoadBranchMeta(path)
1635 if err != nil || !ok {
1636 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
1637 }
1638 meta.Revision++
1639 meta.WriterID = "other-writer"
1640 meta.ContentDigest = "0000000000000000000000000000000000000000000000000000000000000000"
1641 if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
1642 t.Fatalf("stamp foreign digest: %v", err)
1643 }
1644
1645 stale.Replace([]provider.Message{
1646 {Role: provider.RoleSystem, Content: "sys"},
1647 {Role: provider.RoleUser, Content: "summarized first"},
1648 })
1649 err = stale.SaveRewrite(path)
1650 if !errors.Is(err, ErrSessionSnapshotConflict) {
1651 t.Fatalf("SaveRewrite unattributed stamp err = %v, want ErrSessionSnapshotConflict", err)
1652 }
1653 var conflict *SessionSnapshotConflictError
1654 if !errors.As(err, &conflict) || conflict.Kind != SessionSnapshotConflictDiverged {
1655 t.Fatalf("conflict = %+v, want diverged revision conflict", conflict)
1656 }
1657 if conflict.BaseRevision != meta.Revision-1 || conflict.DiskRevision != meta.Revision {
1658 t.Fatalf("conflict revisions = base %d disk %d, want %d/%d",
1659 conflict.BaseRevision, conflict.DiskRevision, meta.Revision-1, meta.Revision)
1660 }
1661
1662 loaded, err := LoadSession(path)
1663 if err != nil {
1664 t.Fatalf("LoadSession: %v", err)
1665 }
1666 if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "one" {
1667 t.Fatalf("tail after rejected rewrite = %q, want one", got)
1668 }
1669 }
1670
1671 func TestSaveSnapshotAllowsOwnedNonPrefixRewrite(t *testing.T) {
1672 path := filepath.Join(t.TempDir(), "session.jsonl")
1673 s := NewSession("sys")
1674 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1675 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1676 if err := s.Save(path); err != nil {
1677 t.Fatalf("Save base: %v", err)
1678 }
1679
1680 s.Replace([]provider.Message{
1681 {Role: provider.RoleSystem, Content: "sys"},
1682 {Role: provider.RoleUser, Content: "summarized first"},
1683 })
1684 // The persisted digest, revision, and ledger digest still describe the
1685 // exact bytes this Session wrote, so a non-prefix snapshot may safely use
1686 // the full-rewrite path without creating a recovery branch.
1687 if err := s.SaveSnapshot(path); err != nil {
1688 t.Fatalf("SaveSnapshot owned non-prefix rewrite: %v", err)
1689 }
1690
1691 loaded, err := LoadSession(path)
1692 if err != nil {
1693 t.Fatalf("LoadSession: %v", err)
1694 }
1695 if got := len(loaded.Messages); got != 2 {
1696 t.Fatalf("message count after accepted snapshot rewrite = %d, want 2", got)
1697 }
1698 if got := loaded.Messages[1].Content; got != "summarized first" {
1699 t.Fatalf("rewritten content = %q, want %q", got, "summarized first")
1700 }
1701 }
1702
1703 func TestSaveSnapshotRejectsInterruptedForeignWriteAtSameRevision(t *testing.T) {
1704 path := schemaOneSessionPath(t, "session.jsonl")
1705 base := NewSession("sys")
1706 base.Add(provider.Message{Role: provider.RoleUser, Content: "base"})
1707 if err := base.Save(path); err != nil {
1708 t.Fatalf("Save base: %v", err)
1709 }
1710
1711 stale, err := LoadSession(path)
1712 if err != nil {
1713 t.Fatalf("LoadSession stale: %v", err)
1714 }
1715 revision, _, err := sessionContentRevision(path)
1716 if err != nil {
1717 t.Fatalf("sessionContentRevision: %v", err)
1718 }
1719
1720 foreignMessages := append(stale.Snapshot(),
1721 provider.Message{Role: provider.RoleAssistant, Content: "foreign writer tail"})
1722 foreignDigest, err := digestSessionMessages(foreignMessages)
1723 if err != nil {
1724 t.Fatalf("digest foreign messages: %v", err)
1725 }
1726 if err := appendSessionReplaceEvent(path, foreignMessages, foreignDigest, revision, "snapshot"); err != nil {
1727 t.Fatalf("append interrupted foreign event: %v", err)
1728 }
1729 if err := writeSessionMessages(path, foreignMessages); err != nil {
1730 t.Fatalf("write interrupted foreign checkpoint: %v", err)
1731 }
1732 // Simulate a crash before recordSessionContentRevision: the transcript and
1733 // event log changed, but the revision still equals stale's baseline.
1734
1735 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "stale writer tail"})
1736 err = stale.SaveSnapshot(path)
1737 if !errors.Is(err, ErrSessionSnapshotConflict) {
1738 t.Fatalf("SaveSnapshot err = %v, want ErrSessionSnapshotConflict", err)
1739 }
1740
1741 loaded, err := LoadSession(path)
1742 if err != nil {
1743 t.Fatalf("LoadSession final: %v", err)
1744 }
1745 if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "foreign writer tail" {
1746 t.Fatalf("foreign tail after rejected snapshot = %q, want preserved", got)
1747 }
1748 }
1749
1750 func TestSaveRewriteAllowsOwnedNonPrefixRewrite(t *testing.T) {
1751 path := filepath.Join(t.TempDir(), "session.jsonl")
1752 s := NewSession("sys")
1753 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1754 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1755 if err := s.Save(path); err != nil {
1756 t.Fatalf("Save base: %v", err)
1757 }
1758
1759 s.Replace([]provider.Message{
1760 {Role: provider.RoleSystem, Content: "sys"},
1761 {Role: provider.RoleUser, Content: "summarized first"},
1762 })
1763 if err := s.SaveRewrite(path); err != nil {
1764 t.Fatalf("SaveRewrite owned rewrite: %v", err)
1765 }
1766
1767 loaded, err := LoadSession(path)
1768 if err != nil {
1769 t.Fatalf("LoadSession: %v", err)
1770 }
1771 if got := len(loaded.Messages); got != 2 {
1772 t.Fatalf("message count after rewrite = %d, want 2", got)
1773 }
1774 if got := loaded.Messages[1].Content; got != "summarized first" {
1775 t.Fatalf("rewritten content = %q, want %q", got, "summarized first")
1776 }
1777 }
1778
1779 func TestCloneWithMessagesPreservesRewriteBaseline(t *testing.T) {
1780 path := filepath.Join(t.TempDir(), "session.jsonl")
1781 s := NewSession("old sys")
1782 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1783 s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "tool-1", Name: "read_file", Arguments: "{}"}}})
1784 s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "tool-1", Name: "read_file", Content: strings.Repeat("detail ", 100)})
1785 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
1786 if err := s.Save(path); err != nil {
1787 t.Fatalf("Save base: %v", err)
1788 }
1789
1790 loaded, err := LoadSession(path)
1791 if err != nil {
1792 t.Fatalf("LoadSession: %v", err)
1793 }
1794 msgs := loaded.Snapshot()
1795 msgs[0].Content = "new sys"
1796 msgs[3].Content = "[elided tool result]"
1797 resumed := loaded.CloneWithMessages(msgs)
1798 if err := resumed.SaveRewrite(path); err != nil {
1799 t.Fatalf("SaveRewrite cloned resume rewrite: %v", err)
1800 }
1801
1802 reloaded, err := LoadSession(path)
1803 if err != nil {
1804 t.Fatalf("LoadSession rewritten: %v", err)
1805 }
1806 if got := reloaded.Messages[0].Content; got != "new sys" {
1807 t.Fatalf("system prompt after rewrite = %q, want new sys", got)
1808 }
1809 if got := reloaded.Messages[3].Content; got != "[elided tool result]" {
1810 t.Fatalf("tool result after rewrite = %q, want elided", got)
1811 }
1812 }
1813
1814 func TestCloneWithMessagesIfCompatibleRejectsHistoryChanges(t *testing.T) {
1815 path := filepath.Join(t.TempDir(), "session.jsonl")
1816 s := NewSession("old sys")
1817 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1818 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1819 if err := s.Save(path); err != nil {
1820 t.Fatalf("Save base: %v", err)
1821 }
1822
1823 loaded, err := LoadSession(path)
1824 if err != nil {
1825 t.Fatalf("LoadSession: %v", err)
1826 }
1827 systemOnly := loaded.Snapshot()
1828 systemOnly[0].Content = "new sys"
1829 if _, ok := loaded.CloneWithMessagesIfCompatible(systemOnly); !ok {
1830 t.Fatal("system-only change should be compatible")
1831 }
1832
1833 changed := loaded.Snapshot()
1834 changed[2].Content = "rewritten assistant"
1835 if _, ok := loaded.CloneWithMessagesIfCompatible(changed); ok {
1836 t.Fatal("non-system history change should not preserve baseline")
1837 }
1838 }
1839
1840 func TestSaveRewriteRejectsStalePrefixOverwrite(t *testing.T) {
1841 path := schemaOneSessionPath(t, "session.jsonl")
1842 current := NewSession("sys")
1843 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1844 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1845 current.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
1846 if err := current.Save(path); err != nil {
1847 t.Fatalf("Save current: %v", err)
1848 }
1849
1850 stale := NewSession("sys")
1851 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1852 if err := stale.SaveRewrite(path); !errors.Is(err, ErrSessionSnapshotConflict) {
1853 t.Fatalf("SaveRewrite stale err = %v, want ErrSessionSnapshotConflict", err)
1854 }
1855
1856 loaded, err := LoadSession(path)
1857 if err != nil {
1858 t.Fatalf("LoadSession: %v", err)
1859 }
1860 if got := len(loaded.Messages); got != 4 {
1861 t.Fatalf("message count after stale rewrite = %d, want 4", got)
1862 }
1863 if got := loaded.Messages[3].Content; got != "second" {
1864 t.Fatalf("last message after stale rewrite = %q, want %q", got, "second")
1865 }
1866 }
1867
1868 func TestSaveRecoveryBranchPersistsDivergedSnapshot(t *testing.T) {
1869 path := schemaOneSessionPath(t, "session.jsonl")
1870 current := NewSession("sys")
1871 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1872 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1873 current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
1874 if err := current.Save(path); err != nil {
1875 t.Fatalf("Save current: %v", err)
1876 }
1877
1878 stale := NewSession("sys")
1879 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1880 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1881 stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
1882 if err := stale.SaveSnapshot(path); !errors.Is(err, ErrSessionSnapshotConflict) {
1883 t.Fatalf("SaveSnapshot stale err = %v, want ErrSessionSnapshotConflict", err)
1884 }
1885
1886 info, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
1887 if err != nil {
1888 t.Fatalf("SaveRecoveryBranch: %v", err)
1889 }
1890 if info.Path == "" || info.Path == path {
1891 t.Fatalf("recovery path = %q, want distinct path", info.Path)
1892 }
1893 if info.Turns != 2 || info.Preview != "first" {
1894 t.Fatalf("recovery preview/turns = %q/%d, want first/2", info.Preview, info.Turns)
1895 }
1896 recovered, err := LoadSession(info.Path)
1897 if err != nil {
1898 t.Fatalf("LoadSession recovery: %v", err)
1899 }
1900 if got := recovered.Messages[len(recovered.Messages)-1].Content; got != "local second" {
1901 t.Fatalf("recovery tail = %q, want local second", got)
1902 }
1903 original, err := LoadSession(path)
1904 if err != nil {
1905 t.Fatalf("LoadSession original: %v", err)
1906 }
1907 if got := original.Messages[len(original.Messages)-1].Content; got != "disk second" {
1908 t.Fatalf("original tail = %q, want disk second", got)
1909 }
1910 meta, ok, err := LoadBranchMeta(info.Path)
1911 if err != nil || !ok {
1912 t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
1913 }
1914 if !meta.Recovered || meta.ParentID != BranchID(path) || meta.Name != RecoveryBranchDefaultName {
1915 t.Fatalf("recovery meta = %+v, want recovered parent/name", meta)
1916 }
1917 if meta.RecoveryDigest == "" || meta.SchemaVersion != BranchMetaCountsVersion {
1918 t.Fatalf("recovery digest/schema = %q/%d", meta.RecoveryDigest, meta.SchemaVersion)
1919 }
1920 if meta.Revision != 1 || meta.ContentDigest != meta.RecoveryDigest || meta.WriterID == "" {
1921 t.Fatalf("recovery persistence meta = %+v, want revision/content digest/writer", meta)
1922 }
1923 }
1924
1925 func TestSaveSnapshotRefusesToRecreateExternallyRemovedBaseline(t *testing.T) {
1926 dir := schemaOneTempDir(t)
1927 path := filepath.Join(dir, "removed.jsonl")
1928 s := NewSession("sys")
1929 s.Add(provider.Message{Role: provider.RoleUser, Content: "keep me"})
1930 if err := s.SaveSnapshot(path); err != nil {
1931 t.Fatal(err)
1932 }
1933 for _, artifact := range append([]string{path}, store.SessionSidecarFiles(path)...) {
1934 if err := os.RemoveAll(artifact); err != nil {
1935 t.Fatal(err)
1936 }
1937 }
1938 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "still in memory"})
1939 if err := s.SaveSnapshot(path); !errors.Is(err, ErrSessionExternallyRemoved) {
1940 t.Fatalf("SaveSnapshot error = %v, want ErrSessionExternallyRemoved", err)
1941 }
1942 if _, err := os.Stat(path); !os.IsNotExist(err) {
1943 t.Fatalf("removed path was silently recreated: %v", err)
1944 }
1945 }
1946
1947 func TestSaveRecoveryBranchSkipsPureStalePrefix(t *testing.T) {
1948 path := filepath.Join(t.TempDir(), "session.jsonl")
1949 current := NewSession("sys")
1950 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1951 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1952 current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
1953 if err := current.Save(path); err != nil {
1954 t.Fatalf("Save current: %v", err)
1955 }
1956
1957 stale := NewSession("sys")
1958 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1959 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1960 if _, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path}); !errors.Is(err, ErrSessionRecoveryNotNeeded) {
1961 t.Fatalf("SaveRecoveryBranch stale prefix err = %v, want ErrSessionRecoveryNotNeeded", err)
1962 }
1963 }
1964
1965 func divergedSessionPair(t *testing.T, dir, name string) (string, *Session) {
1966 t.Helper()
1967 path := filepath.Join(dir, name)
1968 current := NewSession("sys")
1969 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1970 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1971 current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
1972 if err := current.Save(path); err != nil {
1973 t.Fatalf("Save current: %v", err)
1974 }
1975 stale := NewSession("sys")
1976 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1977 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1978 stale.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name})
1979 return path, stale
1980 }
1981
1982 func stampRecoveryMeta(t *testing.T, path string, depth int) {
1983 t.Helper()
1984 meta, ok, err := LoadBranchMeta(path)
1985 if err != nil || !ok {
1986 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
1987 }
1988 meta.Recovered = true
1989 meta.RecoveryDepth = depth
1990 if err := SaveBranchMeta(path, meta); err != nil {
1991 t.Fatalf("SaveBranchMeta: %v", err)
1992 }
1993 }
1994
1995 func TestSaveRecoveryBranchDedupesByDigest(t *testing.T) {
1996 path := filepath.Join(t.TempDir(), "session.jsonl")
1997 current := NewSession("sys")
1998 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1999 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
2000 if err := current.Save(path); err != nil {
2001 t.Fatalf("Save current: %v", err)
2002 }
2003
2004 stale := NewSession("sys")
2005 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2006 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
2007 first, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
2008 if err != nil {
2009 t.Fatalf("first SaveRecoveryBranch: %v", err)
2010 }
2011 second, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
2012 if err != nil {
2013 t.Fatalf("second SaveRecoveryBranch: %v", err)
2014 }
2015 if second.Path != first.Path || !second.Existing {
2016 t.Fatalf("second recovery = %+v, want existing same path %q", second, first.Path)
2017 }
2018 }
2019
2020 func TestSaveRecoveryBranchCompactsLongParentFilename(t *testing.T) {
2021 dir := t.TempDir()
2022 parentID := strings.Repeat("longparent-", 22)
2023 path := filepath.Join(dir, parentID+".jsonl")
2024 current := NewSession("sys")
2025 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2026 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
2027 if err := current.Save(path); err != nil {
2028 t.Fatalf("Save current: %v", err)
2029 }
2030
2031 stale := NewSession("sys")
2032 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2033 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
2034 info, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
2035 if err != nil {
2036 t.Fatalf("SaveRecoveryBranch: %v", err)
2037 }
2038 base := filepath.Base(info.Path)
2039 if len(base) > 140 {
2040 t.Fatalf("recovery basename length = %d (%q), want bounded", len(base), base)
2041 }
2042 for _, suffix := range []string{".lock", ".lease.lock", ".lease.json", ".meta"} {
2043 if len(base+suffix) > 255 {
2044 t.Fatalf("recovery sidecar basename %q length = %d, want <= 255", base+suffix, len(base+suffix))
2045 }
2046 }
2047 meta, ok, err := LoadBranchMeta(info.Path)
2048 if err != nil || !ok {
2049 t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
2050 }
2051 if meta.ParentID != BranchID(path) {
2052 t.Fatalf("recovery parent = %q, want original branch id", meta.ParentID)
2053 }
2054 }
2055
2056 func TestSaveRecoveryBranchDoesNotCascadeRecoveryFilename(t *testing.T) {
2057 dir := t.TempDir()
2058 path := filepath.Join(dir, "session.jsonl")
2059 current := NewSession("sys")
2060 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2061 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
2062 if err := current.Save(path); err != nil {
2063 t.Fatalf("Save current: %v", err)
2064 }
2065
2066 local := NewSession("sys")
2067 local.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2068 local.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
2069 first, err := local.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
2070 if err != nil {
2071 t.Fatalf("first SaveRecoveryBranch: %v", err)
2072 }
2073
2074 recoveryDisk, err := LoadSession(first.Path)
2075 if err != nil {
2076 t.Fatalf("LoadSession recovery: %v", err)
2077 }
2078 recoveryDisk.Add(provider.Message{Role: provider.RoleUser, Content: "disk follow-up"})
2079 if err := recoveryDisk.SaveSnapshot(first.Path); err != nil {
2080 t.Fatalf("SaveSnapshot recovery disk: %v", err)
2081 }
2082 recoveryLocal := NewSession("sys")
2083 recoveryLocal.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2084 recoveryLocal.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
2085 recoveryLocal.Add(provider.Message{Role: provider.RoleUser, Content: "local follow-up"})
2086
2087 second, err := recoveryLocal.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: first.Path})
2088 if err != nil {
2089 t.Fatalf("second SaveRecoveryBranch: %v", err)
2090 }
2091 base := filepath.Base(second.Path)
2092 if count := strings.Count(base, "-recovery-"); count != 1 {
2093 t.Fatalf("recovery basename = %q, contains %d recovery markers, want 1", base, count)
2094 }
2095 if len(base) > 140 {
2096 t.Fatalf("recovery basename length = %d (%q), want bounded", len(base), base)
2097 }
2098 }
2099
2100 // TestSaveSnapshotSameRevisionAllowsOwnedNonPrefixAppend reproduces the scenario from
2101 // #6948: a recovery branch whose snapshot saves systematically diverged because
2102 // checkSnapshotWrite's byte-level prefix comparison failed on messages carrying
2103 // local-only metadata (LocalOnly + interrupted_turn) that survived JSON round-trip
2104 // with subtle differences. The persisted digest proves that the disk still holds
2105 // this Session's baseline, so the save proceeds as a full rewrite instead of
2106 // forking another recovery branch.
2107 func TestSaveSnapshotSameRevisionAllowsOwnedNonPrefixAppend(t *testing.T) {
2108 dir := t.TempDir()
2109 path := filepath.Join(dir, "session.jsonl")
2110
2111 // Simulate a mid-turn snapshot that captured an interrupted tool turn.
2112 // The LocalOnly + interrupted_turn metadata is the byte-level difference
2113 // that makes the normalized disk view diverge from the in-memory snapshot.
2114 s0 := NewSession("sys")
2115 s0.Add(provider.Message{Role: provider.RoleUser, Content: "edit file"})
2116 s0.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok", ToolCalls: []provider.ToolCall{
2117 {ID: "call_1", Name: "edit", Arguments: `{"file":"f","old":"a","new":"b"}`},
2118 }})
2119 s0.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "call_1", Name: "edit",
2120 Content: "edited f (+1 -1)", WorkDurationMs: 1234})
2121 // Simulate interrupted turn: mid-turn snapshot with LocalOnly recovery placeholder.
2122 s0.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "call_2", Name: "bash",
2123 LocalOnly: true, WorkDurationMs: 0,
2124 InterruptedTurn: &provider.InterruptedTurnRecovery{
2125 Pending: true, CompletedTools: []provider.InterruptedToolSummary{
2126 {ID: "call_1", Name: "edit", Added: 1, Removed: 1},
2127 },
2128 },
2129 })
2130 if err := s0.Save(path); err != nil {
2131 t.Fatalf("Save base: %v", err)
2132 }
2133
2134 // Simulate recovery: load the saved transcript into a new session, then
2135 // add more messages. Every subsequent SaveSnapshot must succeed without a
2136 // diverged conflict while the persisted digest still proves ownership.
2137 for turn := range 5 {
2138 s, err := LoadSession(path)
2139 if err != nil {
2140 t.Fatalf("LoadSession turn %d: %v", turn, err)
2141 }
2142 s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("msg %d", turn)})
2143 s.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("reply %d", turn)})
2144 if err := s.SaveSnapshot(path); err != nil {
2145 t.Fatalf("SaveSnapshot turn %d: %v", turn, err)
2146 }
2147 }
2148
2149 // Final transcript must contain all turns.
2150 final, err := LoadSession(path)
2151 if err != nil {
2152 t.Fatalf("LoadSession final: %v", err)
2153 }
2154 got := len(final.Messages)
2155 // base: sys + user + asst(tc) + tool + LocalOnly = 5, + 5*2 = 15
2156 if got < 10 {
2157 t.Fatalf("final message count = %d, want >= 10", got)
2158 }
2159 }
2160
2161 func TestReconcileSessionSidecarsRemovesUnlockedArtifacts(t *testing.T) {
2162 dir := t.TempDir()
2163 path := filepath.Join(dir, "session.jsonl")
2164 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
2165 t.Fatal(err)
2166 }
2167 for _, sidecar := range []string{path + ".lock", path + ".lease.lock", path + ".lease.json"} {
2168 if err := os.WriteFile(sidecar, []byte("{}\n"), 0o644); err != nil {
2169 t.Fatalf("write %s: %v", sidecar, err)
2170 }
2171 }
2172
2173 if err := ReconcileSessionSidecars(dir); err != nil {
2174 t.Fatalf("ReconcileSessionSidecars: %v", err)
2175 }
2176 if _, err := os.Stat(path); err != nil {
2177 t.Fatalf("session transcript removed: %v", err)
2178 }
2179 for _, sidecar := range []string{path + ".lock", path + ".lease.lock", path + ".lease.json"} {
2180 if _, err := os.Stat(sidecar); !os.IsNotExist(err) {
2181 t.Fatalf("%s exists after sidecar cleanup (err=%v)", sidecar, err)
2182 }
2183 }
2184 }
2185
2186 func TestReconcileSessionSidecarsKeepsLiveLocks(t *testing.T) {
2187 dir := t.TempDir()
2188 path := filepath.Join(dir, "session.jsonl")
2189 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
2190 t.Fatal(err)
2191 }
2192 unlock, err := lockSessionFile(path)
2193 if err != nil {
2194 t.Fatalf("lockSessionFile: %v", err)
2195 }
2196 defer unlock()
2197 lease, err := TryAcquireSessionLease(path)
2198 if err != nil {
2199 t.Fatalf("TryAcquireSessionLease: %v", err)
2200 }
2201 defer lease.Release()
2202
2203 if err := ReconcileSessionSidecars(dir); err != nil {
2204 t.Fatalf("ReconcileSessionSidecars: %v", err)
2205 }
2206 for _, sidecar := range []string{path + ".lock", path + ".lease.lock"} {
2207 if _, err := os.Stat(sidecar); err != nil {
2208 t.Fatalf("%s missing while lock is live: %v", sidecar, err)
2209 }
2210 }
2211 }
2212
2213 // TestReconcileSessionSidecarsKeepsFlockOnlyLocks proves the file lock alone
2214 // protects a writer from cleanup: CLI-style savers hold the .lock flock while
2215 // writing without ever taking a session lease.
2216 func TestReconcileSessionSidecarsKeepsFlockOnlyLocks(t *testing.T) {
2217 dir := t.TempDir()
2218 path := filepath.Join(dir, "session.jsonl")
2219 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
2220 t.Fatal(err)
2221 }
2222 unlock, err := lockSessionFile(path)
2223 if err != nil {
2224 t.Fatalf("lockSessionFile: %v", err)
2225 }
2226 if err := ReconcileSessionSidecars(dir); err != nil {
2227 t.Fatalf("ReconcileSessionSidecars: %v", err)
2228 }
2229 if _, err := os.Stat(path + ".lock"); err != nil {
2230 t.Fatalf(".lock removed while a lock-only writer holds it: %v", err)
2231 }
2232 unlock()
2233 if err := ReconcileSessionSidecars(dir); err != nil {
2234 t.Fatalf("ReconcileSessionSidecars after unlock: %v", err)
2235 }
2236 if _, err := os.Stat(path + ".lock"); !os.IsNotExist(err) {
2237 t.Fatalf(".lock survived cleanup after release (err=%v)", err)
2238 }
2239 }
2240
2241 // TestReconcileSessionSidecarsRenamesOverlongSessionFiles covers the
2242 // migration for transcripts left behind by the unbounded recovery cascade
2243 // (#5923): names so long their lock/lease sidecars could not be created. The
2244 // conversation bytes must survive under a bounded name, branch meta must move
2245 // with its ID rewritten, and children must be re-parented onto the new ID.
2246 func TestReconcileSessionSidecarsRenamesOverlongSessionFiles(t *testing.T) {
2247 dir := t.TempDir()
2248 longID := strings.Repeat("p", 240) // 246-byte basename: .lock fits, .lease.lock does not
2249 hugeID := strings.Repeat("q", 248) // 254-byte basename: no sidecar fits at all
2250 oldLong := filepath.Join(dir, longID+".jsonl")
2251 oldHuge := filepath.Join(dir, hugeID+".jsonl")
2252 content := `{"role":"system","content":"sys"}` + "\n" + `{"role":"user","content":"hello"}` + "\n"
2253 for _, p := range []string{oldLong, oldHuge} {
2254 if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
2255 t.Fatal(err)
2256 }
2257 }
2258 if err := os.WriteFile(oldLong+".lock", []byte("{}\n"), 0o644); err != nil {
2259 t.Fatal(err)
2260 }
2261 if err := SaveBranchMeta(oldLong, BranchMeta{Name: "长会话", ParentID: "root-branch"}); err != nil {
2262 t.Fatalf("SaveBranchMeta: %v", err)
2263 }
2264 childPath := filepath.Join(dir, "child.jsonl")
2265 if err := os.WriteFile(childPath, []byte(content), 0o644); err != nil {
2266 t.Fatal(err)
2267 }
2268 if err := SaveBranchMeta(childPath, BranchMeta{Name: "child", ParentID: longID}); err != nil {
2269 t.Fatalf("SaveBranchMeta child: %v", err)
2270 }
2271
2272 if err := ReconcileSessionSidecars(dir); err != nil {
2273 t.Fatalf("ReconcileSessionSidecars: %v", err)
2274 }
2275
2276 for _, gone := range []string{oldLong, oldHuge, oldLong + ".lock", oldLong + ".meta"} {
2277 if _, err := os.Stat(gone); !os.IsNotExist(err) {
2278 t.Fatalf("%s still present after rename (err=%v)", filepath.Base(gone), err)
2279 }
2280 }
2281 newLongID := recoveryParentStem(longID)
2282 newLong := filepath.Join(dir, newLongID+".jsonl")
2283 newHuge := filepath.Join(dir, recoveryParentStem(hugeID)+".jsonl")
2284 for _, p := range []string{newLong, newHuge} {
2285 if base := filepath.Base(p); len(base) > maxSessionBasenameBytes {
2286 t.Fatalf("renamed basename %q length %d exceeds bound %d", base, len(base), maxSessionBasenameBytes)
2287 }
2288 b, err := os.ReadFile(p)
2289 if err != nil {
2290 t.Fatalf("read renamed transcript: %v", err)
2291 }
2292 if string(b) != content {
2293 t.Fatalf("transcript content changed by rename: %q", b)
2294 }
2295 }
2296 meta, ok, err := LoadBranchMeta(newLong)
2297 if err != nil || !ok {
2298 t.Fatalf("LoadBranchMeta renamed ok=%v err=%v", ok, err)
2299 }
2300 if meta.ID != newLongID {
2301 t.Fatalf("migrated meta ID = %q, want %q", meta.ID, newLongID)
2302 }
2303 if meta.Name != "长会话" || meta.ParentID != "root-branch" {
2304 t.Fatalf("migrated meta lost fields: %+v", meta)
2305 }
2306 childMeta, ok, err := LoadBranchMeta(childPath)
2307 if err != nil || !ok {
2308 t.Fatalf("LoadBranchMeta child ok=%v err=%v", ok, err)
2309 }
2310 if childMeta.ParentID != newLongID {
2311 t.Fatalf("child ParentID = %q, want re-parented %q", childMeta.ParentID, newLongID)
2312 }
2313
2314 before, err := filepath.Glob(filepath.Join(dir, "*"))
2315 if err != nil {
2316 t.Fatal(err)
2317 }
2318 if err := ReconcileSessionSidecars(dir); err != nil {
2319 t.Fatalf("ReconcileSessionSidecars rerun: %v", err)
2320 }
2321 after, err := filepath.Glob(filepath.Join(dir, "*"))
2322 if err != nil {
2323 t.Fatal(err)
2324 }
2325 if len(before) != len(after) {
2326 t.Fatalf("rerun changed the directory: before=%d entries, after=%d", len(before), len(after))
2327 }
2328 }
2329
2330 // TestReconcileOverlongRenameStillReparentsWhenSidecarMigrationFails pins the
2331 // point-of-no-return contract: once the transcript rename lands, the mapping
2332 // must be committed — children re-parented, error surfaced as a warning —
2333 // because the old name is gone and no later run can reconstruct it.
2334 func TestReconcileOverlongRenameStillReparentsWhenSidecarMigrationFails(t *testing.T) {
2335 dir := t.TempDir()
2336 longID := strings.Repeat("m", 240)
2337 oldPath := filepath.Join(dir, longID+".jsonl")
2338 content := `{"role":"user","content":"hello"}` + "\n"
2339 if err := os.WriteFile(oldPath, []byte(content), 0o644); err != nil {
2340 t.Fatal(err)
2341 }
2342 if err := SaveBranchMeta(oldPath, BranchMeta{Name: "keep", ParentID: "root"}); err != nil {
2343 t.Fatalf("SaveBranchMeta: %v", err)
2344 }
2345 childPath := filepath.Join(dir, "child.jsonl")
2346 if err := os.WriteFile(childPath, []byte(content), 0o644); err != nil {
2347 t.Fatal(err)
2348 }
2349 if err := SaveBranchMeta(childPath, BranchMeta{Name: "child", ParentID: longID}); err != nil {
2350 t.Fatalf("SaveBranchMeta child: %v", err)
2351 }
2352
2353 newID := recoveryParentStem(longID)
2354 newPath := filepath.Join(dir, newID+".jsonl")
2355 // Sabotage the meta migration: its destination path is a directory.
2356 if err := os.Mkdir(newPath+".meta", 0o755); err != nil {
2357 t.Fatal(err)
2358 }
2359
2360 if err := ReconcileSessionSidecars(dir); err == nil {
2361 t.Fatal("expected the sabotaged meta migration to surface an error")
2362 }
2363 if _, err := os.Stat(newPath); err != nil {
2364 t.Fatalf("renamed transcript missing after partial failure: %v", err)
2365 }
2366 if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
2367 t.Fatalf("old transcript still present (err=%v)", err)
2368 }
2369 childMeta, ok, err := LoadBranchMeta(childPath)
2370 if err != nil || !ok {
2371 t.Fatalf("LoadBranchMeta child ok=%v err=%v", ok, err)
2372 }
2373 if childMeta.ParentID != newID {
2374 t.Fatalf("child ParentID = %q, want %q despite sidecar failure", childMeta.ParentID, newID)
2375 }
2376 // The old meta stays behind as the durable copy of the un-migrated fields.
2377 if _, err := os.Stat(oldPath + ".meta"); err != nil {
2378 t.Fatalf("old meta lost though its migration failed: %v", err)
2379 }
2380 }
2381
2382 // TestListSessionsOrdersByMTime makes sure the picker shows the most
2383 // recently used conversation first — that's what users reach for when they
2384 // hit `reasonix --continue`.
2385 func TestListSessionsOrdersByMTime(t *testing.T) {
2386 dir := t.TempDir()
2387 // Write two sessions with explicit mtimes so the order is deterministic.
2388 for _, name := range []string{"a.jsonl", "b.jsonl"} {
2389 s := NewSession("")
2390 s.Add(provider.Message{Role: provider.RoleUser, Content: "preview for " + name})
2391 if err := s.Save(filepath.Join(dir, name)); err != nil {
2392 t.Fatal(err)
2393 }
2394 }
2395 oldT := time.Now().Add(-1 * time.Hour)
2396 newT := time.Now()
2397 if err := touch(filepath.Join(dir, "a.jsonl"), oldT); err != nil {
2398 t.Fatal(err)
2399 }
2400 if err := touch(filepath.Join(dir, "b.jsonl"), newT); err != nil {
2401 t.Fatal(err)
2402 }
2403
2404 got, err := ListSessions(dir)
2405 if err != nil {
2406 t.Fatal(err)
2407 }
2408 if len(got) != 2 {
2409 t.Fatalf("len = %d, want 2", len(got))
2410 }
2411 if !strings.HasSuffix(got[0].Path, "b.jsonl") {
2412 t.Errorf("first entry = %s, want the newer 'b.jsonl'", got[0].Path)
2413 }
2414 if got[0].Turns != 1 || got[0].Preview != "preview for b.jsonl" {
2415 t.Errorf("preview/turns wrong on newest: turns=%d preview=%q", got[0].Turns, got[0].Preview)
2416 }
2417 }
2418
2419 func TestListSessionsIncludesCustomTitle(t *testing.T) {
2420 dir := t.TempDir()
2421 path := filepath.Join(dir, "named.jsonl")
2422 s := NewSession("")
2423 s.Add(provider.Message{Role: provider.RoleUser, Content: "first user prompt"})
2424 if err := s.Save(path); err != nil {
2425 t.Fatal(err)
2426 }
2427 if err := SaveBranchMetaPreserveUpdated(path, BranchMeta{
2428 TopicTitle: "Topic title",
2429 CustomTitle: "Custom session title",
2430 Preview: "first user prompt",
2431 Turns: 1,
2432 SchemaVersion: BranchMetaCountsVersion,
2433 }); err != nil {
2434 t.Fatalf("SaveBranchMetaPreserveUpdated: %v", err)
2435 }
2436
2437 got, err := ListSessions(dir)
2438 if err != nil {
2439 t.Fatal(err)
2440 }
2441 if len(got) != 1 {
2442 t.Fatalf("len = %d, want 1", len(got))
2443 }
2444 if got[0].CustomTitle != "Custom session title" {
2445 t.Fatalf("custom title = %q, want Custom session title", got[0].CustomTitle)
2446 }
2447 if got[0].TopicTitle != "Topic title" {
2448 t.Fatalf("topic title = %q, want Topic title", got[0].TopicTitle)
2449 }
2450 }
2451
2452 func TestListSessionsSkipsCleanupPending(t *testing.T) {
2453 dir := t.TempDir()
2454 path := filepath.Join(dir, "pending.jsonl")
2455 s := NewSession("")
2456 s.Add(provider.Message{Role: provider.RoleUser, Content: "preview"})
2457 if err := s.Save(path); err != nil {
2458 t.Fatal(err)
2459 }
2460 if err := MarkCleanupPending(path, "delete"); err != nil {
2461 t.Fatal(err)
2462 }
2463 if !IsCleanupPending(path) {
2464 t.Fatal("session should be marked cleanup-pending")
2465 }
2466
2467 got, err := ListSessions(dir)
2468 if err != nil {
2469 t.Fatal(err)
2470 }
2471 if len(got) != 0 {
2472 t.Fatalf("cleanup-pending session should be hidden, got %+v", got)
2473 }
2474
2475 if err := ClearCleanupPending(path); err != nil {
2476 t.Fatal(err)
2477 }
2478 got, err = ListSessions(dir)
2479 if err != nil {
2480 t.Fatal(err)
2481 }
2482 if len(got) != 1 || got[0].Path != path {
2483 t.Fatalf("session should be visible after clearing marker, got %+v", got)
2484 }
2485 }
2486
2487 func TestListSessionsOrdersByLastActivityMeta(t *testing.T) {
2488 dir := t.TempDir()
2489 aPath := filepath.Join(dir, "a.jsonl")
2490 bPath := filepath.Join(dir, "b.jsonl")
2491 for _, path := range []string{aPath, bPath} {
2492 s := NewSession("")
2493 s.Add(provider.Message{Role: provider.RoleUser, Content: "preview for " + filepath.Base(path)})
2494 if err := s.Save(path); err != nil {
2495 t.Fatal(err)
2496 }
2497 }
2498
2499 now := time.Now().UTC()
2500 olderActivity := now.Add(-2 * time.Hour)
2501 newerActivity := now.Add(-1 * time.Hour)
2502 writeBranchMeta(t, aPath, now.Add(-24*time.Hour), newerActivity)
2503 writeBranchMeta(t, bPath, now.Add(-24*time.Hour), olderActivity)
2504 if err := touch(aPath, now.Add(-3*time.Hour)); err != nil {
2505 t.Fatal(err)
2506 }
2507 if err := touch(bPath, now); err != nil {
2508 t.Fatal(err)
2509 }
2510
2511 got, err := ListSessions(dir)
2512 if err != nil {
2513 t.Fatal(err)
2514 }
2515 if len(got) != 2 {
2516 t.Fatalf("len = %d, want 2", len(got))
2517 }
2518 if got[0].Path != aPath {
2519 t.Fatalf("first entry = %s, want activity-newer a.jsonl despite older file mtime", got[0].Path)
2520 }
2521 if !got[0].LastActivityAt.Equal(newerActivity) || !got[0].ModTime.Equal(newerActivity) {
2522 t.Fatalf("activity fields = %s / %s, want %s", got[0].LastActivityAt, got[0].ModTime, newerActivity)
2523 }
2524 }
2525
2526 func TestListSessionOrderIncludesEmptySessionsWithoutPreviewScan(t *testing.T) {
2527 dir := t.TempDir()
2528 emptyPath := filepath.Join(dir, "empty.jsonl")
2529 realPath := filepath.Join(dir, "real.jsonl")
2530 if err := os.WriteFile(emptyPath, nil, 0o644); err != nil {
2531 t.Fatal(err)
2532 }
2533 s := NewSession("")
2534 s.Add(provider.Message{Role: provider.RoleUser, Content: "real prompt"})
2535 if err := s.Save(realPath); err != nil {
2536 t.Fatal(err)
2537 }
2538
2539 now := time.Now().UTC()
2540 writeBranchMeta(t, emptyPath, now, now.Add(time.Hour))
2541 writeBranchMeta(t, realPath, now, now)
2542
2543 ordered, err := ListSessionOrder(dir)
2544 if err != nil {
2545 t.Fatal(err)
2546 }
2547 if len(ordered) != 2 {
2548 t.Fatalf("lightweight order len = %d, want 2", len(ordered))
2549 }
2550 if ordered[0].Path != emptyPath {
2551 t.Fatalf("lightweight order first = %s, want newer empty session %s", ordered[0].Path, emptyPath)
2552 }
2553
2554 listed, err := ListSessions(dir)
2555 if err != nil {
2556 t.Fatal(err)
2557 }
2558 if len(listed) != 1 || listed[0].Path != realPath {
2559 t.Fatalf("ListSessions = %+v, want only the non-empty real session", listed)
2560 }
2561 }
2562
2563 func TestSessionListingsExposeRecoveryMetadata(t *testing.T) {
2564 dir := t.TempDir()
2565 path := filepath.Join(dir, "recovered.jsonl")
2566 s := NewSession("")
2567 s.Add(provider.Message{Role: provider.RoleUser, Content: "continued recovery"})
2568 if err := s.Save(path); err != nil {
2569 t.Fatal(err)
2570 }
2571
2572 meta, ok, err := LoadBranchMeta(path)
2573 if err != nil || !ok {
2574 t.Fatalf("LoadBranchMeta: ok=%v err=%v", ok, err)
2575 }
2576 meta.Recovered = true
2577 meta.RecoveryDigest = strings.Repeat("a", 64)
2578 meta.ParentID = "parent"
2579 if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
2580 t.Fatal(err)
2581 }
2582
2583 ordered, err := ListSessionOrder(dir)
2584 if err != nil {
2585 t.Fatal(err)
2586 }
2587 if len(ordered) != 1 {
2588 t.Fatalf("ListSessionOrder len = %d, want 1", len(ordered))
2589 }
2590 if ordered[0].RecoveryDigest != meta.RecoveryDigest || ordered[0].ParentID != meta.ParentID {
2591 t.Fatalf("ordered recovery metadata = digest:%q parent:%q, want digest:%q parent:%q", ordered[0].RecoveryDigest, ordered[0].ParentID, meta.RecoveryDigest, meta.ParentID)
2592 }
2593
2594 listed, err := ListSessions(dir)
2595 if err != nil {
2596 t.Fatal(err)
2597 }
2598 if len(listed) != 1 {
2599 t.Fatalf("ListSessions len = %d, want 1", len(listed))
2600 }
2601 if listed[0].RecoveryDigest != meta.RecoveryDigest || listed[0].ParentID != meta.ParentID {
2602 t.Fatalf("listed recovery metadata = digest:%q parent:%q, want digest:%q parent:%q", listed[0].RecoveryDigest, listed[0].ParentID, meta.RecoveryDigest, meta.ParentID)
2603 }
2604 }
2605
2606 func writeBranchMeta(t *testing.T, path string, createdAt, updatedAt time.Time) {
2607 t.Helper()
2608 meta := BranchMeta{
2609 ID: BranchID(path),
2610 CreatedAt: createdAt,
2611 UpdatedAt: updatedAt,
2612 }
2613 b, err := json.Marshal(meta)
2614 if err != nil {
2615 t.Fatal(err)
2616 }
2617 if err := os.WriteFile(BranchMetaPath(path), append(b, '\n'), 0o644); err != nil {
2618 t.Fatal(err)
2619 }
2620 }
2621
2622 func TestContinueSessionPathReusesPriorFile(t *testing.T) {
2623 prev := filepath.Join("sessions", "20260602-120000.000000000-deepseek.jsonl")
2624 if got := ContinueSessionPath(prev, "sessions", "other-model"); got != prev {
2625 t.Fatalf("carried conversation should keep its file %q, got %q", prev, got)
2626 }
2627 }
2628
2629 func TestContinueSessionPathMintsFreshWhenNoPrior(t *testing.T) {
2630 dir := t.TempDir()
2631 got := ContinueSessionPath("", dir, "deepseek")
2632 if filepath.Dir(got) != dir || !strings.HasSuffix(got, ".jsonl") {
2633 t.Fatalf("fresh path = %q, want a .jsonl under %q", got, dir)
2634 }
2635 }
2636
2637 func TestContinueSessionPathNoPersistence(t *testing.T) {
2638 if got := ContinueSessionPath("", "", "deepseek"); got != "" {
2639 t.Fatalf("no session dir should disable persistence, got %q", got)
2640 }
2641 }
2642
2643 // TestListSessionsMissingDir returns nil + no error so callers can fall
2644 // through to a fresh session without special-casing.
2645 func TestListSessionsMissingDir(t *testing.T) {
2646 got, err := ListSessions(filepath.Join(t.TempDir(), "never-created"))
2647 if err != nil || got != nil {
2648 t.Errorf("missing dir = %v / %v, want nil/nil", got, err)
2649 }
2650 }
2651
2652 func readSessionEventsForTest(t *testing.T, path string) []sessionEventRecord {
2653 t.Helper()
2654 f, err := os.Open(SessionEventLogPath(path))
2655 if err != nil {
2656 t.Fatalf("open event log: %v", err)
2657 }
2658 defer f.Close()
2659 dec := json.NewDecoder(f)
2660 var out []sessionEventRecord
2661 for {
2662 var rec sessionEventRecord
2663 if err := dec.Decode(&rec); err != nil {
2664 if errors.Is(err, io.EOF) {
2665 break
2666 }
2667 t.Fatalf("decode event log: %v", err)
2668 }
2669 out = append(out, rec)
2670 }
2671 return out
2672 }
2673
2674 // TestReconcileOverlongMigratesDiagnosticSidecars pins two halves of one fix:
2675 // overlong-name reconciliation must skip .events.jsonl / .conflicts.jsonl
2676 // sidecars instead of renaming each into a fake session with fabricated meta,
2677 // and the owning transcript's rename must carry those sidecars along instead
2678 // of orphaning them under the retired stem.
2679 func TestReconcileOverlongMigratesDiagnosticSidecars(t *testing.T) {
2680 dir := t.TempDir()
2681 // 236-byte transcript name: past the 224 reconcile bound, while its
2682 // .events.jsonl (243) and .conflicts.jsonl (246) still fit under 255 —
2683 // exactly the window where the old suffix filter mistook them for
2684 // overlong sessions.
2685 id := strings.Repeat("s", 230)
2686 oldPath := filepath.Join(dir, id+".jsonl")
2687 content := `{"role":"system","content":"sys"}` + "\n" + `{"role":"user","content":"hello"}` + "\n"
2688 if err := os.WriteFile(oldPath, []byte(content), 0o644); err != nil {
2689 t.Fatal(err)
2690 }
2691 if err := os.WriteFile(store.SessionEventLog(oldPath),
2692 []byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"hello"}]}`+"\n"), 0o644); err != nil {
2693 t.Fatal(err)
2694 }
2695 if err := os.WriteFile(store.SessionConflictLog(oldPath),
2696 []byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644); err != nil {
2697 t.Fatal(err)
2698 }
2699 if err := os.WriteFile(store.SessionRecoveryState(oldPath),
2700 []byte(`{"tasks":{"root":{"phase":"diagnosing"}}}`+"\n"), 0o600); err != nil {
2701 t.Fatal(err)
2702 }
2703 // The salvage sidecar holds raw session bytes; orphaning it under the
2704 // retired stem would leave unreachable transcript content behind (#6613
2705 // review follow-up).
2706 if err := os.WriteFile(store.SessionEventLogDamaged(oldPath),
2707 []byte(`{"damaged_tail":true}`+"\ntorn bytes\n"), 0o644); err != nil {
2708 t.Fatal(err)
2709 }
2710
2711 if err := ReconcileSessionSidecars(dir); err != nil {
2712 t.Fatalf("ReconcileSessionSidecars: %v", err)
2713 }
2714
2715 newPath := filepath.Join(dir, recoveryParentStem(id)+".jsonl")
2716 if _, err := os.Stat(newPath); err != nil {
2717 t.Fatalf("renamed transcript missing: %v", err)
2718 }
2719 if _, err := os.Stat(store.SessionEventLog(newPath)); err != nil {
2720 t.Fatalf("event log not migrated with the rename: %v", err)
2721 }
2722 if _, err := os.Stat(store.SessionEventLogDamaged(newPath)); err != nil {
2723 t.Fatalf("damaged salvage sidecar not migrated with the rename: %v", err)
2724 }
2725 if _, err := os.Stat(store.SessionConflictLog(newPath)); err != nil {
2726 t.Fatalf("conflict log not migrated with the rename: %v", err)
2727 }
2728 if _, err := os.Stat(store.SessionRecoveryState(newPath)); err != nil {
2729 t.Fatalf("recovery state not migrated with the rename: %v", err)
2730 }
2731 for _, gone := range []string{oldPath, store.SessionEventLog(oldPath), store.SessionEventLogDamaged(oldPath), store.SessionConflictLog(oldPath), store.SessionRecoveryState(oldPath)} {
2732 if _, err := os.Stat(gone); !os.IsNotExist(err) {
2733 t.Fatalf("%s still present under the retired stem (err=%v)", filepath.Base(gone), err)
2734 }
2735 }
2736 entries, err := os.ReadDir(dir)
2737 if err != nil {
2738 t.Fatal(err)
2739 }
2740 for _, e := range entries {
2741 if store.IsSessionTranscriptName(e.Name()) && e.Name() != filepath.Base(newPath) {
2742 t.Fatalf("reconcile fabricated an extra session from a sidecar: %s", e.Name())
2743 }
2744 }
2745 }
2746
2746 lines GO