| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "go/ast" |
| 5 | "go/parser" |
| 6 | "go/token" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/evidence" |
| 10 | ) |
| 11 | |
| 12 | // taskCarryOver names the fields restartLedger hands to the next task in the |
| 13 | // same session. Everything else must come back zeroed, which is what makes |
| 14 | // "one ledger, one task, one bill" a property of the type rather than of the |
| 15 | // call sites that used to clear these fields one by one. |
| 16 | var taskCarryOver = map[string]bool{ |
| 17 | "scopeID": true, |
| 18 | "checkpoint": true, |
| 19 | "ledger": true, // identity, not contents: executeOne hands the pointer to every tool context |
| 20 | } |
| 21 | |
| 22 | func TestTaskRuntimeRestartCarriesScopeAndResetsAccounting(t *testing.T) { |
| 23 | ledger := evidence.NewLedger() |
| 24 | before := &taskRuntime{ |
| 25 | scopeID: "scope-1", |
| 26 | checkpoint: evidence.DeliveryCheckpoint{ScopeID: "scope-1"}, |
| 27 | ledger: ledger, |
| 28 | outcome: evidence.NewOutcomeTracker(), |
| 29 | budget: runBudget{rounds: 4, requests: 9, cost: 1.5, limit: TaskBudget{}}, |
| 30 | ebm: ebmState{fired: true, captured: true}, |
| 31 | } |
| 32 | after := *before |
| 33 | after.restartLedger() |
| 34 | |
| 35 | if after.scopeID != "scope-1" || after.checkpoint.ScopeID != "scope-1" { |
| 36 | t.Errorf("scope = %q/%q, want it carried: beginRunTurn owns the scope transition", after.scopeID, after.checkpoint.ScopeID) |
| 37 | } |
| 38 | if after.ledger != ledger { |
| 39 | t.Error("ledger pointer replaced; tool contexts hold it for the length of a call") |
| 40 | } |
| 41 | if after.budget.rounds != 0 || after.budget.requests != 0 || after.budget.cost != 0 { |
| 42 | t.Errorf("budget = %+v, want a fresh bill for the new task", after.budget) |
| 43 | } |
| 44 | if after.ebm != (ebmState{}) { |
| 45 | t.Errorf("historical fork state = %+v, want it reset with the ledger", after.ebm) |
| 46 | } |
| 47 | if after.outcome == nil || after.outcome == before.outcome { |
| 48 | t.Error("outcome tracker not replaced; the shadow scorer must not span tasks") |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | // taskRestarted names the fields the test above asserts a new task starts |
| 53 | // from. Together with taskCarryOver it must cover taskRuntime exactly, so a |
| 54 | // field added to the type fails here until someone states which side it is on. |
| 55 | var taskRestarted = map[string]bool{ |
| 56 | "outcome": true, |
| 57 | "budget": true, |
| 58 | "ebm": true, |
| 59 | } |
| 60 | |
| 61 | // restartLedger is one assignment, so an unlisted field resets by default — |
| 62 | // the safe direction. The risk is the other one: a field that quietly ends up |
| 63 | // carried, or reset with nothing asserting it. Both lists are therefore |
| 64 | // checked against the struct rather than trusted. |
| 65 | func TestTaskRuntimeLifetimeListsCoverTheStruct(t *testing.T) { |
| 66 | fset := token.NewFileSet() |
| 67 | file, err := parser.ParseFile(fset, "taskstate.go", nil, 0) |
| 68 | if err != nil { |
| 69 | t.Fatalf("parse taskstate.go: %v", err) |
| 70 | } |
| 71 | fields := map[string]bool{} |
| 72 | ast.Inspect(file, func(n ast.Node) bool { |
| 73 | spec, ok := n.(*ast.TypeSpec) |
| 74 | if !ok || spec.Name.Name != "taskRuntime" { |
| 75 | return true |
| 76 | } |
| 77 | st, ok := spec.Type.(*ast.StructType) |
| 78 | if !ok { |
| 79 | return false |
| 80 | } |
| 81 | for _, field := range st.Fields.List { |
| 82 | for _, name := range field.Names { |
| 83 | fields[name.Name] = true |
| 84 | } |
| 85 | } |
| 86 | return false |
| 87 | }) |
| 88 | if len(fields) == 0 { |
| 89 | t.Fatal("taskRuntime has no fields; the guard would pass vacuously") |
| 90 | } |
| 91 | for _, list := range []map[string]bool{taskCarryOver, taskRestarted} { |
| 92 | for name := range list { |
| 93 | if !fields[name] { |
| 94 | t.Errorf("the lifetime lists name %q, which taskRuntime no longer has", name) |
| 95 | } |
| 96 | } |
| 97 | } |
| 98 | for name := range fields { |
| 99 | switch { |
| 100 | case taskCarryOver[name] && taskRestarted[name]: |
| 101 | t.Errorf("taskRuntime.%s is listed as both carried and restarted", name) |
| 102 | case !taskCarryOver[name] && !taskRestarted[name]: |
| 103 | t.Errorf("taskRuntime.%s is on neither list; decide whether a new task keeps it and assert that above", name) |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 |