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 TestSessionGoalResumeDoesNotConsumeTransitionLimit(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: "initial", Status: GoalActive, Owner: GoalOwnerUser}); err != nil {
346 t.Fatal(err)
347 }
348 done := *sess.Meta.Goal
349 done.Status = GoalDone
350 if err := sess.UpdateGoal(&done); err != nil {
351 t.Fatal(err)
352 }
353 if err := sess.UpdateGoal(&SessionGoal{Objective: "manager work", Status: GoalActive, Owner: GoalOwnerManager}); err != nil {
354 t.Fatal(err)
355 }
356 for cycle := 0; cycle < maxMissionGoalTransitions*2; cycle++ {
357 paused := *sess.Meta.Goal
358 paused.Status = GoalPaused
359 if err := sess.UpdateGoal(&paused); err != nil {
360 t.Fatalf("pause cycle %d: %v", cycle, err)
361 }
362 resumed := *sess.Meta.Goal
363 resumed.Status = GoalActive
364 if err := sess.UpdateGoal(&resumed); err != nil {
365 t.Fatalf("resume cycle %d: %v", cycle, err)
366 }
367 }
368 if got := sess.Meta.Mission.TransitionCount; got != 1 {
369 t.Fatalf("transition count = %d, want 1", got)
370 }
371}
372
373func TestSessionLegacyGoalMigrationPersistsOnNextTransition(t *testing.T) {
374 path := t.TempDir() + "/legacy.jsonl"
375 meta := SessionMeta{ID: "legacy-session", CWD: "/tmp/project", Goal: &SessionGoal{Objective: "finish migration", Status: GoalActive}}
376 line, err := json.Marshal(sessionLine{Type: "meta", Meta: &meta})
377 if err != nil {
378 t.Fatal(err)
379 }
380 if err := os.WriteFile(path, append(line, '\n'), 0o600); err != nil {
381 t.Fatal(err)
382 }
383
384 sess, _, err := OpenSession(path)
385 if err != nil {
386 t.Fatal(err)
387 }
388 if sess.Meta.Mission == nil || sess.Meta.Goal.MissionID != sess.Meta.Mission.ID {
389 t.Fatalf("legacy migration = mission %#v, goal %#v", sess.Meta.Mission, sess.Meta.Goal)
390 }
391 done := *sess.Meta.Goal
392 done.Status = GoalDone
393 if err := sess.UpdateGoal(&done); err != nil {
394 t.Fatal(err)
395 }
396 if err := sess.Close(); err != nil {
397 t.Fatal(err)
398 }
399
400 reopened, _, err := OpenSession(path)
401 if err != nil {
402 t.Fatal(err)
403 }
404 defer reopened.Close()
405 if reopened.Meta.Mission == nil || reopened.Meta.Goal == nil || reopened.Meta.Goal.Status != GoalDone || reopened.Meta.Goal.MissionID != reopened.Meta.Mission.ID {
406 t.Fatalf("persisted migration = mission %#v, goal %#v", reopened.Meta.Mission, reopened.Meta.Goal)
407 }
408}
409
410func TestSessionEnsureMissionCreatesUserBoundaryOnce(t *testing.T) {
411 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
412 if err != nil {
413 t.Fatal(err)
414 }
415 if err := sess.EnsureMission("fix the reported failure", MissionSourceUser); err != nil {
416 t.Fatal(err)
417 }
418 if sess.Meta.Mission == nil || sess.Meta.Mission.Objective != "fix the reported failure" || sess.Meta.Mission.Source != MissionSourceUser {
419 t.Fatalf("mission = %#v", sess.Meta.Mission)
420 }
421 missionID := sess.Meta.Mission.ID
422 if err := sess.EnsureMission("unrelated follow-up", MissionSourceUser); err != nil {
423 t.Fatal(err)
424 }
425 if sess.Meta.Mission.ID != missionID || sess.Meta.Mission.Objective != "fix the reported failure" {
426 t.Fatalf("mission was replaced: %#v", sess.Meta.Mission)
427 }
428 path := sess.Path
429 if err := sess.Close(); err != nil {
430 t.Fatal(err)
431 }
432
433 reopened, _, err := OpenSession(path)
434 if err != nil {
435 t.Fatal(err)
436 }
437 defer reopened.Close()
438 if reopened.Meta.Mission == nil || reopened.Meta.Mission.ID != missionID {
439 t.Fatalf("reopened mission = %#v", reopened.Meta.Mission)
440 }
441}
442
443func TestSessionGoalRoundTripAndClear(t *testing.T) {
444 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
445 if err != nil {
446 t.Fatal(err)
447 }
448 goal := &SessionGoal{Objective: "finish the requested change", Status: GoalActive}
449 if err := sess.UpdateGoal(goal); err != nil {
450 t.Fatal(err)
451 }
452 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 {
453 t.Fatalf("live goal = %#v", sess.Meta.Goal)
454 }
455 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 {
456 t.Fatalf("live mission = %#v", sess.Meta.Mission)
457 }
458 if err := sess.Close(); err != nil {
459 t.Fatal(err)
460 }
461
462 reopened, _, err := OpenSession(sess.Path)
463 if err != nil {
464 t.Fatal(err)
465 }
466 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 {
467 t.Fatalf("reopened goal = %#v", reopened.Meta.Goal)
468 }
469 if err := reopened.UpdateGoal(nil); err != nil {
470 t.Fatal(err)
471 }
472 if err := reopened.Close(); err != nil {
473 t.Fatal(err)
474 }
475
476 reopened, _, err = OpenSession(sess.Path)
477 if err != nil {
478 t.Fatal(err)
479 }
480 defer reopened.Close()
481 if reopened.Meta.Goal != nil {
482 t.Fatalf("cleared goal = %#v, want nil", reopened.Meta.Goal)
483 }
484 if len(reopened.Meta.GoalHistory) != 1 || reopened.Meta.GoalHistory[0].Objective != goal.Objective || reopened.Meta.GoalHistory[0].Status != GoalActive {
485 t.Fatalf("goal history = %#v", reopened.Meta.GoalHistory)
486 }
487}
488
489func TestSessionGoalRuntimeRoundTripDoesNotAddHistory(t *testing.T) {
490 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
491 if err != nil {
492 t.Fatal(err)
493 }
494 budget := uint64(1_000_000)
495 if err := sess.UpdateGoal(&SessionGoal{Objective: "finish the requested change", Status: GoalActive, TokenBudget: &budget}); err != nil {
496 t.Fatal(err)
497 }
498 goal := cloneSessionGoal(sess.Meta.Goal)
499 goal.TokensUsed = 1234
500 goal.ContinuationID = "run-1"
501 goal.ConsecutiveNoProgressTurns = 1
502 if err := sess.UpdateGoalRuntime(goal); err != nil {
503 t.Fatal(err)
504 }
505 if len(sess.Meta.GoalHistory) != 0 {
506 t.Fatalf("runtime update added history: %#v", sess.Meta.GoalHistory)
507 }
508 if err := sess.Close(); err != nil {
509 t.Fatal(err)
510 }
511 reopened, _, err := OpenSession(sess.Path)
512 if err != nil {
513 t.Fatal(err)
514 }
515 defer reopened.Close()
516 if goal := reopened.Meta.Goal; goal == nil || goal.TokenBudget == nil || *goal.TokenBudget != budget || goal.TokensUsed != 1234 || goal.ContinuationID != "run-1" || goal.ConsecutiveNoProgressTurns != 1 {
517 t.Fatalf("reopened runtime goal = %#v", goal)
518 }
519 if len(reopened.Meta.GoalHistory) != 0 {
520 t.Fatalf("reopened runtime history = %#v", reopened.Meta.GoalHistory)
521 }
522}
523
524func TestSessionGoalTerminalStatusSettlesMission(t *testing.T) {
525 for _, test := range []struct {
526 goalStatus GoalStatus
527 missionStatus MissionStatus
528 }{
529 {GoalDone, MissionCompleted},
530 {GoalPaused, MissionPaused},
531 {GoalBlocked, MissionBlocked},
532 {GoalBudgetLimited, MissionBlocked},
533 {GoalStalled, MissionBlocked},
534 } {
535 t.Run(string(test.goalStatus), func(t *testing.T) {
536 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
537 if err != nil {
538 t.Fatal(err)
539 }
540 defer sess.Close()
541 if err := sess.UpdateGoal(&SessionGoal{Objective: "finish", Status: GoalActive}); err != nil {
542 t.Fatal(err)
543 }
544 goal := cloneSessionGoal(sess.Meta.Goal)
545 goal.Status = test.goalStatus
546 goal.Reason = "stopped"
547 if err := sess.UpdateGoal(goal); err != nil {
548 t.Fatal(err)
549 }
550 if sess.Meta.Mission == nil || sess.Meta.Mission.Status != test.missionStatus || sess.Meta.Mission.ActiveGoalID != "" {
551 t.Fatalf("mission = %#v", sess.Meta.Mission)
552 }
553 })
554 }
555}
556
557func TestCostAdd(t *testing.T) {
558 var c CostTracker
559 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01})
560 c.Add(provider.Usage{InputTokens: 200, OutputTokens: 25, ReasoningTokens: 10, ReasoningTokensKnown: true, CostUSD: 0.02})
561 if c.Total.InputTokens != 300 || c.Total.OutputTokens != 75 {
562 t.Fatalf("got %+v", c.Total)
563 }
564 if c.Total.ReasoningTokens != 30 || !c.Total.ReasoningTokensKnown {
565 t.Fatalf("got reasoning usage %+v", c.Total)
566 }
567 if c.Total.CostUSD < 0.0299 || c.Total.CostUSD > 0.0301 {
568 t.Fatalf("got cost %v", c.Total.CostUSD)
569 }
570}