返回 DeepSeek-Reasonix
planner_gate_test.go
根目录 / internal / control / planner_gate_test.go
1 package control
2
3 import (
4 "context"
5 "testing"
6
7 "reasonix/internal/agent"
8 )
9
10 func TestTaskWarrantsPlanner(t *testing.T) {
11 cases := []struct {
12 input string
13 want bool
14 }{
15 {"", false},
16 {" ", false},
17 {"/init", false},
18 {"1", false},
19 {"2.", false},
20 {"A", false},
21 {"好的", false},
22 {"继续", false},
23 {"选 1", false},
24 {"what does this function do?", false}, // low-risk question → executor only
25 {"why did the test fail", false},
26 {"解释一下这段代码", false},
27 {reasoningLanguageBlock("zh") + "\n\nwhat does this function do?", false},
28 {reasoningLanguageBlock("en") + "\n\n" + PlanModeMarker + "\n\nfix the bug", false},
29 {reasoningLanguageBlock("en") + "\n\nfix the bug", true},
30 {"fix the bug", true}, // terse, but a work request → still planned
31 {"add a login button", true}, // ditto
32 {"run the tests", false},
33 {"review this PR", false},
34 {"inspect internal/foo.go", false},
35 {"执行修复", true},
36 {"开始迁移", true},
37 {"继续重构", true},
38 {"continue fixing tests", true},
39 {"implement the new caching layer across the backend", true},
40 {"who wrote this file?", false},
41 {"where is the config file?", false},
42 {"when does this run?", false},
43 {"which file has the error?", false},
44 {"explain this code", false},
45 {"describe the architecture", false},
46 {"tell me about this function", false},
47 {"is this safe?", false},
48 {"are we done?", false},
49 {"can you help?", false},
50 {"can you fix the failing tests across the backend", true},
51 {"could you update the README", false}, // explicit single-target edit → executor only
52 {"should we remove the stale config option", true},
53 {"would you add a regression test", true},
54 {"do you fix flaky tests here", true},
55 {"does it work?", false},
56 {"did the test pass?", false},
57 {"should I use mutex here?", false},
58 {"would this approach work?", false},
59 {"list all the endpoints", false},
60 {"summarize the changes", false},
61 {"compare these two approaches", false},
62 {"what's the status?", false},
63 {"介绍一下这个项目", false},
64 {"说一下这个函数的作用", false},
65 {"帮我看一下这个报错", false},
66 {"是什么意思", false},
67 {"有没有现成的方案", false},
68 {"能不能这样做", false},
69 {"请问这个怎么用", false},
70 {"how do I implement a new caching layer", true},
71 {"what's the best way to refactor this module", true},
72 {"explain how to migrate from v1 to v2", true},
73 {goalContinueTurn, false},
74 {"Goal signaled complete but issues remain:\n- the following tasks are still incomplete:\n - Fix login (in_progress)\nFix or use todo_write/complete_step to mark done, then report complete again via update_goal.", false},
75 {activeGoalBlock("execute plan: fix the parser", GoalResearchAuto) + "\n\n" + goalContinueTurn, false},
76 {activeGoalBlock("implement the new caching layer", GoalResearchAuto) + "\n\nimplement the new caching layer across the backend", true},
77 }
78 for _, c := range cases {
79 if got := TaskWarrantsPlanner(c.input); got != c.want {
80 t.Errorf("TaskWarrantsPlanner(%q) = %v, want %v", c.input, got, c.want)
81 }
82 }
83 }
84
85 func TestNewPlannerGateUsesDeterministicTaskPolicy(t *testing.T) {
86 gate := NewPlannerGate()
87 if gate == nil {
88 t.Fatal("NewPlannerGate returned nil")
89 }
90 if got := gate(context.Background(), "what is this?"); got {
91 t.Error("planner gate should skip low-risk questions")
92 }
93 if got := gate(context.Background(), "fix the bug"); !got {
94 t.Error("planner gate should plan work requests")
95 }
96 }
97
98 func TestDecidePlannerRouteMatrix(t *testing.T) {
99 cases := []struct {
100 name string
101 input string
102 meta plannerTurnMetadata
103 route agent.PlannerRoute
104 depth agent.PlannerDepth
105 reason string
106 }{
107 {
108 name: "explicit plan mode bypasses dual planner",
109 input: "fix the bug",
110 meta: plannerTurnMetadata{ExplicitPlanMode: true},
111 route: agent.PlannerRouteExecutorOnly,
112 depth: agent.PlannerDepthNone,
113 reason: plannerReasonExplicitPlanMode,
114 },
115 {
116 name: "trusted synthetic turn bypasses dual planner",
117 input: "perform a brand new implementation",
118 meta: plannerTurnMetadata{Synthetic: true},
119 route: agent.PlannerRouteExecutorOnly,
120 depth: agent.PlannerDepthNone,
121 reason: plannerReasonSynthetic,
122 },
123 {
124 name: "user asks for plan only",
125 input: "先规划这个认证迁移,不要执行",
126 route: agent.PlannerRoutePlanOnly,
127 depth: agent.PlannerDepthFull,
128 reason: plannerReasonUserPlanOnly,
129 },
130 {
131 name: "bare english plan only",
132 input: "Give me a plan only for the auth migration.",
133 route: agent.PlannerRoutePlanOnly,
134 depth: agent.PlannerDepthFull,
135 reason: plannerReasonUserPlanOnly,
136 },
137 {
138 name: "bare chinese plan only with embedded target",
139 input: "只给认证迁移方案",
140 route: agent.PlannerRoutePlanOnly,
141 depth: agent.PlannerDepthFull,
142 reason: plannerReasonUserPlanOnly,
143 },
144 {
145 name: "task first english plan only boundary",
146 input: "Review the auth migration and give me a plan only; do not execute.",
147 route: agent.PlannerRoutePlanOnly,
148 depth: agent.PlannerDepthFull,
149 reason: plannerReasonUserPlanOnly,
150 },
151 {
152 name: "task first english plan only without redundant no execution",
153 input: "Review the auth migration and give me a plan only.",
154 route: agent.PlannerRoutePlanOnly,
155 depth: agent.PlannerDepthFull,
156 reason: plannerReasonUserPlanOnly,
157 },
158 {
159 name: "work request with no execution boundary",
160 input: "Implement the auth migration, but do not execute.",
161 route: agent.PlannerRoutePlanOnly,
162 depth: agent.PlannerDepthFull,
163 reason: plannerReasonUserPlanOnly,
164 },
165 {
166 name: "task first chinese plan only boundary",
167 input: "评审认证迁移,给我方案即可,不要修改代码",
168 route: agent.PlannerRoutePlanOnly,
169 depth: agent.PlannerDepthFull,
170 reason: plannerReasonUserPlanOnly,
171 },
172 {
173 name: "bare plan first continues to executor",
174 input: "先规划这个认证迁移",
175 route: agent.PlannerRoutePlanAndExecute,
176 depth: agent.PlannerDepthFull,
177 reason: plannerReasonUserPlanAndExecute,
178 },
179 {
180 name: "english plan first continues to executor",
181 input: "plan first, then handle the authentication migration",
182 route: agent.PlannerRoutePlanAndExecute,
183 depth: agent.PlannerDepthFull,
184 reason: plannerReasonUserPlanAndExecute,
185 },
186 {
187 name: "user asks to approve plan before execution",
188 input: "先规划这个认证迁移,等我确认后再执行",
189 route: agent.PlannerRoutePlanForApproval,
190 depth: agent.PlannerDepthFull,
191 reason: plannerReasonUserPlanApproval,
192 },
193 {
194 name: "task first english approval boundary",
195 input: "Implement the auth migration, but show me the plan and wait for my approval.",
196 route: agent.PlannerRoutePlanForApproval,
197 depth: agent.PlannerDepthFull,
198 reason: plannerReasonUserPlanApproval,
199 },
200 {
201 name: "work request with approval boundary",
202 input: "Implement the auth migration and wait for my approval.",
203 route: agent.PlannerRoutePlanForApproval,
204 depth: agent.PlannerDepthFull,
205 reason: plannerReasonUserPlanApproval,
206 },
207 {
208 name: "task first chinese approval boundary",
209 input: "实现认证迁移,但先给我方案并等我确认",
210 route: agent.PlannerRoutePlanForApproval,
211 depth: agent.PlannerDepthFull,
212 reason: plannerReasonUserPlanApproval,
213 },
214 {
215 name: "conditional no execution remains approval boundary",
216 input: "Plan the auth migration and do not execute until I approve.",
217 route: agent.PlannerRoutePlanForApproval,
218 depth: agent.PlannerDepthFull,
219 reason: plannerReasonUserPlanApproval,
220 },
221 {
222 name: "user asks to plan then execute",
223 input: "先规划再执行这个认证迁移",
224 route: agent.PlannerRoutePlanAndExecute,
225 depth: agent.PlannerDepthFull,
226 reason: plannerReasonUserPlanAndExecute,
227 },
228 {
229 name: "user explicitly skips planner",
230 input: "直接改 auth.go,别规划",
231 route: agent.PlannerRouteExecutorOnly,
232 depth: agent.PlannerDepthNone,
233 reason: plannerReasonUserDirect,
234 },
235 {
236 name: "task first english direct execution boundary",
237 input: "Implement the auth migration, but do not plan.",
238 route: agent.PlannerRouteExecutorOnly,
239 depth: agent.PlannerDepthNone,
240 reason: plannerReasonUserDirect,
241 },
242 {
243 name: "do not plan to is a scope constraint not a routing directive",
244 input: "Plan the auth migration, but do not plan to change the database schema.",
245 route: agent.PlannerRoutePlanAndExecute,
246 depth: agent.PlannerDepthFull,
247 reason: plannerReasonComplexIntent,
248 },
249 {
250 name: "task first chinese direct execution boundary",
251 input: "实现认证迁移,别规划,直接改",
252 route: agent.PlannerRouteExecutorOnly,
253 depth: agent.PlannerDepthNone,
254 reason: plannerReasonUserDirect,
255 },
256 {
257 name: "quoted directive is not an override",
258 input: "解释“直接改”是什么意思",
259 route: agent.PlannerRouteExecutorOnly,
260 depth: agent.PlannerDepthNone,
261 reason: plannerReasonLowRiskQuestion,
262 },
263 {
264 name: "quoted no execution phrase is not a boundary",
265 input: "解释“不要执行”是什么意思",
266 route: agent.PlannerRouteExecutorOnly,
267 depth: agent.PlannerDepthNone,
268 reason: plannerReasonLowRiskQuestion,
269 },
270 {
271 name: "quoted english approval phrase is not a boundary",
272 input: "Explain what \"wait for my approval\" means.",
273 route: agent.PlannerRouteExecutorOnly,
274 depth: agent.PlannerDepthNone,
275 reason: plannerReasonLowRiskQuestion,
276 },
277 {
278 name: "single quoted english boundary is not an override",
279 input: "Review the README wording 'wait for my approval'.",
280 route: agent.PlannerRouteExecutorOnly,
281 depth: agent.PlannerDepthNone,
282 reason: plannerReasonAtomicEdit,
283 },
284 {
285 name: "apostrophe in contraction remains a boundary",
286 input: "Review the README but don't execute.",
287 route: agent.PlannerRoutePlanOnly,
288 depth: agent.PlannerDepthFull,
289 reason: plannerReasonUserPlanOnly,
290 },
291 {
292 name: "expanded approval negation is not an approval boundary",
293 input: "Plan the migration; you do not need to wait for my approval.",
294 route: agent.PlannerRoutePlanAndExecute,
295 depth: agent.PlannerDepthFull,
296 reason: plannerReasonComplexIntent,
297 },
298 {
299 name: "negated approval does not override plan only",
300 input: "只给认证迁移方案,不要执行,也不用等我确认",
301 route: agent.PlannerRoutePlanOnly,
302 depth: agent.PlannerDepthFull,
303 reason: plannerReasonUserPlanOnly,
304 },
305 {
306 name: "context dependent fix stays with executor",
307 input: "fix it",
308 meta: plannerTurnMetadata{HasConversationContext: true},
309 route: agent.PlannerRouteExecutorOnly,
310 depth: agent.PlannerDepthNone,
311 reason: plannerReasonContextContinuation,
312 },
313 {
314 name: "standalone context dependent fix remains ambiguous",
315 input: "fix it",
316 route: agent.PlannerRoutePlanAndExecute,
317 depth: agent.PlannerDepthLight,
318 reason: plannerReasonWorkRequest,
319 },
320 {
321 name: "atomic readme edit skips planner",
322 input: "fix typo in README",
323 route: agent.PlannerRouteExecutorOnly,
324 depth: agent.PlannerDepthNone,
325 reason: plannerReasonAtomicEdit,
326 },
327 {
328 name: "atomic nil check skips planner",
329 input: "add a nil check in internal/foo.go",
330 route: agent.PlannerRouteExecutorOnly,
331 depth: agent.PlannerDepthNone,
332 reason: plannerReasonAtomicEdit,
333 },
334 {
335 name: "bounded anchored work gets light plan",
336 input: "add regression coverage in internal/foo_test.go",
337 route: agent.PlannerRoutePlanAndExecute,
338 depth: agent.PlannerDepthLight,
339 reason: plannerReasonAnchoredWork,
340 },
341 {
342 name: "single step read only command skips planner",
343 input: "run the tests",
344 route: agent.PlannerRouteExecutorOnly,
345 depth: agent.PlannerDepthNone,
346 reason: plannerReasonReadOnlyAction,
347 },
348 {
349 name: "single file inspection skips planner",
350 input: "inspect internal/foo.go",
351 route: agent.PlannerRouteExecutorOnly,
352 depth: agent.PlannerDepthNone,
353 reason: plannerReasonReadOnlyAction,
354 },
355 {
356 name: "delivery keeps pure read only command direct",
357 input: "review this PR",
358 meta: plannerTurnMetadata{DeliveryProfile: true},
359 route: agent.PlannerRouteExecutorOnly,
360 depth: agent.PlannerDepthNone,
361 reason: plannerReasonReadOnlyAction,
362 },
363 {
364 name: "high risk read only audit still gets full plan",
365 input: "audit the authentication authorization flow",
366 route: agent.PlannerRoutePlanAndExecute,
367 depth: agent.PlannerDepthFull,
368 reason: plannerReasonHighRisk,
369 },
370 {
371 name: "ambiguous bug gets full plan",
372 input: "fix the bug",
373 route: agent.PlannerRoutePlanAndExecute,
374 depth: agent.PlannerDepthFull,
375 reason: plannerReasonAmbiguousWork,
376 },
377 {
378 name: "anchored ambiguous bug still gets full plan",
379 input: "fix the bug in internal/foo.go",
380 route: agent.PlannerRoutePlanAndExecute,
381 depth: agent.PlannerDepthFull,
382 reason: plannerReasonAmbiguousWork,
383 },
384 {
385 name: "high risk overrides single target",
386 input: "fix the token authorization race in auth.go",
387 route: agent.PlannerRoutePlanAndExecute,
388 depth: agent.PlannerDepthFull,
389 reason: plannerReasonHighRisk,
390 },
391 {
392 name: "cross surface gets full plan",
393 input: "update frontend and backend for the new profile field",
394 route: agent.PlannerRoutePlanAndExecute,
395 depth: agent.PlannerDepthFull,
396 reason: plannerReasonCrossSurface,
397 },
398 {
399 name: "complex guidance gets light plan",
400 input: "how do I implement a local cache?",
401 route: agent.PlannerRoutePlanAndExecute,
402 depth: agent.PlannerDepthLight,
403 reason: plannerReasonGuidance,
404 },
405 {
406 name: "delivery upgrades non atomic work",
407 input: "add a login button",
408 meta: plannerTurnMetadata{DeliveryProfile: true},
409 route: agent.PlannerRoutePlanAndExecute,
410 depth: agent.PlannerDepthFull,
411 reason: plannerReasonWorkRequest,
412 },
413 {
414 name: "delivery keeps atomic edit direct",
415 input: "fix typo in README",
416 meta: plannerTurnMetadata{DeliveryProfile: true},
417 route: agent.PlannerRouteExecutorOnly,
418 depth: agent.PlannerDepthNone,
419 reason: plannerReasonAtomicEdit,
420 },
421 {
422 name: "active goal upgrades non atomic work",
423 input: "add a login button",
424 meta: plannerTurnMetadata{GoalActive: true},
425 route: agent.PlannerRoutePlanAndExecute,
426 depth: agent.PlannerDepthFull,
427 reason: plannerReasonGoalActive,
428 },
429 {
430 name: "active goal alone does not force an atomic edit through planner",
431 input: "fix typo in README",
432 meta: plannerTurnMetadata{GoalActive: true},
433 route: agent.PlannerRouteExecutorOnly,
434 depth: agent.PlannerDepthNone,
435 reason: plannerReasonAtomicEdit,
436 },
437 }
438
439 for _, tc := range cases {
440 t.Run(tc.name, func(t *testing.T) {
441 ctx := withPlannerTurnMetadata(context.Background(), tc.meta)
442 got := DecidePlannerRoute(ctx, tc.input)
443 if got.Route != tc.route || got.Depth != tc.depth || got.Reason != tc.reason {
444 t.Fatalf("decision = %+v, want route=%s depth=%s reason=%s", got, tc.route, tc.depth, tc.reason)
445 }
446 if got.Route != agent.PlannerRouteExecutorOnly && got.MaxResearchRounds <= 0 {
447 t.Fatalf("planned decision has no research budget: %+v", got)
448 }
449 })
450 }
451 }
452
453 func TestPlannerPolicyUsesPristineMetadataInsteadOfInjectedContext(t *testing.T) {
454 ctx := withPlannerTurnMetadata(context.Background(), plannerTurnMetadata{
455 UserText: "fix typo in README",
456 })
457 input := activeGoalBlock("migrate authentication across the backend", GoalResearchAuto) +
458 "\n\n<capability-route>\nhigh risk migration\n</capability-route>\n\nfix typo in README"
459 got := DecidePlannerRoute(ctx, input)
460 if got.Route != agent.PlannerRouteExecutorOnly || got.Reason != plannerReasonAtomicEdit {
461 t.Fatalf("decision used injected context instead of pristine user text: %+v", got)
462 }
463 }
464
464 lines GO