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