1package core
2
3import (
4 "context"
5 "encoding/json"
6 "os"
7 "testing"
8 "time"
9
10 "github.com/bnema/zut/packages/provider"
11)
12
13func TestAgentRejectsEmptyPrompt(t *testing.T) {
14 ag := NewAgent(nil, "model", "", nil)
15 if err := ag.Prompt(context.Background(), "", nil, nil); err == nil {
16 t.Fatal("empty prompt was accepted")
17 }
18 if got := ag.Messages(); len(got) != 0 {
19 t.Fatalf("empty prompt appended %d messages", len(got))
20 }
21}
22
23func TestAgentSetPromptConfigSwapsPromptAndToolsAtomically(t *testing.T) {
24 oldTool := &recordingTool{}
25 newTool := &recordingTool{}
26 oldRegistry := Registry{"old": oldTool}
27 newRegistry := Registry{"new": newTool}
28 ag := NewAgent(nil, "model", "old system", oldRegistry)
29
30 previous := ag.SetPromptConfig("new system", newRegistry)
31 if previous["old"] != oldTool {
32 t.Fatalf("previous registry = %#v, want old registry", previous)
33 }
34 system, tools := ag.PromptConfig()
35 if system != "new system" {
36 t.Fatalf("system prompt = %q, want new system", system)
37 }
38 if tools["new"] != newTool || len(tools) != 1 {
39 t.Fatalf("current registry = %#v, want new registry", tools)
40 }
41}
42
43func TestSessionRoundTrip(t *testing.T) {
44 dir := t.TempDir()
45 os.Setenv("ZUT_HOME", dir)
46
47 sess, err := NewSession(dir, "/tmp/project", "anthropic", "claude-sonnet-4-5", "test")
48 if err != nil {
49 t.Fatal(err)
50 }
51 msg := provider.Message{
52 Role: provider.RoleUser,
53 Content: []provider.Content{
54 provider.TextBlock{Text: "hello"},
55 },
56 Time: time.Now().UTC(),
57 }
58 if err := sess.AppendMessage(msg); err != nil {
59 t.Fatal(err)
60 }
61 if err := sess.Close(); err != nil {
62 t.Fatal(err)
63 }
64
65 reopened, msgs, err := OpenSession(sess.Path)
66 if err != nil {
67 t.Fatal(err)
68 }
69 // OpenSession returns a live append writer; close it before t.TempDir
70 // runs cleanup, otherwise windows refuses to delete the open file.
71 t.Cleanup(func() { _ = reopened.Close() })
72 if len(msgs) != 1 {
73 t.Fatalf("got %d messages", len(msgs))
74 }
75 tb, ok := msgs[0].Content[0].(provider.TextBlock)
76 if !ok || tb.Text != "hello" {
77 t.Fatalf("got %+v", msgs[0])
78 }
79}
80
81func TestSessionRoundTripPreservesThoughtSignatures(t *testing.T) {
82 dir := t.TempDir()
83 sess, err := NewSession(dir, "/tmp/project", "google", "gemini-3-flash", "test")
84 if err != nil {
85 t.Fatal(err)
86 }
87 msg := provider.Message{
88 Role: provider.RoleAssistant,
89 Content: []provider.Content{
90 provider.ReasoningBlock{Summary: "thinking"},
91 provider.TextBlock{Text: "answer", ThoughtSignature: "text-sig"},
92 provider.ImageBlock{MimeType: "image/png", Data: []byte("png"), ThoughtSignature: "image-sig"},
93 provider.ToolCallBlock{
94 ID: "call-1",
95 Name: "read",
96 Arguments: []byte(`{"path":"a"}`),
97 ThoughtSignature: "tool-sig",
98 },
99 },
100 }
101 if err := sess.AppendMessage(msg); err != nil {
102 t.Fatal(err)
103 }
104 if err := sess.AppendMessage(provider.Message{
105 Role: provider.RoleTool,
106 Content: []provider.Content{provider.ToolResultBlock{
107 CallID: "call-1",
108 Content: []provider.Content{provider.TextBlock{Text: "result"}},
109 }},
110 }); err != nil {
111 t.Fatal(err)
112 }
113 if err := sess.Close(); err != nil {
114 t.Fatal(err)
115 }
116
117 reopened, msgs, err := OpenSession(sess.Path)
118 if err != nil {
119 t.Fatal(err)
120 }
121 t.Cleanup(func() { _ = reopened.Close() })
122 if len(msgs) != 2 || len(msgs[0].Content) != 4 {
123 t.Fatalf("round trip content = %+v", msgs)
124 }
125 if rb, ok := msgs[0].Content[0].(provider.ReasoningBlock); !ok || rb.Summary != "thinking" {
126 t.Fatalf("reasoning block = %#v", msgs[0].Content[0])
127 }
128 if tb, ok := msgs[0].Content[1].(provider.TextBlock); !ok || tb.ThoughtSignature != "text-sig" {
129 t.Fatalf("text block = %#v", msgs[0].Content[1])
130 }
131 if ib, ok := msgs[0].Content[2].(provider.ImageBlock); !ok || ib.ThoughtSignature != "image-sig" {
132 t.Fatalf("image block = %#v", msgs[0].Content[2])
133 }
134 if tc, ok := msgs[0].Content[3].(provider.ToolCallBlock); !ok || tc.ThoughtSignature != "tool-sig" {
135 t.Fatalf("tool call block = %#v", msgs[0].Content[3])
136 }
137}
138
139// TestNewSessionAtPathCreatesAtExplicitPath proves the subagent worker's
140// session-persistence fallback works: NewSessionAtPath creates the
141// file (and parent dirs) at the exact path the caller chose,
142// independent of SessionsDir(root, cwd). Without this helper, the
143// subagent worker's --session <path> with a non-existent path would
144// fall through to NewSession and write the session under a
145// different (autogenerated) name, leaving the supervisor-chosen
146// path empty.
147func TestNewSessionAtPathCreatesAtExplicitPath(t *testing.T) {
148 dir := t.TempDir()
149 want := dir + "/nested/sub/session.json"
150
151 s, err := NewSessionAtPath(want, "/tmp/proj", "anthropic", "claude", "test")
152 if err != nil {
153 t.Fatalf("NewSessionAtPath: %v", err)
154 }
155 if s.Path != want {
156 t.Errorf("Path = %q; want %q", s.Path, want)
157 }
158 if err := s.AppendMessage(provider.Message{
159 Role: provider.RoleUser,
160 Content: []provider.Content{provider.TextBlock{Text: "hi"}},
161 Time: time.Now().UTC(),
162 }); err != nil {
163 t.Fatal(err)
164 }
165 if err := s.Close(); err != nil {
166 t.Fatal(err)
167 }
168
169 // Reopen at the same path and the message must still be there.
170 reopened, msgs, err := OpenSession(want)
171 if err != nil {
172 t.Fatalf("OpenSession at fixed path: %v", err)
173 }
174 t.Cleanup(func() { _ = reopened.Close() })
175 if len(msgs) != 1 {
176 t.Fatalf("reopen got %d msgs; want 1", len(msgs))
177 }
178
179 // A second call to NewSessionAtPath at the same path must fail
180 // (O_EXCL): callers should use OpenSession to reattach to an
181 // existing file.
182 if _, err := NewSessionAtPath(want, "/tmp/proj", "anthropic", "claude", "test"); err == nil {
183 t.Error("NewSessionAtPath on existing path returned nil; want error")
184 }
185}
186
187func TestSessionExtensionStateRoundTrip(t *testing.T) {
188 dir := t.TempDir()
189 sess, err := NewSession(dir, "/tmp/project", "anthropic", "claude", "test")
190 if err != nil {
191 t.Fatal(err)
192 }
193 if err := sess.AppendMessage(provider.Message{
194 Role: provider.RoleUser,
195 Content: []provider.Content{provider.TextBlock{Text: "prompt"}},
196 }); err != nil {
197 t.Fatal(err)
198 }
199 state := json.RawMessage(`{"phases":[{"id":"phase-1"}]}`)
200 if err := sess.AppendExtensionState("tasked-phases", state); err != nil {
201 t.Fatal(err)
202 }
203 if err := sess.Close(); err != nil {
204 t.Fatal(err)
205 }
206
207 reopened, _, err := OpenSession(sess.Path)
208 if err != nil {
209 t.Fatal(err)
210 }
211 firstReopened := reopened
212 t.Cleanup(func() { _ = firstReopened.Close() })
213 if got := string(reopened.ExtensionState["tasked-phases"]); got != string(state) {
214 t.Fatalf("extension state = %q, want %q", got, state)
215 }
216 if err := reopened.AppendExtensionState("tasked-phases", nil); err != nil {
217 t.Fatal(err)
218 }
219 if _, ok := reopened.ExtensionState["tasked-phases"]; ok {
220 t.Fatal("nil extension state did not clear the snapshot")
221 }
222 if err := reopened.Close(); err != nil {
223 t.Fatal(err)
224 }
225 reopened, _, err = OpenSession(sess.Path)
226 if err != nil {
227 t.Fatal(err)
228 }
229 t.Cleanup(func() { _ = reopened.Close() })
230 if _, ok := reopened.ExtensionState["tasked-phases"]; ok {
231 t.Fatal("cleared extension state was restored after reopening")
232 }
233}
234
235func TestSessionRetainsExtensionStateWithoutMessages(t *testing.T) {
236 dir := t.TempDir()
237 sess, err := NewSession(dir, "/tmp/project", "anthropic", "claude", "test")
238 if err != nil {
239 t.Fatal(err)
240 }
241 state := json.RawMessage(`{"version":1}`)
242 if err := sess.AppendExtensionState("tasked-phases", state); err != nil {
243 t.Fatal(err)
244 }
245 path := sess.Path
246 if err := sess.Close(); err != nil {
247 t.Fatal(err)
248 }
249 reopened, _, err := OpenSession(path)
250 if err != nil {
251 t.Fatal(err)
252 }
253 defer reopened.Close()
254 if got := string(reopened.ExtensionState["tasked-phases"]); got != string(state) {
255 t.Fatalf("extension state = %q, want %q", got, state)
256 }
257}
258
259func TestSessionCompactHandoffRoundTripAndClear(t *testing.T) {
260 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
261 if err != nil {
262 t.Fatal(err)
263 }
264 state := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
265 if err := sess.UpdateCompactHandoff(state); err != nil {
266 t.Fatal(err)
267 }
268 if got := string(sess.Meta.CompactHandoff); got != string(state) {
269 t.Fatalf("live compact handoff = %q, want %q", got, state)
270 }
271 if err := sess.Close(); err != nil {
272 t.Fatal(err)
273 }
274
275 reopened, _, err := OpenSession(sess.Path)
276 if err != nil {
277 t.Fatal(err)
278 }
279 if got := string(reopened.Meta.CompactHandoff); got != string(state) {
280 t.Fatalf("reopened compact handoff = %q, want %q", got, state)
281 }
282 if err := reopened.UpdateCompactHandoff(nil); err != nil {
283 t.Fatal(err)
284 }
285 if err := reopened.Close(); err != nil {
286 t.Fatal(err)
287 }
288
289 reopened, _, err = OpenSession(sess.Path)
290 if err != nil {
291 t.Fatal(err)
292 }
293 defer reopened.Close()
294 if len(reopened.Meta.CompactHandoff) != 0 {
295 t.Fatalf("cleared compact handoff = %q, want empty", reopened.Meta.CompactHandoff)
296 }
297}
298
299func TestSessionGoalRoundTripAndClear(t *testing.T) {
300 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
301 if err != nil {
302 t.Fatal(err)
303 }
304 goal := &SessionGoal{Objective: "finish the requested change", Status: GoalActive}
305 if err := sess.UpdateGoal(goal); err != nil {
306 t.Fatal(err)
307 }
308 if sess.Meta.Goal == nil || *sess.Meta.Goal != *goal {
309 t.Fatalf("live goal = %#v, want %#v", sess.Meta.Goal, goal)
310 }
311 if err := sess.Close(); err != nil {
312 t.Fatal(err)
313 }
314
315 reopened, _, err := OpenSession(sess.Path)
316 if err != nil {
317 t.Fatal(err)
318 }
319 if reopened.Meta.Goal == nil || *reopened.Meta.Goal != *goal {
320 t.Fatalf("reopened goal = %#v, want %#v", reopened.Meta.Goal, goal)
321 }
322 if err := reopened.UpdateGoal(nil); err != nil {
323 t.Fatal(err)
324 }
325 if err := reopened.Close(); err != nil {
326 t.Fatal(err)
327 }
328
329 reopened, _, err = OpenSession(sess.Path)
330 if err != nil {
331 t.Fatal(err)
332 }
333 defer reopened.Close()
334 if reopened.Meta.Goal != nil {
335 t.Fatalf("cleared goal = %#v, want nil", reopened.Meta.Goal)
336 }
337}
338
339func TestCostAdd(t *testing.T) {
340 var c CostTracker
341 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01})
342 c.Add(provider.Usage{InputTokens: 200, OutputTokens: 25, ReasoningTokens: 10, ReasoningTokensKnown: true, CostUSD: 0.02})
343 if c.Total.InputTokens != 300 || c.Total.OutputTokens != 75 {
344 t.Fatalf("got %+v", c.Total)
345 }
346 if c.Total.ReasoningTokens != 30 || !c.Total.ReasoningTokensKnown {
347 t.Fatalf("got reasoning usage %+v", c.Total)
348 }
349 if c.Total.CostUSD < 0.0299 || c.Total.CostUSD > 0.0301 {
350 t.Fatalf("got cost %v", c.Total.CostUSD)
351 }
352}