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