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 TestNormalizeSessionGoalMetaMigratesLegacyGoal(t *testing.T) {
300 meta := SessionMeta{ID: "legacy-session", Goal: &SessionGoal{Objective: "finish migration", Status: GoalActive}}
301 normalizeSessionGoalMeta(&meta)
302 if meta.Mission == nil || meta.Mission.ID != "legacy-legacy-session" || meta.Mission.Source != MissionSourceUser || meta.Mission.ActiveGoalID != "legacy-goal-legacy-session" {
303 t.Fatalf("mission = %#v", meta.Mission)
304 }
305 if meta.Goal.Owner != GoalOwnerUser || meta.Goal.ID != "legacy-goal-legacy-session" || meta.Goal.MissionID != meta.Mission.ID || meta.Goal.Ordinal != 1 {
306 t.Fatalf("goal = %#v", meta.Goal)
307 }
308}
309
310func TestSessionGoalTransitionLimitRollsBackState(t *testing.T) {
311 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
312 if err != nil {
313 t.Fatal(err)
314 }
315 defer sess.Close()
316 if err := sess.UpdateGoal(&SessionGoal{Objective: "initial", Status: GoalActive, Owner: GoalOwnerUser}); err != nil {
317 t.Fatal(err)
318 }
319 for transition := 0; transition < maxMissionGoalTransitions; transition++ {
320 done := *sess.Meta.Goal
321 done.Status = GoalDone
322 if err := sess.UpdateGoal(&done); err != nil {
323 t.Fatalf("settle transition %d: %v", transition, err)
324 }
325 if err := sess.UpdateGoal(&SessionGoal{Objective: "next", Status: GoalActive, Owner: GoalOwnerManager}); err != nil {
326 t.Fatalf("start transition %d: %v", transition, err)
327 }
328 }
329 done := *sess.Meta.Goal
330 done.Status = GoalDone
331 if err := sess.UpdateGoal(&done); err != nil {
332 t.Fatal(err)
333 }
334 previousMission := *sess.Meta.Mission
335 previousGoal := *sess.Meta.Goal
336 if err := sess.UpdateGoal(&SessionGoal{Objective: "one too many", Status: GoalActive, Owner: GoalOwnerManager}); err == nil {
337 t.Fatal("transition limit was not enforced")
338 }
339 if *sess.Meta.Mission != previousMission || *sess.Meta.Goal != previousGoal {
340 t.Fatalf("state changed after rejected transition: mission %#v, goal %#v", sess.Meta.Mission, sess.Meta.Goal)
341 }
342}
343
344func TestSessionGoalResumeDoesNotConsumeTransitionLimit(t *testing.T) {
345 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
346 if err != nil {
347 t.Fatal(err)
348 }
349 defer sess.Close()
350 if err := sess.UpdateGoal(&SessionGoal{Objective: "initial", Status: GoalActive, Owner: GoalOwnerUser}); err != nil {
351 t.Fatal(err)
352 }
353 done := *sess.Meta.Goal
354 done.Status = GoalDone
355 if err := sess.UpdateGoal(&done); err != nil {
356 t.Fatal(err)
357 }
358 if err := sess.UpdateGoal(&SessionGoal{Objective: "manager work", Status: GoalActive, Owner: GoalOwnerManager}); err != nil {
359 t.Fatal(err)
360 }
361 for cycle := 0; cycle < maxMissionGoalTransitions*2; cycle++ {
362 paused := *sess.Meta.Goal
363 paused.Status = GoalPaused
364 if err := sess.UpdateGoal(&paused); err != nil {
365 t.Fatalf("pause cycle %d: %v", cycle, err)
366 }
367 resumed := *sess.Meta.Goal
368 resumed.Status = GoalActive
369 if err := sess.UpdateGoal(&resumed); err != nil {
370 t.Fatalf("resume cycle %d: %v", cycle, err)
371 }
372 }
373 if got := sess.Meta.Mission.TransitionCount; got != 1 {
374 t.Fatalf("transition count = %d, want 1", got)
375 }
376}
377
378func TestSessionLegacyGoalMigrationPersistsOnNextTransition(t *testing.T) {
379 path := t.TempDir() + "/legacy.jsonl"
380 meta := SessionMeta{ID: "legacy-session", CWD: "/tmp/project", Goal: &SessionGoal{Objective: "finish migration", Status: GoalActive}}
381 line, err := json.Marshal(sessionLine{Type: "meta", Meta: &meta})
382 if err != nil {
383 t.Fatal(err)
384 }
385 if err := os.WriteFile(path, append(line, '\n'), 0o600); err != nil {
386 t.Fatal(err)
387 }
388
389 sess, _, err := OpenSession(path)
390 if err != nil {
391 t.Fatal(err)
392 }
393 if sess.Meta.Mission == nil || sess.Meta.Goal.MissionID != sess.Meta.Mission.ID {
394 t.Fatalf("legacy migration = mission %#v, goal %#v", sess.Meta.Mission, sess.Meta.Goal)
395 }
396 done := *sess.Meta.Goal
397 done.Status = GoalDone
398 if err := sess.UpdateGoal(&done); err != nil {
399 t.Fatal(err)
400 }
401 if err := sess.Close(); err != nil {
402 t.Fatal(err)
403 }
404
405 reopened, _, err := OpenSession(path)
406 if err != nil {
407 t.Fatal(err)
408 }
409 defer reopened.Close()
410 if reopened.Meta.Mission == nil || reopened.Meta.Goal == nil || reopened.Meta.Goal.Status != GoalDone || reopened.Meta.Goal.MissionID != reopened.Meta.Mission.ID {
411 t.Fatalf("persisted migration = mission %#v, goal %#v", reopened.Meta.Mission, reopened.Meta.Goal)
412 }
413}
414
415func TestSessionEnsureMissionCreatesUserBoundaryOnce(t *testing.T) {
416 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
417 if err != nil {
418 t.Fatal(err)
419 }
420 if err := sess.EnsureMission("fix the reported failure", MissionSourceUser); err != nil {
421 t.Fatal(err)
422 }
423 if sess.Meta.Mission == nil || sess.Meta.Mission.Objective != "fix the reported failure" || sess.Meta.Mission.Source != MissionSourceUser {
424 t.Fatalf("mission = %#v", sess.Meta.Mission)
425 }
426 missionID := sess.Meta.Mission.ID
427 if err := sess.EnsureMission("unrelated follow-up", MissionSourceUser); err != nil {
428 t.Fatal(err)
429 }
430 if sess.Meta.Mission.ID != missionID || sess.Meta.Mission.Objective != "fix the reported failure" {
431 t.Fatalf("mission was replaced: %#v", sess.Meta.Mission)
432 }
433 path := sess.Path
434 if err := sess.Close(); err != nil {
435 t.Fatal(err)
436 }
437
438 reopened, _, err := OpenSession(path)
439 if err != nil {
440 t.Fatal(err)
441 }
442 defer reopened.Close()
443 if reopened.Meta.Mission == nil || reopened.Meta.Mission.ID != missionID {
444 t.Fatalf("reopened mission = %#v", reopened.Meta.Mission)
445 }
446}
447
448func TestSessionGoalRoundTripAndClear(t *testing.T) {
449 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
450 if err != nil {
451 t.Fatal(err)
452 }
453 goal := &SessionGoal{Objective: "finish the requested change", Status: GoalActive}
454 if err := sess.UpdateGoal(goal); err != nil {
455 t.Fatal(err)
456 }
457 if sess.Meta.Goal == nil || sess.Meta.Goal.Objective != goal.Objective || sess.Meta.Goal.Status != goal.Status || sess.Meta.Goal.Owner != GoalOwnerUser || sess.Meta.Goal.ID == "" || sess.Meta.Goal.MissionID == "" || sess.Meta.Goal.Ordinal != 1 {
458 t.Fatalf("live goal = %#v", sess.Meta.Goal)
459 }
460 if sess.Meta.Mission == nil || sess.Meta.Mission.ID != sess.Meta.Goal.MissionID || sess.Meta.Mission.Objective != goal.Objective || sess.Meta.Mission.ActiveGoalID != sess.Meta.Goal.ID {
461 t.Fatalf("live mission = %#v", sess.Meta.Mission)
462 }
463 if err := sess.Close(); err != nil {
464 t.Fatal(err)
465 }
466
467 reopened, _, err := OpenSession(sess.Path)
468 if err != nil {
469 t.Fatal(err)
470 }
471 if reopened.Meta.Goal == nil || reopened.Meta.Goal.Objective != goal.Objective || reopened.Meta.Goal.Status != goal.Status || reopened.Meta.Goal.MissionID != sess.Meta.Goal.MissionID {
472 t.Fatalf("reopened goal = %#v", reopened.Meta.Goal)
473 }
474 if err := reopened.UpdateGoal(nil); err != nil {
475 t.Fatal(err)
476 }
477 if err := reopened.Close(); err != nil {
478 t.Fatal(err)
479 }
480
481 reopened, _, err = OpenSession(sess.Path)
482 if err != nil {
483 t.Fatal(err)
484 }
485 defer reopened.Close()
486 if reopened.Meta.Goal != nil {
487 t.Fatalf("cleared goal = %#v, want nil", reopened.Meta.Goal)
488 }
489 if len(reopened.Meta.GoalHistory) != 1 || reopened.Meta.GoalHistory[0].Objective != goal.Objective || reopened.Meta.GoalHistory[0].Status != GoalActive {
490 t.Fatalf("goal history = %#v", reopened.Meta.GoalHistory)
491 }
492}
493
494func TestCostAdd(t *testing.T) {
495 var c CostTracker
496 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01})
497 c.Add(provider.Usage{InputTokens: 200, OutputTokens: 25, ReasoningTokens: 10, ReasoningTokensKnown: true, CostUSD: 0.02})
498 if c.Total.InputTokens != 300 || c.Total.OutputTokens != 75 {
499 t.Fatalf("got %+v", c.Total)
500 }
501 if c.Total.ReasoningTokens != 30 || !c.Total.ReasoningTokensKnown {
502 t.Fatalf("got reasoning usage %+v", c.Total)
503 }
504 if c.Total.CostUSD < 0.0299 || c.Total.CostUSD > 0.0301 {
505 t.Fatalf("got cost %v", c.Total.CostUSD)
506 }
507}