v0.20.0 29be61c

session_snapshot_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close