v0.14.0 75d4151

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

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