返回 DeepSeek-Reasonix
taskstate_test.go
根目录 / internal / agent / taskstate_test.go
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
107 lines GO