1package core
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/bnema/zut/packages/provider"
14)
15
16func TestAppendMessageRejectsEmptyContent(t *testing.T) {
17 session, err := NewSession(t.TempDir(), "/workspace", "provider", "model", "test")
18 if err != nil {
19 t.Fatal(err)
20 }
21 if err := session.AppendMessage(provider.Message{Role: provider.RoleUser}); err == nil {
22 t.Fatal("empty message content was accepted")
23 }
24 if err := session.Close(); err != nil {
25 t.Fatal(err)
26 }
27}
28
29func TestReadSessionSnapshotUsesLatestCompactionAndRepairsToolPairs(t *testing.T) {
30 root := t.TempDir()
31 session, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
32 if err != nil {
33 t.Fatal(err)
34 }
35 if err := session.AppendMessage(textMessage(provider.RoleUser, "old history")); err != nil {
36 t.Fatal(err)
37 }
38 if err := session.AppendCompaction([]provider.Message{
39 textMessage(provider.RoleAssistant, "summary"),
40 {Role: provider.RoleAssistant, Content: []provider.Content{
41 provider.ToolCallBlock{ID: "call-1", Name: "read", Arguments: []byte(`{"path":"/tmp/file"}`)},
42 }},
43 }); err != nil {
44 t.Fatal(err)
45 }
46 if err := session.AppendUsage(provider.Usage{InputTokens: 10}, provider.Usage{InputTokens: 10}); err != nil {
47 t.Fatal(err)
48 }
49 path := session.Path
50 if err := session.Close(); err != nil {
51 t.Fatal(err)
52 }
53
54 snapshot, err := ReadSessionSnapshot(path)
55 if err != nil {
56 t.Fatalf("ReadSessionSnapshot: %v", err)
57 }
58 if snapshot.Meta.ID == "" {
59 t.Fatal("snapshot lost session metadata")
60 }
61 assertMessageTexts(t, snapshot.Messages, []string{"summary", "", ""})
62 stub := snapshot.Messages[2].Content[0].(provider.ToolResultBlock).Content[0].(provider.TextBlock)
63 if stub.Text != "tool call was aborted; no result recorded." {
64 t.Errorf("repaired result text = %q", stub.Text)
65 }
66 if got := snapshot.Messages[1].Role; got != provider.RoleAssistant {
67 t.Fatalf("repaired transcript message 1 role = %q, want assistant", got)
68 }
69 if got := snapshot.Messages[2].Role; got != provider.RoleTool {
70 t.Fatalf("repaired transcript message 2 role = %q, want tool", got)
71 }
72 if got := snapshot.Messages[2].Content[0].(provider.ToolResultBlock).CallID; got != "call-1" {
73 t.Errorf("repaired result call id = %q, want call-1", got)
74 }
75 if len(snapshot.UsageCheckpoints) != 1 {
76 t.Fatalf("usage checkpoints = %d, want 1", len(snapshot.UsageCheckpoints))
77 }
78 if got := snapshot.UsageCheckpoints[0].MessageCount; got != 3 {
79 t.Fatalf("usage checkpoint message count = %d, want repaired count 3", got)
80 }
81 if got := snapshot.UsageCheckpoints[0].Cumulative.InputTokens; got != 10 {
82 t.Errorf("checkpoint cumulative input = %d, want 10", got)
83 }
84
85 opened, messages, err := OpenSession(path)
86 if err != nil {
87 t.Fatalf("OpenSession: %v", err)
88 }
89 defer opened.Close()
90 if len(messages) != len(snapshot.Messages) {
91 t.Fatalf("OpenSession messages = %d, snapshot messages = %d", len(messages), len(snapshot.Messages))
92 }
93}
94
95func TestReadSessionSnapshotMergesExistingToolResultWithoutDuplicateRepair(t *testing.T) {
96 root := t.TempDir()
97 session, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
98 if err != nil {
99 t.Fatal(err)
100 }
101 if err := session.AppendMessage(provider.Message{
102 Role: provider.RoleAssistant,
103 Content: []provider.Content{
104 provider.ToolCallBlock{ID: "call-1", Name: "read"},
105 provider.ToolCallBlock{ID: "call-2", Name: "bash"},
106 },
107 }); err != nil {
108 t.Fatal(err)
109 }
110 if err := session.AppendMessage(provider.Message{
111 Role: provider.RoleTool,
112 Content: []provider.Content{provider.ToolResultBlock{
113 CallID: "call-1",
114 Content: []provider.Content{provider.TextBlock{Text: "ok"}},
115 }},
116 }); err != nil {
117 t.Fatal(err)
118 }
119 path := session.Path
120 if err := session.Close(); err != nil {
121 t.Fatal(err)
122 }
123
124 snapshot, err := ReadSessionSnapshot(path)
125 if err != nil {
126 t.Fatal(err)
127 }
128 if len(snapshot.Messages) != 2 {
129 t.Fatalf("messages = %d, want existing tool row plus assistant row", len(snapshot.Messages))
130 }
131 tool := snapshot.Messages[1]
132 if len(tool.Content) != 2 {
133 t.Fatalf("tool result blocks = %d, want 2", len(tool.Content))
134 }
135 if got := tool.Content[0].(provider.ToolResultBlock).CallID; got != "call-1" {
136 t.Errorf("existing result call id = %q", got)
137 }
138 if got := tool.Content[1].(provider.ToolResultBlock).CallID; got != "call-2" {
139 t.Errorf("repaired result call id = %q", got)
140 }
141}
142
143func TestReadSessionSnapshotRecordsCheckpointsAtEffectiveBoundaries(t *testing.T) {
144 root := t.TempDir()
145 session, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
146 if err != nil {
147 t.Fatal(err)
148 }
149 _ = session.AppendMessage(textMessage(provider.RoleUser, "one"))
150 _ = session.AppendUsage(provider.Usage{InputTokens: 3}, provider.Usage{InputTokens: 3})
151 _ = session.AppendMessage(textMessage(provider.RoleAssistant, "two"))
152 _ = session.AppendUsage(provider.Usage{InputTokens: 4}, provider.Usage{InputTokens: 7})
153 _ = session.AppendCompaction([]provider.Message{textMessage(provider.RoleAssistant, "compact")})
154 _ = session.AppendMessage(textMessage(provider.RoleUser, "tail"))
155 _ = session.AppendUsage(provider.Usage{InputTokens: 2}, provider.Usage{InputTokens: 9})
156 path := session.Path
157 if err := session.Close(); err != nil {
158 t.Fatal(err)
159 }
160
161 snapshot, err := ReadSessionSnapshot(path)
162 if err != nil {
163 t.Fatal(err)
164 }
165 assertMessageTexts(t, snapshot.Messages, []string{"compact", "tail"})
166 if len(snapshot.UsageCheckpoints) != 3 {
167 t.Fatalf("checkpoints = %d, want 3", len(snapshot.UsageCheckpoints))
168 }
169 wantCounts := []int{1, 2, 2}
170 wantCosts := []int{3, 7, 9}
171 for i, checkpoint := range snapshot.UsageCheckpoints {
172 if checkpoint.MessageCount != wantCounts[i] {
173 t.Errorf("checkpoint %d count = %d, want %d", i, checkpoint.MessageCount, wantCounts[i])
174 }
175 if checkpoint.Cumulative.InputTokens != wantCosts[i] {
176 t.Errorf("checkpoint %d input = %d, want %d", i, checkpoint.Cumulative.InputTokens, wantCosts[i])
177 }
178 }
179}
180
181func TestReadSessionSnapshotRejectsMalformedRows(t *testing.T) {
182 meta := SessionMeta{ID: "session-1", CWD: "/workspace", Started: time.Now().UTC()}
183 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &meta})
184 if err != nil {
185 t.Fatal(err)
186 }
187 cases := []string{
188 string(metaLine) + "\nnot-json\n",
189 string(metaLine) + `
190{"type":"message","message":{"role":"user"}}
191`,
192 string(metaLine) + `
193{"type":"usage","cumulative":null}
194`,
195 string(metaLine) + `
196{"type":"future-row"}
197`,
198 }
199 for idx, contents := range cases {
200 path := filepath.Join(t.TempDir(), "session.jsonl")
201 if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
202 t.Fatal(err)
203 }
204 if _, err := ReadSessionSnapshot(path); err == nil {
205 t.Errorf("case %d: ReadSessionSnapshot returned nil error", idx)
206 }
207 if _, _, err := OpenSession(path); err == nil {
208 t.Errorf("case %d: OpenSession returned nil error", idx)
209 }
210 }
211}
212
213func TestReadSessionSnapshotKeepsOldOptionalMetadataReadable(t *testing.T) {
214 path := filepath.Join(t.TempDir(), "old.jsonl")
215 contents := `{"type":"meta","meta":{"id":"old-id","cwd":"/workspace","model":"claude","provider":"anthropic"}}
216{"type":"message","message":{"role":"user","content":[{"text":"hello"}]}}
217`
218 if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
219 t.Fatal(err)
220 }
221 snapshot, err := ReadSessionSnapshot(path)
222 if err != nil {
223 t.Fatal(err)
224 }
225 if snapshot.Meta.Parent != "" || snapshot.Meta.HideFromSessions {
226 t.Fatalf("old optional metadata unexpectedly populated: %+v", snapshot.Meta)
227 }
228 assertMessageTexts(t, snapshot.Messages, []string{"hello"})
229}
230
231func TestBranchAfterCompactionRetainsPreCompactionUsageAtReplacementBoundary(t *testing.T) {
232 root := t.TempDir()
233 parent, err := NewSession(root, "/workspace", "test", "model", "test")
234 if err != nil {
235 t.Fatal(err)
236 }
237 for _, text := range []string{"one", "two", "three"} {
238 _ = parent.AppendMessage(textMessage(provider.RoleUser, text))
239 }
240 _ = parent.AppendUsage(provider.Usage{InputTokens: 10}, provider.Usage{InputTokens: 10})
241 _ = parent.AppendCompaction([]provider.Message{textMessage(provider.RoleAssistant, "summary")})
242 path := parent.Path
243 if err := parent.Close(); err != nil {
244 t.Fatal(err)
245 }
246 branchPath, err := BranchSessionHidden(path, root, "/workspace", "test", 1)
247 if err != nil {
248 t.Fatal(err)
249 }
250 usage, err := SessionUsage(branchPath)
251 if err != nil {
252 t.Fatal(err)
253 }
254 if usage.InputTokens != 10 {
255 t.Fatalf("branch usage input = %d, want pre-compaction cumulative usage", usage.InputTokens)
256 }
257}
258
259func TestBranchSessionUsesSharedSnapshotAndPreservesUsage(t *testing.T) {
260 root := t.TempDir()
261 parent, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
262 if err != nil {
263 t.Fatal(err)
264 }
265 _ = parent.AppendMessage(textMessage(provider.RoleUser, "first"))
266 _ = parent.AppendUsage(provider.Usage{InputTokens: 11}, provider.Usage{InputTokens: 11})
267 _ = parent.AppendMessage(textMessage(provider.RoleAssistant, "reply"))
268 _ = parent.AppendUsage(provider.Usage{InputTokens: 12}, provider.Usage{InputTokens: 23})
269 parentPath := parent.Path
270 if err := parent.Close(); err != nil {
271 t.Fatal(err)
272 }
273
274 branchPath, err := BranchSessionHidden(parentPath, root, "/workspace", "test", 1)
275 if err != nil {
276 t.Fatalf("BranchSessionHidden: %v", err)
277 }
278 branch, messages, err := OpenSession(branchPath)
279 if err != nil {
280 t.Fatal(err)
281 }
282 branch.Close()
283 assertMessageTexts(t, messages, []string{"first"})
284 if branch.Meta.ForkPoint != 1 {
285 t.Errorf("fork point = %d, want 1", branch.Meta.ForkPoint)
286 }
287 if !branch.Meta.HideFromSessions {
288 t.Error("hidden branch did not set HideFromSessions")
289 }
290 usage, err := SessionUsage(branchPath)
291 if err != nil {
292 t.Fatal(err)
293 }
294 if usage.InputTokens != 11 {
295 t.Errorf("branch usage input = %d, want 11", usage.InputTokens)
296 }
297
298 if _, err := BranchSession(parentPath, root, "/workspace", "test", 99); err == nil {
299 t.Error("out-of-range fork returned nil error")
300 }
301}
302
303func TestBranchSessionRepairsToolBoundaryAndSupportsZeroAndEnd(t *testing.T) {
304 root := t.TempDir()
305 parent, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
306 if err != nil {
307 t.Fatal(err)
308 }
309 _ = parent.AppendMessage(textMessage(provider.RoleUser, "before"))
310 _ = parent.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{
311 provider.ToolCallBlock{ID: "call-1", Name: "read"},
312 }})
313 _ = parent.AppendMessage(provider.Message{Role: provider.RoleTool, Content: []provider.Content{
314 provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "result"}}},
315 }})
316 _ = parent.AppendMessage(textMessage(provider.RoleUser, "after"))
317 path := parent.Path
318 if err := parent.Close(); err != nil {
319 t.Fatal(err)
320 }
321
322 toolBranchPath, err := BranchSession(parent.Path, root, "/workspace", "test", 2)
323 if err != nil {
324 t.Fatal(err)
325 }
326 toolBranch, toolMessages, err := OpenSession(toolBranchPath)
327 if err != nil {
328 t.Fatal(err)
329 }
330 toolBranch.Close()
331 if len(toolMessages) != 3 {
332 t.Fatalf("tool boundary branch messages = %d, want 3", len(toolMessages))
333 }
334 if toolBranch.Meta.ForkPoint != 3 {
335 t.Errorf("tool boundary fork point = %d, want 3", toolBranch.Meta.ForkPoint)
336 }
337
338 zeroPath, err := BranchSessionHidden(path, root, "/workspace", "test", 0)
339 if err != nil {
340 t.Fatal(err)
341 }
342 zero, zeroMessages, err := OpenSession(zeroPath)
343 if err != nil {
344 t.Fatal(err)
345 }
346 zero.Close()
347 if len(zeroMessages) != 0 || zero.Meta.ForkPoint != 0 {
348 t.Errorf("zero branch = %d messages, fork %d", len(zeroMessages), zero.Meta.ForkPoint)
349 }
350
351 endPath, err := BranchSession(path, root, "/workspace", "test", 4)
352 if err != nil {
353 t.Fatal(err)
354 }
355 end, endMessages, err := OpenSession(endPath)
356 if err != nil {
357 t.Fatal(err)
358 }
359 end.Close()
360 if len(endMessages) != 4 || end.Meta.ForkPoint != 4 {
361 t.Errorf("end branch = %d messages, fork %d", len(endMessages), end.Meta.ForkPoint)
362 }
363}
364
365func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) {
366 root := t.TempDir()
367 parent, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
368 if err != nil {
369 t.Fatal(err)
370 }
371 _ = parent.AppendMessage(textMessage(provider.RoleUser, "root"))
372 parentPath := parent.Path
373 parentID := parent.ID
374 if err := parent.Close(); err != nil {
375 t.Fatal(err)
376 }
377 childPath, err := BranchSessionHidden(parentPath, root, "/workspace", "test", 1)
378 if err != nil {
379 t.Fatal(err)
380 }
381 if summary := DescribeSession(childPath); !summary.HideFromSessions {
382 t.Fatalf("DescribeSession hidden flag = %v, want true", summary.HideFromSessions)
383 }
384 if got := DescribeSessions(root, "/workspace"); len(got) != 1 || got[0].Path != parentPath {
385 t.Fatalf("DescribeSessions = %+v, want only parent", got)
386 }
387 tree := BuildSessionTree(root, "/workspace")
388 if len(tree) != 1 || tree[0].Meta.ID != parentID {
389 t.Fatalf("tree roots = %+v, want parent root", tree)
390 }
391 if len(tree[0].Children) != 1 || tree[0].Children[0].Summary.Path != childPath {
392 t.Fatalf("tree children = %+v, want hidden child", tree[0].Children)
393 }
394}
395
396func TestForEachStrictJSONLLineContextStopsAfterCancellation(t *testing.T) {
397 ctx, cancel := context.WithCancel(context.Background())
398 calls := 0
399 err := forEachStrictJSONLLineContext(ctx, strings.NewReader("first\nsecond\n"), func(line []byte, _ int) error {
400 calls++
401 cancel()
402 return nil
403 })
404 if !errors.Is(err, context.Canceled) {
405 t.Fatalf("context-aware JSONL read error = %v, want context.Canceled", err)
406 }
407 if calls != 1 {
408 t.Fatalf("context-aware JSONL callback count = %d, want 1", calls)
409 }
410}
411
412func TestDescribeSessionContextHonorsCancellation(t *testing.T) {
413 root := t.TempDir()
414 session, err := NewSession(root, "/workspace", "test", "model", "test")
415 if err != nil {
416 t.Fatal(err)
417 }
418 if err := session.AppendMessage(textMessage(provider.RoleUser, "cancel me")); err != nil {
419 t.Fatal(err)
420 }
421 path := session.Path
422 if err := session.Close(); err != nil {
423 t.Fatal(err)
424 }
425
426 ctx, cancel := context.WithCancel(context.Background())
427 cancel()
428 summary := DescribeSessionContext(ctx, path)
429 if summary.Path != path || summary.MessageCount != 0 {
430 t.Fatalf("canceled summary = %+v, want path only", summary)
431 }
432}
433
434func TestBuildSessionTreeStrictRejectsMalformedSessionMember(t *testing.T) {
435 root := t.TempDir()
436 cwd := "/workspace/strict-tree"
437 parent, err := NewSession(root, cwd, "test", "model", "test")
438 if err != nil {
439 t.Fatal(err)
440 }
441 _ = parent.AppendMessage(textMessage(provider.RoleUser, "parent"))
442 parentPath := parent.Path
443 if err := parent.Close(); err != nil {
444 t.Fatal(err)
445 }
446 badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl")
447 if err := os.WriteFile(badPath, []byte("{not-json}\n"), 0o644); err != nil {
448 t.Fatal(err)
449 }
450 if _, err := BuildSessionTreeStrict(root, cwd); err == nil {
451 t.Fatal("BuildSessionTreeStrict accepted a malformed session member")
452 }
453 if _, err := os.Stat(parentPath); err != nil {
454 t.Fatalf("valid parent disappeared: %v", err)
455 }
456}
457
458func TestSessionUsageDetailUsesCurrentCacheAfterCounterReset(t *testing.T) {
459 root := t.TempDir()
460 session, err := NewSession(root, "/workspace", "anthropic", "model", "test")
461 if err != nil {
462 t.Fatal(err)
463 }
464 if err := session.AppendMessage(textMessage(provider.RoleUser, "prompt")); err != nil {
465 t.Fatal(err)
466 }
467 if err := session.AppendUsage(provider.Usage{}, provider.Usage{
468 InputTokens: 10,
469 OutputTokens: 4,
470 ReasoningTokens: 2,
471 ReasoningTokensKnown: true,
472 CacheReadTokens: 100,
473 CacheWriteTokens: 40,
474 CostUSD: 0.5,
475 }); err != nil {
476 t.Fatal(err)
477 }
478 if err := session.AppendUsage(provider.Usage{}, provider.Usage{
479 InputTokens: 13,
480 OutputTokens: 7,
481 ReasoningTokens: 3,
482 ReasoningTokensKnown: true,
483 CacheReadTokens: 6,
484 CacheWriteTokens: 2,
485 CostUSD: 0.7,
486 }); err != nil {
487 t.Fatal(err)
488 }
489 path := session.Path
490 if err := session.Close(); err != nil {
491 t.Fatal(err)
492 }
493
494 cumulative, last, err := SessionUsageDetail(path)
495 if err != nil {
496 t.Fatal(err)
497 }
498 if cumulative.CacheReadTokens != 6 || cumulative.CacheWriteTokens != 2 {
499 t.Fatalf("cumulative cache = (%d, %d), want (6, 2)", cumulative.CacheReadTokens, cumulative.CacheWriteTokens)
500 }
501 if last.InputTokens != 3 || last.OutputTokens != 3 || last.ReasoningTokens != 1 {
502 t.Fatalf("last token delta = %+v, want input=3 output=3 reasoning=1", last)
503 }
504 if last.CacheReadTokens != 6 || last.CacheWriteTokens != 2 {
505 t.Fatalf("last cache = (%d, %d), want current checkpoint values (6, 2)", last.CacheReadTokens, last.CacheWriteTokens)
506 }
507 if !last.ReasoningTokensKnown {
508 t.Fatal("last-turn reasoning-token known flag was lost")
509 }
510}
511
512func TestSnapshotRemovesOrphanedToolResultsAndMapsCheckpointCount(t *testing.T) {
513 root := t.TempDir()
514 session, err := NewSession(root, "/workspace", "anthropic", "model", "test")
515 if err != nil {
516 t.Fatal(err)
517 }
518 _ = session.AppendMessage(textMessage(provider.RoleUser, "prompt"))
519 _ = session.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{
520 provider.ToolCallBlock{ID: "call-1", Name: "read"},
521 }})
522 _ = session.AppendMessage(provider.Message{Role: provider.RoleTool, Content: []provider.Content{
523 provider.ToolResultBlock{CallID: "orphan", Content: []provider.Content{provider.TextBlock{Text: "stale"}}},
524 }})
525 _ = session.AppendUsage(provider.Usage{}, provider.Usage{InputTokens: 9})
526 path := session.Path
527 if err := session.Close(); err != nil {
528 t.Fatal(err)
529 }
530
531 snapshot, err := ReadSessionSnapshot(path)
532 if err != nil {
533 t.Fatal(err)
534 }
535 if len(snapshot.Messages) != 3 {
536 t.Fatalf("repaired messages = %d, want 3", len(snapshot.Messages))
537 }
538 tool := snapshot.Messages[2]
539 if len(tool.Content) != 1 {
540 t.Fatalf("repaired tool blocks = %d, want synthetic result only", len(tool.Content))
541 }
542 result, ok := tool.Content[0].(provider.ToolResultBlock)
543 if !ok || result.CallID != "call-1" {
544 t.Fatalf("repaired result = %#v, want call-1", tool.Content[0])
545 }
546 if snapshot.UsageCheckpoints[0].MessageCount != 3 {
547 t.Fatalf("checkpoint message count = %d, want repaired count 3", snapshot.UsageCheckpoints[0].MessageCount)
548 }
549
550 branchPath, err := BranchSessionHidden(path, root, "/workspace", "test", len(snapshot.Messages))
551 if err != nil {
552 t.Fatal(err)
553 }
554 branchSession, branchMessages, err := OpenSession(branchPath)
555 if err != nil {
556 t.Fatal(err)
557 }
558 if err := branchSession.Close(); err != nil {
559 t.Fatal(err)
560 }
561 if len(branchMessages) != 3 {
562 t.Fatalf("branch messages = %d, want 3", len(branchMessages))
563 }
564 branchResult := branchMessages[2].Content[0].(provider.ToolResultBlock)
565 if branchResult.CallID != "call-1" {
566 t.Fatalf("branch kept orphan result call id %q", branchResult.CallID)
567 }
568}
569
570func TestAppendCompactionEmptyAndHistoricalNullRowsRoundTrip(t *testing.T) {
571 root := t.TempDir()
572 session, err := NewSession(root, "/workspace", "anthropic", "model", "test")
573 if err != nil {
574 t.Fatal(err)
575 }
576 _ = session.AppendMessage(textMessage(provider.RoleUser, "before"))
577 if err := session.AppendCompaction(nil); err != nil {
578 t.Fatal(err)
579 }
580 if err := session.AppendCompaction([]provider.Message{}); err != nil {
581 t.Fatal(err)
582 }
583 path := session.Path
584 if err := session.Close(); err != nil {
585 t.Fatal(err)
586 }
587 if _, err := os.Stat(path); err != nil {
588 t.Fatalf("empty compaction session was removed: %v", err)
589 }
590 snapshot, err := ReadSessionSnapshot(path)
591 if err != nil {
592 t.Fatal(err)
593 }
594 if len(snapshot.Messages) != 0 {
595 t.Fatalf("empty compaction messages = %d, want 0", len(snapshot.Messages))
596 }
597
598 meta := SessionMeta{ID: "historical-empty", CWD: "/workspace"}
599 metaLine, _ := json.Marshal(sessionLine{Type: "meta", Meta: &meta})
600 for _, compaction := range []string{`{"type":"compaction"}`, `{"type":"compaction","messages":null}`} {
601 historicalPath := filepath.Join(t.TempDir(), "historical.jsonl")
602 contents := string(metaLine) + "\n" + compaction + "\n"
603 if err := os.WriteFile(historicalPath, []byte(contents), 0o644); err != nil {
604 t.Fatal(err)
605 }
606 historical, err := ReadSessionSnapshot(historicalPath)
607 if err != nil {
608 t.Fatalf("historical %s: %v", compaction, err)
609 }
610 if len(historical.Messages) != 0 {
611 t.Fatalf("historical %s messages = %d, want 0", compaction, len(historical.Messages))
612 }
613 }
614}
615
616func TestBranchPreservesCumulativeCheckpointsForLastTurnDelta(t *testing.T) {
617 root := t.TempDir()
618 parent, err := NewSession(root, "/workspace", "anthropic", "model", "test")
619 if err != nil {
620 t.Fatal(err)
621 }
622 _ = parent.AppendMessage(textMessage(provider.RoleUser, "first"))
623 _ = parent.AppendUsage(provider.Usage{}, provider.Usage{InputTokens: 10, CacheReadTokens: 4})
624 _ = parent.AppendMessage(textMessage(provider.RoleAssistant, "second"))
625 _ = parent.AppendUsage(provider.Usage{}, provider.Usage{InputTokens: 25, CacheReadTokens: 7})
626 parentPath := parent.Path
627 if err := parent.Close(); err != nil {
628 t.Fatal(err)
629 }
630
631 branchPath, err := BranchSession(parentPath, root, "/workspace", "test", 2)
632 if err != nil {
633 t.Fatal(err)
634 }
635 cumulative, last, err := SessionUsageDetail(branchPath)
636 if err != nil {
637 t.Fatal(err)
638 }
639 if cumulative.InputTokens != 25 || cumulative.CacheReadTokens != 7 {
640 t.Fatalf("branch cumulative = %+v, want input=25 cache_read=7", cumulative)
641 }
642 if last.InputTokens != 15 || last.CacheReadTokens != 3 {
643 t.Fatalf("branch last turn = %+v, want input=15 cache_read=3", last)
644 }
645}
646
647func textMessage(role provider.Role, text string) provider.Message {
648 return provider.Message{Role: role, Content: []provider.Content{provider.TextBlock{Text: text}}}
649}
650
651func TestSnapshotMessagesDoNotExposeMutableRawCompactionState(t *testing.T) {
652 // This is a small API contract check: callers can safely materialize a
653 // branch from the returned slice without changing the source file.
654 root := t.TempDir()
655 session, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
656 if err != nil {
657 t.Fatal(err)
658 }
659 _ = session.AppendMessage(textMessage(provider.RoleUser, "original"))
660 path := session.Path
661 _ = session.Close()
662 snapshot, err := ReadSessionSnapshot(path)
663 if err != nil {
664 t.Fatal(err)
665 }
666 snapshot.Messages[0].Content[0] = provider.TextBlock{Text: "changed in memory"}
667 again, err := ReadSessionSnapshot(path)
668 if err != nil {
669 t.Fatal(err)
670 }
671 if got := extractText(again.Messages[0]); strings.Contains(got, "changed") {
672 t.Fatal("mutating snapshot changed on-disk transcript")
673 }
674}