v0.12.1 b9ff687

session_portable_test.go RawHistoryFolder

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

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 477

↑↓ moveenter openesc close