v0.15.0 6b178f8

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

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