返回 DeepSeek-Reasonix
updategoal_test.go
根目录 / internal / tool / builtin / updategoal_test.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strings"
8 "testing"
9
10 goaldomain "reasonix/internal/goal"
11 "reasonix/internal/tool"
12 )
13
14 type goalLifecycleStub struct {
15 view *goaldomain.View
16 createRequest goaldomain.CreateRequest
17 updateRequest tool.GoalUpdateRequest
18 authority tool.GoalAuthority
19 err error
20 }
21
22 func (s *goalLifecycleStub) GetGoal(context.Context) (*goaldomain.View, error) { return s.view, s.err }
23 func (s *goalLifecycleStub) CreateGoal(_ context.Context, request goaldomain.CreateRequest, authority tool.GoalAuthority) (goaldomain.View, error) {
24 s.createRequest, s.authority = request, authority
25 if s.err != nil {
26 return goaldomain.View{}, s.err
27 }
28 return *s.view, nil
29 }
30 func (s *goalLifecycleStub) UpdateGoal(_ context.Context, request tool.GoalUpdateRequest, authority tool.GoalAuthority) (goaldomain.View, error) {
31 s.updateRequest, s.authority = request, authority
32 if s.err != nil {
33 return goaldomain.View{}, s.err
34 }
35 return *s.view, nil
36 }
37
38 func goalLifecycleContext(stub *goalLifecycleStub, source tool.GoalSource) context.Context {
39 return tool.WithGoalLifecycle(context.Background(), stub, tool.GoalAuthority{
40 Source: source, SessionID: "session-1", RuntimeEpoch: "epoch-1", ActivityID: 4,
41 GoalID: "goal-1", Revision: 3, Round: 2,
42 })
43 }
44
45 func goalView() *goaldomain.View {
46 return &goaldomain.View{Snapshot: goaldomain.Snapshot{
47 ID: "goal-1", Revision: 3, Objective: "ship", Phase: goaldomain.PhaseActive, RoundsStarted: 2,
48 }, Activation: goaldomain.ActivationArmed}
49 }
50
51 func TestGetGoalReturnsCurrentViewOrNull(t *testing.T) {
52 stub := &goalLifecycleStub{view: goalView()}
53 got, err := (getGoal{}).Execute(goalLifecycleContext(stub, tool.GoalSourceDirectHuman), nil)
54 if err != nil || !strings.Contains(got, `"id":"goal-1"`) || !strings.Contains(got, `"maxGoalRounds":null`) {
55 t.Fatalf("result = %q, err = %v", got, err)
56 }
57 stub.view = nil
58 got, err = (getGoal{}).Execute(goalLifecycleContext(stub, tool.GoalSourceDirectHuman), nil)
59 if err != nil || got != `{"goal":null}` {
60 t.Fatalf("empty result = %q, err = %v", got, err)
61 }
62 }
63
64 func TestGoalMutationToolsAreSerializedWithOtherSideEffects(t *testing.T) {
65 if (createGoal{}).ReadOnly() || (updateGoal{}).ReadOnly() {
66 t.Fatal("goal lifecycle mutations must not enter the read-only parallel tool lane")
67 }
68 if !(getGoal{}).ReadOnly() {
69 t.Fatal("get_goal should remain read-only")
70 }
71 }
72
73 func TestCreateGoalDefaultsToUnlimited(t *testing.T) {
74 stub := &goalLifecycleStub{view: goalView()}
75 got, err := (createGoal{}).Execute(goalLifecycleContext(stub, tool.GoalSourceDirectHuman), json.RawMessage(`{"objective":" ship "}`))
76 if err != nil {
77 t.Fatal(err)
78 }
79 if stub.createRequest.Objective != "ship" || stub.createRequest.MaxGoalRounds != nil {
80 t.Fatalf("request = %+v", stub.createRequest)
81 }
82 if !strings.Contains(got, `"activation":"armed"`) {
83 t.Fatalf("result = %s", got)
84 }
85 }
86
87 func TestCreateGoalAcceptsExplicitLimit(t *testing.T) {
88 stub := &goalLifecycleStub{view: goalView()}
89 _, err := (createGoal{}).Execute(goalLifecycleContext(stub, tool.GoalSourceDirectHuman), json.RawMessage(`{"objective":"ship","max_goal_rounds":12}`))
90 if err != nil || stub.createRequest.MaxGoalRounds == nil || *stub.createRequest.MaxGoalRounds != 12 {
91 t.Fatalf("request = %+v, err = %v", stub.createRequest, err)
92 }
93 }
94
95 func TestUpdateGoalForwardsExactRevisionAndAction(t *testing.T) {
96 stub := &goalLifecycleStub{view: goalView()}
97 _, err := (updateGoal{}).Execute(goalLifecycleContext(stub, tool.GoalSourceGoalRound), json.RawMessage(`{"goal_id":"goal-1","revision":3,"action":"blocked","blocked_reason":"dependency unavailable"}`))
98 if err != nil {
99 t.Fatal(err)
100 }
101 request := stub.updateRequest
102 if request.Ref.ID != "goal-1" || request.Ref.Revision != 3 || request.Action != tool.GoalActionBlocked {
103 t.Fatalf("request = %+v", request)
104 }
105 if request.BlockedReason == nil || request.BlockedReason.Code != "model-blocked" || request.BlockedReason.Message != "dependency unavailable" {
106 t.Fatalf("blocked reason = %+v", request.BlockedReason)
107 }
108 }
109
110 func TestUpdateGoalEditDistinguishesOmittedAndNullLimit(t *testing.T) {
111 stub := &goalLifecycleStub{view: goalView()}
112 ctx := goalLifecycleContext(stub, tool.GoalSourceDirectHuman)
113 _, err := (updateGoal{}).Execute(ctx, json.RawMessage(`{"goal_id":"goal-1","revision":3,"action":"edit","objective":"ship safely"}`))
114 if err != nil || stub.updateRequest.MaxGoalRounds.Set {
115 t.Fatalf("omitted request = %+v, err = %v", stub.updateRequest, err)
116 }
117 _, err = (updateGoal{}).Execute(ctx, json.RawMessage(`{"goal_id":"goal-1","revision":3,"action":"edit","max_goal_rounds":null}`))
118 if err != nil || !stub.updateRequest.MaxGoalRounds.Set || stub.updateRequest.MaxGoalRounds.Value != nil {
119 t.Fatalf("null request = %+v, err = %v", stub.updateRequest, err)
120 }
121 }
122
123 func TestGoalToolsFailClosedWithoutHostBinding(t *testing.T) {
124 for _, candidate := range []struct {
125 name string
126 tool tool.Tool
127 args string
128 }{
129 {"get_goal", getGoal{}, `{}`},
130 {"create_goal", createGoal{}, `{"objective":"ship"}`},
131 {"update_goal", updateGoal{}, `{"goal_id":"g","revision":1,"action":"complete"}`},
132 } {
133 if _, err := candidate.tool.Execute(context.Background(), json.RawMessage(candidate.args)); err == nil || !strings.Contains(err.Error(), "host-attested goal context") {
134 t.Fatalf("%s error = %v", candidate.name, err)
135 }
136 }
137 }
138
139 func TestUpdateGoalRejectsLegacyContinueProtocol(t *testing.T) {
140 stub := &goalLifecycleStub{view: goalView()}
141 _, err := (updateGoal{}).Execute(goalLifecycleContext(stub, tool.GoalSourceGoalRound), json.RawMessage(`{"status":"continue","reason":"working"}`))
142 if err == nil || !strings.Contains(err.Error(), "legacy update_goal protocol") {
143 t.Fatalf("error = %v", err)
144 }
145 }
146
147 func TestUpdateGoalSurfacesStructuredDomainError(t *testing.T) {
148 stub := &goalLifecycleStub{view: goalView(), err: &goaldomain.Error{Code: goaldomain.ErrStaleRevision, Message: "stale"}}
149 _, err := (updateGoal{}).Execute(goalLifecycleContext(stub, tool.GoalSourceDirectHuman), json.RawMessage(`{"goal_id":"goal-1","revision":3,"action":"complete"}`))
150 if err == nil || !strings.Contains(err.Error(), string(goaldomain.ErrStaleRevision)) || !errors.Is(err, stub.err) {
151 t.Fatalf("error = %v", err)
152 }
153 }
154
155 func TestUpdateGoalTerminalResultRequestsFinalSummary(t *testing.T) {
156 stub := &goalLifecycleStub{view: &goaldomain.View{Snapshot: goaldomain.Snapshot{
157 ID: "goal-1", Revision: 4, Objective: "ship", Phase: goaldomain.PhaseComplete,
158 }, Activation: goaldomain.ActivationDisarmed}}
159 got, err := (updateGoal{}).Execute(goalLifecycleContext(stub, tool.GoalSourceGoalRound), json.RawMessage(`{"goal_id":"goal-1","revision":3,"action":"complete"}`))
160 if err != nil || !strings.Contains(got, `"instruction":"Finish the current turn`) {
161 t.Fatalf("terminal result = %q, err = %v", got, err)
162 }
163 }
164
165 func TestUpdateGoalValidatesActionSpecificFields(t *testing.T) {
166 stub := &goalLifecycleStub{view: goalView()}
167 ctx := goalLifecycleContext(stub, tool.GoalSourceDirectHuman)
168 for _, args := range []string{
169 `{"goal_id":"goal-1","revision":3,"action":"blocked"}`,
170 `{"goal_id":"goal-1","revision":3,"action":"complete","objective":"wrong"}`,
171 `{"goal_id":"goal-1","revision":3,"action":"edit"}`,
172 `{"goal_id":"goal-1","revision":3,"action":"continue"}`,
173 } {
174 if _, err := (updateGoal{}).Execute(ctx, json.RawMessage(args)); err == nil {
175 t.Fatalf("accepted invalid args %s", args)
176 }
177 }
178 }
179
179 lines GO