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