返回 DeepSeek-Reasonix
coordinator_test.go
根目录 / internal / agent / coordinator_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "reasonix/internal/event"
8 "strings"
9 "testing"
10
11 "reasonix/internal/provider"
12 "reasonix/internal/tool"
13 )
14
15 // mockProvider replays preset chunks and records the last request it received.
16 type mockProvider struct {
17 name string
18 chunks []provider.Chunk
19 streams [][]provider.Chunk
20 lastReq provider.Request
21 requests []provider.Request
22 }
23
24 func (m *mockProvider) Name() string { return m.name }
25
26 func (m *mockProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
27 m.lastReq = req
28 call := len(m.requests)
29 m.requests = append(m.requests, req)
30 chunks := m.chunks
31 if len(m.streams) > 0 {
32 if call >= len(m.streams) {
33 call = len(m.streams) - 1
34 }
35 chunks = m.streams[call]
36 }
37 ch := make(chan provider.Chunk, len(chunks))
38 for _, c := range chunks {
39 ch <- c
40 }
41 close(ch)
42 return ch, nil
43 }
44
45 func lastUser(req provider.Request) string {
46 for i := len(req.Messages) - 1; i >= 0; i-- {
47 if req.Messages[i].Role == provider.RoleUser {
48 return req.Messages[i].Content
49 }
50 }
51 return ""
52 }
53
54 // TestCoordinatorHandsPlanToExecutor checks the two-session handoff: the planner
55 // sees the raw task in its own session, and the executor receives the plan.
56 func TestCoordinatorHandsPlanToExecutor(t *testing.T) {
57 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
58 {Type: provider.ChunkText, Text: "1. read main.go\n2. fix the loop"},
59 {Type: provider.ChunkDone},
60 }}
61 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
62 {Type: provider.ChunkText, Text: "Done."},
63 {Type: provider.ChunkDone},
64 }}
65
66 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
67 plannerSess := NewSession("planner-sys")
68 coord := NewCoordinator(planner, plannerSess, nil, nil, Options{}, executor, 0, event.Discard, nil)
69
70 if err := coord.Run(context.Background(), "fix the bug"); err != nil {
71 t.Fatalf("Run: %v", err)
72 }
73
74 if got := lastUser(planner.lastReq); !strings.Contains(got, "fix the bug") {
75 t.Errorf("planner saw user %q, want it to contain the task", got)
76 }
77 if got := lastUser(exec.requests[0]); !strings.Contains(got, "read main.go") || !strings.Contains(got, "fix the bug") || !strings.Contains(got, "You are the executor now") {
78 t.Errorf("executor saw user %q, want task + plan", got)
79 }
80 // planner session must accumulate (system, user, assistant-plan) so its
81 // prefix grows prepend-only and stays cache-stable.
82 if n := len(plannerSess.Messages); n != 3 {
83 t.Errorf("planner session has %d messages, want 3", n)
84 }
85 }
86
87 type coordinatorApprovalGate struct {
88 calls int
89 allow bool
90 }
91
92 func (g *coordinatorApprovalGate) RunWithPlannerApproval(ctx context.Context, _ string, run func(context.Context) error) error {
93 g.calls++
94 if !g.allow {
95 return nil
96 }
97 return run(ctx)
98 }
99
100 type coordinatorDecisionGate struct {
101 calls int
102 answer string
103 }
104
105 func (g *coordinatorDecisionGate) RunWithPlannerUserDecision(ctx context.Context, _ string, _ event.AskQuestion, run func(context.Context, string) error) error {
106 g.calls++
107 if strings.TrimSpace(g.answer) == "" {
108 return nil
109 }
110 return run(ctx, g.answer)
111 }
112
113 func TestCoordinatorBindsPlannerApprovalRequestBeforeExecutor(t *testing.T) {
114 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
115 {Type: provider.ChunkText, Text: "Plan:\n1. edit main.go\n\n是否批准这个方案?"},
116 {Type: provider.ChunkDone},
117 }}
118 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
119 {Type: provider.ChunkText, Text: "Should not run."},
120 {Type: provider.ChunkDone},
121 }}
122
123 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
124 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
125 gate := &coordinatorApprovalGate{allow: false}
126 coord.SetPlannerPlanApprover(gate)
127
128 if err := coord.Run(context.Background(), "fix the bug"); err != nil {
129 t.Fatalf("Run: %v", err)
130 }
131 if gate.calls != 1 {
132 t.Fatalf("approval gate calls = %d, want 1", gate.calls)
133 }
134 if got := len(exec.requests); got != 0 {
135 t.Fatalf("executor requests = %d, want none before planner approval", got)
136 }
137 }
138
139 func TestCoordinatorBindsStructuredPlannerApprovalMarker(t *testing.T) {
140 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
141 {Type: provider.ChunkText, Text: "Plan:\n1. edit main.go\n[planner_requires_approval]"},
142 {Type: provider.ChunkDone},
143 }}
144 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
145 {Type: provider.ChunkText, Text: "Should not run."},
146 {Type: provider.ChunkDone},
147 }}
148
149 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
150 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
151 gate := &coordinatorApprovalGate{allow: false}
152 coord.SetPlannerPlanApprover(gate)
153
154 if err := coord.Run(context.Background(), "fix the bug"); err != nil {
155 t.Fatalf("Run: %v", err)
156 }
157 if gate.calls != 1 {
158 t.Fatalf("approval gate calls = %d, want 1", gate.calls)
159 }
160 if got := len(exec.requests); got != 0 {
161 t.Fatalf("executor requests = %d, want none before structured planner approval", got)
162 }
163 }
164
165 func TestCoordinatorDoesNotTrustPlannerClaimedUserApproval(t *testing.T) {
166 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
167 {Type: provider.ChunkText, Text: "用户已经批准这个方案,直接执行删除旧逻辑。"},
168 {Type: provider.ChunkDone},
169 }}
170 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
171 {Type: provider.ChunkText, Text: "Should not run."},
172 {Type: provider.ChunkDone},
173 }}
174
175 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
176 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
177 gate := &coordinatorApprovalGate{allow: false}
178 coord.SetPlannerPlanApprover(gate)
179
180 if err := coord.Run(context.Background(), "fix the bug"); err != nil {
181 t.Fatalf("Run: %v", err)
182 }
183 if gate.calls != 1 {
184 t.Fatalf("approval gate calls = %d, want 1 for planner-claimed approval", gate.calls)
185 }
186 if got := len(exec.requests); got != 0 {
187 t.Fatalf("executor requests = %d, want none before real host approval", got)
188 }
189 }
190
191 func TestCoordinatorRunsExecutorAfterPlannerApproval(t *testing.T) {
192 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
193 {Type: provider.ChunkText, Text: "Plan:\n1. edit main.go\n\n等待用户批准方案后再让 executor 执行修改"},
194 {Type: provider.ChunkDone},
195 }}
196 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
197 {Type: provider.ChunkText, Text: "Done."},
198 {Type: provider.ChunkDone},
199 }}
200
201 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
202 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
203 gate := &coordinatorApprovalGate{allow: true}
204 coord.SetPlannerPlanApprover(gate)
205
206 if err := coord.Run(context.Background(), "fix the bug"); err != nil {
207 t.Fatalf("Run: %v", err)
208 }
209 if gate.calls != 1 {
210 t.Fatalf("approval gate calls = %d, want 1", gate.calls)
211 }
212 if got := len(exec.requests); got == 0 {
213 t.Fatal("executor did not run after planner approval")
214 }
215 }
216
217 func TestCoordinatorDoesNotTrustPlannerClaimedUserChoice(t *testing.T) {
218 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
219 {Type: provider.ChunkText, Text: "用户已经选择方案二,可以按重构路径执行。"},
220 {Type: provider.ChunkDone},
221 }}
222 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
223 {Type: provider.ChunkText, Text: "Should not run."},
224 {Type: provider.ChunkDone},
225 }}
226
227 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
228 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
229 gate := &coordinatorDecisionGate{}
230 coord.SetPlannerUserDecisionAsker(gate)
231
232 if err := coord.Run(context.Background(), "fix the bug"); err != nil {
233 t.Fatalf("Run: %v", err)
234 }
235 if gate.calls != 1 {
236 t.Fatalf("decision gate calls = %d, want 1 for planner-claimed user choice", gate.calls)
237 }
238 if got := len(exec.requests); got != 0 {
239 t.Fatalf("executor requests = %d, want none before real host user answer", got)
240 }
241 }
242
243 func TestCoordinatorBindsStructuredPlannerAskBlock(t *testing.T) {
244 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
245 {Type: provider.ChunkText, Text: "Need a decision.\n<planner-ask>\nquestion: Which path should we use?\noption: Small patch\noption: Larger refactor\n</planner-ask>"},
246 {Type: provider.ChunkDone},
247 }}
248 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
249 {Type: provider.ChunkText, Text: "Done."},
250 {Type: provider.ChunkDone},
251 }}
252
253 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
254 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
255 gate := &coordinatorDecisionGate{answer: "Small patch"}
256 coord.SetPlannerUserDecisionAsker(gate)
257
258 if err := coord.Run(context.Background(), "fix the bug"); err != nil {
259 t.Fatalf("Run: %v", err)
260 }
261 if gate.calls != 1 {
262 t.Fatalf("decision gate calls = %d, want 1", gate.calls)
263 }
264 if got := len(exec.requests); got == 0 {
265 t.Fatal("executor did not run after structured planner ask answer")
266 }
267 if got := lastUser(exec.requests[0]); !strings.Contains(got, "Host user answer to planner question") || !strings.Contains(got, "Small patch") {
268 t.Fatalf("executor handoff missing structured host answer:\n%s", got)
269 }
270 }
271
272 func TestCoordinatorBindsPlannerUserDecisionBeforeExecutor(t *testing.T) {
273 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
274 {Type: provider.ChunkText, Text: "需要用户选择方案:\n方案一:小改当前逻辑\n方案二:重构控制流\n请选择哪个方案。"},
275 {Type: provider.ChunkDone},
276 }}
277 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
278 {Type: provider.ChunkText, Text: "Should not run."},
279 {Type: provider.ChunkDone},
280 }}
281
282 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
283 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
284 gate := &coordinatorDecisionGate{}
285 coord.SetPlannerUserDecisionAsker(gate)
286
287 if err := coord.Run(context.Background(), "fix the bug"); err != nil {
288 t.Fatalf("Run: %v", err)
289 }
290 if gate.calls != 1 {
291 t.Fatalf("decision gate calls = %d, want 1", gate.calls)
292 }
293 if got := len(exec.requests); got != 0 {
294 t.Fatalf("executor requests = %d, want none before user decision", got)
295 }
296 }
297
298 func TestCoordinatorDoesNotAskForOrdinaryPlanVerificationWording(t *testing.T) {
299 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
300 {Type: provider.ChunkText, Text: "Plan:\n1. 确认文件存在\n2. 修改 main.go\n3. 运行测试"},
301 {Type: provider.ChunkDone},
302 }}
303 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
304 {Type: provider.ChunkText, Text: "Done."},
305 {Type: provider.ChunkDone},
306 }}
307
308 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
309 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
310 gate := &coordinatorDecisionGate{answer: "should not be used"}
311 coord.SetPlannerUserDecisionAsker(gate)
312
313 if err := coord.Run(context.Background(), "fix the bug"); err != nil {
314 t.Fatalf("Run: %v", err)
315 }
316 if gate.calls != 0 {
317 t.Fatalf("decision gate calls = %d, want no AskRequest for ordinary verification wording", gate.calls)
318 }
319 if got := len(exec.requests); got == 0 {
320 t.Fatal("executor should run for ordinary plan wording")
321 }
322 }
323
324 func TestCoordinatorPassesHostUserDecisionToExecutor(t *testing.T) {
325 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
326 {Type: provider.ChunkText, Text: "需要用户选择方案:\n方案一:小改当前逻辑\n方案二:重构控制流\n请选择哪个方案。"},
327 {Type: provider.ChunkDone},
328 }}
329 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
330 {Type: provider.ChunkText, Text: "Done."},
331 {Type: provider.ChunkDone},
332 }}
333
334 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
335 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
336 gate := &coordinatorDecisionGate{answer: "方案二:重构控制流"}
337 coord.SetPlannerUserDecisionAsker(gate)
338
339 if err := coord.Run(context.Background(), "fix the bug"); err != nil {
340 t.Fatalf("Run: %v", err)
341 }
342 if gate.calls != 1 {
343 t.Fatalf("decision gate calls = %d, want 1", gate.calls)
344 }
345 if got := len(exec.requests); got == 0 {
346 t.Fatal("executor did not run after user decision")
347 }
348 if got := lastUser(exec.requests[0]); !strings.Contains(got, "Host user answer to planner question") || !strings.Contains(got, "方案二") {
349 t.Fatalf("executor handoff missing host user answer:\n%s", got)
350 }
351 }
352
353 // TestHandoffTaskRecoversOriginalInput guards the dual-model auto-title path
354 // (#3860): previews must surface the user's words, not handoff boilerplate.
355 func TestHandoffTaskRecoversOriginalInput(t *testing.T) {
356 if got := HandoffTask(formatHandoff("修复登录页的 bug", "1. read login.go")); got != "修复登录页的 bug" {
357 t.Errorf("HandoffTask(handoff) = %q, want the original task", got)
358 }
359 multi := "fix the bug\n\nsteps:\n- a\n- b"
360 if got := HandoffTask(formatHandoff(multi, "plan")); got != multi {
361 t.Errorf("HandoffTask(multi-line) = %q, want %q", got, multi)
362 }
363 for _, plain := range []string{"ordinary input", "", "# Reasonix executor handoff with no sections"} {
364 if got := HandoffTask(plain); got != plain {
365 t.Errorf("HandoffTask(%q) = %q, want unchanged", plain, got)
366 }
367 }
368 }
369
370 // TestCoordinatorSkipsPlannerForTrivialTurn checks the gate: when shouldPlan
371 // rejects the turn, the planner is never called and the executor gets the raw
372 // input (no plan handoff).
373 func TestCoordinatorSkipsPlannerForTrivialTurn(t *testing.T) {
374 planner := &mockProvider{name: "planner"}
375 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
376 {Type: provider.ChunkText, Text: "It does X."},
377 {Type: provider.ChunkDone},
378 }}
379
380 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
381 plannerSess := NewSession("planner-sys")
382 coord := NewCoordinator(planner, plannerSess, nil, nil, Options{}, executor, 0, event.Discard, func(context.Context, string) bool { return false })
383
384 if err := coord.Run(context.Background(), "what does this function do?"); err != nil {
385 t.Fatalf("Run: %v", err)
386 }
387
388 if planner.lastReq.Messages != nil {
389 t.Error("planner should not be called for a skipped turn")
390 }
391 if got := lastUser(exec.lastReq); got != "what does this function do?" {
392 t.Errorf("executor saw %q, want the raw input with no plan handoff", got)
393 }
394 if n := len(plannerSess.Messages); n != 1 { // just the system message
395 t.Errorf("planner session has %d messages, want 1 (untouched)", n)
396 }
397 }
398
399 func TestCoordinatorStructuredPolicyUsesStableDepthMetadata(t *testing.T) {
400 planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
401 {{Type: provider.ChunkText, Text: "Light plan."}, {Type: provider.ChunkDone}},
402 {{Type: provider.ChunkText, Text: "Full plan."}, {Type: provider.ChunkDone}},
403 }}
404 exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
405 {{Type: provider.ChunkText, Text: "Light done."}, {Type: provider.ChunkDone}},
406 {{Type: provider.ChunkText, Text: "Full done."}, {Type: provider.ChunkDone}},
407 }}
408 policy := func(_ context.Context, input string) PlannerDecision {
409 if strings.Contains(input, "light") {
410 return PlannerDecision{
411 Route: PlannerRoutePlanAndExecute, Depth: PlannerDepthLight,
412 Reason: "test_light", MaxResearchRounds: 2,
413 }
414 }
415 return PlannerDecision{
416 Route: PlannerRoutePlanAndExecute, Depth: PlannerDepthFull,
417 Reason: "test_full", MaxResearchRounds: 6,
418 }
419 }
420 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
421 coord := NewCoordinatorWithPlannerPolicy(
422 planner, NewSession("stable planner system"), nil, nil, Options{},
423 executor, 0, event.Discard, policy,
424 )
425
426 if err := coord.Run(context.Background(), "light task"); err != nil {
427 t.Fatalf("light Run: %v", err)
428 }
429 if err := coord.Run(context.Background(), "full task"); err != nil {
430 t.Fatalf("full Run: %v", err)
431 }
432
433 if got := lastUser(planner.requests[0]); !strings.Contains(got, "depth: light") || !strings.Contains(got, "route: plan_and_execute") {
434 t.Fatalf("light planner input missing route metadata: %q", got)
435 }
436 if got := lastUser(planner.requests[1]); !strings.Contains(got, "depth: full") || !strings.Contains(got, "route: plan_and_execute") {
437 t.Fatalf("full planner input missing route metadata: %q", got)
438 }
439 for i, req := range planner.requests {
440 if len(req.Messages) == 0 || req.Messages[0].Role != provider.RoleSystem || req.Messages[0].Content != "stable planner system" {
441 t.Fatalf("planner request %d changed stable system prefix: %+v", i, req.Messages)
442 }
443 }
444 var handoffs []string
445 for _, req := range exec.requests {
446 if got := lastUser(req); strings.Contains(got, executorHandoffMarker) {
447 handoffs = append(handoffs, got)
448 }
449 }
450 if len(handoffs) != 2 {
451 t.Fatalf("executor handoffs = %d, want one light and one full handoff", len(handoffs))
452 }
453 if !strings.Contains(handoffs[0], "Planning depth: light") {
454 t.Fatalf("light handoff missing depth: %q", handoffs[0])
455 }
456 if !strings.Contains(handoffs[1], "Planning depth: full") {
457 t.Fatalf("full handoff missing depth: %q", handoffs[1])
458 }
459 }
460
461 func TestCoordinatorPlanForApprovalDoesNotDependOnPlannerMarker(t *testing.T) {
462 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
463 {Type: provider.ChunkText, Text: "1. inspect auth\n2. migrate tokens"},
464 {Type: provider.ChunkDone},
465 }}
466 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
467 {Type: provider.ChunkText, Text: "must not run"},
468 {Type: provider.ChunkDone},
469 }}
470 policy := func(context.Context, string) PlannerDecision {
471 return PlannerDecision{
472 Route: PlannerRoutePlanForApproval, Depth: PlannerDepthFull,
473 Reason: "user_plan_for_approval", MaxResearchRounds: 6,
474 }
475 }
476 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
477 coord := NewCoordinatorWithPlannerPolicy(
478 planner, NewSession("planner-sys"), nil, nil, Options{},
479 executor, 0, event.Discard, policy,
480 )
481 approval := &coordinatorApprovalGate{allow: false}
482 coord.SetPlannerPlanApprover(approval)
483
484 if err := coord.Run(context.Background(), "plan auth migration first"); err != nil {
485 t.Fatalf("Run: %v", err)
486 }
487 if approval.calls != 1 {
488 t.Fatalf("approval calls = %d, want 1 without planner marker", approval.calls)
489 }
490 if len(exec.requests) != 0 {
491 t.Fatal("executor ran before structured plan approval")
492 }
493 }
494
495 func TestCoordinatorPlanForApprovalHandsOffAfterApproval(t *testing.T) {
496 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
497 {Type: provider.ChunkText, Text: "1. inspect auth\n2. migrate tokens"},
498 {Type: provider.ChunkDone},
499 }}
500 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
501 {Type: provider.ChunkText, Text: "Done."},
502 {Type: provider.ChunkDone},
503 }}
504 policy := func(context.Context, string) PlannerDecision {
505 return PlannerDecision{Route: PlannerRoutePlanForApproval, Depth: PlannerDepthFull, Reason: "user_plan_for_approval"}
506 }
507 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
508 coord := NewCoordinatorWithPlannerPolicy(
509 planner, NewSession("planner-sys"), nil, nil, Options{},
510 executor, 0, event.Discard, policy,
511 )
512 approval := &coordinatorApprovalGate{allow: true}
513 coord.SetPlannerPlanApprover(approval)
514
515 if err := coord.Run(context.Background(), "plan auth migration, then wait for my approval"); err != nil {
516 t.Fatalf("Run: %v", err)
517 }
518 if approval.calls != 1 {
519 t.Fatalf("approval calls = %d, want 1", approval.calls)
520 }
521 if len(exec.requests) == 0 {
522 t.Fatal("executor did not run after approval")
523 }
524 if got := lastUser(exec.requests[0]); !strings.Contains(got, "migrate tokens") {
525 t.Fatalf("executor handoff = %q, want approved planner output", got)
526 }
527 }
528
529 func TestCoordinatorHeadlessPlanForApprovalPersistsForContinuation(t *testing.T) {
530 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
531 {Type: provider.ChunkText, Text: "1. inspect auth\n2. migrate tokens"},
532 {Type: provider.ChunkDone},
533 }}
534 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
535 {Type: provider.ChunkText, Text: "must not run"},
536 {Type: provider.ChunkDone},
537 }}
538 policy := func(context.Context, string) PlannerDecision {
539 return PlannerDecision{Route: PlannerRoutePlanForApproval, Depth: PlannerDepthFull, Reason: "user_plan_for_approval"}
540 }
541 sink := &recordSink{}
542 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, sink)
543 coord := NewCoordinatorWithPlannerPolicy(
544 planner, NewSession("planner-sys"), nil, nil, Options{},
545 executor, 0, sink, policy,
546 )
547
548 if err := coord.Run(context.Background(), "plan auth migration first"); err != nil {
549 t.Fatalf("Run: %v", err)
550 }
551 if len(exec.requests) != 0 {
552 t.Fatal("headless executor ran without a plan approval channel")
553 }
554 msgs := executor.Session().Messages
555 if len(msgs) < 2 || !strings.Contains(msgs[len(msgs)-1].Content, plannerPlanAwaitingApprovalNote) {
556 t.Fatalf("headless approval turn was not persisted for continuation: %+v", msgs)
557 }
558 }
559
560 func TestCoordinatorPlanOnlyDoesNotRunExecutor(t *testing.T) {
561 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
562 {Type: provider.ChunkText, Text: "1. inspect auth\n2. migrate tokens"},
563 {Type: provider.ChunkDone},
564 }}
565 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
566 {Type: provider.ChunkText, Text: "must not run"},
567 {Type: provider.ChunkDone},
568 }}
569 policy := func(context.Context, string) PlannerDecision {
570 return PlannerDecision{Route: PlannerRoutePlanOnly, Depth: PlannerDepthFull, Reason: "user_plan_only"}
571 }
572 sink := &recordSink{}
573 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, sink)
574 coord := NewCoordinatorWithPlannerPolicy(
575 planner, NewSession("planner-sys"), nil, nil, Options{},
576 executor, 0, sink, policy,
577 )
578 approval := &coordinatorApprovalGate{allow: true}
579 coord.SetPlannerPlanApprover(approval)
580
581 if err := coord.Run(context.Background(), "只规划认证迁移,不要执行"); err != nil {
582 t.Fatalf("Run: %v", err)
583 }
584 if approval.calls != 0 {
585 t.Fatalf("approval calls = %d, want 0 for an explicit no-execution request", approval.calls)
586 }
587 if len(exec.requests) != 0 {
588 t.Fatal("executor ran for an explicit plan-only request")
589 }
590 msgs := executor.Session().Messages
591 if len(msgs) < 2 || !strings.Contains(msgs[len(msgs)-1].Content, plannerPlanOnlyNote) {
592 t.Fatalf("plan-only turn was not persisted for a later user continuation: %+v", msgs)
593 }
594 }
595
596 func TestCoordinatorPlanOnlyContinuesWithExecutorOnNextTurn(t *testing.T) {
597 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
598 {Type: provider.ChunkText, Text: "1. inspect auth\n2. migrate tokens"},
599 {Type: provider.ChunkDone},
600 }}
601 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
602 {Type: provider.ChunkText, Text: "Migration complete."},
603 {Type: provider.ChunkDone},
604 }}
605 policy := func(_ context.Context, input string) PlannerDecision {
606 if strings.Contains(input, "只规划") {
607 return PlannerDecision{Route: PlannerRoutePlanOnly, Depth: PlannerDepthFull, Reason: "user_plan_only"}
608 }
609 return PlannerDecision{Route: PlannerRouteExecutorOnly, Depth: PlannerDepthNone, Reason: "short_reply"}
610 }
611 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
612 coord := NewCoordinatorWithPlannerPolicy(
613 planner, NewSession("planner-sys"), nil, nil, Options{},
614 executor, 0, event.Discard, policy,
615 )
616
617 if err := coord.Run(context.Background(), "只规划认证迁移,不要执行"); err != nil {
618 t.Fatalf("plan-only Run: %v", err)
619 }
620 if got := len(exec.requests); got != 0 {
621 t.Fatalf("executor requests after plan-only turn = %d, want none", got)
622 }
623
624 if err := coord.Run(context.Background(), "执行"); err != nil {
625 t.Fatalf("continuation Run: %v", err)
626 }
627 if got := len(exec.requests); got != 1 {
628 t.Fatalf("executor requests after continuation = %d, want one", got)
629 }
630 req := exec.requests[0]
631 if got := lastUser(req); !strings.Contains(got, "执行") {
632 t.Fatalf("executor continuation input = %q, want the user's execution request", got)
633 }
634 foundSavedPlan := false
635 for _, msg := range req.Messages {
636 if msg.Role == provider.RoleAssistant &&
637 strings.Contains(msg.Content, "migrate tokens") &&
638 strings.Contains(msg.Content, plannerPlanOnlyNote) {
639 foundSavedPlan = true
640 break
641 }
642 }
643 if !foundSavedPlan {
644 t.Fatalf("executor continuation did not receive the saved plan-only turn: %+v", req.Messages)
645 }
646 if got := len(planner.requests); got != 1 {
647 t.Fatalf("planner requests = %d, want only the original plan-only turn", got)
648 }
649 }
650
651 func TestCoordinatorPlannerFailurePreservesExecutionBoundary(t *testing.T) {
652 cases := []struct {
653 name string
654 route PlannerRoute
655 reason string
656 input string
657 }{
658 {
659 name: "plan only",
660 route: PlannerRoutePlanOnly,
661 reason: "user_plan_only",
662 input: "只规划认证迁移,不要执行",
663 },
664 {
665 name: "plan for approval",
666 route: PlannerRoutePlanForApproval,
667 reason: "user_plan_for_approval",
668 input: "先规划认证迁移,等我确认后再执行",
669 },
670 }
671 for _, tc := range cases {
672 t.Run(tc.name, func(t *testing.T) {
673 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
674 {Type: provider.ChunkError, Err: fmt.Errorf("rate limited")},
675 }}
676 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
677 {Type: provider.ChunkText, Text: "must not run"},
678 {Type: provider.ChunkDone},
679 }}
680 policy := func(context.Context, string) PlannerDecision {
681 return PlannerDecision{Route: tc.route, Depth: PlannerDepthFull, Reason: tc.reason}
682 }
683 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
684 coord := NewCoordinatorWithPlannerPolicy(
685 planner, NewSession("planner-sys"), nil, nil, Options{},
686 executor, 0, event.Discard, policy,
687 )
688
689 err := coord.Run(context.Background(), tc.input)
690 if err == nil || !strings.Contains(err.Error(), "planner:") {
691 t.Fatalf("Run = %v, want planner failure", err)
692 }
693 if len(exec.requests) != 0 {
694 t.Fatal("executor fallback violated the requested execution boundary")
695 }
696 })
697 }
698 }
699
700 type coordinatorTestTool struct {
701 name string
702 readOnly bool
703 output string
704 }
705
706 func (t coordinatorTestTool) Name() string { return t.name }
707 func (t coordinatorTestTool) Description() string { return t.name + " test tool" }
708 func (t coordinatorTestTool) Schema() json.RawMessage {
709 return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`)
710 }
711 func (t coordinatorTestTool) Execute(context.Context, json.RawMessage) (string, error) {
712 return t.output, nil
713 }
714 func (t coordinatorTestTool) ReadOnly() bool { return t.readOnly }
715
716 func TestCoordinatorPlannerUsesReadOnlyResearchTools(t *testing.T) {
717 planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
718 {
719 {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"REASONIX.md"}`}},
720 {Type: provider.ChunkDone},
721 },
722 {
723 {Type: provider.ChunkText, Text: "1. follow the loaded rule\n2. edit the narrow file"},
724 {Type: provider.ChunkDone},
725 },
726 }}
727 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
728 {Type: provider.ChunkText, Text: "Done."},
729 {Type: provider.ChunkDone},
730 }}
731
732 parentReg := tool.NewRegistry()
733 parentReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "Rule: keep changes narrow."})
734 parentReg.Add(coordinatorTestTool{name: "write_file", readOnly: false})
735 parentReg.Add(coordinatorTestTool{name: "todo_write", readOnly: true})
736
737 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
738 plannerSess := NewSession(PlannerPromptWithContext("Rule: keep changes narrow."))
739 coord := NewCoordinator(planner, plannerSess, nil, PlannerToolRegistry(parentReg), Options{MaxSteps: 4}, executor, 0, event.Discard, nil)
740
741 if err := coord.Run(context.Background(), "fix the bug"); err != nil {
742 t.Fatalf("Run: %v", err)
743 }
744
745 if len(planner.requests) < 2 {
746 t.Fatalf("planner made %d provider request(s), want a tool round and a final plan", len(planner.requests))
747 }
748 tools := toolSchemaNames(planner.requests[0].Tools)
749 if !contains(tools, "read_file") {
750 t.Fatalf("planner tools = %v, want read_file", tools)
751 }
752 for _, forbidden := range []string{"write_file", "todo_write"} {
753 if contains(tools, forbidden) {
754 t.Fatalf("planner tools = %v, must not include %s", tools, forbidden)
755 }
756 }
757 if got := lastUser(exec.requests[0]); !strings.Contains(got, "follow the loaded rule") || !strings.Contains(got, "fix the bug") {
758 t.Errorf("executor saw user %q, want task + planner plan", got)
759 }
760 if got := plannerSess.Messages[0].Content; !strings.Contains(got, "Rule: keep changes narrow.") {
761 t.Errorf("planner system prompt missing planning context: %q", got)
762 }
763 }
764
765 func TestCoordinatorSetReasoningLanguageClearsPlannerAgent(t *testing.T) {
766 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
767 {Type: provider.ChunkText, Text: "1. inspect the narrow path"},
768 {Type: provider.ChunkDone},
769 }}
770 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
771 {Type: provider.ChunkText, Text: "Done."},
772 {Type: provider.ChunkDone},
773 }}
774
775 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{ReasoningLanguage: "zh"}, event.Discard)
776 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, tool.NewRegistry(), Options{ReasoningLanguage: "zh"}, executor, 0, event.Discard, nil)
777 coord.SetReasoningLanguage("auto")
778
779 if err := coord.Run(context.Background(), "plan a change"); err != nil {
780 t.Fatalf("Run: %v", err)
781 }
782
783 if got := lastUser(planner.requests[0]); strings.Contains(got, "<reasoning-language>") {
784 t.Fatalf("planner should clear stale reasoning language after live auto update, got %q", got)
785 }
786 if got := lastUser(exec.requests[0]); strings.Contains(got, "<reasoning-language>") {
787 t.Fatalf("executor should clear stale reasoning language after live auto update, got %q", got)
788 }
789 }
790
791 func TestCoordinatorPlannerMaxStepsUsesExplicitRuntimeKey(t *testing.T) {
792 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
793 {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"REASONIX.md"}`}},
794 {Type: provider.ChunkDone},
795 }}
796 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
797 {Type: provider.ChunkText, Text: "Done."},
798 {Type: provider.ChunkDone},
799 }}
800
801 parentReg := tool.NewRegistry()
802 parentReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "keep reading"})
803 sink := &recordSink{}
804 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, sink)
805 plannerSess := NewSession("planner-sys")
806 coord := NewCoordinator(planner, plannerSess, nil, PlannerToolRegistry(parentReg), Options{
807 MaxSteps: 2,
808 MaxStepsKey: "planner max_steps",
809 }, executor, 0, sink, nil)
810
811 err := coord.Run(context.Background(), "plan a change")
812 if err != nil {
813 t.Fatalf("Run should fall back to the executor when the planner cannot finalize: %v", err)
814 }
815 if got := len(planner.requests); got != 3 {
816 t.Fatalf("planner requests = %d, want 2 research rounds plus finalization", got)
817 }
818 if got := len(exec.requests); got != 1 {
819 t.Fatalf("executor requests = %d, want one fallback run", got)
820 }
821 if got := lastUser(exec.requests[0]); !strings.Contains(got, "plan a change") || strings.Contains(got, executorHandoffMarker) {
822 t.Fatalf("executor fallback input = %q, want the original task without a fabricated handoff", got)
823 }
824 if got := len(plannerSess.Messages); got != 1 {
825 t.Fatalf("planner session messages = %d, want the incomplete turn rolled back", got)
826 }
827 notices := sink.kinds(event.Notice)
828 if len(notices) == 0 || notices[len(notices)-1].Text != plannerResearchFallbackNotice {
829 t.Fatalf("notices = %+v, want planner research fallback notice", notices)
830 }
831 if detail := notices[len(notices)-1].Detail; !strings.Contains(detail, "planner max_steps") ||
832 strings.Contains(detail, "set planner max_steps") {
833 t.Fatalf("fallback detail = %q, want the bounded diagnostic without configuration advice", detail)
834 }
835 }
836
837 func TestCoordinatorPlannerMaxStepsZeroIsUnlimited(t *testing.T) {
838 planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
839 {
840 {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"a"}`}},
841 {Type: provider.ChunkDone},
842 },
843 {
844 {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-2", Name: "read_file", Arguments: `{"path":"b"}`}},
845 {Type: provider.ChunkDone},
846 },
847 {
848 {Type: provider.ChunkText, Text: "1. use both files"},
849 {Type: provider.ChunkDone},
850 },
851 }}
852 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
853 {Type: provider.ChunkText, Text: "Done."},
854 {Type: provider.ChunkDone},
855 }}
856
857 parentReg := tool.NewRegistry()
858 parentReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "ok"})
859 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
860 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, PlannerToolRegistry(parentReg), Options{
861 MaxSteps: 0,
862 MaxStepsKey: "planner max_steps",
863 }, executor, 0, event.Discard, nil)
864
865 if err := coord.Run(context.Background(), "plan a change"); err != nil {
866 t.Fatalf("Run with planner max steps 0 should not pause: %v", err)
867 }
868 if got := len(planner.requests); got != 3 {
869 t.Fatalf("planner requests = %d, want all 3 scripted planner turns", got)
870 }
871 if got := lastUser(exec.requests[0]); !strings.Contains(got, "use both files") {
872 t.Fatalf("executor did not receive planner output: %q", got)
873 }
874 }
875
876 func TestCoordinatorPlannerDepthAppliesPerTurnResearchBudget(t *testing.T) {
877 planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
878 {{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"a"}`}}, {Type: provider.ChunkDone}},
879 {{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-2", Name: "read_file", Arguments: `{"path":"b"}`}}, {Type: provider.ChunkDone}},
880 {{Type: provider.ChunkText, Text: "1. apply the narrow change\n2. run the focused test"}, {Type: provider.ChunkDone}},
881 }}
882 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
883 {Type: provider.ChunkText, Text: "Done."},
884 {Type: provider.ChunkDone},
885 }}
886 parentReg := tool.NewRegistry()
887 parentReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "ok"})
888 policy := func(context.Context, string) PlannerDecision {
889 return PlannerDecision{
890 Route: PlannerRoutePlanAndExecute, Depth: PlannerDepthLight,
891 Reason: "bounded_work", MaxResearchRounds: 2,
892 }
893 }
894 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
895 coord := NewCoordinatorWithPlannerPolicy(
896 planner, NewSession("planner-sys"), nil, PlannerToolRegistry(parentReg), Options{MaxSteps: 0},
897 executor, 0, event.Discard, policy,
898 )
899
900 if err := coord.Run(context.Background(), "make the bounded change"); err != nil {
901 t.Fatalf("Run: %v", err)
902 }
903 if got := len(planner.requests); got != 3 {
904 t.Fatalf("planner requests = %d, want two research rounds plus one finalization round", got)
905 }
906 if got := lastUser(planner.requests[2]); !strings.Contains(got, "planner research rounds") ||
907 !strings.Contains(got, "Do not call any more tools") ||
908 !strings.Contains(got, "label remaining uncertainty") ||
909 strings.Contains(got, "increase planner research rounds") {
910 t.Fatalf("planner did not receive the depth budget finalization nudge: %q", got)
911 }
912 var sawHandoff bool
913 for _, req := range exec.requests {
914 if strings.Contains(lastUser(req), executorHandoffMarker) {
915 sawHandoff = true
916 }
917 }
918 if !sawHandoff {
919 t.Fatalf("executor requests = %d, none received the bounded plan handoff", len(exec.requests))
920 }
921 }
922
923 func TestCoordinatorNudgesExecutorThatAnswersWithoutActing(t *testing.T) {
924 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
925 {Type: provider.ChunkText, Text: "Write the requested skill file."},
926 {Type: provider.ChunkDone},
927 }}
928 // The first turn is a plain final answer with no tool call and no
929 // planner-vocabulary — the nudge must fire on the missing action, not on words.
930 exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
931 {
932 {Type: provider.ChunkText, Text: "这个计划看起来没问题,应该很好实现。"},
933 {Type: provider.ChunkDone},
934 },
935 {
936 {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "write_file", Arguments: `{"path":"kan-tu.md"}`}},
937 {Type: provider.ChunkDone},
938 },
939 {
940 {Type: provider.ChunkText, Text: "Done."},
941 {Type: provider.ChunkDone},
942 },
943 }}
944
945 execReg := tool.NewRegistry()
946 execReg.Add(coordinatorTestTool{name: "write_file", readOnly: false, output: "wrote file"})
947 executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
948 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
949
950 if err := coord.Run(context.Background(), "install the skill"); err != nil {
951 t.Fatalf("Run: %v", err)
952 }
953 if got := len(exec.requests); got != 3 {
954 t.Fatalf("executor requests = %d, want answer-without-acting, nudge tool call, final answer", got)
955 }
956 if got := lastUser(exec.requests[1]); !strings.Contains(got, "Use your available tools now to carry out the task") {
957 t.Fatalf("second executor request missing handoff nudge message: %q", got)
958 }
959 }
960
961 func TestExecutorHandoffRetryMessageKeepsUserChoicesInteractive(t *testing.T) {
962 msg := executorHandoffRetryMessage()
963 lower := strings.ToLower(msg)
964 for _, want := range []string{
965 "ask tool",
966 "wait for its tool result",
967 "do not ask in prose",
968 "do not claim the user answered",
969 } {
970 if !strings.Contains(lower, want) {
971 t.Fatalf("executorHandoffRetryMessage() missing %q:\n%s", want, msg)
972 }
973 }
974 }
975
976 func TestCoordinatorAllowsGuidanceOnlyExecutorHandoff(t *testing.T) {
977 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
978 {Type: provider.ChunkText, Text: "Tell the user to open the audio app, enable the Peace checkbox, and play a song to compare the difference."},
979 {Type: provider.ChunkDone},
980 }}
981 exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
982 {
983 {Type: provider.ChunkText, Text: "Open the audio app, enable the Peace checkbox, then play a familiar song and compare the sound with the switch on and off."},
984 {Type: provider.ChunkDone},
985 },
986 }}
987
988 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
989 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
990
991 if err := coord.Run(context.Background(), "I just installed EqualizerAPO, now what?"); err != nil {
992 t.Fatalf("Run: %v", err)
993 }
994 if got := len(exec.requests); got != 1 {
995 t.Fatalf("executor requests = %d, want one guidance-only final answer with no handoff nudge", got)
996 }
997 }
998
999 func TestCoordinatorAllowsGuidanceOnlyPlanWithExecutorToolContext(t *testing.T) {
1000 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1001 {Type: provider.ChunkText, Text: "Tell the user to open the audio app, enable the checkbox, and listen to compare the difference."},
1002 {Type: provider.ChunkDone},
1003 }}
1004 exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
1005 {
1006 {Type: provider.ChunkText, Text: "Open the app, enable the checkbox, then listen and compare."},
1007 {Type: provider.ChunkDone},
1008 },
1009 }}
1010
1011 execReg := tool.NewRegistry()
1012 execReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "file"})
1013 execReg.Add(coordinatorTestTool{name: "write_file", readOnly: false, output: "wrote file"})
1014 executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
1015 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
1016
1017 if err := coord.Run(context.Background(), "Please advise on the manual audio check."); err != nil {
1018 t.Fatalf("Run: %v", err)
1019 }
1020 if got := len(exec.requests); got != 1 {
1021 t.Fatalf("executor requests = %d, want guidance final answer without nudge despite tool context", got)
1022 }
1023 }
1024
1025 func TestCoordinatorNudgesWorkTaskEvenIfPlannerMentionsUserGuidance(t *testing.T) {
1026 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1027 {Type: provider.ChunkText, Text: "Tell the user to edit main.go and add the missing branch."},
1028 {Type: provider.ChunkDone},
1029 }}
1030 exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
1031 {
1032 {Type: provider.ChunkText, Text: "Open main.go and add the missing branch in the handler."},
1033 {Type: provider.ChunkDone},
1034 },
1035 {
1036 {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "write_file", Arguments: `{"path":"main.go"}`}},
1037 {Type: provider.ChunkDone},
1038 },
1039 {
1040 {Type: provider.ChunkText, Text: "Done."},
1041 {Type: provider.ChunkDone},
1042 },
1043 }}
1044
1045 execReg := tool.NewRegistry()
1046 execReg.Add(coordinatorTestTool{name: "write_file", readOnly: false, output: "wrote file"})
1047 executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
1048 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
1049
1050 if err := coord.Run(context.Background(), "fix the bug"); err != nil {
1051 t.Fatalf("Run: %v", err)
1052 }
1053 if got := len(exec.requests); got != 3 {
1054 t.Fatalf("executor requests = %d, want text answer, nudge tool call, final answer", got)
1055 }
1056 if got := lastUser(exec.requests[1]); !strings.Contains(got, "Use your available tools now to carry out the task") {
1057 t.Fatalf("second executor request missing handoff nudge message: %q", got)
1058 }
1059 }
1060
1061 func TestCoordinatorNudgesMixedGuidanceAndWorkTask(t *testing.T) {
1062 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1063 {Type: provider.ChunkText, Text: "Tell the user to summarize the behavior and update README."},
1064 {Type: provider.ChunkDone},
1065 }}
1066 exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
1067 {
1068 {Type: provider.ChunkText, Text: "Here is the current behavior summary."},
1069 {Type: provider.ChunkDone},
1070 },
1071 {
1072 {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "write_file", Arguments: `{"path":"README.md"}`}},
1073 {Type: provider.ChunkDone},
1074 },
1075 {
1076 {Type: provider.ChunkText, Text: "Done."},
1077 {Type: provider.ChunkDone},
1078 },
1079 }}
1080
1081 execReg := tool.NewRegistry()
1082 execReg.Add(coordinatorTestTool{name: "write_file", readOnly: false, output: "wrote file"})
1083 executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
1084 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
1085
1086 if err := coord.Run(context.Background(), "summarize the current behavior and update the README"); err != nil {
1087 t.Fatalf("Run: %v", err)
1088 }
1089 if got := len(exec.requests); got != 3 {
1090 t.Fatalf("executor requests = %d, want mixed guidance/work task to nudge before tool call", got)
1091 }
1092 if got := lastUser(exec.requests[1]); !strings.Contains(got, "Use your available tools now to carry out the task") {
1093 t.Fatalf("second executor request missing handoff nudge message: %q", got)
1094 }
1095 }
1096
1097 func TestCoordinatorSkipsExecutorWhenPlannerConcludesNoChanges(t *testing.T) {
1098 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1099 {Type: provider.ChunkText, Text: "No changes are needed; the current implementation already handles this.\n[no_changes]"},
1100 {Type: provider.ChunkDone},
1101 }}
1102 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
1103 {Type: provider.ChunkText, Text: "Should not run."},
1104 {Type: provider.ChunkDone},
1105 }}
1106
1107 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
1108 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
1109
1110 if err := coord.Run(context.Background(), "check whether the fix is already present"); err != nil {
1111 t.Fatalf("Run: %v", err)
1112 }
1113 if got := len(exec.requests); got != 0 {
1114 t.Fatalf("executor requests = %d, want skip after no-op planner conclusion", got)
1115 }
1116 messages := executor.session.Messages
1117 if got := len(messages); got != 3 {
1118 t.Fatalf("executor session messages = %d, want system + user + no-op assistant", got)
1119 }
1120 if got := messages[1].Content; !strings.Contains(got, "check whether the fix is already present") {
1121 t.Fatalf("persisted executor user message = %q, want original task", got)
1122 }
1123 if got := messages[2].Content; !strings.Contains(got, "No changes are needed") {
1124 t.Fatalf("persisted executor assistant message = %q, want no-op planner conclusion", got)
1125 }
1126 }
1127
1128 func TestCoordinatorDoesNotTreatGenericPositivePlanAsNoOp(t *testing.T) {
1129 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1130 {Type: provider.ChunkText, Text: "Looks good. Edit main.go and add the missing guard."},
1131 {Type: provider.ChunkDone},
1132 }}
1133 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
1134 {Type: provider.ChunkText, Text: "Done."},
1135 {Type: provider.ChunkDone},
1136 }}
1137
1138 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
1139 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
1140
1141 if err := coord.Run(context.Background(), "fix the missing guard"); err != nil {
1142 t.Fatalf("Run: %v", err)
1143 }
1144 if got := len(exec.requests); got == 0 {
1145 t.Fatal("executor should run for a plan that still contains work")
1146 }
1147 }
1148
1149 func TestCoordinatorDoesNotSkipExecutorForPartialNoOpPlanWithActions(t *testing.T) {
1150 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1151 {Type: provider.ChunkText, Text: "No changes are needed in code, but run the test suite."},
1152 {Type: provider.ChunkDone},
1153 }}
1154 exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
1155 {
1156 {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "bash", Arguments: `{"cmd":"go test ./..."}`}},
1157 {Type: provider.ChunkDone},
1158 },
1159 {
1160 {Type: provider.ChunkText, Text: "Tests passed."},
1161 {Type: provider.ChunkDone},
1162 },
1163 }}
1164
1165 execReg := tool.NewRegistry()
1166 execReg.Add(coordinatorTestTool{name: "bash", readOnly: false, output: "ok"})
1167 executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
1168 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
1169
1170 if err := coord.Run(context.Background(), "check the implementation and test it"); err != nil {
1171 t.Fatalf("Run: %v", err)
1172 }
1173 if got := len(exec.requests); got != 2 {
1174 t.Fatalf("executor requests = %d, want tool execution and final answer", got)
1175 }
1176 }
1177
1178 func TestCoordinatorHandoffAffirmsExecutorToolSchemasWhenPlannerClaimsNoMCP(t *testing.T) {
1179 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1180 {Type: provider.ChunkText, Text: "I only have read-only tools and cannot access GitHub MCP; use the executor to search GitHub."},
1181 {Type: provider.ChunkDone},
1182 }}
1183 exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
1184 {
1185 {Type: provider.ChunkText, Text: "GitHub MCP is unavailable."},
1186 {Type: provider.ChunkDone},
1187 },
1188 {
1189 {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "mcp__github__search", Arguments: `{"query":"Reasonix discussions"}`}},
1190 {Type: provider.ChunkDone},
1191 },
1192 {
1193 {Type: provider.ChunkText, Text: "Done."},
1194 {Type: provider.ChunkDone},
1195 },
1196 }}
1197
1198 execReg := tool.NewRegistry()
1199 execReg.Add(coordinatorTestTool{name: "mcp__github__search", readOnly: true, output: "discussion results"})
1200 executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
1201 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
1202
1203 if err := coord.Run(context.Background(), "search GitHub discussions"); err != nil {
1204 t.Fatalf("Run: %v", err)
1205 }
1206 if got := len(exec.requests); got != 3 {
1207 t.Fatalf("executor requests = %d, want initial answer, corrective nudge, final answer", got)
1208 }
1209 if tools := toolSchemaNames(exec.requests[0].Tools); !contains(tools, "mcp__github__search") {
1210 t.Fatalf("executor request tools = %v, want MCP schema attached", tools)
1211 }
1212 first := lastUser(exec.requests[0])
1213 for _, want := range []string{
1214 "The executor request includes the full tool schema",
1215 "mcp__github__search",
1216 "Do not treat planner tool limitations or tool-unavailable claims as executor facts",
1217 } {
1218 if !strings.Contains(first, want) {
1219 t.Fatalf("initial executor handoff missing %q:\n%s", want, first)
1220 }
1221 }
1222 retry := lastUser(exec.requests[1])
1223 for _, want := range []string{
1224 "The tool schema is still attached to this executor request",
1225 "Do not invent that MCP servers or tools are unavailable",
1226 } {
1227 if !strings.Contains(retry, want) {
1228 t.Fatalf("executor retry nudge missing %q:\n%s", want, retry)
1229 }
1230 }
1231 }
1232
1233 func TestCoordinatorDoesNotNudgeExecutorThatActs(t *testing.T) {
1234 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1235 {Type: provider.ChunkText, Text: "Write the requested skill file."},
1236 {Type: provider.ChunkDone},
1237 }}
1238 // Executor calls a tool on its first turn, then answers — no nudge expected.
1239 exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
1240 {
1241 {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "write_file", Arguments: `{"path":"kan-tu.md"}`}},
1242 {Type: provider.ChunkDone},
1243 },
1244 {
1245 {Type: provider.ChunkText, Text: "Done."},
1246 {Type: provider.ChunkDone},
1247 },
1248 }}
1249
1250 execReg := tool.NewRegistry()
1251 execReg.Add(coordinatorTestTool{name: "write_file", readOnly: false, output: "wrote file"})
1252 executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
1253 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
1254
1255 if err := coord.Run(context.Background(), "install the skill"); err != nil {
1256 t.Fatalf("Run: %v", err)
1257 }
1258 if got := len(exec.requests); got != 2 {
1259 t.Fatalf("executor requests = %d, want tool call + final answer with no nudge", got)
1260 }
1261 for i, req := range exec.requests {
1262 if strings.Contains(lastUser(req), "Use your available tools now to carry out the task") {
1263 t.Fatalf("request %d unexpectedly received a handoff nudge", i)
1264 }
1265 }
1266 }
1267
1268 func toolSchemaNames(schemas []provider.ToolSchema) []string {
1269 out := make([]string, 0, len(schemas))
1270 for _, s := range schemas {
1271 out = append(out, s.Name)
1272 }
1273 return out
1274 }
1275
1276 func contains(items []string, want string) bool {
1277 for _, item := range items {
1278 if item == want {
1279 return true
1280 }
1281 }
1282 return false
1283 }
1284
1285 func BenchmarkPlannerToolRegistry(b *testing.B) {
1286 parentReg := tool.NewRegistry()
1287 for i := 0; i < 200; i++ {
1288 parentReg.Add(coordinatorTestTool{
1289 name: fmt.Sprintf("tool_%03d", i),
1290 readOnly: i%3 != 0,
1291 })
1292 }
1293 parentReg.Add(coordinatorTestTool{name: "todo_write", readOnly: true})
1294 parentReg.Add(coordinatorTestTool{name: "write_file", readOnly: false})
1295
1296 b.ReportAllocs()
1297 for i := 0; i < b.N; i++ {
1298 reg := PlannerToolRegistry(parentReg)
1299 if reg.Len() == 0 {
1300 b.Fatal("planner registry should retain read-only research tools")
1301 }
1302 }
1303 }
1304
1305 func TestCoordinatorSetPlanModePropagates(t *testing.T) {
1306 prov := &mockProvider{name: "planner", chunks: []provider.Chunk{
1307 {Type: provider.ChunkText, Text: "plan"},
1308 {Type: provider.ChunkDone},
1309 }}
1310 plannerSess := NewSession("planner-sys")
1311 plannerReg := tool.NewRegistry()
1312 plannerReg.Add(coordinatorTestTool{name: "read_file", readOnly: true})
1313 plannerTools := PlannerToolRegistry(plannerReg)
1314
1315 exec := New(nil, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
1316
1317 coord := NewCoordinator(prov, plannerSess, nil, plannerTools, Options{MaxSteps: 2}, exec, 0, event.Discard, nil)
1318
1319 // Both should start with planMode=false
1320 if coord.plannerAgent.planMode.Load() {
1321 t.Error("planner should start with planMode=false")
1322 }
1323 if coord.executor.planMode.Load() {
1324 t.Error("executor should start with planMode=false")
1325 }
1326
1327 // SetPlanMode(true) should propagate to both
1328 coord.SetPlanMode(true)
1329 if !coord.plannerAgent.planMode.Load() {
1330 t.Error("planner should have planMode=true after SetPlanMode(true)")
1331 }
1332 if !coord.executor.planMode.Load() {
1333 t.Error("executor should have planMode=true after SetPlanMode(true)")
1334 }
1335
1336 // SetPlanMode(false) should propagate to both
1337 coord.SetPlanMode(false)
1338 if coord.plannerAgent.planMode.Load() {
1339 t.Error("planner should have planMode=false after SetPlanMode(false)")
1340 }
1341 if coord.executor.planMode.Load() {
1342 t.Error("executor should have planMode=false after SetPlanMode(false)")
1343 }
1344 }
1345
1346 func TestCoordinatorSetPlanModeNilSafety(t *testing.T) {
1347 var c *Coordinator
1348 c.SetPlanMode(true) // should not panic
1349 c.SetPlanMode(false) // should not panic
1350 }
1351
1352 // errorProvider fails every Stream call, standing in for a down/misconfigured
1353 // planner provider.
1354 type errorProvider struct{ name string }
1355
1356 func (e *errorProvider) Name() string { return e.name }
1357
1358 func (e *errorProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
1359 return nil, fmt.Errorf("provider unavailable")
1360 }
1361
1362 // TestIsNoOpPlan pins the no-op conclusion contract: only a final non-empty
1363 // line that is exactly the [no_changes] marker skips the executor. Phrase
1364 // conclusions without the marker deliberately do not — a wrong skip silently
1365 // drops the task, a missed one costs a single executor round.
1366 func TestIsNoOpPlan(t *testing.T) {
1367 cases := []struct {
1368 name string
1369 plan string
1370 want bool
1371 }{
1372 {"empty", "", false},
1373 {"conclusion phrase without marker", "No changes are needed; the current implementation already handles this.", false},
1374 {"already implemented with follow-up work", "The auth flow is already implemented; extend it to cover refresh tokens.", false},
1375 {"mid-plan aside is not a conclusion", "Findings:\nThis part is already handled by the retry helper.\nConfirm the desired direction with the user.", false},
1376 {"explicit marker on final line", "The retry logic exists in client.go and the tests already run this path.\n[no_changes]", true},
1377 {"marker with surrounding whitespace", "Notes on the guard.\n [no_changes] ", true},
1378 {"marker mentioned before remaining work", "[no_changes] does not apply here.\nEdit main.go to add the missing guard.", false},
1379 {"final line mentions marker in prose", "The guard exists but the tests are missing.\nDo not emit [no_changes] because work remains.", false},
1380 {"marker with trailing prose on final line", "[no_changes] — but confirm the flag default first.", false},
1381 {"negated conclusion", "It is not already implemented.", false},
1382 {"no-op phrase with action verb", "No changes are needed in code, but run the test suite.", false},
1383 {"chinese conclusion without marker", "无需改动,当前逻辑已经覆盖该场景。", false},
1384 {"chinese follow-up work", "重试逻辑已经实现,但需要扩展覆盖刷新令牌。", false},
1385 }
1386 for _, tc := range cases {
1387 t.Run(tc.name, func(t *testing.T) {
1388 if got := isNoOpPlan(tc.plan); got != tc.want {
1389 t.Errorf("isNoOpPlan(%q) = %v, want %v", tc.plan, got, tc.want)
1390 }
1391 })
1392 }
1393 }
1394
1395 // TestDefaultPlannerPromptRequestsNoChangesMarker keeps the producer and parser
1396 // of the no-op contract in sync: isNoOpPlan trusts the marker because the
1397 // planner prompt asks for it.
1398 func TestDefaultPlannerPromptRequestsNoChangesMarker(t *testing.T) {
1399 if !strings.Contains(DefaultPlannerPrompt, noChangesMarker) {
1400 t.Fatalf("DefaultPlannerPrompt does not request the %s marker isNoOpPlan parses", noChangesMarker)
1401 }
1402 }
1403
1404 func TestDefaultPlannerPromptDefinesLightAndFullEvidenceContracts(t *testing.T) {
1405 for _, want := range []string{
1406 "depth=light",
1407 "depth=full",
1408 "verified touchpoints",
1409 "candidate touchpoints",
1410 "command-level verification",
1411 "Label assumptions",
1412 } {
1413 if !strings.Contains(DefaultPlannerPrompt, want) {
1414 t.Fatalf("DefaultPlannerPrompt missing %q planning contract", want)
1415 }
1416 }
1417 }
1418
1419 // TestCoordinatorDoesNotSkipExecutorForAlreadyImplementedPlanWithFollowUp is
1420 // the motivating regression: a plan acknowledging existing code while asking
1421 // for follow-up work must not be treated as a no-op conclusion.
1422 func TestCoordinatorDoesNotSkipExecutorForAlreadyImplementedPlanWithFollowUp(t *testing.T) {
1423 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1424 {Type: provider.ChunkText, Text: "The auth flow is already implemented; extend it to cover refresh tokens."},
1425 {Type: provider.ChunkDone},
1426 }}
1427 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
1428 {Type: provider.ChunkText, Text: "Done."},
1429 {Type: provider.ChunkDone},
1430 }}
1431
1432 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
1433 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
1434
1435 if err := coord.Run(context.Background(), "add refresh token support"); err != nil {
1436 t.Fatalf("Run: %v", err)
1437 }
1438 if got := len(exec.requests); got == 0 {
1439 t.Fatal("executor skipped: an already-implemented plan with follow-up work was treated as no-op")
1440 }
1441 if got := lastUser(exec.requests[0]); !strings.Contains(got, "extend it to cover refresh tokens") {
1442 t.Fatalf("executor handoff missing the plan: %q", got)
1443 }
1444 }
1445
1446 // TestCoordinatorSkipsExecutorOnExplicitNoChangesMarker checks the marker
1447 // contract end to end: research prose above the marker may mention runs/tests
1448 // of existing code without vetoing the explicit conclusion.
1449 func TestCoordinatorSkipsExecutorOnExplicitNoChangesMarker(t *testing.T) {
1450 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1451 {Type: provider.ChunkText, Text: "The retry logic exists in client.go and the tests already run this path.\n[no_changes]"},
1452 {Type: provider.ChunkDone},
1453 }}
1454 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
1455 {Type: provider.ChunkText, Text: "Should not run."},
1456 {Type: provider.ChunkDone},
1457 }}
1458
1459 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
1460 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
1461
1462 if err := coord.Run(context.Background(), "check whether retries are covered"); err != nil {
1463 t.Fatalf("Run: %v", err)
1464 }
1465 if got := len(exec.requests); got != 0 {
1466 t.Fatalf("executor requests = %d, want skip on explicit [no_changes] marker", got)
1467 }
1468 messages := executor.session.Messages
1469 if got := len(messages); got != 3 {
1470 t.Fatalf("executor session messages = %d, want system + user + no-op assistant", got)
1471 }
1472 if got := messages[2].Content; !strings.Contains(got, "[no_changes]") {
1473 t.Fatalf("persisted executor assistant message = %q, want the planner conclusion", got)
1474 }
1475 }
1476
1477 // TestCoordinatorFallsBackToExecutorWhenPlannerFails checks that a planner
1478 // failure degrades the turn to executor-only instead of failing it: the
1479 // executor gets the raw input (no handoff boilerplate), a warning notice is
1480 // emitted, and the planner session is rolled back so the next plan does not
1481 // start with consecutive user messages.
1482 func TestCoordinatorFallsBackToExecutorWhenPlannerFails(t *testing.T) {
1483 cases := []struct {
1484 name string
1485 planner provider.Provider
1486 }{
1487 {"stream call fails", &errorProvider{name: "planner"}},
1488 {"stream emits error chunk", &mockProvider{name: "planner", chunks: []provider.Chunk{
1489 {Type: provider.ChunkError, Err: fmt.Errorf("rate limited")},
1490 }}},
1491 }
1492 for _, tc := range cases {
1493 t.Run(tc.name, func(t *testing.T) {
1494 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
1495 {Type: provider.ChunkText, Text: "Done."},
1496 {Type: provider.ChunkDone},
1497 }}
1498 var events []event.Event
1499 sink := event.FuncSink(func(e event.Event) { events = append(events, e) })
1500
1501 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
1502 plannerSess := NewSession("planner-sys")
1503 coord := NewCoordinator(tc.planner, plannerSess, nil, nil, Options{}, executor, 0, sink, nil)
1504
1505 if err := coord.Run(context.Background(), "fix the bug"); err != nil {
1506 t.Fatalf("Run should fall back to the executor, got: %v", err)
1507 }
1508 if got := len(exec.requests); got != 1 {
1509 t.Fatalf("executor requests = %d, want 1 fallback run", got)
1510 }
1511 got := lastUser(exec.requests[0])
1512 if got != "fix the bug" || strings.Contains(got, "You are the executor now") {
1513 t.Fatalf("fallback executor input = %q, want the raw task without handoff boilerplate", got)
1514 }
1515 if n := len(plannerSess.Messages); n != 1 {
1516 t.Fatalf("planner session messages = %d, want rollback to system only", n)
1517 }
1518 var warned bool
1519 for _, e := range events {
1520 if e.Kind == event.Notice && e.Level == event.LevelWarn && strings.Contains(e.Text, "Planner failed") {
1521 warned = true
1522 }
1523 }
1524 if !warned {
1525 t.Fatal("missing warn notice about the planner fallback")
1526 }
1527 })
1528 }
1529 }
1530
1531 // TestCoordinatorPropagatesPlannerErrorWhenTurnCancelled keeps cancellation
1532 // semantics: a turn the user aborted must not silently restart on the executor.
1533 func TestCoordinatorPropagatesPlannerErrorWhenTurnCancelled(t *testing.T) {
1534 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
1535 {Type: provider.ChunkText, Text: "Should not run."},
1536 {Type: provider.ChunkDone},
1537 }}
1538 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
1539 coord := NewCoordinator(&errorProvider{name: "planner"}, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
1540
1541 ctx, cancel := context.WithCancel(context.Background())
1542 cancel()
1543 err := coord.Run(ctx, "fix the bug")
1544 if err == nil || !strings.Contains(err.Error(), "planner:") {
1545 t.Fatalf("Run = %v, want propagated planner error on cancelled turn", err)
1546 }
1547 if got := len(exec.requests); got != 0 {
1548 t.Fatalf("executor requests = %d, want none after user cancellation", got)
1549 }
1550 }
1551
1552 // TestCoordinatorRollsBackPlannerSessionOnToolPlannerFailure covers the
1553 // production two-model wiring (boot passes PlannerToolRegistry, so planning
1554 // runs through planWithTools): when the tool-enabled planner fails, the
1555 // executor fallback must not leave the planner session with a dangling user
1556 // message or partial tool rounds — the next plan would otherwise start with
1557 // consecutive user roles, which some providers reject.
1558 func TestCoordinatorRollsBackPlannerSessionOnToolPlannerFailure(t *testing.T) {
1559 cases := []struct {
1560 name string
1561 planner provider.Provider
1562 }{
1563 {"stream call fails", &errorProvider{name: "planner"}},
1564 {"fails after a tool round", &mockProvider{name: "planner", streams: [][]provider.Chunk{
1565 {
1566 {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"main.go"}`}},
1567 {Type: provider.ChunkDone},
1568 },
1569 {
1570 {Type: provider.ChunkError, Err: fmt.Errorf("rate limited")},
1571 },
1572 }}},
1573 }
1574 for _, tc := range cases {
1575 t.Run(tc.name, func(t *testing.T) {
1576 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
1577 {Type: provider.ChunkText, Text: "Done."},
1578 {Type: provider.ChunkDone},
1579 }}
1580 plannerReg := tool.NewRegistry()
1581 plannerReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "package main"})
1582
1583 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
1584 plannerSess := NewSession("planner-sys")
1585 coord := NewCoordinator(tc.planner, plannerSess, nil, plannerReg, Options{}, executor, 0, event.Discard, nil)
1586
1587 if err := coord.Run(context.Background(), "fix the bug"); err != nil {
1588 t.Fatalf("Run should fall back to the executor, got: %v", err)
1589 }
1590 if got := len(exec.requests); got != 1 {
1591 t.Fatalf("executor requests = %d, want 1 fallback run", got)
1592 }
1593 if n := len(plannerSess.Messages); n != 1 {
1594 t.Fatalf("planner session messages = %d, want rollback to system only", n)
1595 }
1596 })
1597 }
1598 }
1599
1600 func TestCoordinatorPlannerResearchPausePreservesExecutionBoundaries(t *testing.T) {
1601 for _, route := range []PlannerRoute{PlannerRoutePlanOnly, PlannerRoutePlanForApproval} {
1602 t.Run(string(route), func(t *testing.T) {
1603 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1604 {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"main.go"}`}},
1605 {Type: provider.ChunkDone},
1606 }}
1607 exec := &mockProvider{name: "executor"}
1608 plannerReg := tool.NewRegistry()
1609 plannerReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "package main"})
1610 policy := func(context.Context, string) PlannerDecision {
1611 return PlannerDecision{Route: route, Depth: PlannerDepthFull, Reason: "explicit_boundary", MaxResearchRounds: 1}
1612 }
1613
1614 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
1615 plannerSess := NewSession("planner-sys")
1616 coord := NewCoordinatorWithPlannerPolicy(
1617 planner, plannerSess, nil, plannerReg, Options{MaxSteps: 0},
1618 executor, 0, event.Discard, policy,
1619 )
1620
1621 err := coord.Run(context.Background(), "plan the migration")
1622 if err == nil || err.Error() != plannerResearchBoundaryError {
1623 t.Fatalf("Run = %v, want the safe planner boundary error", err)
1624 }
1625 if strings.Contains(err.Error(), "set planner research rounds") {
1626 t.Fatalf("pause exposed a non-configurable setting: %q", err)
1627 }
1628 if got := len(exec.requests); got != 0 {
1629 t.Fatalf("executor requests = %d, want none across %s", got, route)
1630 }
1631 if got := len(plannerSess.Messages); got != 1 {
1632 t.Fatalf("planner session messages = %d, want the incomplete turn rolled back", got)
1633 }
1634 })
1635 }
1636 }
1637
1638 func TestCoordinatorRollbackAfterRewriteDropsPausedPlannerToolCall(t *testing.T) {
1639 plannerSess := NewSession("planner-sys")
1640 before := plannerSess.Snapshot()
1641 rewriteBefore := plannerSess.RewriteVersion()
1642
1643 plannerSess.Replace([]provider.Message{
1644 {Role: provider.RoleSystem, Content: "planner-sys"},
1645 {Role: provider.RoleUser, Content: summaryTagOpen + "\ncompacted research\n</summary>"},
1646 {Role: provider.RoleAssistant, Content: "Completed evidence from the bounded research rounds."},
1647 })
1648 plannerSess.IncrementRewrite()
1649 plannerSess.Add(provider.Message{Role: provider.RoleUser, Content: "Do not call any more tools; finalize."})
1650 plannerSess.Add(provider.Message{
1651 Role: provider.RoleAssistant,
1652 ToolCalls: []provider.ToolCall{{
1653 ID: "ignored-finalization-call", Name: "read_file", Arguments: `{"path":"more.go"}`,
1654 }},
1655 })
1656
1657 coord := &Coordinator{plannerSess: plannerSess}
1658 coord.rollbackPlannerTurn(before, rewriteBefore)
1659
1660 msgs := plannerSess.Snapshot()
1661 if len(msgs) != 3 {
1662 t.Fatalf("planner session messages = %d, want compacted prefix plus completed evidence", len(msgs))
1663 }
1664 if last := msgs[len(msgs)-1]; last.Role != provider.RoleAssistant ||
1665 len(last.ToolCalls) != 0 || last.Content == "" {
1666 t.Fatalf("planner session has an unusable pause tail: %+v", last)
1667 }
1668 if normalized := provider.NormalizeMessages(msgs); len(normalized) != len(msgs) {
1669 t.Fatalf("planner session still needs tool-pair repair after rollback: %+v", normalized)
1670 }
1671 }
1672
1673 // TestCoordinatorRunsExecutorWhenMarkerNotAlone is the F2 regression: a final
1674 // line that mentions [no_changes] in prose is not the no-op conclusion, so the
1675 // executor must still run.
1676 func TestCoordinatorRunsExecutorWhenMarkerNotAlone(t *testing.T) {
1677 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1678 {Type: provider.ChunkText, Text: "The guard exists but the tests are missing.\nDo not emit [no_changes] because work remains."},
1679 {Type: provider.ChunkDone},
1680 }}
1681 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
1682 {Type: provider.ChunkText, Text: "Done."},
1683 {Type: provider.ChunkDone},
1684 }}
1685
1686 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
1687 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
1688
1689 if err := coord.Run(context.Background(), "add the missing tests"); err != nil {
1690 t.Fatalf("Run: %v", err)
1691 }
1692 if got := len(exec.requests); got == 0 {
1693 t.Fatal("executor skipped: a final line mentioning the marker in prose was treated as a no-op conclusion")
1694 }
1695 }
1696
1697 // TestCoordinatorHandoffSurvivesPlannerCompaction pins the plan-scan boundary
1698 // against session rewrites: when the tool-enabled planner's final answer pushes
1699 // usage past the compaction trigger, Agent.Run rewrites and shortens the
1700 // planner session right after producing the plan. The pre-turn message count
1701 // then no longer bounds "this turn's messages" — scanning from it must not
1702 // hide the plan, or Coordinator.Run degrades to a raw executor turn despite a
1703 // successful plan.
1704 func TestCoordinatorHandoffSurvivesPlannerCompaction(t *testing.T) {
1705 planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
1706 { // the plan, with usage past the force-compaction watermark
1707 {Type: provider.ChunkText, Text: "Edit main.go and add the missing guard."},
1708 {Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 1900, TotalTokens: 1950}},
1709 {Type: provider.ChunkDone},
1710 },
1711 { // the compaction summarizer call
1712 {Type: provider.ChunkText, Text: "- goal: guard work\n- pending: none"},
1713 {Type: provider.ChunkDone},
1714 },
1715 }}
1716 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
1717 {Type: provider.ChunkText, Text: "Done."},
1718 {Type: provider.ChunkDone},
1719 }}
1720
1721 plannerReg := tool.NewRegistry()
1722 plannerReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "ok"})
1723
1724 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
1725 plannerSess := NewSession("planner-sys")
1726 // Preset enough planner history that the fold shrinks the session to (or
1727 // below) its pre-turn length, which is what strands a boundary based on
1728 // the pre-turn message count.
1729 filler := strings.Repeat("planner history filler. ", 150)
1730 for i := 0; i < 3; i++ {
1731 plannerSess.Add(provider.Message{Role: provider.RoleUser, Content: filler})
1732 plannerSess.Add(provider.Message{Role: provider.RoleAssistant, Content: filler})
1733 }
1734 coord := NewCoordinator(planner, plannerSess, nil, plannerReg, Options{ContextWindow: 2000}, executor, 0, event.Discard, nil)
1735
1736 if err := coord.Run(context.Background(), "fix the bug"); err != nil {
1737 t.Fatalf("Run: %v", err)
1738 }
1739 if plannerSess.RewriteVersion() == 0 {
1740 t.Fatal("test setup: planner compaction did not fire, the rewrite boundary is not exercised")
1741 }
1742 if got := len(exec.requests); got == 0 {
1743 t.Fatal("executor never ran")
1744 }
1745 got := lastUser(exec.requests[0])
1746 if !strings.Contains(got, "Edit main.go and add the missing guard.") || !strings.Contains(got, executorHandoffMarker) {
1747 t.Fatalf("executor input lost the plan handoff after planner compaction:\n%s", got)
1748 }
1749 }
1750
1751 // TestCoordinatorNoOpConclusionAttributedToPlanner pins the event source on the
1752 // relayed no-op conclusion: it is planner text and must not be attributed to
1753 // the executor by sinks that key styling/usage off Source.
1754 func TestCoordinatorNoOpConclusionAttributedToPlanner(t *testing.T) {
1755 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1756 {Type: provider.ChunkText, Text: "The guard already exists in parser.go.\n[no_changes]"},
1757 {Type: provider.ChunkDone},
1758 }}
1759 exec := &mockProvider{name: "executor"}
1760 var events []event.Event
1761 sink := event.FuncSink(func(e event.Event) { events = append(events, e) })
1762
1763 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
1764 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, sink, nil)
1765
1766 if err := coord.Run(context.Background(), "check the parser guard"); err != nil {
1767 t.Fatalf("Run: %v", err)
1768 }
1769 var conclusion *event.Event
1770 for i := range events {
1771 if events[i].Kind == event.Text && strings.Contains(events[i].Text, "[no_changes]") {
1772 conclusion = &events[i]
1773 }
1774 }
1775 if conclusion == nil {
1776 t.Fatal("no-op conclusion text event not emitted")
1777 }
1778 if conclusion.Source != event.UsageSourcePlanner {
1779 t.Fatalf("no-op conclusion Source = %q, want planner attribution", conclusion.Source)
1780 }
1781 }
1782
1783 // TestCoordinatorHandoffOmitsToolContextWithoutMCPTools checks that the handoff
1784 // does not restate the built-in tool schema: the tool-context block exists to
1785 // counter planner claims about MCP availability and is dropped entirely when
1786 // the executor carries no MCP tools.
1787 func TestCoordinatorHandoffOmitsToolContextWithoutMCPTools(t *testing.T) {
1788 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1789 {Type: provider.ChunkText, Text: "Edit main.go and add the missing guard."},
1790 {Type: provider.ChunkDone},
1791 }}
1792 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
1793 {Type: provider.ChunkText, Text: "Done."},
1794 {Type: provider.ChunkDone},
1795 }}
1796
1797 execReg := tool.NewRegistry()
1798 execReg.Add(coordinatorTestTool{name: "write_file", readOnly: false, output: "ok"})
1799 executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
1800 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
1801
1802 if err := coord.Run(context.Background(), "fix the missing guard"); err != nil {
1803 t.Fatalf("Run: %v", err)
1804 }
1805 got := lastUser(exec.requests[0])
1806 for _, unwanted := range []string{"Executor tool context", "Tool names include"} {
1807 if strings.Contains(got, unwanted) {
1808 t.Fatalf("handoff restates built-in tool schema (%q):\n%s", unwanted, got)
1809 }
1810 }
1811 if !strings.Contains(got, "Edit main.go") {
1812 t.Fatalf("handoff missing the plan: %q", got)
1813 }
1814 }
1815
1816 // TestCoordinatorPassesTurnContextToPlannerGate pins the C2 contract: the gate
1817 // receives the live turn context, so a classifier-backed gate is cancelled
1818 // with the turn instead of running out its own timeout.
1819 func TestCoordinatorPassesTurnContextToPlannerGate(t *testing.T) {
1820 type gateCtxKey struct{}
1821 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
1822 {Type: provider.ChunkText, Text: "It does X."},
1823 {Type: provider.ChunkDone},
1824 }}
1825 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
1826
1827 var sawTurnValue bool
1828 gate := func(ctx context.Context, _ string) bool {
1829 sawTurnValue = ctx.Value(gateCtxKey{}) != nil
1830 return false
1831 }
1832 coord := NewCoordinator(&mockProvider{name: "planner"}, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, gate)
1833
1834 ctx := context.WithValue(context.Background(), gateCtxKey{}, "turn")
1835 if err := coord.Run(ctx, "what does this do?"); err != nil {
1836 t.Fatalf("Run: %v", err)
1837 }
1838 if !sawTurnValue {
1839 t.Fatal("planner gate did not receive the turn context")
1840 }
1841 }
1842
1843 // TestCoordinatorFailedTurnRollbackKeepsCompaction pins the rewrite-aware
1844 // rollback economics: when auto-compaction fires mid-turn (after a tool round)
1845 // and the planner THEN fails, restoring the pre-turn snapshot would revert the
1846 // compaction — wasting its summarizer call and re-growing the prompt. The
1847 // rollback must instead keep the compacted log and only drop trailing plain
1848 // user messages, so the next plan still starts from the folded history without
1849 // consecutive user roles.
1850 func TestCoordinatorFailedTurnRollbackKeepsCompaction(t *testing.T) {
1851 planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
1852 { // tool round whose usage crosses the force-compaction watermark
1853 {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"main.go"}`}},
1854 {Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 1900, TotalTokens: 1950}},
1855 {Type: provider.ChunkDone},
1856 },
1857 { // the compaction summarizer call
1858 {Type: provider.ChunkText, Text: "- goal: guard work\n- pending: continue"},
1859 {Type: provider.ChunkDone},
1860 },
1861 { // the next planner round fails
1862 {Type: provider.ChunkError, Err: fmt.Errorf("rate limited")},
1863 },
1864 }}
1865 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
1866 {Type: provider.ChunkText, Text: "Done."},
1867 {Type: provider.ChunkDone},
1868 }}
1869
1870 plannerReg := tool.NewRegistry()
1871 plannerReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "package main"})
1872
1873 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
1874 plannerSess := NewSession("planner-sys")
1875 filler := strings.Repeat("planner history filler. ", 150)
1876 for i := 0; i < 3; i++ {
1877 plannerSess.Add(provider.Message{Role: provider.RoleUser, Content: filler})
1878 plannerSess.Add(provider.Message{Role: provider.RoleAssistant, Content: filler})
1879 }
1880 coord := NewCoordinator(planner, plannerSess, nil, plannerReg, Options{ContextWindow: 2000}, executor, 0, event.Discard, nil)
1881
1882 if err := coord.Run(context.Background(), "fix the bug"); err != nil {
1883 t.Fatalf("Run should fall back to the executor, got: %v", err)
1884 }
1885 if got := len(exec.requests); got != 1 {
1886 t.Fatalf("executor requests = %d, want 1 fallback run", got)
1887 }
1888 if plannerSess.RewriteVersion() == 0 {
1889 t.Fatal("test setup: planner compaction did not fire, the rewrite-aware rollback is not exercised")
1890 }
1891 msgs := plannerSess.Snapshot()
1892 var hasSummary bool
1893 for _, m := range msgs {
1894 if isCompactionSummary(m) {
1895 hasSummary = true
1896 }
1897 }
1898 if !hasSummary {
1899 t.Fatal("rollback reverted the compaction: no compaction summary left in the planner session")
1900 }
1901 if last := msgs[len(msgs)-1]; last.Role == provider.RoleUser && !isCompactionSummary(last) {
1902 t.Fatalf("planner session ends in a plain user message after rollback: %q", last.Content)
1903 }
1904 }
1905
1906 // TestCoordinatorPersistsDeniedPlanTurnToExecutorSession pins the denial
1907 // bookkeeping: a plan the user declines must still land in the executor
1908 // session (like the no-op path) so the turn survives save/reload, with a note
1909 // telling the next executor turn that nothing ran, plus a user-facing notice.
1910 func TestCoordinatorPersistsDeniedPlanTurnToExecutorSession(t *testing.T) {
1911 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1912 {Type: provider.ChunkText, Text: "Plan: rewrite auth.\n[planner_requires_approval]"},
1913 {Type: provider.ChunkDone},
1914 }}
1915 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
1916 {Type: provider.ChunkText, Text: "should not run"},
1917 {Type: provider.ChunkDone},
1918 }}
1919 sink := &recordSink{}
1920 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, sink)
1921 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, sink, nil)
1922 gate := &coordinatorApprovalGate{allow: false}
1923 coord.SetPlannerPlanApprover(gate)
1924
1925 if err := coord.Run(context.Background(), "rewrite auth"); err != nil {
1926 t.Fatalf("Run: %v", err)
1927 }
1928 if gate.calls != 1 {
1929 t.Fatalf("approval gate calls = %d, want 1", gate.calls)
1930 }
1931 if len(exec.requests) != 0 {
1932 t.Fatal("executor must not run when the plan is denied")
1933 }
1934 msgs := executor.session.Messages
1935 if len(msgs) < 2 {
1936 t.Fatalf("executor session messages = %d, want the denied turn persisted", len(msgs))
1937 }
1938 last := msgs[len(msgs)-1]
1939 if last.Role != provider.RoleAssistant || !strings.Contains(last.Content, plannerPlanNotApprovedNote) {
1940 t.Fatalf("last executor message = %q (%s), want plan with not-approved note", last.Content, last.Role)
1941 }
1942 prev := msgs[len(msgs)-2]
1943 if prev.Role != provider.RoleUser || !strings.Contains(prev.Content, "rewrite auth") {
1944 t.Fatalf("persisted user turn = %q (%s), want original input", prev.Content, prev.Role)
1945 }
1946 foundNotice := false
1947 for _, e := range sink.kinds(event.Notice) {
1948 if strings.Contains(e.Text, "not approved") {
1949 foundNotice = true
1950 }
1951 }
1952 if !foundNotice {
1953 t.Fatal("denied plan should emit a user-facing notice")
1954 }
1955 }
1956
1957 // TestCoordinatorPersistsUnansweredDecisionTurnToExecutorSession is the same
1958 // contract for the ask path: a cancelled/unanswered planner question must not
1959 // erase the turn from the persisted executor session.
1960 func TestCoordinatorPersistsUnansweredDecisionTurnToExecutorSession(t *testing.T) {
1961 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1962 {Type: provider.ChunkText, Text: "Plan draft.\n<planner-ask>\nquestion: Which database?\noption: sqlite\noption: postgres\n</planner-ask>"},
1963 {Type: provider.ChunkDone},
1964 }}
1965 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
1966 {Type: provider.ChunkText, Text: "should not run"},
1967 {Type: provider.ChunkDone},
1968 }}
1969 sink := &recordSink{}
1970 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, sink)
1971 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, sink, nil)
1972 gate := &coordinatorDecisionGate{answer: ""}
1973 coord.SetPlannerUserDecisionAsker(gate)
1974
1975 if err := coord.Run(context.Background(), "set up storage"); err != nil {
1976 t.Fatalf("Run: %v", err)
1977 }
1978 if gate.calls != 1 {
1979 t.Fatalf("decision gate calls = %d, want 1", gate.calls)
1980 }
1981 if len(exec.requests) != 0 {
1982 t.Fatal("executor must not run without a user answer")
1983 }
1984 msgs := executor.session.Messages
1985 if len(msgs) < 2 {
1986 t.Fatalf("executor session messages = %d, want the unanswered turn persisted", len(msgs))
1987 }
1988 last := msgs[len(msgs)-1]
1989 if last.Role != provider.RoleAssistant || !strings.Contains(last.Content, plannerDecisionUnansweredNote) {
1990 t.Fatalf("last executor message = %q, want plan with unanswered-decision note", last.Content)
1991 }
1992 }
1993
1994 // TestCoordinatorSkipsApprovalGateForNegatedApprovalWording pins the negation
1995 // veto: a plan that explicitly rules out an approval round must hand off
1996 // directly instead of raising a needless approval prompt.
1997 func TestCoordinatorSkipsApprovalGateForNegatedApprovalWording(t *testing.T) {
1998 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
1999 {Type: provider.ChunkText, Text: "Plan:\n1. 修改 config.go\n2. 无需等待用户批准,直接执行修改"},
2000 {Type: provider.ChunkDone},
2001 }}
2002 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
2003 {Type: provider.ChunkText, Text: "Done."},
2004 {Type: provider.ChunkDone},
2005 }}
2006 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
2007 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
2008 gate := &coordinatorApprovalGate{allow: false}
2009 coord.SetPlannerPlanApprover(gate)
2010
2011 if err := coord.Run(context.Background(), "tweak config"); err != nil {
2012 t.Fatalf("Run: %v", err)
2013 }
2014 if gate.calls != 0 {
2015 t.Fatalf("approval gate calls = %d, want 0 for negated approval wording", gate.calls)
2016 }
2017 if len(exec.requests) == 0 {
2018 t.Fatal("executor should run directly for negated approval wording")
2019 }
2020 }
2021
2022 // TestCoordinatorDoesNotAskForTargetConfirmationWording pins the pruned
2023 // decision phrases: ordinary verification wording such as "确认目标行为不变"
2024 // must not conjure an ask dialog.
2025 func TestCoordinatorDoesNotAskForTargetConfirmationWording(t *testing.T) {
2026 planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
2027 {Type: provider.ChunkText, Text: "Plan:\n1. 修改 handler.go\n2. 运行测试确认目标行为不变\n3. 更新用户选择器组件"},
2028 {Type: provider.ChunkDone},
2029 }}
2030 exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
2031 {Type: provider.ChunkText, Text: "Done."},
2032 {Type: provider.ChunkDone},
2033 }}
2034 executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
2035 coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
2036 gate := &coordinatorDecisionGate{answer: "should not be used"}
2037 coord.SetPlannerUserDecisionAsker(gate)
2038
2039 if err := coord.Run(context.Background(), "refactor handler"); err != nil {
2040 t.Fatalf("Run: %v", err)
2041 }
2042 if gate.calls != 0 {
2043 t.Fatalf("decision gate calls = %d, want 0 for ordinary verification wording", gate.calls)
2044 }
2045 if len(exec.requests) == 0 {
2046 t.Fatal("executor should run for ordinary plan wording")
2047 }
2048 }
2049
2049 lines GO