| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | |
| 8 | "reasonix/internal/event" |
| 9 | "reasonix/internal/provider" |
| 10 | "reasonix/internal/tool" |
| 11 | ) |
| 12 | |
| 13 | type recordingPlanApprover struct { |
| 14 | plan string |
| 15 | called bool |
| 16 | allow bool |
| 17 | } |
| 18 | |
| 19 | func (r *recordingPlanApprover) RunWithPlannerApproval(ctx context.Context, plan string, run func(context.Context) error) error { |
| 20 | r.called, r.plan = true, plan |
| 21 | if !r.allow { |
| 22 | return nil |
| 23 | } |
| 24 | return run(ctx) |
| 25 | } |
| 26 | |
| 27 | // submitPlanCall includes an unhelpful acknowledgement round to prove the host |
| 28 | // stops as soon as the structured plan lands and never pays for that round. |
| 29 | func submitPlanCall(args string) [][]provider.Chunk { |
| 30 | return [][]provider.Chunk{ |
| 31 | { |
| 32 | {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "submit_plan", Arguments: args}}, |
| 33 | {Type: provider.ChunkDone}, |
| 34 | }, |
| 35 | { |
| 36 | {Type: provider.ChunkText, Text: "I have submitted the plan above."}, |
| 37 | {Type: provider.ChunkDone}, |
| 38 | }, |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | func submitPlanCoordinator(t *testing.T, planner, exec *mockProvider, sink event.Sink) (*Coordinator, *Agent) { |
| 43 | t.Helper() |
| 44 | parentReg := tool.NewRegistry() |
| 45 | parentReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "contents"}) |
| 46 | parentReg.Add(NewAskTool()) |
| 47 | executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard) |
| 48 | coord := NewCoordinator(planner, NewSession("planner-sys"), nil, PlannerToolRegistry(parentReg), |
| 49 | Options{MaxSteps: 4}, executor, 0, sink, nil) |
| 50 | return coord, executor |
| 51 | } |
| 52 | |
| 53 | const e2ePlanArgs = `{ |
| 54 | "objective":"make the cache key model-aware", |
| 55 | "steps":[ |
| 56 | {"id":"p1","title":"thread the model ref through","verified_files":["internal/provider/cache.go"]}, |
| 57 | {"id":"s1","parent_id":"p1","title":"extend cacheKey", |
| 58 | "verification":[{"command":"go test ./internal/provider/","expect":"all green"}]} |
| 59 | ] |
| 60 | }` |
| 61 | |
| 62 | // The effect that matters: what the executor actually receives. A submitted plan |
| 63 | // must reach it as the rendered plan, not as whatever prose the planner ended on. |
| 64 | func TestSubmittedPlanReachesTheExecutorHandoff(t *testing.T) { |
| 65 | planner := &mockProvider{name: "planner", streams: submitPlanCall(e2ePlanArgs)} |
| 66 | exec := &mockProvider{name: "executor", chunks: []provider.Chunk{ |
| 67 | {Type: provider.ChunkText, Text: "Done."}, |
| 68 | {Type: provider.ChunkDone}, |
| 69 | }} |
| 70 | coord, _ := submitPlanCoordinator(t, planner, exec, event.Discard) |
| 71 | |
| 72 | if err := coord.Run(withNoClosedLoop(context.Background()), "fix the cache key"); err != nil { |
| 73 | t.Fatalf("Run: %v", err) |
| 74 | } |
| 75 | if len(exec.requests) == 0 { |
| 76 | t.Fatal("executor never ran") |
| 77 | } |
| 78 | if got := len(planner.requests); got != 1 { |
| 79 | t.Fatalf("planner requests = %d, want submit_plan to end the planner turn immediately", got) |
| 80 | } |
| 81 | handoff := lastUser(exec.requests[0]) |
| 82 | for _, want := range []string{ |
| 83 | "fix the cache key", |
| 84 | "**Objective** — make the cache key model-aware", |
| 85 | "1. thread the model ref through", |
| 86 | " - extend cacheKey", |
| 87 | "verified: internal/provider/cache.go", |
| 88 | "verify: go test ./internal/provider/ — all green", |
| 89 | } { |
| 90 | if !strings.Contains(handoff, want) { |
| 91 | t.Errorf("executor handoff missing %q:\n%s", want, handoff) |
| 92 | } |
| 93 | } |
| 94 | if strings.Contains(handoff, "I have submitted the plan above.") { |
| 95 | t.Errorf("executor received the planner's prose instead of the plan:\n%s", handoff) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // The user must see the plan itself, not the planner's acknowledgement of having |
| 100 | // submitted one — the host renders it because the plan is no longer prose. |
| 101 | func TestSubmittedPlanIsRenderedToTheSink(t *testing.T) { |
| 102 | var texts []string |
| 103 | sink := event.FuncSink(func(e event.Event) { |
| 104 | if e.Kind == event.Text && e.Source == event.UsageSourcePlanner { |
| 105 | texts = append(texts, e.Text) |
| 106 | } |
| 107 | }) |
| 108 | planner := &mockProvider{name: "planner", streams: submitPlanCall(e2ePlanArgs)} |
| 109 | exec := &mockProvider{name: "executor", chunks: []provider.Chunk{ |
| 110 | {Type: provider.ChunkText, Text: "Done."}, |
| 111 | {Type: provider.ChunkDone}, |
| 112 | }} |
| 113 | coord, _ := submitPlanCoordinator(t, planner, exec, sink) |
| 114 | |
| 115 | if err := coord.Run(withNoClosedLoop(context.Background()), "fix the cache key"); err != nil { |
| 116 | t.Fatalf("Run: %v", err) |
| 117 | } |
| 118 | joined := strings.Join(texts, "\n") |
| 119 | if !strings.Contains(joined, "1. thread the model ref through") { |
| 120 | t.Fatalf("the rendered plan never reached the sink:\n%s", joined) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | // requires_approval is a field now, so the gate fires on a plan whose prose says |
| 125 | // nothing about approval — the case the 24-phrase fallback cannot catch. |
| 126 | func TestSubmittedPlanGatesOnRequiresApprovalField(t *testing.T) { |
| 127 | args := `{"objective":"drop the legacy table","requires_approval":true, |
| 128 | "steps":[{"title":"drop payments_v1"}]}` |
| 129 | planner := &mockProvider{name: "planner", streams: submitPlanCall(args)} |
| 130 | exec := &mockProvider{name: "executor", chunks: []provider.Chunk{ |
| 131 | {Type: provider.ChunkText, Text: "Done."}, |
| 132 | {Type: provider.ChunkDone}, |
| 133 | }} |
| 134 | coord, _ := submitPlanCoordinator(t, planner, exec, event.Discard) |
| 135 | approver := &recordingPlanApprover{allow: true} |
| 136 | coord.SetPlannerPlanApprover(approver) |
| 137 | |
| 138 | if err := coord.Run(withNoClosedLoop(context.Background()), "drop the old table"); err != nil { |
| 139 | t.Fatalf("Run: %v", err) |
| 140 | } |
| 141 | if !approver.called { |
| 142 | t.Fatal("requires_approval did not gate execution") |
| 143 | } |
| 144 | if !strings.Contains(approver.plan, "1. drop payments_v1") { |
| 145 | t.Errorf("the approval card got %q, want the rendered plan", approver.plan) |
| 146 | } |
| 147 | if len(exec.requests) == 0 { |
| 148 | t.Fatal("approval was granted but the executor never ran") |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | func TestSubmittedPlanWithoutApprovalRunsStraightThrough(t *testing.T) { |
| 153 | planner := &mockProvider{name: "planner", streams: submitPlanCall(e2ePlanArgs)} |
| 154 | exec := &mockProvider{name: "executor", chunks: []provider.Chunk{ |
| 155 | {Type: provider.ChunkText, Text: "Done."}, |
| 156 | {Type: provider.ChunkDone}, |
| 157 | }} |
| 158 | coord, _ := submitPlanCoordinator(t, planner, exec, event.Discard) |
| 159 | approver := &recordingPlanApprover{allow: true} |
| 160 | coord.SetPlannerPlanApprover(approver) |
| 161 | |
| 162 | if err := coord.Run(withNoClosedLoop(context.Background()), "fix the cache key"); err != nil { |
| 163 | t.Fatalf("Run: %v", err) |
| 164 | } |
| 165 | if approver.called { |
| 166 | t.Fatal("a plan that did not request approval must not gate") |
| 167 | } |
| 168 | if len(exec.requests) == 0 { |
| 169 | t.Fatal("executor never ran") |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | // A planner that ignores submit_plan fails the turn as a protocol error: the |
| 174 | // prose path is gone, and its text never reaches the executor. |
| 175 | func TestPlannerWithoutSubmitPlanFailsAsProtocolError(t *testing.T) { |
| 176 | planner := &mockProvider{name: "planner", chunks: []provider.Chunk{ |
| 177 | {Type: provider.ChunkText, Text: "1. edit the cache key\n2. run the tests"}, |
| 178 | {Type: provider.ChunkDone}, |
| 179 | }} |
| 180 | exec := &mockProvider{name: "executor", chunks: []provider.Chunk{ |
| 181 | {Type: provider.ChunkText, Text: "Done."}, |
| 182 | {Type: provider.ChunkDone}, |
| 183 | }} |
| 184 | coord, _ := submitPlanCoordinator(t, planner, exec, event.Discard) |
| 185 | |
| 186 | err := coord.Run(withNoClosedLoop(context.Background()), "fix the cache key") |
| 187 | if err == nil || !strings.Contains(err.Error(), plannerProtocolError) { |
| 188 | t.Fatalf("Run = %v, want the planner protocol error", err) |
| 189 | } |
| 190 | if len(exec.requests) != 0 { |
| 191 | t.Fatal("executor ran on planner prose without a submitted plan") |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | // The planner asks with the real tool now, so a user-owned decision is settled |
| 196 | // while planning and the answer shapes the plan the executor receives — the |
| 197 | // prose-question path used to staple it onto a finished plan instead. |
| 198 | func TestPlannerAsksWithTheRealToolAndPlansFromTheAnswer(t *testing.T) { |
| 199 | planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{ |
| 200 | { |
| 201 | {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "ask-1", Name: "ask", Arguments: `{"questions":[{"header":"Store","question":"Which database?","options":[{"label":"Keep going"},{"label":"postgres"}]}]}`}}, |
| 202 | {Type: provider.ChunkDone}, |
| 203 | }, |
| 204 | { |
| 205 | {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "submit_plan", Arguments: `{"objective":"add the store","steps":[{"title":"wire the chosen database"}]}`}}, |
| 206 | {Type: provider.ChunkDone}, |
| 207 | }, |
| 208 | { |
| 209 | {Type: provider.ChunkText, Text: "Submitted."}, |
| 210 | {Type: provider.ChunkDone}, |
| 211 | }, |
| 212 | }} |
| 213 | exec := &mockProvider{name: "executor", chunks: []provider.Chunk{ |
| 214 | {Type: provider.ChunkText, Text: "Done."}, |
| 215 | {Type: provider.ChunkDone}, |
| 216 | }} |
| 217 | coord, _ := submitPlanCoordinator(t, planner, exec, event.Discard) |
| 218 | asker := &recordingAsker{} |
| 219 | coord.SetAsker(asker) |
| 220 | |
| 221 | if err := coord.Run(withNoClosedLoop(context.Background()), "add a store"); err != nil { |
| 222 | t.Fatalf("Run: %v", err) |
| 223 | } |
| 224 | if len(asker.questions) == 0 { |
| 225 | t.Fatal("the planner's ask never reached the host") |
| 226 | } |
| 227 | if got := asker.questions[0].Prompt; got != "Which database?" { |
| 228 | t.Errorf("question = %q, want the planner's own wording", got) |
| 229 | } |
| 230 | if len(exec.requests) == 0 { |
| 231 | t.Fatal("executor never ran after the decision was settled") |
| 232 | } |
| 233 | if got := lastUser(exec.requests[0]); !strings.Contains(got, "wire the chosen database") { |
| 234 | t.Errorf("executor handoff = %q, want the plan built after the answer", got) |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | func TestPlannerRegistryCarriesAsk(t *testing.T) { |
| 239 | parent := tool.NewRegistry() |
| 240 | parent.Add(NewAskTool()) |
| 241 | parent.Add(coordinatorTestTool{name: "read_file", readOnly: true}) |
| 242 | reg := PlannerToolRegistry(parent) |
| 243 | if _, ok := reg.Get("ask"); !ok { |
| 244 | t.Fatalf("planner registry lacks ask: %v", reg.Names()) |
| 245 | } |
| 246 | } |
| 247 |