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