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