v0.11.2 619aabb

session_portable_test.go RawHistoryFolder

1package core
2
3import (
4	"encoding/json"
5	"os"
6	"path/filepath"
7	"strings"
8	"testing"
9
10	"github.com/bnema/zut/packages/provider"
11)
12
13// TestSessionExportImportRoundTrip writes a few messages to a live
14// session, exports it, imports the export under a different cwd,
15// and verifies OpenSession on the imported file yields the same
16// message payloads.
17func TestSessionExportImportRoundTrip(t *testing.T) {
18	root := t.TempDir()
19	originalCWD := "/path/to/project"
20	sess, err := NewSession(root, originalCWD, "anthropic", "claude-opus-4-7", "0.0.0-test")
21	if err != nil {
22		t.Fatal(err)
23	}
24	_ = sess.AppendMessage(provider.Message{
25		Role:    provider.RoleUser,
26		Content: []provider.Content{provider.TextBlock{Text: "hello from the exporter"}},
27	})
28	_ = sess.AppendMessage(provider.Message{
29		Role:    provider.RoleAssistant,
30		Content: []provider.Content{provider.TextBlock{Text: "hi — reply from the assistant"}},
31	})
32	handoff := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
33	if err := sess.UpdateCompactHandoff(handoff); err != nil {
34		t.Fatal(err)
35	}
36	_ = sess.Close()
37
38	// Export to a directory — helper should build a name inside it.
39	exportDir := t.TempDir()
40	exportPath, err := ExportSession(sess.Path, exportDir)
41	if err != nil {
42		t.Fatalf("ExportSession: %v", err)
43	}
44	if !strings.HasSuffix(exportPath, PortableExt) {
45		t.Errorf("exported path should end in %s, got %q", PortableExt, exportPath)
46	}
47	if _, err := os.Stat(exportPath); err != nil {
48		t.Fatalf("exported file doesn't exist: %v", err)
49	}
50
51	// Import into a different root + cwd.
52	root2 := t.TempDir()
53	cwd2 := "/some/other/project"
54	importedPath, err := ImportSession(exportPath, root2, cwd2, "0.0.0-test")
55	if err != nil {
56		t.Fatalf("ImportSession: %v", err)
57	}
58	if filepath.Dir(importedPath) != SessionsDir(root2, cwd2) {
59		t.Errorf("imported file should land in SessionsDir, got %q", importedPath)
60	}
61
62	// Reopen and verify message round-trip.
63	imported, msgs, err := OpenSession(importedPath)
64	if err != nil {
65		t.Fatalf("OpenSession: %v", err)
66	}
67	defer imported.Close()
68	if imported.Meta.CWD != cwd2 {
69		t.Errorf("meta cwd: want %q, got %q", cwd2, imported.Meta.CWD)
70	}
71	if imported.Meta.ID == sess.ID {
72		t.Errorf("imported session kept the original id %q; must be rotated", sess.ID)
73	}
74	if imported.Meta.Model != "claude-opus-4-7" {
75		t.Errorf("model not preserved: %q", imported.Meta.Model)
76	}
77	if got := string(imported.Meta.CompactHandoff); got != string(handoff) {
78		t.Errorf("compact handoff = %q, want %q", got, handoff)
79	}
80	if len(msgs) != 2 {
81		t.Fatalf("want 2 messages, got %d", len(msgs))
82	}
83	// Text should round-trip.
84	if extractText(msgs[0]) != "hello from the exporter" {
85		t.Errorf("msg 0 mismatch: %q", extractText(msgs[0]))
86	}
87	if extractText(msgs[1]) != "hi — reply from the assistant" {
88		t.Errorf("msg 1 mismatch: %q", extractText(msgs[1]))
89	}
90}
91
92func TestExportImportUsesLatestProviderModelMetadata(t *testing.T) {
93	root := t.TempDir()
94	session, err := NewSession(root, "/source", "old-provider", "old-model", "test")
95	if err != nil {
96		t.Fatal(err)
97	}
98	_ = session.AppendMessage(provider.Message{
99		Role:    provider.RoleUser,
100		Content: []provider.Content{provider.TextBlock{Text: "metadata prompt"}},
101	})
102	if err := session.UpdateModel("new-provider", "new-model"); err != nil {
103		t.Fatal(err)
104	}
105	path := session.Path
106	if err := session.Close(); err != nil {
107		t.Fatal(err)
108	}
109
110	exportPath, err := ExportSession(path, filepath.Join(t.TempDir(), "latest"+PortableExt))
111	if err != nil {
112		t.Fatalf("ExportSession: %v", err)
113	}
114	exported, err := ReadSessionSnapshot(exportPath)
115	if err != nil {
116		t.Fatalf("Read exported snapshot: %v", err)
117	}
118	if exported.Meta.Provider != "new-provider" || exported.Meta.Model != "new-model" {
119		t.Fatalf("export metadata = provider %q model %q, want latest values", exported.Meta.Provider, exported.Meta.Model)
120	}
121
122	importedPath, err := ImportSession(exportPath, t.TempDir(), "/destination", "test")
123	if err != nil {
124		t.Fatalf("ImportSession: %v", err)
125	}
126	imported, _, err := OpenSession(importedPath)
127	if err != nil {
128		t.Fatalf("Open imported session: %v", err)
129	}
130	defer imported.Close()
131	if imported.Meta.Provider != "new-provider" || imported.Meta.Model != "new-model" {
132		t.Fatalf("import metadata = provider %q model %q, want latest values", imported.Meta.Provider, imported.Meta.Model)
133	}
134}
135
136// TestScanSessionMetaReturnsLatestRowWithoutHydratingMessages verifies that
137// metadata lookup follows append-only model updates without reconstructing the
138// transcript.
139func TestScanSessionMetaReturnsLatestRowWithoutHydratingMessages(t *testing.T) {
140	root := t.TempDir()
141	session, err := NewSession(root, "/workspace", "old-provider", "old-model", "test")
142	if err != nil {
143		t.Fatal(err)
144	}
145	if err := session.AppendMessage(provider.Message{
146		Role:    provider.RoleUser,
147		Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("large transcript ", 1000)}},
148	}); err != nil {
149		t.Fatal(err)
150	}
151	if err := session.UpdateModel("new-provider", "new-model"); err != nil {
152		t.Fatal(err)
153	}
154	if err := session.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
155		t.Fatal(err)
156	}
157	path := session.Path
158	if err := session.Close(); err != nil {
159		t.Fatal(err)
160	}
161
162	meta, err := scanSessionMeta(path)
163	if err != nil {
164		t.Fatal(err)
165	}
166	if meta.Provider != "new-provider" || meta.Model != "new-model" {
167		t.Fatalf("scanned metadata = %q/%q, want latest values", meta.Provider, meta.Model)
168	}
169}
170
171// TestExportToFilePath writes to an explicit file path (no directory
172// guessing) and checks the .zutsession extension is appended when missing.
173func TestExportToFilePath(t *testing.T) {
174	root := t.TempDir()
175	sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
176	if err != nil {
177		t.Fatal(err)
178	}
179	_ = sess.AppendMessage(provider.Message{
180		Role:    provider.RoleUser,
181		Content: []provider.Content{provider.TextBlock{Text: "x"}},
182	})
183	_ = sess.Close()
184
185	// No extension — should add .zutsession.
186	dst := filepath.Join(t.TempDir(), "mysession")
187	out, err := ExportSession(sess.Path, dst)
188	if err != nil {
189		t.Fatal(err)
190	}
191	if !strings.HasSuffix(out, PortableExt) {
192		t.Errorf("want .zutsession suffix on %q", out)
193	}
194}
195
196// TestExportStripsCWDFromMeta verifies the exported meta no longer
197// carries the source user's cwd (not useful to the recipient).
198func TestExportSessionHandlesHugeJSONLRows(t *testing.T) {
199	root := t.TempDir()
200	sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
201	if err != nil {
202		t.Fatal(err)
203	}
204	_ = sess.AppendMessage(provider.Message{
205		Role:    provider.RoleUser,
206		Content: []provider.Content{provider.TextBlock{Text: "first prompt names export"}},
207	})
208	_ = sess.AppendMessage(provider.Message{
209		Role:    provider.RoleAssistant,
210		Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", 22*1024*1024)}},
211	})
212	_ = sess.Close()
213
214	exportDir := t.TempDir()
215	out, err := ExportSession(sess.Path, exportDir)
216	if err != nil {
217		t.Fatalf("ExportSession with huge row: %v", err)
218	}
219	if _, err := os.Stat(out); err != nil {
220		t.Fatalf("exported file missing: %v", err)
221	}
222	opened, msgs, err := OpenSession(sess.Path)
223	if err != nil {
224		t.Fatalf("OpenSession with huge row: %v", err)
225	}
226	_ = opened.Close()
227	if len(msgs) != 2 {
228		t.Fatalf("OpenSession messages=%d, want 2", len(msgs))
229	}
230	// Ensure the exported file is still readable JSONL and contains the
231	// huge assistant message row, not just the meta/header.
232	b, err := os.ReadFile(out)
233	if err != nil {
234		t.Fatal(err)
235	}
236	if !strings.Contains(string(b), strings.Repeat("x", 1024)) {
237		t.Fatalf("export does not appear to contain huge assistant row")
238	}
239	for n, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
240		var raw map[string]any
241		if err := json.Unmarshal([]byte(line), &raw); err != nil {
242			t.Fatalf("exported line %d is invalid json: %v", n+1, err)
243		}
244	}
245}
246
247func TestExportStripsCWDFromMeta(t *testing.T) {
248	root := t.TempDir()
249	sess, err := NewSession(root, "/original/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
250	if err != nil {
251		t.Fatal(err)
252	}
253	_ = sess.AppendMessage(provider.Message{
254		Role:    provider.RoleUser,
255		Content: []provider.Content{provider.TextBlock{Text: "x"}},
256	})
257	_ = sess.Close()
258
259	out, err := ExportSession(sess.Path, filepath.Join(t.TempDir(), "x"+PortableExt))
260	if err != nil {
261		t.Fatal(err)
262	}
263	b, _ := os.ReadFile(out)
264	if strings.Contains(string(b), "/original/cwd") {
265		t.Errorf("exported file leaks the source cwd: %s", string(b))
266	}
267}
268
269// TestBranchSessionCopiesPrefix writes several messages to a
270// session, branches at message index 2 (first user + first
271// assistant), and verifies the new session has exactly those two
272// messages with parent + fork_point meta set.
273func TestBranchSessionCopiesPrefix(t *testing.T) {
274	root := t.TempDir()
275	cwd := "/project"
276	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
277	if err != nil {
278		t.Fatal(err)
279	}
280	_ = parent.AppendMessage(provider.Message{
281		Role:    provider.RoleUser,
282		Content: []provider.Content{provider.TextBlock{Text: "first"}},
283	})
284	_ = parent.AppendMessage(provider.Message{
285		Role:    provider.RoleAssistant,
286		Content: []provider.Content{provider.TextBlock{Text: "first reply"}},
287	})
288	_ = parent.AppendMessage(provider.Message{
289		Role:    provider.RoleUser,
290		Content: []provider.Content{provider.TextBlock{Text: "second"}},
291	})
292	_ = parent.Close()
293
294	// Branch at the first user+assistant pair (upToMessageIdx=2).
295	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
296	if err != nil {
297		t.Fatalf("BranchSession: %v", err)
298	}
299	branch, msgs, err := OpenSession(branchPath)
300	if err != nil {
301		t.Fatal(err)
302	}
303	defer branch.Close()
304
305	if len(msgs) != 2 {
306		t.Errorf("want 2 copied messages, got %d", len(msgs))
307	}
308	if branch.Meta.Parent != parent.Meta.ID {
309		t.Errorf("parent id: want %q, got %q", parent.Meta.ID, branch.Meta.Parent)
310	}
311	if branch.Meta.ForkPoint != 2 {
312		t.Errorf("fork_point: want 2, got %d", branch.Meta.ForkPoint)
313	}
314	if branch.Meta.ID == parent.Meta.ID {
315		t.Errorf("branch kept parent id; must rotate")
316	}
317	if branch.Meta.Title != "" {
318		t.Errorf("meta title should remain append-only, got %q", branch.Meta.Title)
319	}
320	if summary := describeSession(branchPath); summary.Title != "first" {
321		t.Errorf("branch title: want %q, got %q", "first", summary.Title)
322	}
323}
324
325func TestBranchSessionTitleUsesSelectedUserPrompt(t *testing.T) {
326	root := t.TempDir()
327	cwd := "/project"
328	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
329	if err != nil {
330		t.Fatal(err)
331	}
332	for _, msg := range []provider.Message{
333		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first prompt"}}},
334		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first reply"}}},
335		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second prompt"}}},
336	} {
337		_ = parent.AppendMessage(msg)
338	}
339	_ = parent.Close()
340
341	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 3)
342	if err != nil {
343		t.Fatalf("BranchSession: %v", err)
344	}
345	if summary := describeSession(branchPath); summary.Title != "second prompt" {
346		t.Errorf("branch title: want %q, got %q", "second prompt", summary.Title)
347	}
348}
349
350func TestBranchSessionTitleUsesFirstPromptAfterForkWhenBranchDiverges(t *testing.T) {
351	root := t.TempDir()
352	cwd := "/project"
353	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
354	if err != nil {
355		t.Fatal(err)
356	}
357	_ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
358	_ = parent.Close()
359
360	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
361	if err != nil {
362		t.Fatalf("BranchSession: %v", err)
363	}
364	branch, _, err := OpenSession(branchPath)
365	if err != nil {
366		t.Fatal(err)
367	}
368	_ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
369	_ = branch.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "fine"}}})
370	_ = branch.Close()
371
372	if summary := describeSession(branchPath); summary.Title != "how are you?" {
373		t.Errorf("branch title: want %q, got %q", "how are you?", summary.Title)
374	}
375}
376
377func TestGeneratedBranchTitleWinsWhenWrittenBeforeBranchPrompt(t *testing.T) {
378	root := t.TempDir()
379	cwd := "/project"
380	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
381	if err != nil {
382		t.Fatal(err)
383	}
384	_ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
385	_ = parent.Close()
386
387	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
388	if err != nil {
389		t.Fatalf("BranchSession: %v", err)
390	}
391	branch, _, err := OpenSession(branchPath)
392	if err != nil {
393		t.Fatal(err)
394	}
395	if err := branch.UpdateTitle("AI branch title"); err != nil {
396		t.Fatal(err)
397	}
398	if err := branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}}); err != nil {
399		t.Fatal(err)
400	}
401	if err := branch.Close(); err != nil {
402		t.Fatal(err)
403	}
404
405	if summary := describeSession(branchPath); summary.Title != "AI branch title" {
406		t.Errorf("branch title: want %q, got %q", "AI branch title", summary.Title)
407	}
408}
409
410func TestRenamedBranchKeepsExplicitTitle(t *testing.T) {
411	root := t.TempDir()
412	cwd := "/project"
413	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
414	if err != nil {
415		t.Fatal(err)
416	}
417	_ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
418	_ = parent.Close()
419
420	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
421	if err != nil {
422		t.Fatalf("BranchSession: %v", err)
423	}
424	branch, _, err := OpenSession(branchPath)
425	if err != nil {
426		t.Fatal(err)
427	}
428	_ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
429	_ = branch.Close()
430	if err := RenameSession(branchPath, "friendly branch name"); err != nil {
431		t.Fatal(err)
432	}
433
434	if summary := describeSession(branchPath); summary.Title != "friendly branch name" {
435		t.Errorf("branch title: want %q, got %q", "friendly branch name", summary.Title)
436	}
437}
438
439func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) {
440	root := t.TempDir()
441	cwd := "/project"
442	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
443	if err != nil {
444		t.Fatal(err)
445	}
446	for _, text := range []string{"raw-a", "raw-b", "raw-c", "raw-d"} {
447		_ = parent.AppendMessage(provider.Message{
448			Role:    provider.RoleUser,
449			Content: []provider.Content{provider.TextBlock{Text: text}},
450		})
451	}
452	_ = parent.AppendCompaction([]provider.Message{
453		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "summary"}}},
454		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "tail-c"}}},
455		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "tail-d"}}},
456	})
457	_ = parent.AppendMessage(provider.Message{
458		Role:    provider.RoleUser,
459		Content: []provider.Content{provider.TextBlock{Text: "after-compact"}},
460	})
461	_ = parent.Close()
462
463	opened, msgs, err := OpenSession(parent.Path)
464	if err != nil {
465		t.Fatalf("OpenSession parent: %v", err)
466	}
467	_ = opened.Close()
468	assertMessageTexts(t, msgs, []string{"summary", "tail-c", "tail-d", "after-compact"})
469
470	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 4)
471	if err != nil {
472		t.Fatalf("BranchSession: %v", err)
473	}
474	branch, branchMsgs, err := OpenSession(branchPath)
475	if err != nil {
476		t.Fatalf("OpenSession branch: %v", err)
477	}
478	defer branch.Close()
479
480	assertMessageTexts(t, branchMsgs, []string{"summary", "tail-c", "tail-d", "after-compact"})
481	if branch.Meta.Parent != parent.Meta.ID {
482		t.Errorf("parent id: want %q, got %q", parent.Meta.ID, branch.Meta.Parent)
483	}
484	if branch.Meta.ForkPoint != 4 {
485		t.Errorf("fork_point: want 4, got %d", branch.Meta.ForkPoint)
486	}
487
488	shortBranchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
489	if err != nil {
490		t.Fatalf("BranchSession short fork: %v", err)
491	}
492	shortBranch, shortBranchMsgs, err := OpenSession(shortBranchPath)
493	if err != nil {
494		t.Fatalf("OpenSession short branch: %v", err)
495	}
496	defer shortBranch.Close()
497
498	assertMessageTexts(t, shortBranchMsgs, []string{"summary", "tail-c"})
499	if shortBranch.Meta.ForkPoint != 2 {
500		t.Errorf("short branch fork_point: want 2, got %d", shortBranch.Meta.ForkPoint)
501	}
502}
503
504func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
505	root := t.TempDir()
506	cwd := "/project"
507	parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
508	if err != nil {
509		t.Fatal(err)
510	}
511	for _, text := range []string{"first", "second"} {
512		if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}); err != nil {
513			t.Fatal(err)
514		}
515	}
516	handoff := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
517	if err := parent.UpdateCompactHandoff(handoff); err != nil {
518		t.Fatal(err)
519	}
520	if err := parent.Close(); err != nil {
521		t.Fatal(err)
522	}
523
524	fullPath, err := BranchSession(parent.Path, root, cwd, "test", 2)
525	if err != nil {
526		t.Fatal(err)
527	}
528	full, _, err := OpenSession(fullPath)
529	if err != nil {
530		t.Fatal(err)
531	}
532	t.Cleanup(func() { _ = full.Close() })
533	if got := string(full.Meta.CompactHandoff); got != string(handoff) {
534		t.Fatalf("full branch compact handoff = %q, want %q", got, handoff)
535	}
536
537	prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
538	if err != nil {
539		t.Fatal(err)
540	}
541	prefix, _, err := OpenSession(prefixPath)
542	if err != nil {
543		t.Fatal(err)
544	}
545	t.Cleanup(func() { _ = prefix.Close() })
546	if len(prefix.Meta.CompactHandoff) != 0 {
547		t.Fatalf("prefix branch compact handoff = %q, want empty", prefix.Meta.CompactHandoff)
548	}
549}
550
551func TestBranchSessionHiddenFromHistoryClearsCompactHandoff(t *testing.T) {
552	root := t.TempDir()
553	cwd := "/project"
554	parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
555	if err != nil {
556		t.Fatal(err)
557	}
558	if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old"}}}); err != nil {
559		t.Fatal(err)
560	}
561	if err := parent.AppendCompaction([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summary"}}}}); err != nil {
562		t.Fatal(err)
563	}
564	if err := parent.UpdateCompactHandoff(json.RawMessage(`{"version":1,"reason":"forced_length"}`)); err != nil {
565		t.Fatal(err)
566	}
567	if err := parent.Close(); err != nil {
568		t.Fatal(err)
569	}
570
571	history, err := ReadSessionHistory(parent.Path)
572	if err != nil {
573		t.Fatal(err)
574	}
575	if len(history.Segments) < 2 {
576		t.Fatalf("history segments = %d, want at least 2", len(history.Segments))
577	}
578	branchPath, err := BranchSessionHiddenFromHistory(parent.Path, root, cwd, "test", history.Segments[0], len(history.Segments[0].Messages))
579	if err != nil {
580		t.Fatal(err)
581	}
582	branch, _, err := OpenSession(branchPath)
583	if err != nil {
584		t.Fatal(err)
585	}
586	t.Cleanup(func() { _ = branch.Close() })
587	if len(branch.Meta.CompactHandoff) != 0 {
588		t.Fatalf("historical branch compact handoff = %q, want empty", branch.Meta.CompactHandoff)
589	}
590}
591
592func TestBranchSessionCopiesExtensionStateAtFork(t *testing.T) {
593	root := t.TempDir()
594	cwd := "/project"
595	parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
596	if err != nil {
597		t.Fatal(err)
598	}
599	if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}}); err != nil {
600		t.Fatal(err)
601	}
602	if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
603		t.Fatal(err)
604	}
605	if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second"}}}); err != nil {
606		t.Fatal(err)
607	}
608	if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":2}`)); err != nil {
609		t.Fatal(err)
610	}
611	if err := parent.Close(); err != nil {
612		t.Fatal(err)
613	}
614
615	branchPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
616	if err != nil {
617		t.Fatal(err)
618	}
619	branch, _, err := OpenSession(branchPath)
620	if err != nil {
621		t.Fatal(err)
622	}
623	t.Cleanup(func() { _ = branch.Close() })
624	if got := string(branch.ExtensionState["tasked-phases"]); got != `{"version":1}` {
625		t.Fatalf("branch extension state = %q, want version 1", got)
626	}
627}
628
629func TestBranchSessionCarriesStateAcrossCompactionBoundary(t *testing.T) {
630	root := t.TempDir()
631	cwd := "/project"
632	parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
633	if err != nil {
634		t.Fatal(err)
635	}
636	for _, text := range []string{"before-a", "before-b"} {
637		if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}); err != nil {
638			t.Fatal(err)
639		}
640	}
641	if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
642		t.Fatal(err)
643	}
644	if err := parent.AppendCompaction([]provider.Message{
645		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "summary"}}},
646		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "tail"}}},
647	}); err != nil {
648		t.Fatal(err)
649	}
650	if err := parent.Close(); err != nil {
651		t.Fatal(err)
652	}
653
654	branchPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
655	if err != nil {
656		t.Fatal(err)
657	}
658	branch, _, err := OpenSession(branchPath)
659	if err != nil {
660		t.Fatal(err)
661	}
662	t.Cleanup(func() { _ = branch.Close() })
663	if got := string(branch.ExtensionState["tasked-phases"]); got != `{"version":1}` {
664		t.Fatalf("compacted branch extension state = %q, want version 1", got)
665	}
666}
667
668// TestBuildSessionTree verifies parent/child edges are rebuilt
669// from meta + sibling-scan.
670func TestBuildSessionTree(t *testing.T) {
671	root := t.TempDir()
672	cwd := "/project"
673	parent, _ := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
674	_ = parent.AppendMessage(provider.Message{
675		Role:    provider.RoleUser,
676		Content: []provider.Content{provider.TextBlock{Text: "x"}},
677	})
678	_ = parent.Close()
679
680	childA, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
681	if err != nil {
682		t.Fatal(err)
683	}
684	childB, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
685	if err != nil {
686		t.Fatal(err)
687	}
688	_ = childA
689	_ = childB
690
691	tree := BuildSessionTree(root, cwd)
692	if len(tree) != 1 {
693		t.Fatalf("want 1 root, got %d", len(tree))
694	}
695	rootNode := tree[0]
696	if rootNode.Meta.ID != parent.Meta.ID {
697		t.Errorf("root should be parent %q, got %q", parent.Meta.ID, rootNode.Meta.ID)
698	}
699	if len(rootNode.Children) != 2 {
700		t.Errorf("want 2 children, got %d", len(rootNode.Children))
701	}
702}
703
704func assertMessageTexts(t *testing.T, msgs []provider.Message, want []string) {
705	t.Helper()
706	if len(msgs) != len(want) {
707		t.Fatalf("message count: want %d, got %d", len(want), len(msgs))
708	}
709	for i, msg := range msgs {
710		if got := extractText(msg); got != want[i] {
711			t.Errorf("message %d: want %q, got %q", i, want[i], got)
712		}
713	}
714}

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