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