| 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 |