1package core
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "github.com/patriceckhart/zot/internal/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}
233
234// TestBuildSessionTree verifies parent/child edges are rebuilt
235// from meta + sibling-scan.
236func TestBuildSessionTree(t *testing.T) {
237 root := t.TempDir()
238 cwd := "/project"
239 parent, _ := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
240 _ = parent.AppendMessage(provider.Message{
241 Role: provider.RoleUser,
242 Content: []provider.Content{provider.TextBlock{Text: "x"}},
243 })
244 _ = parent.Close()
245
246 childA, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
247 if err != nil {
248 t.Fatal(err)
249 }
250 childB, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
251 if err != nil {
252 t.Fatal(err)
253 }
254 _ = childA
255 _ = childB
256
257 tree := BuildSessionTree(root, cwd)
258 if len(tree) != 1 {
259 t.Fatalf("want 1 root, got %d", len(tree))
260 }
261 rootNode := tree[0]
262 if rootNode.Meta.ID != parent.Meta.ID {
263 t.Errorf("root should be parent %q, got %q", parent.Meta.ID, rootNode.Meta.ID)
264 }
265 if len(rootNode.Children) != 2 {
266 t.Errorf("want 2 children, got %d", len(rootNode.Children))
267 }
268}