1package core
2
3import (
4 "os"
5 "testing"
6 "time"
7
8 "github.com/patriceckhart/zot/packages/provider"
9)
10
11func TestSessionRoundTrip(t *testing.T) {
12 dir := t.TempDir()
13 os.Setenv("ZOT_HOME", dir)
14
15 sess, err := NewSession(dir, "/tmp/project", "anthropic", "claude-sonnet-4-5", "test")
16 if err != nil {
17 t.Fatal(err)
18 }
19 msg := provider.Message{
20 Role: provider.RoleUser,
21 Content: []provider.Content{
22 provider.TextBlock{Text: "hello"},
23 },
24 Time: time.Now().UTC(),
25 }
26 if err := sess.AppendMessage(msg); err != nil {
27 t.Fatal(err)
28 }
29 if err := sess.Close(); err != nil {
30 t.Fatal(err)
31 }
32
33 reopened, msgs, err := OpenSession(sess.Path)
34 if err != nil {
35 t.Fatal(err)
36 }
37 // OpenSession returns a live append writer; close it before t.TempDir
38 // runs cleanup, otherwise windows refuses to delete the open file.
39 t.Cleanup(func() { _ = reopened.Close() })
40 if len(msgs) != 1 {
41 t.Fatalf("got %d messages", len(msgs))
42 }
43 tb, ok := msgs[0].Content[0].(provider.TextBlock)
44 if !ok || tb.Text != "hello" {
45 t.Fatalf("got %+v", msgs[0])
46 }
47}
48
49func TestSessionRoundTripPreservesThoughtSignatures(t *testing.T) {
50 dir := t.TempDir()
51 sess, err := NewSession(dir, "/tmp/project", "google", "gemini-3-flash", "test")
52 if err != nil {
53 t.Fatal(err)
54 }
55 msg := provider.Message{
56 Role: provider.RoleAssistant,
57 Content: []provider.Content{
58 provider.ReasoningBlock{Summary: "thinking"},
59 provider.TextBlock{Text: "answer", ThoughtSignature: "text-sig"},
60 provider.ImageBlock{MimeType: "image/png", Data: []byte("png"), ThoughtSignature: "image-sig"},
61 provider.ToolCallBlock{
62 ID: "call-1",
63 Name: "read",
64 Arguments: []byte(`{"path":"a"}`),
65 ThoughtSignature: "tool-sig",
66 },
67 },
68 }
69 if err := sess.AppendMessage(msg); err != nil {
70 t.Fatal(err)
71 }
72 if err := sess.AppendMessage(provider.Message{
73 Role: provider.RoleTool,
74 Content: []provider.Content{provider.ToolResultBlock{
75 CallID: "call-1",
76 Content: []provider.Content{provider.TextBlock{Text: "result"}},
77 }},
78 }); err != nil {
79 t.Fatal(err)
80 }
81 if err := sess.Close(); err != nil {
82 t.Fatal(err)
83 }
84
85 reopened, msgs, err := OpenSession(sess.Path)
86 if err != nil {
87 t.Fatal(err)
88 }
89 t.Cleanup(func() { _ = reopened.Close() })
90 if len(msgs) != 2 || len(msgs[0].Content) != 4 {
91 t.Fatalf("round trip content = %+v", msgs)
92 }
93 if rb, ok := msgs[0].Content[0].(provider.ReasoningBlock); !ok || rb.Summary != "thinking" {
94 t.Fatalf("reasoning block = %#v", msgs[0].Content[0])
95 }
96 if tb, ok := msgs[0].Content[1].(provider.TextBlock); !ok || tb.ThoughtSignature != "text-sig" {
97 t.Fatalf("text block = %#v", msgs[0].Content[1])
98 }
99 if ib, ok := msgs[0].Content[2].(provider.ImageBlock); !ok || ib.ThoughtSignature != "image-sig" {
100 t.Fatalf("image block = %#v", msgs[0].Content[2])
101 }
102 if tc, ok := msgs[0].Content[3].(provider.ToolCallBlock); !ok || tc.ThoughtSignature != "tool-sig" {
103 t.Fatalf("tool call block = %#v", msgs[0].Content[3])
104 }
105}
106
107// TestNewSessionAtPathCreatesAtExplicitPath proves the swarm child's
108// session-persistence fallback works: NewSessionAtPath creates the
109// file (and parent dirs) at the exact path the caller chose,
110// independent of SessionsDir(root, cwd). Without this helper, the
111// swarm child's --session <path> with a non-existent path would
112// fall through to NewSession and write the session under a
113// different (autogenerated) name, leaving the supervisor-chosen
114// path empty.
115func TestNewSessionAtPathCreatesAtExplicitPath(t *testing.T) {
116 dir := t.TempDir()
117 want := dir + "/nested/sub/session.json"
118
119 s, err := NewSessionAtPath(want, "/tmp/proj", "anthropic", "claude", "test")
120 if err != nil {
121 t.Fatalf("NewSessionAtPath: %v", err)
122 }
123 if s.Path != want {
124 t.Errorf("Path = %q; want %q", s.Path, want)
125 }
126 if err := s.AppendMessage(provider.Message{
127 Role: provider.RoleUser,
128 Content: []provider.Content{provider.TextBlock{Text: "hi"}},
129 Time: time.Now().UTC(),
130 }); err != nil {
131 t.Fatal(err)
132 }
133 if err := s.Close(); err != nil {
134 t.Fatal(err)
135 }
136
137 // Reopen at the same path and the message must still be there.
138 reopened, msgs, err := OpenSession(want)
139 if err != nil {
140 t.Fatalf("OpenSession at fixed path: %v", err)
141 }
142 t.Cleanup(func() { _ = reopened.Close() })
143 if len(msgs) != 1 {
144 t.Fatalf("reopen got %d msgs; want 1", len(msgs))
145 }
146
147 // A second call to NewSessionAtPath at the same path must fail
148 // (O_EXCL): callers should use OpenSession to reattach to an
149 // existing file.
150 if _, err := NewSessionAtPath(want, "/tmp/proj", "anthropic", "claude", "test"); err == nil {
151 t.Error("NewSessionAtPath on existing path returned nil; want error")
152 }
153}
154
155func TestCostAdd(t *testing.T) {
156 var c CostTracker
157 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01})
158 c.Add(provider.Usage{InputTokens: 200, OutputTokens: 25, ReasoningTokens: 10, ReasoningTokensKnown: true, CostUSD: 0.02})
159 if c.Total.InputTokens != 300 || c.Total.OutputTokens != 75 {
160 t.Fatalf("got %+v", c.Total)
161 }
162 if c.Total.ReasoningTokens != 30 || !c.Total.ReasoningTokensKnown {
163 t.Fatalf("got reasoning usage %+v", c.Total)
164 }
165 if c.Total.CostUSD < 0.0299 || c.Total.CostUSD > 0.0301 {
166 t.Fatalf("got cost %v", c.Total.CostUSD)
167 }
168}