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 that callers with an
140// explicit persistence location get the requested file and parent
141// directories, independent of SessionsDir(root, cwd).
142func TestNewSessionAtPathCreatesAtExplicitPath(t *testing.T) {
143 dir := t.TempDir()
144 want := dir + "/nested/sub/session.json"
145
146 s, err := NewSessionAtPath(want, "/tmp/proj", "anthropic", "claude", "test")
147 if err != nil {
148 t.Fatalf("NewSessionAtPath: %v", err)
149 }
150 if s.Path != want {
151 t.Errorf("Path = %q; want %q", s.Path, want)
152 }
153 if err := s.AppendMessage(provider.Message{
154 Role: provider.RoleUser,
155 Content: []provider.Content{provider.TextBlock{Text: "hi"}},
156 Time: time.Now().UTC(),
157 }); err != nil {
158 t.Fatal(err)
159 }
160 if err := s.Close(); err != nil {
161 t.Fatal(err)
162 }
163
164 // Reopen at the same path and the message must still be there.
165 reopened, msgs, err := OpenSession(want)
166 if err != nil {
167 t.Fatalf("OpenSession at fixed path: %v", err)
168 }
169 t.Cleanup(func() { _ = reopened.Close() })
170 if len(msgs) != 1 {
171 t.Fatalf("reopen got %d msgs; want 1", len(msgs))
172 }
173
174 // A second call to NewSessionAtPath at the same path must fail
175 // (O_EXCL): callers should use OpenSession to reattach to an
176 // existing file.
177 if _, err := NewSessionAtPath(want, "/tmp/proj", "anthropic", "claude", "test"); err == nil {
178 t.Error("NewSessionAtPath on existing path returned nil; want error")
179 }
180}
181
182func TestSessionExtensionStateRoundTrip(t *testing.T) {
183 dir := t.TempDir()
184 sess, err := NewSession(dir, "/tmp/project", "anthropic", "claude", "test")
185 if err != nil {
186 t.Fatal(err)
187 }
188 if err := sess.AppendMessage(provider.Message{
189 Role: provider.RoleUser,
190 Content: []provider.Content{provider.TextBlock{Text: "prompt"}},
191 }); err != nil {
192 t.Fatal(err)
193 }
194 state := json.RawMessage(`{"phases":[{"id":"phase-1"}]}`)
195 if err := sess.AppendExtensionState("tasked-phases", state); err != nil {
196 t.Fatal(err)
197 }
198 if err := sess.Close(); err != nil {
199 t.Fatal(err)
200 }
201
202 reopened, _, err := OpenSession(sess.Path)
203 if err != nil {
204 t.Fatal(err)
205 }
206 firstReopened := reopened
207 t.Cleanup(func() { _ = firstReopened.Close() })
208 if got := string(reopened.ExtensionState["tasked-phases"]); got != string(state) {
209 t.Fatalf("extension state = %q, want %q", got, state)
210 }
211 if err := reopened.AppendExtensionState("tasked-phases", nil); err != nil {
212 t.Fatal(err)
213 }
214 if _, ok := reopened.ExtensionState["tasked-phases"]; ok {
215 t.Fatal("nil extension state did not clear the snapshot")
216 }
217 if err := reopened.Close(); err != nil {
218 t.Fatal(err)
219 }
220 reopened, _, err = OpenSession(sess.Path)
221 if err != nil {
222 t.Fatal(err)
223 }
224 t.Cleanup(func() { _ = reopened.Close() })
225 if _, ok := reopened.ExtensionState["tasked-phases"]; ok {
226 t.Fatal("cleared extension state was restored after reopening")
227 }
228}
229
230func TestSessionRetainsExtensionStateWithoutMessages(t *testing.T) {
231 dir := t.TempDir()
232 sess, err := NewSession(dir, "/tmp/project", "anthropic", "claude", "test")
233 if err != nil {
234 t.Fatal(err)
235 }
236 state := json.RawMessage(`{"version":1}`)
237 if err := sess.AppendExtensionState("tasked-phases", state); err != nil {
238 t.Fatal(err)
239 }
240 path := sess.Path
241 if err := sess.Close(); err != nil {
242 t.Fatal(err)
243 }
244 reopened, _, err := OpenSession(path)
245 if err != nil {
246 t.Fatal(err)
247 }
248 defer reopened.Close()
249 if got := string(reopened.ExtensionState["tasked-phases"]); got != string(state) {
250 t.Fatalf("extension state = %q, want %q", got, state)
251 }
252}
253
254func TestSessionCompactHandoffRoundTripAndClear(t *testing.T) {
255 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
256 if err != nil {
257 t.Fatal(err)
258 }
259 state := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
260 if err := sess.UpdateCompactHandoff(state); err != nil {
261 t.Fatal(err)
262 }
263 if got := string(sess.Meta.CompactHandoff); got != string(state) {
264 t.Fatalf("live compact handoff = %q, want %q", got, state)
265 }
266 if err := sess.Close(); err != nil {
267 t.Fatal(err)
268 }
269
270 reopened, _, err := OpenSession(sess.Path)
271 if err != nil {
272 t.Fatal(err)
273 }
274 if got := string(reopened.Meta.CompactHandoff); got != string(state) {
275 t.Fatalf("reopened compact handoff = %q, want %q", got, state)
276 }
277 if err := reopened.UpdateCompactHandoff(nil); err != nil {
278 t.Fatal(err)
279 }
280 if err := reopened.Close(); err != nil {
281 t.Fatal(err)
282 }
283
284 reopened, _, err = OpenSession(sess.Path)
285 if err != nil {
286 t.Fatal(err)
287 }
288 defer reopened.Close()
289 if len(reopened.Meta.CompactHandoff) != 0 {
290 t.Fatalf("cleared compact handoff = %q, want empty", reopened.Meta.CompactHandoff)
291 }
292}
293
294func TestNormalizeSessionGoalMetaMigratesLegacyGoal(t *testing.T) {
295 meta := SessionMeta{ID: "legacy-session", Goal: &SessionGoal{Objective: "finish migration", Status: GoalActive}}
296 normalizeSessionGoalMeta(&meta)
297 if meta.Mission == nil || meta.Mission.ID != "legacy-legacy-session" || meta.Mission.Source != MissionSourceUser || meta.Mission.ActiveGoalID != "legacy-goal-legacy-session" {
298 t.Fatalf("mission = %#v", meta.Mission)
299 }
300 if meta.Goal.Owner != GoalOwnerUser || meta.Goal.ID != "legacy-goal-legacy-session" || meta.Goal.MissionID != meta.Mission.ID || meta.Goal.Ordinal != 1 {
301 t.Fatalf("goal = %#v", meta.Goal)
302 }
303}
304
305func TestSessionGoalTransitionLimitRollsBackState(t *testing.T) {
306 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
307 if err != nil {
308 t.Fatal(err)
309 }
310 defer sess.Close()
311 if err := sess.UpdateGoal(&SessionGoal{Objective: "initial", Status: GoalActive, Owner: GoalOwnerUser}); err != nil {
312 t.Fatal(err)
313 }
314 for transition := 0; transition < maxMissionGoalTransitions; transition++ {
315 done := *sess.Meta.Goal
316 done.Status = GoalDone
317 if err := sess.UpdateGoal(&done); err != nil {
318 t.Fatalf("settle transition %d: %v", transition, err)
319 }
320 if err := sess.UpdateGoal(&SessionGoal{Objective: "next", Status: GoalActive, Owner: GoalOwnerManager}); err != nil {
321 t.Fatalf("start transition %d: %v", transition, err)
322 }
323 }
324 done := *sess.Meta.Goal
325 done.Status = GoalDone
326 if err := sess.UpdateGoal(&done); err != nil {
327 t.Fatal(err)
328 }
329 previousMission := *sess.Meta.Mission
330 previousGoal := *sess.Meta.Goal
331 if err := sess.UpdateGoal(&SessionGoal{Objective: "one too many", Status: GoalActive, Owner: GoalOwnerManager}); err == nil {
332 t.Fatal("transition limit was not enforced")
333 }
334 if *sess.Meta.Mission != previousMission || *sess.Meta.Goal != previousGoal {
335 t.Fatalf("state changed after rejected transition: mission %#v, goal %#v", sess.Meta.Mission, sess.Meta.Goal)
336 }
337}
338
339func TestSessionSupersedeGoalArchivesAndReplacesAtomically(t *testing.T) {
340 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
341 if err != nil {
342 t.Fatal(err)
343 }
344 defer sess.Close()
345 if err := sess.UpdateGoal(&SessionGoal{Objective: "use the legacy API", Status: GoalActive, Owner: GoalOwnerUser}); err != nil {
346 t.Fatal(err)
347 }
348 missionID := sess.Meta.Mission.ID
349 originalID := sess.Meta.Goal.ID
350
351 if err := sess.SupersedeGoal(&SessionGoal{Objective: "use the supported API", Status: GoalActive, Owner: GoalOwnerManager}); err != nil {
352 t.Fatal(err)
353 }
354 if got := sess.Meta.Goal; got == nil || got.Objective != "use the supported API" || got.Status != GoalActive || got.MissionID != missionID || got.ID == originalID {
355 t.Fatalf("replacement goal = %#v", got)
356 }
357 if len(sess.Meta.GoalHistory) != 1 {
358 t.Fatalf("goal history = %#v", sess.Meta.GoalHistory)
359 }
360 archived := sess.Meta.GoalHistory[0]
361 if archived.ID != originalID || archived.Status != GoalSuperseded || archived.Reason == "" {
362 t.Fatalf("archived goal = %#v", archived)
363 }
364 if sess.Meta.Mission.TransitionCount != 1 || sess.Meta.Mission.ActiveGoalID != sess.Meta.Goal.ID {
365 t.Fatalf("mission = %#v", sess.Meta.Mission)
366 }
367}
368
369func TestSessionSupersedeGoalRejectsInvalidReplacement(t *testing.T) {
370 for _, test := range []struct {
371 name string
372 replacement *SessionGoal
373 }{
374 {name: "blank objective", replacement: &SessionGoal{Status: GoalActive}},
375 {name: "reused id", replacement: &SessionGoal{ID: "old-goal", Objective: "replacement", Status: GoalActive}},
376 {name: "other mission", replacement: &SessionGoal{MissionID: "other", Objective: "replacement", Status: GoalActive}},
377 } {
378 t.Run(test.name, func(t *testing.T) {
379 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
380 if err != nil {
381 t.Fatal(err)
382 }
383 defer sess.Close()
384 if err := sess.UpdateGoal(&SessionGoal{ID: "old-goal", Objective: "current", Status: GoalActive}); err != nil {
385 t.Fatal(err)
386 }
387 before := *sess.Meta.Goal
388 if err := sess.SupersedeGoal(test.replacement); err == nil {
389 t.Fatal("invalid replacement was accepted")
390 }
391 if *sess.Meta.Goal != before || len(sess.Meta.GoalHistory) != 0 {
392 t.Fatalf("state changed after rejection: goal=%#v history=%#v", sess.Meta.Goal, sess.Meta.GoalHistory)
393 }
394 })
395 }
396}
397
398func TestSessionGoalResumeDoesNotConsumeTransitionLimit(t *testing.T) {
399 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
400 if err != nil {
401 t.Fatal(err)
402 }
403 defer sess.Close()
404 if err := sess.UpdateGoal(&SessionGoal{Objective: "initial", Status: GoalActive, Owner: GoalOwnerUser}); err != nil {
405 t.Fatal(err)
406 }
407 done := *sess.Meta.Goal
408 done.Status = GoalDone
409 if err := sess.UpdateGoal(&done); err != nil {
410 t.Fatal(err)
411 }
412 if err := sess.UpdateGoal(&SessionGoal{Objective: "manager work", Status: GoalActive, Owner: GoalOwnerManager}); err != nil {
413 t.Fatal(err)
414 }
415 for cycle := 0; cycle < maxMissionGoalTransitions*2; cycle++ {
416 paused := *sess.Meta.Goal
417 paused.Status = GoalPaused
418 if err := sess.UpdateGoal(&paused); err != nil {
419 t.Fatalf("pause cycle %d: %v", cycle, err)
420 }
421 resumed := *sess.Meta.Goal
422 resumed.Status = GoalActive
423 if err := sess.UpdateGoal(&resumed); err != nil {
424 t.Fatalf("resume cycle %d: %v", cycle, err)
425 }
426 }
427 if got := sess.Meta.Mission.TransitionCount; got != 1 {
428 t.Fatalf("transition count = %d, want 1", got)
429 }
430}
431
432func TestSessionLegacyGoalMigrationPersistsOnNextTransition(t *testing.T) {
433 path := t.TempDir() + "/legacy.jsonl"
434 meta := SessionMeta{ID: "legacy-session", CWD: "/tmp/project", Goal: &SessionGoal{Objective: "finish migration", Status: GoalActive}}
435 line, err := json.Marshal(sessionLine{Type: "meta", Meta: &meta})
436 if err != nil {
437 t.Fatal(err)
438 }
439 if err := os.WriteFile(path, append(line, '\n'), 0o600); err != nil {
440 t.Fatal(err)
441 }
442
443 sess, _, err := OpenSession(path)
444 if err != nil {
445 t.Fatal(err)
446 }
447 if sess.Meta.Mission == nil || sess.Meta.Goal.MissionID != sess.Meta.Mission.ID {
448 t.Fatalf("legacy migration = mission %#v, goal %#v", sess.Meta.Mission, sess.Meta.Goal)
449 }
450 done := *sess.Meta.Goal
451 done.Status = GoalDone
452 if err := sess.UpdateGoal(&done); err != nil {
453 t.Fatal(err)
454 }
455 if err := sess.Close(); err != nil {
456 t.Fatal(err)
457 }
458
459 reopened, _, err := OpenSession(path)
460 if err != nil {
461 t.Fatal(err)
462 }
463 defer reopened.Close()
464 if reopened.Meta.Mission == nil || reopened.Meta.Goal == nil || reopened.Meta.Goal.Status != GoalDone || reopened.Meta.Goal.MissionID != reopened.Meta.Mission.ID {
465 t.Fatalf("persisted migration = mission %#v, goal %#v", reopened.Meta.Mission, reopened.Meta.Goal)
466 }
467}
468
469func TestSessionEnsureMissionCreatesUserBoundaryOnce(t *testing.T) {
470 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
471 if err != nil {
472 t.Fatal(err)
473 }
474 if err := sess.EnsureMission("fix the reported failure", MissionSourceUser); err != nil {
475 t.Fatal(err)
476 }
477 if sess.Meta.Mission == nil || sess.Meta.Mission.Objective != "fix the reported failure" || sess.Meta.Mission.Source != MissionSourceUser {
478 t.Fatalf("mission = %#v", sess.Meta.Mission)
479 }
480 missionID := sess.Meta.Mission.ID
481 if err := sess.EnsureMission("unrelated follow-up", MissionSourceUser); err != nil {
482 t.Fatal(err)
483 }
484 if sess.Meta.Mission.ID != missionID || sess.Meta.Mission.Objective != "fix the reported failure" {
485 t.Fatalf("mission was replaced: %#v", sess.Meta.Mission)
486 }
487 path := sess.Path
488 if err := sess.Close(); err != nil {
489 t.Fatal(err)
490 }
491
492 reopened, _, err := OpenSession(path)
493 if err != nil {
494 t.Fatal(err)
495 }
496 defer reopened.Close()
497 if reopened.Meta.Mission == nil || reopened.Meta.Mission.ID != missionID {
498 t.Fatalf("reopened mission = %#v", reopened.Meta.Mission)
499 }
500}
501
502func TestSessionGoalRoundTripAndClear(t *testing.T) {
503 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
504 if err != nil {
505 t.Fatal(err)
506 }
507 goal := &SessionGoal{Objective: "finish the requested change", Status: GoalActive}
508 if err := sess.UpdateGoal(goal); err != nil {
509 t.Fatal(err)
510 }
511 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 {
512 t.Fatalf("live goal = %#v", sess.Meta.Goal)
513 }
514 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 {
515 t.Fatalf("live mission = %#v", sess.Meta.Mission)
516 }
517 if err := sess.Close(); err != nil {
518 t.Fatal(err)
519 }
520
521 reopened, _, err := OpenSession(sess.Path)
522 if err != nil {
523 t.Fatal(err)
524 }
525 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 {
526 t.Fatalf("reopened goal = %#v", reopened.Meta.Goal)
527 }
528 if err := reopened.UpdateGoal(nil); err != nil {
529 t.Fatal(err)
530 }
531 if err := reopened.Close(); err != nil {
532 t.Fatal(err)
533 }
534
535 reopened, _, err = OpenSession(sess.Path)
536 if err != nil {
537 t.Fatal(err)
538 }
539 defer reopened.Close()
540 if reopened.Meta.Goal != nil {
541 t.Fatalf("cleared goal = %#v, want nil", reopened.Meta.Goal)
542 }
543 if len(reopened.Meta.GoalHistory) != 1 || reopened.Meta.GoalHistory[0].Objective != goal.Objective || reopened.Meta.GoalHistory[0].Status != GoalActive {
544 t.Fatalf("goal history = %#v", reopened.Meta.GoalHistory)
545 }
546}
547
548func TestSessionGoalRuntimeRoundTripDoesNotAddHistory(t *testing.T) {
549 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
550 if err != nil {
551 t.Fatal(err)
552 }
553 budget := uint64(1_000_000)
554 if err := sess.UpdateGoal(&SessionGoal{Objective: "finish the requested change", Status: GoalActive, TokenBudget: &budget}); err != nil {
555 t.Fatal(err)
556 }
557 goal := cloneSessionGoal(sess.Meta.Goal)
558 goal.TokensUsed = 1234
559 goal.ContinuationID = "run-1"
560 goal.ConsecutiveNoProgressTurns = 1
561 if err := sess.UpdateGoalRuntime(goal); err != nil {
562 t.Fatal(err)
563 }
564 if len(sess.Meta.GoalHistory) != 0 {
565 t.Fatalf("runtime update added history: %#v", sess.Meta.GoalHistory)
566 }
567 if err := sess.Close(); err != nil {
568 t.Fatal(err)
569 }
570 reopened, _, err := OpenSession(sess.Path)
571 if err != nil {
572 t.Fatal(err)
573 }
574 defer reopened.Close()
575 if goal := reopened.Meta.Goal; goal == nil || goal.TokenBudget == nil || *goal.TokenBudget != budget || goal.TokensUsed != 1234 || goal.ContinuationID != "run-1" || goal.ConsecutiveNoProgressTurns != 1 {
576 t.Fatalf("reopened runtime goal = %#v", goal)
577 }
578 if len(reopened.Meta.GoalHistory) != 0 {
579 t.Fatalf("reopened runtime history = %#v", reopened.Meta.GoalHistory)
580 }
581}
582
583func TestSessionGoalTerminalStatusSettlesMission(t *testing.T) {
584 for _, test := range []struct {
585 goalStatus GoalStatus
586 missionStatus MissionStatus
587 }{
588 {GoalDone, MissionCompleted},
589 {GoalPaused, MissionPaused},
590 {GoalBlocked, MissionBlocked},
591 {GoalBudgetLimited, MissionBlocked},
592 {GoalStalled, MissionBlocked},
593 } {
594 t.Run(string(test.goalStatus), func(t *testing.T) {
595 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
596 if err != nil {
597 t.Fatal(err)
598 }
599 defer sess.Close()
600 if err := sess.UpdateGoal(&SessionGoal{Objective: "finish", Status: GoalActive}); err != nil {
601 t.Fatal(err)
602 }
603 goal := cloneSessionGoal(sess.Meta.Goal)
604 goal.Status = test.goalStatus
605 goal.Reason = "stopped"
606 if err := sess.UpdateGoal(goal); err != nil {
607 t.Fatal(err)
608 }
609 if sess.Meta.Mission == nil || sess.Meta.Mission.Status != test.missionStatus || sess.Meta.Mission.ActiveGoalID != "" {
610 t.Fatalf("mission = %#v", sess.Meta.Mission)
611 }
612 })
613 }
614}
615
616func TestCostAdd(t *testing.T) {
617 var c CostTracker
618 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01})
619 c.Add(provider.Usage{InputTokens: 200, OutputTokens: 25, ReasoningTokens: 10, ReasoningTokensKnown: true, CostUSD: 0.02})
620 if c.Total.InputTokens != 300 || c.Total.OutputTokens != 75 {
621 t.Fatalf("got %+v", c.Total)
622 }
623 if c.Total.ReasoningTokens != 30 || !c.Total.ReasoningTokensKnown {
624 t.Fatalf("got reasoning usage %+v", c.Total)
625 }
626 if c.Total.CostUSD < 0.0299 || c.Total.CostUSD > 0.0301 {
627 t.Fatalf("got cost %v", c.Total.CostUSD)
628 }
629}