返回 DeepSeek-Reasonix
submit_plan_test.go
根目录 / internal / agent / submit_plan_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "strings"
7 "testing"
8
9 "reasonix/internal/plancontract"
10 )
11
12 func submitPlan(t *testing.T, ctx context.Context, args string) (string, error) {
13 t.Helper()
14 return (&SubmitPlanTool{}).Execute(ctx, json.RawMessage(args))
15 }
16
17 const wellFormedPlanArgs = `{
18 "objective":"make the cache key model-aware",
19 "assumptions":[{"text":"warm caches are disposable","confirm":"rg cacheKey internal/provider"}],
20 "steps":[
21 {"id":"p1","title":"thread the model ref through","verified_files":["internal/provider/cache.go"],"candidate_files":["internal/boot/boot.go"]},
22 {"id":"s1","parent_id":"p1","title":"extend cacheKey",
23 "acceptance":[{"text":"two model refs never share an entry"},{"text":"existing hits keep hitting","regression":true}],
24 "verification":[{"command":"go test ./internal/provider/","expect":"all green"}]},
25 {"id":"p2","title":"record the hit rate"}
26 ]
27 }`
28
29 func TestSubmitPlanRecordsAStructuredPlan(t *testing.T) {
30 ctx, submission := WithPlanSubmission(context.Background())
31 out, err := submitPlan(t, ctx, wellFormedPlanArgs)
32 if err != nil {
33 t.Fatalf("submit_plan: %v", err)
34 }
35 if !strings.Contains(out, "revision 1") || !strings.Contains(out, "2 phase(s), 1 sub-step(s)") {
36 t.Fatalf("result = %q", out)
37 }
38 plan, ok := submission.Plan()
39 if !ok {
40 t.Fatal("submission holds no plan")
41 }
42 if plan.Objective != "make the cache key model-aware" || len(plan.Steps) != 3 {
43 t.Fatalf("plan = %+v", plan)
44 }
45 step := plan.Steps[1]
46 if len(step.VerifiedFiles) != 0 || len(step.Acceptance) != 2 || !step.Acceptance[1].Regression {
47 t.Fatalf("sub-step lost its fields: %+v", step)
48 }
49 if plan.Steps[0].VerifiedFiles[0] != "internal/provider/cache.go" || plan.Steps[0].CandidateFiles[0] != "internal/boot/boot.go" {
50 t.Fatalf("verified/candidate surfaces did not survive: %+v", plan.Steps[0])
51 }
52 }
53
54 // Identity is host-assigned: a planner that claims one must not get it, which is
55 // why the fields carry json:"-" rather than a prompt rule.
56 func TestSubmitPlanIgnoresPlannerAssignedIdentity(t *testing.T) {
57 ctx, submission := WithPlanSubmission(context.Background())
58 _, err := submitPlan(t, ctx, `{"id":"forged","revision":42,"objective":"o","steps":[{"title":"do it"}]}`)
59 if err != nil {
60 t.Fatalf("submit_plan: %v", err)
61 }
62 plan, _ := submission.Plan()
63 if plan.ID != "" {
64 t.Errorf("planner-supplied plan id survived: %q", plan.ID)
65 }
66 if plan.Revision != 1 {
67 t.Errorf("revision = %d, want the host's count of 1", plan.Revision)
68 }
69 }
70
71 func TestSubmitPlanCountsRevisions(t *testing.T) {
72 ctx, submission := WithPlanSubmission(context.Background())
73 for range 3 {
74 if _, err := submitPlan(t, ctx, `{"objective":"o","steps":[{"title":"do it"}]}`); err != nil {
75 t.Fatalf("submit_plan: %v", err)
76 }
77 }
78 plan, _ := submission.Plan()
79 if plan.Revision != 3 {
80 t.Fatalf("revision = %d, want 3", plan.Revision)
81 }
82 }
83
84 func TestSubmitPlanReturnsAnActionableValidationError(t *testing.T) {
85 ctx, submission := WithPlanSubmission(context.Background())
86 _, err := submitPlan(t, ctx, `{"objective":"","steps":[]}`)
87 if err == nil {
88 t.Fatal("an empty plan must be rejected")
89 }
90 for _, want := range []string{"no objective", "no steps"} {
91 if !strings.Contains(err.Error(), want) {
92 t.Errorf("error %q should name %q so the planner can fix it in one round", err, want)
93 }
94 }
95 if _, ok := submission.Plan(); ok {
96 t.Fatal("a rejected plan must not be recorded")
97 }
98 }
99
100 func TestSubmitPlanRefusesOutsideAPlanningTurn(t *testing.T) {
101 _, err := submitPlan(t, context.Background(), wellFormedPlanArgs)
102 if err == nil || !strings.Contains(err.Error(), "only available while planning") {
103 t.Fatalf("error = %v", err)
104 }
105 if (&SubmitPlanTool{}).ProviderVisible(context.Background()) {
106 t.Fatal("submit_plan must not read as available outside a planning turn")
107 }
108 ctx, _ := WithPlanSubmission(context.Background())
109 if !(&SubmitPlanTool{}).ProviderVisible(ctx) {
110 t.Fatal("submit_plan must be available once the host arms the turn")
111 }
112 }
113
114 func TestSubmitPlanIsReadOnlyAndInThePlannerRegistry(t *testing.T) {
115 if !(&SubmitPlanTool{}).ReadOnly() {
116 t.Fatal("submitting a plan touches nothing and must be read-only")
117 }
118 reg := PlannerToolRegistry(nil)
119 if _, ok := reg.Get("submit_plan"); !ok {
120 t.Fatalf("planner registry lacks submit_plan: %v", reg.Names())
121 }
122 }
123
124 func TestPlannerOutcomeReadsApprovalFromTheFieldWhenStructured(t *testing.T) {
125 // requires_approval is the only approval source; rendered prose never gates.
126 structured := plannerOutcome{
127 text: "The plan is ready. Waiting for approval before I continue.",
128 plan: plancontract.Plan{Objective: "o", Steps: []plancontract.Step{{Title: "do it"}}},
129 }
130 if structured.requestsApproval() {
131 t.Fatal("approval prose must not gate a plan whose requires_approval is false")
132 }
133 structured.plan.RequiresApproval = true
134 if !structured.requestsApproval() {
135 t.Fatal("requires_approval must gate execution")
136 }
137 }
138
139 // The evidence contract is only as strong as its enforcement: a prompt sentence
140 // can be ignored, a schema field cannot be filled with a claim of another kind.
141 func TestSubmitPlanSchemaCarriesTheEvidenceContract(t *testing.T) {
142 schema := string((&SubmitPlanTool{}).Schema())
143 for _, want := range []string{
144 "verified_files", "candidate_files", "acceptance", "verification",
145 "regression", "assumptions", "requires_approval", "depends_on", "parent_id",
146 } {
147 if !strings.Contains(schema, want) {
148 t.Errorf("submit_plan schema missing %q", want)
149 }
150 }
151 if strings.Contains(schema, "revision") {
152 t.Error("submit_plan schema exposes revision; identity is host-assigned")
153 }
154 }
155
156 // A planner that resubmits is told what its own edit changed. Left to describe
157 // it, a model reports intent rather than effect — and a step it dropped by
158 // accident reads exactly like one it meant to keep.
159 func TestSubmitPlanTellsARevisionWhatItChanged(t *testing.T) {
160 ctx, _ := WithPlanSubmission(context.Background())
161 first := `{"objective":"o","steps":[{"id":"s1","title":"change the DB"},{"id":"s2","title":"change the API"}]}`
162 if _, err := submitPlan(t, ctx, first); err != nil {
163 t.Fatalf("first submission: %v", err)
164 }
165 second := `{"objective":"o","steps":[{"id":"s1","title":"change the DB"},{"id":"s3","title":"add the migration"},{"id":"s2","title":"change the API"}]}`
166 out, err := submitPlan(t, ctx, second)
167 if err != nil {
168 t.Fatalf("revision: %v", err)
169 }
170 for _, want := range []string{"Revision 1 → 2", "**Added**", "s3", "expands the approved scope"} {
171 if !strings.Contains(out, want) {
172 t.Errorf("revision result missing %q:\n%s", want, out)
173 }
174 }
175 if strings.Contains(out, "**Changed**") {
176 t.Errorf("an insertion must not report its neighbours as changed:\n%s", out)
177 }
178 }
179
180 func TestSubmitPlanSaysNothingAboutAFirstSubmission(t *testing.T) {
181 ctx, _ := WithPlanSubmission(context.Background())
182 out, err := submitPlan(t, ctx, `{"objective":"o","steps":[{"title":"do it"}]}`)
183 if err != nil {
184 t.Fatalf("submit_plan: %v", err)
185 }
186 if strings.Contains(out, "Revision") && strings.Contains(out, "→") {
187 t.Errorf("a first submission replaces nothing and must not render a diff:\n%s", out)
188 }
189 }
190
190 lines GO