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