| 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 | {"好的", false}, |
| 20 | {"what does this function do?", false}, |
| 21 | {"解释一下这段代码", false}, |
| 22 | {"fix the bug", false}, |
| 23 | {"复杂重构认证迁移", false}, |
| 24 | {"直接修改 parser.go", false}, |
| 25 | {"先规划这个认证迁移,不要执行", true}, |
| 26 | {"plan first then implement the cache", true}, |
| 27 | {"give me a plan only", true}, |
| 28 | {"fix auth and wait for my approval", true}, |
| 29 | } |
| 30 | for _, c := range cases { |
| 31 | if got := TaskWarrantsPlanner(c.input); got != c.want { |
| 32 | t.Errorf("TaskWarrantsPlanner(%q) = %v, want %v", c.input, got, c.want) |
| 33 | } |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | func TestNewPlannerGateIsExplicitOnly(t *testing.T) { |
| 38 | gate := NewPlannerGate() |
| 39 | if gate == nil { |
| 40 | t.Fatal("NewPlannerGate returned nil") |
| 41 | } |
| 42 | if got := gate(context.Background(), "what is this?"); got { |
| 43 | t.Error("planner gate should skip questions") |
| 44 | } |
| 45 | if got := gate(context.Background(), "fix the bug"); got { |
| 46 | t.Error("ordinary work must stay executor-only") |
| 47 | } |
| 48 | if got := gate(context.Background(), "先规划再执行认证迁移"); !got { |
| 49 | t.Error("explicit plan-then-execute must call planner") |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func TestDecidePlannerRouteExplicitOnly(t *testing.T) { |
| 54 | cases := []struct { |
| 55 | name string |
| 56 | input string |
| 57 | meta plannerTurnMetadata |
| 58 | route agent.PlannerRoute |
| 59 | reason string |
| 60 | }{ |
| 61 | { |
| 62 | name: "explicit plan mode bypasses dual planner", |
| 63 | input: "fix the bug", |
| 64 | meta: plannerTurnMetadata{ExplicitPlanMode: true}, |
| 65 | route: agent.PlannerRouteExecutorOnly, |
| 66 | reason: plannerReasonExplicitPlanMode, |
| 67 | }, |
| 68 | { |
| 69 | name: "trusted synthetic turn bypasses dual planner", |
| 70 | input: "perform a brand new implementation", |
| 71 | meta: plannerTurnMetadata{Synthetic: true}, |
| 72 | route: agent.PlannerRouteExecutorOnly, |
| 73 | reason: plannerReasonSynthetic, |
| 74 | }, |
| 75 | { |
| 76 | name: "user text matching a legacy host prefix stays user authored", |
| 77 | input: agent.CompletionValidationContinuationPrefix + " give me a plan only", |
| 78 | meta: plannerTurnMetadata{UserText: agent.CompletionValidationContinuationPrefix + " give me a plan only"}, |
| 79 | route: agent.PlannerRoutePlanOnly, |
| 80 | reason: plannerReasonUserPlanOnly, |
| 81 | }, |
| 82 | { |
| 83 | name: "user asks for plan only", |
| 84 | input: "先规划这个认证迁移,不要执行", |
| 85 | route: agent.PlannerRoutePlanOnly, |
| 86 | reason: plannerReasonUserPlanOnly, |
| 87 | }, |
| 88 | { |
| 89 | name: "user asks for plan then execute", |
| 90 | input: "先规划再执行这个缓存改造", |
| 91 | route: agent.PlannerRoutePlanAndExecute, |
| 92 | reason: plannerReasonUserPlanAndExecute, |
| 93 | }, |
| 94 | { |
| 95 | name: "user asks for approval", |
| 96 | input: "plan the migration and wait for my approval", |
| 97 | route: agent.PlannerRoutePlanForApproval, |
| 98 | reason: plannerReasonUserPlanApproval, |
| 99 | }, |
| 100 | { |
| 101 | name: "direct request stays executor only", |
| 102 | input: "直接修改 parser.go", |
| 103 | route: agent.PlannerRouteExecutorOnly, |
| 104 | reason: plannerReasonUserDirect, |
| 105 | }, |
| 106 | { |
| 107 | name: "ordinary long multi-file request stays executor only", |
| 108 | input: "refactor the parser across reader.py and writer.py and add tests", |
| 109 | route: agent.PlannerRouteExecutorOnly, |
| 110 | reason: plannerReasonDefault, |
| 111 | }, |
| 112 | { |
| 113 | name: "auth wording does not auto plan", |
| 114 | input: "修复登录超时", |
| 115 | route: agent.PlannerRouteExecutorOnly, |
| 116 | reason: plannerReasonDefault, |
| 117 | }, |
| 118 | { |
| 119 | name: "explicit goal start plans once", |
| 120 | input: "fix the crash", |
| 121 | meta: plannerTurnMetadata{ExplicitGoalStart: true}, |
| 122 | route: agent.PlannerRoutePlanAndExecute, |
| 123 | reason: plannerReasonGoalStart, |
| 124 | }, |
| 125 | { |
| 126 | name: "context dependent fix stays with executor", |
| 127 | input: "fix it", |
| 128 | meta: plannerTurnMetadata{HasConversationContext: true}, |
| 129 | route: agent.PlannerRouteExecutorOnly, |
| 130 | reason: plannerReasonContextContinuation, |
| 131 | }, |
| 132 | } |
| 133 | for _, tc := range cases { |
| 134 | t.Run(tc.name, func(t *testing.T) { |
| 135 | ctx := withPlannerTurnMetadata(context.Background(), tc.meta) |
| 136 | got := DecidePlannerRoute(ctx, tc.input) |
| 137 | if got.Route != tc.route || got.Reason != tc.reason { |
| 138 | t.Fatalf("decision = %+v, want route=%s reason=%s", got, tc.route, tc.reason) |
| 139 | } |
| 140 | }) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func TestPlannerPolicyUsesPristineMetadataInsteadOfInjectedContext(t *testing.T) { |
| 145 | ctx := withPlannerTurnMetadata(context.Background(), plannerTurnMetadata{ |
| 146 | UserText: "fix typo in README", |
| 147 | }) |
| 148 | input := activeGoalBlock("migrate authentication across the backend") + |
| 149 | "\n\n<capability-route>\nhigh risk migration\n</capability-route>\n\nfix typo in README" |
| 150 | got := DecidePlannerRoute(ctx, input) |
| 151 | if got.Route != agent.PlannerRouteExecutorOnly { |
| 152 | t.Fatalf("decision used injected context instead of pristine user text: %+v", got) |
| 153 | } |
| 154 | } |
| 155 |