v0.51.0 a9d674e

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 TestFindManagedSessionByIDScopesManagedStoresAndIgnoresUnrelatedCorruption(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	agentRoot := filepath.Join(root, "sessions", "agents", "scheduled-worker")
41	agentSession, err := NewSession(agentRoot, cwd, "provider", "model", "test")
42	if err != nil {
43		t.Fatal(err)
44	}
45	if err := agentSession.AppendMessage(provider.Message{
46		Role:    provider.RoleUser,
47		Content: []provider.Content{provider.TextBlock{Text: "agent managed"}},
48	}); err != nil {
49		t.Fatal(err)
50	}
51	if err := agentSession.Close(); err != nil {
52		t.Fatal(err)
53	}
54	got, err = FindManagedSessionByID(context.Background(), root, agentSession.ID)
55	if err != nil {
56		t.Fatalf("FindManagedSessionByID(agent session): %v", err)
57	}
58	if got != agentSession.Path {
59		t.Fatalf("agent managed session path = %q, want %q", got, agentSession.Path)
60	}
61
62	badMeta, err := json.Marshal(sessionLine{Type: "meta", Meta: &SessionMeta{ID: "unrelated", CWD: cwd}})
63	if err != nil {
64		t.Fatal(err)
65	}
66	badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl")
67	if err := os.WriteFile(badPath, append(append(badMeta, '\n'), []byte("not json\n")...), 0o644); err != nil {
68		t.Fatal(err)
69	}
70	got, err = FindManagedSessionByID(context.Background(), root, session.ID)
71	if err != nil {
72		t.Fatalf("FindManagedSessionByID with unrelated corrupt session: %v", err)
73	}
74	if got != session.Path {
75		t.Fatalf("managed session path with unrelated corruption = %q, want %q", got, session.Path)
76	}
77}
78
79func TestFindManagedSessionByIDFindsTargetWithCorruptBody(t *testing.T) {
80	root := t.TempDir()
81	cwd := t.TempDir()
82	session, err := NewSession(root, cwd, "provider", "model", "test")
83	if err != nil {
84		t.Fatal(err)
85	}
86	if err := session.AppendMessage(provider.Message{
87		Role:    provider.RoleUser,
88		Content: []provider.Content{provider.TextBlock{Text: "keep the session"}},
89	}); err != nil {
90		t.Fatal(err)
91	}
92	if err := session.Close(); err != nil {
93		t.Fatal(err)
94	}
95
96	f, err := os.OpenFile(session.Path, os.O_APPEND|os.O_WRONLY, 0o644)
97	if err != nil {
98		t.Fatal(err)
99	}
100	if _, err := f.WriteString("not json\n"); err != nil {
101		_ = f.Close()
102		t.Fatal(err)
103	}
104	if err := f.Close(); err != nil {
105		t.Fatal(err)
106	}
107
108	got, err := FindManagedSessionByID(context.Background(), root, session.ID)
109	if err != nil {
110		t.Fatalf("FindManagedSessionByID: %v", err)
111	}
112	if got != session.Path {
113		t.Fatalf("managed session path = %q, want %q", got, session.Path)
114	}
115	if _, _, err := OpenSession(got); err == nil || !strings.Contains(err.Error(), "invalid JSON") {
116		t.Fatalf("OpenSession corrupt target error = %v, want invalid JSON", err)
117	}
118}
119
120func TestFindManagedSessionByIDRejectsDuplicateHeaders(t *testing.T) {
121	root := t.TempDir()
122	cwd := t.TempDir()
123	session, err := NewSession(root, cwd, "provider", "model", "test")
124	if err != nil {
125		t.Fatal(err)
126	}
127	if err := session.AppendMessage(provider.Message{
128		Role:    provider.RoleUser,
129		Content: []provider.Content{provider.TextBlock{Text: "duplicate me"}},
130	}); err != nil {
131		t.Fatal(err)
132	}
133	if err := session.Close(); err != nil {
134		t.Fatal(err)
135	}
136	contents, err := os.ReadFile(session.Path)
137	if err != nil {
138		t.Fatal(err)
139	}
140	duplicatePath := filepath.Join(SessionsDir(root, cwd), "duplicate.jsonl")
141	if err := os.WriteFile(duplicatePath, contents, 0o644); err != nil {
142		t.Fatal(err)
143	}
144
145	if _, err := FindManagedSessionByID(context.Background(), root, session.ID); err == nil || !strings.Contains(err.Error(), "ambiguous") {
146		t.Fatalf("FindManagedSessionByID duplicate error = %v, want ambiguity", err)
147	}
148}
149
150func TestFindManagedSessionByIDIgnoresSessionInWrongBucket(t *testing.T) {
151	root := t.TempDir()
152	cwd := t.TempDir()
153	session, err := NewSession(root, cwd, "provider", "model", "test")
154	if err != nil {
155		t.Fatal(err)
156	}
157	if err := session.AppendMessage(provider.Message{
158		Role:    provider.RoleUser,
159		Content: []provider.Content{provider.TextBlock{Text: "keep the session"}},
160	}); err != nil {
161		t.Fatal(err)
162	}
163	if err := session.Close(); err != nil {
164		t.Fatal(err)
165	}
166	contents, err := os.ReadFile(session.Path)
167	if err != nil {
168		t.Fatal(err)
169	}
170	wrongBucket := filepath.Join(root, "sessions", "0123456789abcdef")
171	if err := os.MkdirAll(wrongBucket, 0o755); err != nil {
172		t.Fatal(err)
173	}
174	if err := os.WriteFile(filepath.Join(wrongBucket, "misplaced.jsonl"), contents, 0o644); err != nil {
175		t.Fatal(err)
176	}
177
178	got, err := FindManagedSessionByID(context.Background(), root, session.ID)
179	if err != nil {
180		t.Fatalf("FindManagedSessionByID: %v", err)
181	}
182	if got != session.Path {
183		t.Fatalf("managed session path = %q, want %q", got, session.Path)
184	}
185}
186
187func TestFindManagedSessionByIDIgnoresSymlink(t *testing.T) {
188	root := t.TempDir()
189	cwd := t.TempDir()
190	session, err := NewSession(root, cwd, "provider", "model", "test")
191	if err != nil {
192		t.Fatal(err)
193	}
194	if err := session.AppendMessage(provider.Message{
195		Role:    provider.RoleUser,
196		Content: []provider.Content{provider.TextBlock{Text: "keep the session"}},
197	}); err != nil {
198		t.Fatal(err)
199	}
200	if err := session.Close(); err != nil {
201		t.Fatal(err)
202	}
203	symlink := filepath.Join(SessionsDir(root, cwd), "link.jsonl")
204	if err := os.Symlink(session.Path, symlink); err != nil {
205		t.Skipf("create session symlink: %v", err)
206	}
207
208	got, err := FindManagedSessionByID(context.Background(), root, session.ID)
209	if err != nil {
210		t.Fatalf("FindManagedSessionByID: %v", err)
211	}
212	if got != session.Path {
213		t.Fatalf("managed session path = %q, want %q", got, session.Path)
214	}
215}
216
217func TestFindManagedSessionByIDHonorsCancellation(t *testing.T) {
218	ctx, cancel := context.WithCancel(context.Background())
219	cancel()
220
221	if _, err := FindManagedSessionByID(ctx, t.TempDir(), "session-id"); !errors.Is(err, context.Canceled) {
222		t.Fatalf("FindManagedSessionByID error = %v, want context.Canceled", err)
223	}
224}
225
226// TestSessionExportImportRoundTrip writes a few messages to a live
227// session, exports it, imports the export under a different cwd,
228// and verifies OpenSession on the imported file yields the same
229// message payloads.
230func TestSessionExportImportRoundTrip(t *testing.T) {
231	root := t.TempDir()
232	originalCWD := "/path/to/project"
233	sess, err := NewSession(root, originalCWD, "anthropic", "claude-opus-4-7", "0.0.0-test")
234	if err != nil {
235		t.Fatal(err)
236	}
237	_ = sess.AppendMessage(provider.Message{
238		Role:    provider.RoleUser,
239		Content: []provider.Content{provider.TextBlock{Text: "hello from the exporter"}},
240	})
241	_ = sess.AppendMessage(provider.Message{
242		Role:    provider.RoleAssistant,
243		Content: []provider.Content{provider.TextBlock{Text: "hi — reply from the assistant"}},
244	})
245	handoff := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
246	if err := sess.UpdateCompactHandoff(handoff); err != nil {
247		t.Fatal(err)
248	}
249	goal := &SessionGoal{Objective: "finish the portable task", Status: GoalPaused}
250	if err := sess.UpdateGoal(goal); err != nil {
251		t.Fatal(err)
252	}
253	_ = sess.Close()
254
255	// Export to a directory — helper should build a name inside it.
256	exportDir := t.TempDir()
257	exportPath, err := ExportSession(sess.Path, exportDir)
258	if err != nil {
259		t.Fatalf("ExportSession: %v", err)
260	}
261	if !strings.HasSuffix(exportPath, PortableExt) {
262		t.Errorf("exported path should end in %s, got %q", PortableExt, exportPath)
263	}
264	if _, err := os.Stat(exportPath); err != nil {
265		t.Fatalf("exported file doesn't exist: %v", err)
266	}
267
268	// Import into a different root + cwd.
269	root2 := t.TempDir()
270	cwd2 := "/some/other/project"
271	importedPath, err := ImportSession(exportPath, root2, cwd2, "0.0.0-test")
272	if err != nil {
273		t.Fatalf("ImportSession: %v", err)
274	}
275	if filepath.Dir(importedPath) != SessionsDir(root2, cwd2) {
276		t.Errorf("imported file should land in SessionsDir, got %q", importedPath)
277	}
278
279	// Reopen and verify message round-trip.
280	imported, msgs, err := OpenSession(importedPath)
281	if err != nil {
282		t.Fatalf("OpenSession: %v", err)
283	}
284	defer imported.Close()
285	if imported.Meta.CWD != cwd2 {
286		t.Errorf("meta cwd: want %q, got %q", cwd2, imported.Meta.CWD)
287	}
288	if imported.Meta.ID == sess.ID {
289		t.Errorf("imported session kept the original id %q; must be rotated", sess.ID)
290	}
291	if imported.Meta.Model != "claude-opus-4-7" {
292		t.Errorf("model not preserved: %q", imported.Meta.Model)
293	}
294	if got := string(imported.Meta.CompactHandoff); got != string(handoff) {
295		t.Errorf("compact handoff = %q, want %q", got, handoff)
296	}
297	if imported.Meta.Goal == nil || imported.Meta.Goal.Objective != goal.Objective || imported.Meta.Goal.Status != goal.Status || imported.Meta.Goal.MissionID == "" {
298		t.Errorf("goal = %#v", imported.Meta.Goal)
299	}
300	if imported.Meta.Mission == nil || imported.Meta.Mission.ID != imported.Meta.Goal.MissionID || imported.Meta.Mission.Objective != goal.Objective {
301		t.Errorf("mission = %#v", imported.Meta.Mission)
302	}
303	if len(msgs) != 2 {
304		t.Fatalf("want 2 messages, got %d", len(msgs))
305	}
306	// Text should round-trip.
307	if extractText(msgs[0]) != "hello from the exporter" {
308		t.Errorf("msg 0 mismatch: %q", extractText(msgs[0]))
309	}
310	if extractText(msgs[1]) != "hi — reply from the assistant" {
311		t.Errorf("msg 1 mismatch: %q", extractText(msgs[1]))
312	}
313}
314
315func TestExportImportUsesLatestProviderModelMetadata(t *testing.T) {
316	root := t.TempDir()
317	session, err := NewSession(root, "/source", "old-provider", "old-model", "test")
318	if err != nil {
319		t.Fatal(err)
320	}
321	_ = session.AppendMessage(provider.Message{
322		Role:    provider.RoleUser,
323		Content: []provider.Content{provider.TextBlock{Text: "metadata prompt"}},
324	})
325	if err := session.UpdateModel("new-provider", "new-model"); err != nil {
326		t.Fatal(err)
327	}
328	path := session.Path
329	if err := session.Close(); err != nil {
330		t.Fatal(err)
331	}
332
333	exportPath, err := ExportSession(path, filepath.Join(t.TempDir(), "latest"+PortableExt))
334	if err != nil {
335		t.Fatalf("ExportSession: %v", err)
336	}
337	exported, err := ReadSessionSnapshot(exportPath)
338	if err != nil {
339		t.Fatalf("Read exported snapshot: %v", err)
340	}
341	if exported.Meta.Provider != "new-provider" || exported.Meta.Model != "new-model" {
342		t.Fatalf("export metadata = provider %q model %q, want latest values", exported.Meta.Provider, exported.Meta.Model)
343	}
344
345	importedPath, err := ImportSession(exportPath, t.TempDir(), "/destination", "test")
346	if err != nil {
347		t.Fatalf("ImportSession: %v", err)
348	}
349	imported, _, err := OpenSession(importedPath)
350	if err != nil {
351		t.Fatalf("Open imported session: %v", err)
352	}
353	defer imported.Close()
354	if imported.Meta.Provider != "new-provider" || imported.Meta.Model != "new-model" {
355		t.Fatalf("import metadata = provider %q model %q, want latest values", imported.Meta.Provider, imported.Meta.Model)
356	}
357}
358
359// TestScanSessionMetaReturnsLatestRowWithoutHydratingMessages verifies that
360// metadata lookup follows append-only model updates without reconstructing the
361// transcript.
362func TestScanSessionMetaReturnsLatestRowWithoutHydratingMessages(t *testing.T) {
363	root := t.TempDir()
364	session, err := NewSession(root, "/workspace", "old-provider", "old-model", "test")
365	if err != nil {
366		t.Fatal(err)
367	}
368	if err := session.AppendMessage(provider.Message{
369		Role:    provider.RoleUser,
370		Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("large transcript ", 1000)}},
371	}); err != nil {
372		t.Fatal(err)
373	}
374	if err := session.UpdateModel("new-provider", "new-model"); err != nil {
375		t.Fatal(err)
376	}
377	if err := session.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
378		t.Fatal(err)
379	}
380	path := session.Path
381	if err := session.Close(); err != nil {
382		t.Fatal(err)
383	}
384
385	meta, err := scanSessionMeta(path)
386	if err != nil {
387		t.Fatal(err)
388	}
389	if meta.Provider != "new-provider" || meta.Model != "new-model" {
390		t.Fatalf("scanned metadata = %q/%q, want latest values", meta.Provider, meta.Model)
391	}
392}
393
394// TestExportToFilePath writes to an explicit file path (no directory
395// guessing) and checks the .zutsession extension is appended when missing.
396func TestExportToFilePath(t *testing.T) {
397	root := t.TempDir()
398	sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
399	if err != nil {
400		t.Fatal(err)
401	}
402	_ = sess.AppendMessage(provider.Message{
403		Role:    provider.RoleUser,
404		Content: []provider.Content{provider.TextBlock{Text: "x"}},
405	})
406	_ = sess.Close()
407
408	// No extension — should add .zutsession.
409	dst := filepath.Join(t.TempDir(), "mysession")
410	out, err := ExportSession(sess.Path, dst)
411	if err != nil {
412		t.Fatal(err)
413	}
414	if !strings.HasSuffix(out, PortableExt) {
415		t.Errorf("want .zutsession suffix on %q", out)
416	}
417}
418
419// TestExportStripsCWDFromMeta verifies the exported meta no longer
420// carries the source user's cwd (not useful to the recipient).
421func TestExportSessionHandlesHugeJSONLRows(t *testing.T) {
422	root := t.TempDir()
423	sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
424	if err != nil {
425		t.Fatal(err)
426	}
427	_ = sess.AppendMessage(provider.Message{
428		Role:    provider.RoleUser,
429		Content: []provider.Content{provider.TextBlock{Text: "first prompt names export"}},
430	})
431	_ = sess.AppendMessage(provider.Message{
432		Role:    provider.RoleAssistant,
433		Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", 22*1024*1024)}},
434	})
435	_ = sess.Close()
436
437	exportDir := t.TempDir()
438	out, err := ExportSession(sess.Path, exportDir)
439	if err != nil {
440		t.Fatalf("ExportSession with huge row: %v", err)
441	}
442	if _, err := os.Stat(out); err != nil {
443		t.Fatalf("exported file missing: %v", err)
444	}
445	opened, msgs, err := OpenSession(sess.Path)
446	if err != nil {
447		t.Fatalf("OpenSession with huge row: %v", err)
448	}
449	_ = opened.Close()
450	if len(msgs) != 2 {
451		t.Fatalf("OpenSession messages=%d, want 2", len(msgs))
452	}
453	// Ensure the exported file is still readable JSONL and contains the
454	// huge assistant message row, not just the meta/header.
455	b, err := os.ReadFile(out)
456	if err != nil {
457		t.Fatal(err)
458	}
459	if !strings.Contains(string(b), strings.Repeat("x", 1024)) {
460		t.Fatalf("export does not appear to contain huge assistant row")
461	}
462	for n, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
463		var raw map[string]any
464		if err := json.Unmarshal([]byte(line), &raw); err != nil {
465			t.Fatalf("exported line %d is invalid json: %v", n+1, err)
466		}
467	}
468}
469
470func TestExportStripsCWDFromMeta(t *testing.T) {
471	root := t.TempDir()
472	sess, err := NewSession(root, "/original/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
473	if err != nil {
474		t.Fatal(err)
475	}
476	_ = sess.AppendMessage(provider.Message{
477		Role:    provider.RoleUser,
478		Content: []provider.Content{provider.TextBlock{Text: "x"}},
479	})
480	_ = sess.Close()
481
482	out, err := ExportSession(sess.Path, filepath.Join(t.TempDir(), "x"+PortableExt))
483	if err != nil {
484		t.Fatal(err)
485	}
486	b, _ := os.ReadFile(out)
487	if strings.Contains(string(b), "/original/cwd") {
488		t.Errorf("exported file leaks the source cwd: %s", string(b))
489	}
490}
491
492// TestBranchSessionCopiesPrefix writes several messages to a
493// session, branches at message index 2 (first user + first
494// assistant), and verifies the new session has exactly those two
495// messages with parent + fork_point meta set.
496func TestBranchSessionCopiesPrefix(t *testing.T) {
497	root := t.TempDir()
498	cwd := "/project"
499	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
500	if err != nil {
501		t.Fatal(err)
502	}
503	_ = parent.AppendMessage(provider.Message{
504		Role:    provider.RoleUser,
505		Content: []provider.Content{provider.TextBlock{Text: "first"}},
506	})
507	_ = parent.AppendMessage(provider.Message{
508		Role:    provider.RoleAssistant,
509		Content: []provider.Content{provider.TextBlock{Text: "first reply"}},
510	})
511	_ = parent.AppendMessage(provider.Message{
512		Role:    provider.RoleUser,
513		Content: []provider.Content{provider.TextBlock{Text: "second"}},
514	})
515	_ = parent.Close()
516
517	// Branch at the first user+assistant pair (upToMessageIdx=2).
518	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
519	if err != nil {
520		t.Fatalf("BranchSession: %v", err)
521	}
522	branch, msgs, err := OpenSession(branchPath)
523	if err != nil {
524		t.Fatal(err)
525	}
526	defer branch.Close()
527
528	if len(msgs) != 2 {
529		t.Errorf("want 2 copied messages, got %d", len(msgs))
530	}
531	if branch.Meta.Parent != parent.Meta.ID {
532		t.Errorf("parent id: want %q, got %q", parent.Meta.ID, branch.Meta.Parent)
533	}
534	if branch.Meta.ForkPoint != 2 {
535		t.Errorf("fork_point: want 2, got %d", branch.Meta.ForkPoint)
536	}
537	if branch.Meta.ID == parent.Meta.ID {
538		t.Errorf("branch kept parent id; must rotate")
539	}
540	if branch.Meta.Title != "" {
541		t.Errorf("meta title should remain append-only, got %q", branch.Meta.Title)
542	}
543	if summary := describeSession(branchPath); summary.Title != "first" {
544		t.Errorf("branch title: want %q, got %q", "first", summary.Title)
545	}
546}
547
548func TestBranchSessionTitleUsesSelectedUserPrompt(t *testing.T) {
549	root := t.TempDir()
550	cwd := "/project"
551	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
552	if err != nil {
553		t.Fatal(err)
554	}
555	for _, msg := range []provider.Message{
556		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first prompt"}}},
557		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first reply"}}},
558		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second prompt"}}},
559	} {
560		_ = parent.AppendMessage(msg)
561	}
562	_ = parent.Close()
563
564	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 3)
565	if err != nil {
566		t.Fatalf("BranchSession: %v", err)
567	}
568	if summary := describeSession(branchPath); summary.Title != "second prompt" {
569		t.Errorf("branch title: want %q, got %q", "second prompt", summary.Title)
570	}
571}
572
573func TestBranchSessionTitleUsesFirstPromptAfterForkWhenBranchDiverges(t *testing.T) {
574	root := t.TempDir()
575	cwd := "/project"
576	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
577	if err != nil {
578		t.Fatal(err)
579	}
580	_ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
581	_ = parent.Close()
582
583	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
584	if err != nil {
585		t.Fatalf("BranchSession: %v", err)
586	}
587	branch, _, err := OpenSession(branchPath)
588	if err != nil {
589		t.Fatal(err)
590	}
591	_ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
592	_ = branch.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "fine"}}})
593	_ = branch.Close()
594
595	if summary := describeSession(branchPath); summary.Title != "how are you?" {
596		t.Errorf("branch title: want %q, got %q", "how are you?", summary.Title)
597	}
598}
599
600func TestGeneratedBranchTitleWinsWhenWrittenBeforeBranchPrompt(t *testing.T) {
601	root := t.TempDir()
602	cwd := "/project"
603	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
604	if err != nil {
605		t.Fatal(err)
606	}
607	_ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
608	_ = parent.Close()
609
610	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
611	if err != nil {
612		t.Fatalf("BranchSession: %v", err)
613	}
614	branch, _, err := OpenSession(branchPath)
615	if err != nil {
616		t.Fatal(err)
617	}
618	if err := branch.UpdateTitle("AI branch title"); err != nil {
619		t.Fatal(err)
620	}
621	if err := branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}}); err != nil {
622		t.Fatal(err)
623	}
624	if err := branch.Close(); err != nil {
625		t.Fatal(err)
626	}
627
628	if summary := describeSession(branchPath); summary.Title != "AI branch title" {
629		t.Errorf("branch title: want %q, got %q", "AI branch title", summary.Title)
630	}
631}
632
633func TestRenamedBranchKeepsExplicitTitle(t *testing.T) {
634	root := t.TempDir()
635	cwd := "/project"
636	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
637	if err != nil {
638		t.Fatal(err)
639	}
640	_ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
641	_ = parent.Close()
642
643	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
644	if err != nil {
645		t.Fatalf("BranchSession: %v", err)
646	}
647	branch, _, err := OpenSession(branchPath)
648	if err != nil {
649		t.Fatal(err)
650	}
651	_ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
652	_ = branch.Close()
653	if err := RenameSession(branchPath, "friendly branch name"); err != nil {
654		t.Fatal(err)
655	}
656
657	if summary := describeSession(branchPath); summary.Title != "friendly branch name" {
658		t.Errorf("branch title: want %q, got %q", "friendly branch name", summary.Title)
659	}
660}
661
662func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) {
663	root := t.TempDir()
664	cwd := "/project"
665	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
666	if err != nil {
667		t.Fatal(err)
668	}
669	for _, text := range []string{"raw-a", "raw-b", "raw-c", "raw-d"} {
670		_ = parent.AppendMessage(provider.Message{
671			Role:    provider.RoleUser,
672			Content: []provider.Content{provider.TextBlock{Text: text}},
673		})
674	}
675	_ = parent.AppendCompaction([]provider.Message{
676		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "summary"}}},
677		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "tail-c"}}},
678		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "tail-d"}}},
679	})
680	_ = parent.AppendMessage(provider.Message{
681		Role:    provider.RoleUser,
682		Content: []provider.Content{provider.TextBlock{Text: "after-compact"}},
683	})
684	_ = parent.Close()
685
686	opened, msgs, err := OpenSession(parent.Path)
687	if err != nil {
688		t.Fatalf("OpenSession parent: %v", err)
689	}
690	_ = opened.Close()
691	assertMessageTexts(t, msgs, []string{"summary", "tail-c", "tail-d", "after-compact"})
692
693	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 4)
694	if err != nil {
695		t.Fatalf("BranchSession: %v", err)
696	}
697	branch, branchMsgs, err := OpenSession(branchPath)
698	if err != nil {
699		t.Fatalf("OpenSession branch: %v", err)
700	}
701	defer branch.Close()
702
703	assertMessageTexts(t, branchMsgs, []string{"summary", "tail-c", "tail-d", "after-compact"})
704	if branch.Meta.Parent != parent.Meta.ID {
705		t.Errorf("parent id: want %q, got %q", parent.Meta.ID, branch.Meta.Parent)
706	}
707	if branch.Meta.ForkPoint != 4 {
708		t.Errorf("fork_point: want 4, got %d", branch.Meta.ForkPoint)
709	}
710
711	shortBranchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
712	if err != nil {
713		t.Fatalf("BranchSession short fork: %v", err)
714	}
715	shortBranch, shortBranchMsgs, err := OpenSession(shortBranchPath)
716	if err != nil {
717		t.Fatalf("OpenSession short branch: %v", err)
718	}
719	defer shortBranch.Close()
720
721	assertMessageTexts(t, shortBranchMsgs, []string{"summary", "tail-c"})
722	if shortBranch.Meta.ForkPoint != 2 {
723		t.Errorf("short branch fork_point: want 2, got %d", shortBranch.Meta.ForkPoint)
724	}
725}
726
727func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
728	root := t.TempDir()
729	cwd := "/project"
730	parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
731	if err != nil {
732		t.Fatal(err)
733	}
734	for _, text := range []string{"first", "second"} {
735		if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}); err != nil {
736			t.Fatal(err)
737		}
738	}
739	handoff := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
740	if err := parent.UpdateCompactHandoff(handoff); err != nil {
741		t.Fatal(err)
742	}
743	goal := &SessionGoal{Objective: "finish the branch", Status: GoalActive}
744	if err := parent.UpdateGoal(goal); err != nil {
745		t.Fatal(err)
746	}
747	if err := parent.Close(); err != nil {
748		t.Fatal(err)
749	}
750
751	fullPath, err := BranchSession(parent.Path, root, cwd, "test", 2)
752	if err != nil {
753		t.Fatal(err)
754	}
755	full, _, err := OpenSession(fullPath)
756	if err != nil {
757		t.Fatal(err)
758	}
759	t.Cleanup(func() { _ = full.Close() })
760	if got := string(full.Meta.CompactHandoff); got != string(handoff) {
761		t.Fatalf("full branch compact handoff = %q, want %q", got, handoff)
762	}
763	if full.Meta.Goal == nil || full.Meta.Goal.Objective != goal.Objective || full.Meta.Goal.Status != goal.Status || full.Meta.Goal.MissionID == "" {
764		t.Fatalf("full branch goal = %#v", full.Meta.Goal)
765	}
766	if full.Meta.Mission == nil || full.Meta.Mission.ID != full.Meta.Goal.MissionID {
767		t.Fatalf("full branch mission = %#v", full.Meta.Mission)
768	}
769
770	prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
771	if err != nil {
772		t.Fatal(err)
773	}
774	prefix, _, err := OpenSession(prefixPath)
775	if err != nil {
776		t.Fatal(err)
777	}
778	t.Cleanup(func() { _ = prefix.Close() })
779	if len(prefix.Meta.CompactHandoff) != 0 {
780		t.Fatalf("prefix branch compact handoff = %q, want empty", prefix.Meta.CompactHandoff)
781	}
782	if prefix.Meta.Goal != nil {
783		t.Fatalf("prefix branch goal = %#v, want nil", prefix.Meta.Goal)
784	}
785	if prefix.Meta.Mission != nil || len(prefix.Meta.GoalHistory) != 0 {
786		t.Fatalf("prefix branch mission/history = %#v / %#v, want nil", prefix.Meta.Mission, prefix.Meta.GoalHistory)
787	}
788}
789
790func TestBranchSessionHiddenFromHistoryClearsCompactHandoff(t *testing.T) {
791	root := t.TempDir()
792	cwd := "/project"
793	parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
794	if err != nil {
795		t.Fatal(err)
796	}
797	if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old"}}}); err != nil {
798		t.Fatal(err)
799	}
800	if err := parent.AppendCompaction([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summary"}}}}); err != nil {
801		t.Fatal(err)
802	}
803	if err := parent.UpdateCompactHandoff(json.RawMessage(`{"version":1,"reason":"forced_length"}`)); err != nil {
804		t.Fatal(err)
805	}
806	if err := parent.Close(); err != nil {
807		t.Fatal(err)
808	}
809
810	history, err := ReadSessionHistory(parent.Path)
811	if err != nil {
812		t.Fatal(err)
813	}
814	if len(history.Segments) < 2 {
815		t.Fatalf("history segments = %d, want at least 2", len(history.Segments))
816	}
817	branchPath, err := BranchSessionHiddenFromHistory(parent.Path, root, cwd, "test", history.Segments[0], len(history.Segments[0].Messages))
818	if err != nil {
819		t.Fatal(err)
820	}
821	branch, _, err := OpenSession(branchPath)
822	if err != nil {
823		t.Fatal(err)
824	}
825	t.Cleanup(func() { _ = branch.Close() })
826	if len(branch.Meta.CompactHandoff) != 0 {
827		t.Fatalf("historical branch compact handoff = %q, want empty", branch.Meta.CompactHandoff)
828	}
829}
830
831func TestBranchSessionCopiesExtensionStateAtFork(t *testing.T) {
832	root := t.TempDir()
833	cwd := "/project"
834	parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
835	if err != nil {
836		t.Fatal(err)
837	}
838	if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}}); err != nil {
839		t.Fatal(err)
840	}
841	if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
842		t.Fatal(err)
843	}
844	if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second"}}}); err != nil {
845		t.Fatal(err)
846	}
847	if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":2}`)); err != nil {
848		t.Fatal(err)
849	}
850	if err := parent.Close(); err != nil {
851		t.Fatal(err)
852	}
853
854	branchPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
855	if err != nil {
856		t.Fatal(err)
857	}
858	branch, _, err := OpenSession(branchPath)
859	if err != nil {
860		t.Fatal(err)
861	}
862	t.Cleanup(func() { _ = branch.Close() })
863	if got := string(branch.ExtensionState["tasked-phases"]); got != `{"version":1}` {
864		t.Fatalf("branch extension state = %q, want version 1", got)
865	}
866}
867
868func TestBranchSessionCarriesStateAcrossCompactionBoundary(t *testing.T) {
869	root := t.TempDir()
870	cwd := "/project"
871	parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
872	if err != nil {
873		t.Fatal(err)
874	}
875	for _, text := range []string{"before-a", "before-b"} {
876		if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}); err != nil {
877			t.Fatal(err)
878		}
879	}
880	if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
881		t.Fatal(err)
882	}
883	if err := parent.AppendCompaction([]provider.Message{
884		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "summary"}}},
885		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "tail"}}},
886	}); err != nil {
887		t.Fatal(err)
888	}
889	if err := parent.Close(); err != nil {
890		t.Fatal(err)
891	}
892
893	branchPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
894	if err != nil {
895		t.Fatal(err)
896	}
897	branch, _, err := OpenSession(branchPath)
898	if err != nil {
899		t.Fatal(err)
900	}
901	t.Cleanup(func() { _ = branch.Close() })
902	if got := string(branch.ExtensionState["tasked-phases"]); got != `{"version":1}` {
903		t.Fatalf("compacted branch extension state = %q, want version 1", got)
904	}
905}
906
907// TestBuildSessionTree verifies parent/child edges are rebuilt
908// from meta + sibling-scan.
909func TestBuildSessionTree(t *testing.T) {
910	root := t.TempDir()
911	cwd := "/project"
912	parent, _ := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
913	_ = parent.AppendMessage(provider.Message{
914		Role:    provider.RoleUser,
915		Content: []provider.Content{provider.TextBlock{Text: "x"}},
916	})
917	_ = parent.Close()
918
919	childA, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
920	if err != nil {
921		t.Fatal(err)
922	}
923	childB, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
924	if err != nil {
925		t.Fatal(err)
926	}
927	_ = childA
928	_ = childB
929
930	tree := BuildSessionTree(root, cwd)
931	if len(tree) != 1 {
932		t.Fatalf("want 1 root, got %d", len(tree))
933	}
934	rootNode := tree[0]
935	if rootNode.Meta.ID != parent.Meta.ID {
936		t.Errorf("root should be parent %q, got %q", parent.Meta.ID, rootNode.Meta.ID)
937	}
938	if len(rootNode.Children) != 2 {
939		t.Errorf("want 2 children, got %d", len(rootNode.Children))
940	}
941}
942
943func assertMessageTexts(t *testing.T, msgs []provider.Message, want []string) {
944	t.Helper()
945	if len(msgs) != len(want) {
946		t.Fatalf("message count: want %d, got %d", len(want), len(msgs))
947	}
948	for i, msg := range msgs {
949		if got := extractText(msg); got != want[i] {
950			t.Errorf("message %d: want %q, got %q", i, want[i], got)
951		}
952	}
953}

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