v0.27.1 131edba

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

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