1package core
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "github.com/patriceckhart/zot/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 _ = sess.Close()
33
34 // Export to a directory — helper should build a name inside it.
35 exportDir := t.TempDir()
36 exportPath, err := ExportSession(sess.Path, exportDir)
37 if err != nil {
38 t.Fatalf("ExportSession: %v", err)
39 }
40 if !strings.HasSuffix(exportPath, PortableExt) {
41 t.Errorf("exported path should end in %s, got %q", PortableExt, exportPath)
42 }
43 if _, err := os.Stat(exportPath); err != nil {
44 t.Fatalf("exported file doesn't exist: %v", err)
45 }
46
47 // Import into a different root + cwd.
48 root2 := t.TempDir()
49 cwd2 := "/some/other/project"
50 importedPath, err := ImportSession(exportPath, root2, cwd2, "0.0.0-test")
51 if err != nil {
52 t.Fatalf("ImportSession: %v", err)
53 }
54 if filepath.Dir(importedPath) != SessionsDir(root2, cwd2) {
55 t.Errorf("imported file should land in SessionsDir, got %q", importedPath)
56 }
57
58 // Reopen and verify message round-trip.
59 imported, msgs, err := OpenSession(importedPath)
60 if err != nil {
61 t.Fatalf("OpenSession: %v", err)
62 }
63 defer imported.Close()
64 if imported.Meta.CWD != cwd2 {
65 t.Errorf("meta cwd: want %q, got %q", cwd2, imported.Meta.CWD)
66 }
67 if imported.Meta.ID == sess.ID {
68 t.Errorf("imported session kept the original id %q; must be rotated", sess.ID)
69 }
70 if imported.Meta.Model != "claude-opus-4-7" {
71 t.Errorf("model not preserved: %q", imported.Meta.Model)
72 }
73 if len(msgs) != 2 {
74 t.Fatalf("want 2 messages, got %d", len(msgs))
75 }
76 // Text should round-trip.
77 if extractText(msgs[0]) != "hello from the exporter" {
78 t.Errorf("msg 0 mismatch: %q", extractText(msgs[0]))
79 }
80 if extractText(msgs[1]) != "hi — reply from the assistant" {
81 t.Errorf("msg 1 mismatch: %q", extractText(msgs[1]))
82 }
83}
84
85// TestExportToFilePath writes to an explicit file path (no
86// directory guessing) and checks the .zotsession extension is
87// appended when missing.
88func TestExportToFilePath(t *testing.T) {
89 root := t.TempDir()
90 sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
91 if err != nil {
92 t.Fatal(err)
93 }
94 _ = sess.AppendMessage(provider.Message{
95 Role: provider.RoleUser,
96 Content: []provider.Content{provider.TextBlock{Text: "x"}},
97 })
98 _ = sess.Close()
99
100 // No extension — should add .zotsession.
101 dst := filepath.Join(t.TempDir(), "mysession")
102 out, err := ExportSession(sess.Path, dst)
103 if err != nil {
104 t.Fatal(err)
105 }
106 if !strings.HasSuffix(out, PortableExt) {
107 t.Errorf("want .zotsession suffix on %q", out)
108 }
109}
110
111// TestExportStripsCWDFromMeta verifies the exported meta no longer
112// carries the source user's cwd (not useful to the recipient).
113func TestExportSessionHandlesHugeJSONLRows(t *testing.T) {
114 root := t.TempDir()
115 sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
116 if err != nil {
117 t.Fatal(err)
118 }
119 _ = sess.AppendMessage(provider.Message{
120 Role: provider.RoleUser,
121 Content: []provider.Content{provider.TextBlock{Text: "first prompt names export"}},
122 })
123 _ = sess.AppendMessage(provider.Message{
124 Role: provider.RoleAssistant,
125 Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", 22*1024*1024)}},
126 })
127 _ = sess.Close()
128
129 exportDir := t.TempDir()
130 out, err := ExportSession(sess.Path, exportDir)
131 if err != nil {
132 t.Fatalf("ExportSession with huge row: %v", err)
133 }
134 if _, err := os.Stat(out); err != nil {
135 t.Fatalf("exported file missing: %v", err)
136 }
137 opened, msgs, err := OpenSession(sess.Path)
138 if err != nil {
139 t.Fatalf("OpenSession with huge row: %v", err)
140 }
141 _ = opened.Close()
142 if len(msgs) != 2 {
143 t.Fatalf("OpenSession messages=%d, want 2", len(msgs))
144 }
145 // Ensure the exported file is still readable JSONL and contains the
146 // huge assistant message row, not just the meta/header.
147 b, err := os.ReadFile(out)
148 if err != nil {
149 t.Fatal(err)
150 }
151 if !strings.Contains(string(b), strings.Repeat("x", 1024)) {
152 t.Fatalf("export does not appear to contain huge assistant row")
153 }
154 for n, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
155 var raw map[string]any
156 if err := json.Unmarshal([]byte(line), &raw); err != nil {
157 t.Fatalf("exported line %d is invalid json: %v", n+1, err)
158 }
159 }
160}
161
162func TestExportStripsCWDFromMeta(t *testing.T) {
163 root := t.TempDir()
164 sess, err := NewSession(root, "/original/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
165 if err != nil {
166 t.Fatal(err)
167 }
168 _ = sess.AppendMessage(provider.Message{
169 Role: provider.RoleUser,
170 Content: []provider.Content{provider.TextBlock{Text: "x"}},
171 })
172 _ = sess.Close()
173
174 out, err := ExportSession(sess.Path, filepath.Join(t.TempDir(), "x"+PortableExt))
175 if err != nil {
176 t.Fatal(err)
177 }
178 b, _ := os.ReadFile(out)
179 if strings.Contains(string(b), "/original/cwd") {
180 t.Errorf("exported file leaks the source cwd: %s", string(b))
181 }
182}
183
184// TestBranchSessionCopiesPrefix writes several messages to a
185// session, branches at message index 2 (first user + first
186// assistant), and verifies the new session has exactly those two
187// messages with parent + fork_point meta set.
188func TestBranchSessionCopiesPrefix(t *testing.T) {
189 root := t.TempDir()
190 cwd := "/project"
191 parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
192 if err != nil {
193 t.Fatal(err)
194 }
195 _ = parent.AppendMessage(provider.Message{
196 Role: provider.RoleUser,
197 Content: []provider.Content{provider.TextBlock{Text: "first"}},
198 })
199 _ = parent.AppendMessage(provider.Message{
200 Role: provider.RoleAssistant,
201 Content: []provider.Content{provider.TextBlock{Text: "first reply"}},
202 })
203 _ = parent.AppendMessage(provider.Message{
204 Role: provider.RoleUser,
205 Content: []provider.Content{provider.TextBlock{Text: "second"}},
206 })
207 _ = parent.Close()
208
209 // Branch at the first user+assistant pair (upToMessageIdx=2).
210 branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
211 if err != nil {
212 t.Fatalf("BranchSession: %v", err)
213 }
214 branch, msgs, err := OpenSession(branchPath)
215 if err != nil {
216 t.Fatal(err)
217 }
218 defer branch.Close()
219
220 if len(msgs) != 2 {
221 t.Errorf("want 2 copied messages, got %d", len(msgs))
222 }
223 if branch.Meta.Parent != parent.Meta.ID {
224 t.Errorf("parent id: want %q, got %q", parent.Meta.ID, branch.Meta.Parent)
225 }
226 if branch.Meta.ForkPoint != 2 {
227 t.Errorf("fork_point: want 2, got %d", branch.Meta.ForkPoint)
228 }
229 if branch.Meta.ID == parent.Meta.ID {
230 t.Errorf("branch kept parent id; must rotate")
231 }
232 if branch.Meta.Title != "" {
233 t.Errorf("meta title should remain append-only, got %q", branch.Meta.Title)
234 }
235 if summary := describeSession(branchPath); summary.Title != "first" {
236 t.Errorf("branch title: want %q, got %q", "first", summary.Title)
237 }
238}
239
240func TestBranchSessionTitleUsesSelectedUserPrompt(t *testing.T) {
241 root := t.TempDir()
242 cwd := "/project"
243 parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
244 if err != nil {
245 t.Fatal(err)
246 }
247 for _, msg := range []provider.Message{
248 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first prompt"}}},
249 {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first reply"}}},
250 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second prompt"}}},
251 } {
252 _ = parent.AppendMessage(msg)
253 }
254 _ = parent.Close()
255
256 branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 3)
257 if err != nil {
258 t.Fatalf("BranchSession: %v", err)
259 }
260 if summary := describeSession(branchPath); summary.Title != "second prompt" {
261 t.Errorf("branch title: want %q, got %q", "second prompt", summary.Title)
262 }
263}
264
265func TestBranchSessionTitleUsesFirstPromptAfterForkWhenBranchDiverges(t *testing.T) {
266 root := t.TempDir()
267 cwd := "/project"
268 parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
269 if err != nil {
270 t.Fatal(err)
271 }
272 _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
273 _ = parent.Close()
274
275 branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
276 if err != nil {
277 t.Fatalf("BranchSession: %v", err)
278 }
279 branch, _, err := OpenSession(branchPath)
280 if err != nil {
281 t.Fatal(err)
282 }
283 _ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
284 _ = branch.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "fine"}}})
285 _ = branch.Close()
286
287 if summary := describeSession(branchPath); summary.Title != "how are you?" {
288 t.Errorf("branch title: want %q, got %q", "how are you?", summary.Title)
289 }
290}
291
292func TestRenamedBranchKeepsExplicitTitle(t *testing.T) {
293 root := t.TempDir()
294 cwd := "/project"
295 parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
296 if err != nil {
297 t.Fatal(err)
298 }
299 _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
300 _ = parent.Close()
301
302 branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
303 if err != nil {
304 t.Fatalf("BranchSession: %v", err)
305 }
306 branch, _, err := OpenSession(branchPath)
307 if err != nil {
308 t.Fatal(err)
309 }
310 _ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
311 _ = branch.Close()
312 if err := RenameSession(branchPath, "friendly branch name"); err != nil {
313 t.Fatal(err)
314 }
315
316 if summary := describeSession(branchPath); summary.Title != "friendly branch name" {
317 t.Errorf("branch title: want %q, got %q", "friendly branch name", summary.Title)
318 }
319}
320
321func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) {
322 root := t.TempDir()
323 cwd := "/project"
324 parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
325 if err != nil {
326 t.Fatal(err)
327 }
328 for _, text := range []string{"raw-a", "raw-b", "raw-c", "raw-d"} {
329 _ = parent.AppendMessage(provider.Message{
330 Role: provider.RoleUser,
331 Content: []provider.Content{provider.TextBlock{Text: text}},
332 })
333 }
334 _ = parent.AppendCompaction([]provider.Message{
335 {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "summary"}}},
336 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "tail-c"}}},
337 {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "tail-d"}}},
338 })
339 _ = parent.AppendMessage(provider.Message{
340 Role: provider.RoleUser,
341 Content: []provider.Content{provider.TextBlock{Text: "after-compact"}},
342 })
343 _ = parent.Close()
344
345 opened, msgs, err := OpenSession(parent.Path)
346 if err != nil {
347 t.Fatalf("OpenSession parent: %v", err)
348 }
349 _ = opened.Close()
350 assertMessageTexts(t, msgs, []string{"summary", "tail-c", "tail-d", "after-compact"})
351
352 branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 4)
353 if err != nil {
354 t.Fatalf("BranchSession: %v", err)
355 }
356 branch, branchMsgs, err := OpenSession(branchPath)
357 if err != nil {
358 t.Fatalf("OpenSession branch: %v", err)
359 }
360 defer branch.Close()
361
362 assertMessageTexts(t, branchMsgs, []string{"summary", "tail-c", "tail-d", "after-compact"})
363 if branch.Meta.Parent != parent.Meta.ID {
364 t.Errorf("parent id: want %q, got %q", parent.Meta.ID, branch.Meta.Parent)
365 }
366 if branch.Meta.ForkPoint != 4 {
367 t.Errorf("fork_point: want 4, got %d", branch.Meta.ForkPoint)
368 }
369
370 shortBranchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
371 if err != nil {
372 t.Fatalf("BranchSession short fork: %v", err)
373 }
374 shortBranch, shortBranchMsgs, err := OpenSession(shortBranchPath)
375 if err != nil {
376 t.Fatalf("OpenSession short branch: %v", err)
377 }
378 defer shortBranch.Close()
379
380 assertMessageTexts(t, shortBranchMsgs, []string{"summary", "tail-c"})
381 if shortBranch.Meta.ForkPoint != 2 {
382 t.Errorf("short branch fork_point: want 2, got %d", shortBranch.Meta.ForkPoint)
383 }
384}
385
386// TestBuildSessionTree verifies parent/child edges are rebuilt
387// from meta + sibling-scan.
388func TestBuildSessionTree(t *testing.T) {
389 root := t.TempDir()
390 cwd := "/project"
391 parent, _ := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
392 _ = parent.AppendMessage(provider.Message{
393 Role: provider.RoleUser,
394 Content: []provider.Content{provider.TextBlock{Text: "x"}},
395 })
396 _ = parent.Close()
397
398 childA, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
399 if err != nil {
400 t.Fatal(err)
401 }
402 childB, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
403 if err != nil {
404 t.Fatal(err)
405 }
406 _ = childA
407 _ = childB
408
409 tree := BuildSessionTree(root, cwd)
410 if len(tree) != 1 {
411 t.Fatalf("want 1 root, got %d", len(tree))
412 }
413 rootNode := tree[0]
414 if rootNode.Meta.ID != parent.Meta.ID {
415 t.Errorf("root should be parent %q, got %q", parent.Meta.ID, rootNode.Meta.ID)
416 }
417 if len(rootNode.Children) != 2 {
418 t.Errorf("want 2 children, got %d", len(rootNode.Children))
419 }
420}
421
422func assertMessageTexts(t *testing.T, msgs []provider.Message, want []string) {
423 t.Helper()
424 if len(msgs) != len(want) {
425 t.Fatalf("message count: want %d, got %d", len(want), len(msgs))
426 }
427 for i, msg := range msgs {
428 if got := extractText(msg); got != want[i] {
429 t.Errorf("message %d: want %q, got %q", i, want[i], got)
430 }
431 }
432}