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}