返回 DeepSeek-Reasonix
yolo_test.go
根目录 / internal / control / yolo_test.go
1 package control
2
3 import (
4 "context"
5 "encoding/json"
6 "strings"
7 "testing"
8 "time"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/event"
12 "reasonix/internal/permission"
13 "reasonix/internal/provider"
14 "reasonix/internal/sandbox"
15 "reasonix/internal/tool"
16 )
17
18 // TestAutoApproveToolsStillRequiresExplicitPlanApproval proves that YOLO/full
19 // tool access does not bypass the separate Plan Mode collaboration gate.
20 func TestAutoApproveToolsStillRequiresExplicitPlanApproval(t *testing.T) {
21 prov := &scriptedTurns{turns: [][]provider.Chunk{
22 textTurn("Plan:\n1. Add the config field\n2. Wire it into boot\n3. Add tests"),
23 textTurn("Done — implemented the approved plan."),
24 }}
25 ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
26
27 approvalRequests := make(chan event.Approval, 1)
28 var seeded bool
29 c := New(Options{
30 Runner: ag,
31 Executor: ag,
32 Sink: event.FuncSink(func(e event.Event) {
33 switch e.Kind {
34 case event.ApprovalRequest:
35 approvalRequests <- e.Approval
36 case event.ToolDispatch:
37 if e.Tool.ID == "plan-seed" {
38 seeded = true
39 }
40 }
41 }),
42 })
43 c.SetAutoApproveTools(true)
44 c.SetPlanMode(true)
45
46 input := "实现 issue #2395:新增配置项、自动判断复杂任务、补测试和文档"
47 done := make(chan error, 1)
48 go func() { done <- c.runTurnWithRaw(context.Background(), input, input) }()
49
50 var approval event.Approval
51 select {
52 case approval = <-approvalRequests:
53 case <-time.After(30 * time.Second):
54 t.Fatal("tool auto-approval must not suppress plan approval")
55 }
56 if approval.Tool != planApprovalTool {
57 t.Fatalf("approval tool = %q, want %q", approval.Tool, planApprovalTool)
58 }
59
60 if !c.PlanMode() {
61 t.Fatal("controller should stay in plan mode while waiting for approval")
62 }
63 c.Approve(approval.ID, true, false, false)
64
65 select {
66 case err := <-done:
67 if err != nil {
68 t.Fatalf("runTurnWithRaw: %v", err)
69 }
70 case <-time.After(30 * time.Second):
71 t.Fatal("approved plan did not continue into execution")
72 }
73 if got := agent.StripTransientUserBlocks(firstUserMessage(ag.Session().Messages)); !strings.HasPrefix(got, PlanModeMarker) {
74 t.Fatalf("first model input = %q, want the plan marker prefixed", got)
75 }
76 if c.PlanMode() {
77 t.Fatal("plan mode should be off after approval")
78 }
79 if !c.AutoApproveTools() {
80 t.Fatal("tool auto-approval should remain on after plan approval")
81 }
82 if !seeded {
83 t.Fatal("approved plan should seed the task list")
84 }
85 if prov.call != 2 {
86 t.Fatalf("provider called %d times, want 2 (plan + execution)", prov.call)
87 }
88 }
89
90 // TestRequestApprovalHonorsAutoApproveTools guards the underlying gate: ordinary
91 // tool approvals must return allow immediately without emitting anything under
92 // tool auto-approval.
93 func TestRequestApprovalHonorsAutoApproveTools(t *testing.T) {
94 var approvalRequested bool
95 c := New(Options{
96 Sink: event.FuncSink(func(e event.Event) {
97 if e.Kind == event.ApprovalRequest {
98 approvalRequested = true
99 }
100 }),
101 })
102 c.SetAutoApproveTools(true)
103
104 done := make(chan bool, 1)
105 go func() {
106 allow, _, err := c.requestApproval(context.Background(), "multi_edit", "/tmp/file", nil)
107 if err != nil {
108 t.Errorf("requestApproval: %v", err)
109 }
110 done <- allow
111 }()
112
113 select {
114 case allow := <-done:
115 if !allow {
116 t.Fatal("tool auto-approval should allow the approval")
117 }
118 case <-time.After(30 * time.Second):
119 t.Fatal("requestApproval blocked under tool auto-approval")
120 }
121
122 if approvalRequested {
123 t.Fatal("tool auto-approval must not emit an ApprovalRequest event")
124 }
125 }
126
127 func TestMemoryApprovalIgnoresAutoApproveTools(t *testing.T) {
128 approvalRequests := make(chan event.Approval, 1)
129 c := New(Options{
130 Sink: event.FuncSink(func(e event.Event) {
131 if e.Kind == event.ApprovalRequest {
132 approvalRequests <- e.Approval
133 }
134 }),
135 })
136 c.SetAutoApproveTools(true)
137
138 done := make(chan bool, 1)
139 errs := make(chan error, 1)
140 go func() {
141 allow, _, err := c.requestApproval(context.Background(), "remember", "", nil)
142 if err != nil {
143 errs <- err
144 return
145 }
146 done <- allow
147 }()
148
149 var approval event.Approval
150 select {
151 case approval = <-approvalRequests:
152 case <-time.After(30 * time.Second):
153 t.Fatal("memory approval request was not emitted under tool auto-approval")
154 }
155 if approval.Tool != "remember" {
156 t.Fatalf("approval tool = %q, want remember", approval.Tool)
157 }
158
159 select {
160 case err := <-errs:
161 t.Fatalf("requestApproval: %v", err)
162 case allow := <-done:
163 t.Fatalf("memory approval must wait for manual approval, got allow=%v", allow)
164 case <-time.After(50 * time.Millisecond):
165 }
166
167 c.Approve(approval.ID, true, true, true)
168 select {
169 case err := <-errs:
170 t.Fatalf("requestApproval: %v", err)
171 case allow := <-done:
172 if !allow {
173 t.Fatal("manual approval should allow memory write")
174 }
175 case <-time.After(30 * time.Second):
176 t.Fatal("memory approval stayed blocked after Approve")
177 }
178 }
179
180 func TestToolApprovalModeAutoKeepsAskRules(t *testing.T) {
181 c := New(Options{
182 Policy: permission.New("ask", nil, []string{"bash(git commit*)"}, []string{"bash(rm*)"}),
183 })
184 c.SetToolApprovalMode(ToolApprovalAuto)
185
186 gate := c.newInteractiveGate()
187 if got := gate.Policy.Decide("bash", false, json.RawMessage(`{"command":"go test ./..."}`)); got != permission.Allow {
188 t.Fatalf("auto mode fallback = %v, want allow", got)
189 }
190 if got := gate.Policy.Decide("bash", false, json.RawMessage(`{"command":"git commit -m x"}`)); got != permission.Ask {
191 t.Fatalf("explicit ask rule = %v, want ask", got)
192 }
193 if got := gate.Policy.Decide("bash", false, json.RawMessage(`{"command":"rm -rf build"}`)); got != permission.Deny {
194 t.Fatalf("deny rule = %v, want deny", got)
195 }
196 if c.AutoApproveTools() {
197 t.Fatal("auto approval must not report as YOLO")
198 }
199 }
200
201 func TestToolApprovalModeAutoForcesMemoryAskRules(t *testing.T) {
202 c := New(Options{})
203 c.SetToolApprovalMode(ToolApprovalAuto)
204
205 gate := c.newInteractiveGate()
206 for _, toolName := range []string{"remember", "forget"} {
207 if got := gate.Policy.Decide(toolName, false, json.RawMessage(`{}`)); got != permission.Ask {
208 t.Fatalf("%s under auto mode = %v, want ask", toolName, got)
209 }
210 }
211 }
212
213 func TestToolApprovalModeYoloForcesMemoryAskRules(t *testing.T) {
214 c := New(Options{})
215 c.SetToolApprovalMode(ToolApprovalYolo)
216
217 gate := c.newInteractiveGate()
218 for _, toolName := range []string{"remember", "forget"} {
219 if got := gate.Policy.Decide(toolName, false, json.RawMessage(`{}`)); got != permission.Ask {
220 t.Fatalf("%s under yolo mode = %v, want ask", toolName, got)
221 }
222 }
223 // Verify that regular tools ARE auto-allowed in YOLO (sanity check).
224 if got := gate.Policy.Decide("bash", false, json.RawMessage(`{"command":"go test ./..."}`)); got != permission.Allow {
225 t.Fatalf("regular tool under yolo mode = %v, want allow", got)
226 }
227 }
228
229 func TestToolApprovalModeDontAskDeniesWithoutPrompt(t *testing.T) {
230 requests := 0
231 c := New(Options{
232 Policy: permission.New("ask", nil, []string{"bash(git commit*)"}, nil).
233 WithSessionAllow([]string{"bash(go test*)"}),
234 Sink: event.FuncSink(func(e event.Event) {
235 if e.Kind == event.ApprovalRequest {
236 requests++
237 }
238 }),
239 })
240 c.SetToolApprovalMode(ToolApprovalDontAsk)
241 gate := c.newInteractiveGate()
242
243 allow, _, err := gate.Check(context.Background(), "bash", json.RawMessage(`{"command":"go test ./..."}`), false)
244 if err != nil || !allow {
245 t.Fatalf("session-allowed call = (%v, %v), want allow", allow, err)
246 }
247 allow, _, err = gate.Check(context.Background(), "bash", json.RawMessage(`{"command":"git commit -m x"}`), false)
248 if err != nil || allow {
249 t.Fatalf("explicit ask under dontAsk = (%v, %v), want deny", allow, err)
250 }
251 allow, _, err = gate.Check(context.Background(), "write_file", json.RawMessage(`{"path":"x.txt"}`), false)
252 if err != nil || allow {
253 t.Fatalf("fallback under dontAsk = (%v, %v), want deny", allow, err)
254 }
255 if requests != 0 {
256 t.Fatalf("dontAsk emitted %d approval requests, want 0", requests)
257 }
258 }
259
260 func TestToolApprovalModeAutoDrainsPendingFallbackApproval(t *testing.T) {
261 approvalRequests := make(chan event.Approval, 1)
262 c := New(Options{
263 Policy: permission.New("ask", nil, nil, nil),
264 Sink: event.FuncSink(func(e event.Event) {
265 if e.Kind == event.ApprovalRequest {
266 approvalRequests <- e.Approval
267 }
268 }),
269 })
270
271 done := make(chan bool, 1)
272 errs := make(chan error, 1)
273 go func() {
274 allow, _, err := c.requestApproval(context.Background(), "multi_edit", "/tmp/file", nil)
275 if err != nil {
276 errs <- err
277 return
278 }
279 done <- allow
280 }()
281
282 select {
283 case <-approvalRequests:
284 case <-time.After(30 * time.Second):
285 t.Fatal("approval request was not emitted")
286 }
287
288 c.SetToolApprovalMode(ToolApprovalAuto)
289
290 select {
291 case err := <-errs:
292 t.Fatalf("requestApproval: %v", err)
293 case allow := <-done:
294 if !allow {
295 t.Fatal("pending fallback approval should be allowed when auto approval turns on")
296 }
297 case <-time.After(30 * time.Second):
298 t.Fatal("pending fallback approval stayed blocked after auto approval turned on")
299 }
300 if c.AutoApproveTools() {
301 t.Fatal("auto mode must not report as YOLO")
302 }
303 }
304
305 func TestToolApprovalModeAutoDoesNotDrainPendingExplicitAsk(t *testing.T) {
306 approvalRequests := make(chan event.Approval, 1)
307 c := New(Options{
308 Policy: permission.New("ask", nil, []string{"bash(git commit*)"}, nil),
309 Sink: event.FuncSink(func(e event.Event) {
310 if e.Kind == event.ApprovalRequest {
311 approvalRequests <- e.Approval
312 }
313 }),
314 })
315
316 done := make(chan bool, 1)
317 errs := make(chan error, 1)
318 go func() {
319 allow, _, err := c.requestApproval(context.Background(), "bash", "git commit -m x", nil)
320 if err != nil {
321 errs <- err
322 return
323 }
324 done <- allow
325 }()
326
327 var approval event.Approval
328 select {
329 case approval = <-approvalRequests:
330 case <-time.After(30 * time.Second):
331 t.Fatal("approval request was not emitted")
332 }
333
334 c.SetToolApprovalMode(ToolApprovalAuto)
335
336 select {
337 case err := <-errs:
338 t.Fatalf("requestApproval: %v", err)
339 case allow := <-done:
340 t.Fatalf("auto mode must not answer explicit ask rules; got allow=%v", allow)
341 case <-time.After(50 * time.Millisecond):
342 }
343
344 c.Approve(approval.ID, true, false, false)
345
346 select {
347 case err := <-errs:
348 t.Fatalf("requestApproval: %v", err)
349 case allow := <-done:
350 if !allow {
351 t.Fatal("manual approval should allow the explicit ask request")
352 }
353 case <-time.After(30 * time.Second):
354 t.Fatal("explicit ask approval stayed blocked after manual Approve")
355 }
356 }
357
358 func TestToolApprovalModeYoloBypassesApprovalPrompts(t *testing.T) {
359 c := New(Options{})
360 c.SetToolApprovalMode(ToolApprovalYolo)
361 if !c.AutoApproveTools() {
362 t.Fatal("YOLO mode should satisfy legacy AutoApproveTools")
363 }
364 allow, remember, err := c.requestApproval(context.Background(), "bash", "go test ./...", nil)
365 if err != nil || !allow || remember {
366 t.Fatalf("requestApproval in YOLO = (%v,%v,%v), want allow without remember", allow, remember, err)
367 }
368 }
369
370 func TestPlanApprovalIgnoresAutoApproveTools(t *testing.T) {
371 approvalRequests := make(chan event.Approval, 1)
372 c := New(Options{
373 Sink: event.FuncSink(func(e event.Event) {
374 if e.Kind == event.ApprovalRequest {
375 approvalRequests <- e.Approval
376 }
377 }),
378 })
379 c.SetAutoApproveTools(true)
380
381 done := make(chan bool, 1)
382 errs := make(chan error, 1)
383 go func() {
384 allow, _, err := c.requestApproval(context.Background(), planApprovalTool, "", nil)
385 if err != nil {
386 errs <- err
387 return
388 }
389 done <- allow
390 }()
391
392 var approval event.Approval
393 select {
394 case approval = <-approvalRequests:
395 case <-time.After(30 * time.Second):
396 t.Fatal("plan approval must still prompt under tool auto-approval")
397 }
398 if approval.Tool != planApprovalTool {
399 t.Fatalf("approval tool = %q, want %q", approval.Tool, planApprovalTool)
400 }
401 select {
402 case allow := <-done:
403 t.Fatalf("plan approval must wait for the user under tool auto-approval; got allow=%v", allow)
404 case err := <-errs:
405 t.Fatalf("requestApproval: %v", err)
406 default:
407 }
408
409 c.Approve(approval.ID, true, false, false)
410
411 select {
412 case err := <-errs:
413 t.Fatalf("requestApproval: %v", err)
414 case allow := <-done:
415 if !allow {
416 t.Fatal("manual plan approval should allow")
417 }
418 case <-time.After(30 * time.Second):
419 t.Fatal("plan approval stayed blocked after Approve")
420 }
421 }
422
423 // TestSetAutoApproveToolsAllowsPendingApproval covers the desktop case where the
424 // approval card is already visible, then the user switches to YOLO/full access.
425 // Turning tool auto-approval on must unblock that pending tool gate too.
426 func TestSetAutoApproveToolsAllowsPendingApproval(t *testing.T) {
427 c, ids, _ := approvalIDs()
428
429 done := make(chan bool, 1)
430 errs := make(chan error, 1)
431 go func() {
432 allow, _, err := c.requestApproval(context.Background(), "multi_edit", "/tmp/file", nil)
433 if err != nil {
434 errs <- err
435 return
436 }
437 done <- allow
438 }()
439
440 select {
441 case <-ids:
442 case <-time.After(30 * time.Second):
443 t.Fatal("approval request was not emitted")
444 }
445
446 c.SetAutoApproveTools(true)
447
448 select {
449 case err := <-errs:
450 t.Fatalf("requestApproval: %v", err)
451 case allow := <-done:
452 if !allow {
453 t.Fatal("pending approval should be allowed when tool auto-approval turns on")
454 }
455 case <-time.After(30 * time.Second):
456 t.Fatal("pending approval stayed blocked after tool auto-approval turned on")
457 }
458 if !c.AutoApproveTools() {
459 t.Fatal("tool auto-approval should remain on after draining pending approvals")
460 }
461 }
462
463 func TestSandboxEscapeApprovalIgnoresAutoApproveTools(t *testing.T) {
464 approvalRequests := make(chan event.Approval, 1)
465 c := New(Options{
466 Sink: event.FuncSink(func(e event.Event) {
467 if e.Kind == event.ApprovalRequest {
468 approvalRequests <- e.Approval
469 }
470 }),
471 })
472 c.SetAutoApproveTools(true)
473
474 type escapeResult struct {
475 allow bool
476 reason string
477 err error
478 }
479 done := make(chan escapeResult, 1)
480 go func() {
481 allow, reason, err := sandboxEscapeApprover{c}.ApproveSandboxEscape(context.Background(), sandbox.EscapeRequest{
482 Command: "go test ./...",
483 Reason: "Windows sandbox failed. Run this command unconfined once?",
484 })
485 done <- escapeResult{allow: allow, reason: reason, err: err}
486 }()
487
488 var approval event.Approval
489 select {
490 case approval = <-approvalRequests:
491 case <-time.After(30 * time.Second):
492 t.Fatal("sandbox escape approval request was not emitted")
493 }
494 if approval.Tool != SandboxEscapeApprovalTool {
495 t.Fatalf("approval tool = %q, want %q", approval.Tool, SandboxEscapeApprovalTool)
496 }
497
498 c.SetAutoApproveTools(true)
499 select {
500 case got := <-done:
501 t.Fatalf("tool auto-approval must not answer sandbox escape; got %+v", got)
502 case <-time.After(50 * time.Millisecond):
503 }
504
505 c.Approve(approval.ID, true, true, true)
506 select {
507 case got := <-done:
508 if got.err != nil || !got.allow || got.reason != "" {
509 t.Fatalf("sandbox escape result = %+v, want allowed without reason/error", got)
510 }
511 case <-time.After(30 * time.Second):
512 t.Fatal("sandbox escape approval stayed blocked after Approve")
513 }
514
515 if !(sandboxEscapeApprover{c}).SandboxEscapeSessionAllowed(context.Background(), sandbox.EscapeRequest{Command: "npm test"}) {
516 t.Fatal("sandbox escape session checker = false, want true after session grant")
517 }
518 allow, reason, err := sandboxEscapeApprover{c}.ApproveSandboxEscape(context.Background(), sandbox.EscapeRequest{
519 Command: "npm test",
520 Reason: "Windows sandbox failed. Run this command unconfined once?",
521 })
522 if err != nil || !allow || reason != "" {
523 t.Fatalf("sandbox escape session grant result = (%v,%q,%v), want allow", allow, reason, err)
524 }
525 select {
526 case approval := <-approvalRequests:
527 t.Fatalf("sandbox escape session grant emitted another approval: %+v", approval)
528 default:
529 }
530 }
531
532 func TestSetAutoApproveToolsDoesNotDrainPendingPlanApproval(t *testing.T) {
533 approvalRequests := make(chan event.Approval, 1)
534 c := New(Options{
535 Sink: event.FuncSink(func(e event.Event) {
536 if e.Kind == event.ApprovalRequest {
537 approvalRequests <- e.Approval
538 }
539 }),
540 })
541
542 done := make(chan bool, 1)
543 errs := make(chan error, 1)
544 go func() {
545 allow, _, err := c.requestApproval(context.Background(), planApprovalTool, "", nil)
546 if err != nil {
547 errs <- err
548 return
549 }
550 done <- allow
551 }()
552
553 var approval event.Approval
554 select {
555 case approval = <-approvalRequests:
556 case <-time.After(30 * time.Second):
557 t.Fatal("plan approval request was not emitted")
558 }
559
560 c.SetAutoApproveTools(true)
561
562 select {
563 case err := <-errs:
564 t.Fatalf("requestApproval: %v", err)
565 case allow := <-done:
566 t.Fatalf("SetAutoApproveTools must not auto-answer pending plan approval; got allow=%v", allow)
567 case <-time.After(50 * time.Millisecond):
568 }
569 if !c.AutoApproveTools() {
570 t.Fatal("tool auto-approval should turn on while plan approval stays pending")
571 }
572
573 c.Approve(approval.ID, true, false, false)
574
575 select {
576 case err := <-errs:
577 t.Fatalf("requestApproval: %v", err)
578 case allow := <-done:
579 if !allow {
580 t.Fatal("manual plan approval should allow")
581 }
582 case <-time.After(30 * time.Second):
583 t.Fatal("plan approval stayed blocked after Approve")
584 }
585 }
586
587 func TestSetAutoApproveToolsDoesNotDrainPendingPlanModeReadOnlyCommandTrust(t *testing.T) {
588 approvalRequests := make(chan event.Approval, 1)
589 c := New(Options{
590 Sink: event.FuncSink(func(e event.Event) {
591 if e.Kind == event.ApprovalRequest {
592 approvalRequests <- e.Approval
593 }
594 }),
595 })
596
597 type trustResult struct {
598 allow bool
599 reason string
600 err error
601 }
602 done := make(chan trustResult, 1)
603 req := agent.PlanModeReadOnlyTrustRequest{
604 ToolName: agent.PlanModeReadOnlyCommandApprovalTool,
605 Command: "gh issue view 5867",
606 Prefix: "gh issue view",
607 }
608 go func() {
609 allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req)
610 done <- trustResult{allow: allow, reason: reason, err: err}
611 }()
612
613 var approval event.Approval
614 select {
615 case approval = <-approvalRequests:
616 case <-time.After(30 * time.Second):
617 t.Fatal("plan-mode bash read-only command trust approval request was not emitted")
618 }
619 if approval.Tool != agent.PlanModeReadOnlyCommandApprovalTool {
620 t.Fatalf("approval tool = %q, want %q", approval.Tool, agent.PlanModeReadOnlyCommandApprovalTool)
621 }
622
623 c.SetAutoApproveTools(true)
624
625 select {
626 case got := <-done:
627 t.Fatalf("SetAutoApproveTools must not auto-answer plan-mode bash read-only command trust; got %+v", got)
628 case <-time.After(50 * time.Millisecond):
629 }
630 if !c.AutoApproveTools() {
631 t.Fatal("tool auto-approval should turn on while plan-mode bash read-only command trust stays pending")
632 }
633
634 c.Approve(approval.ID, true, false, false)
635 select {
636 case got := <-done:
637 if got.err != nil || !got.allow || got.reason != "" {
638 t.Fatalf("manual plan-mode bash read-only command trust approval = %+v, want allow", got)
639 }
640 case <-time.After(30 * time.Second):
641 t.Fatal("plan-mode bash read-only command trust approval stayed blocked after Approve")
642 }
643 }
644
645 func TestSetAutoApproveToolsDoesNotDrainPendingMemoryApproval(t *testing.T) {
646 approvalRequests := make(chan event.Approval, 1)
647 c := New(Options{
648 Sink: event.FuncSink(func(e event.Event) {
649 if e.Kind == event.ApprovalRequest {
650 approvalRequests <- e.Approval
651 }
652 }),
653 })
654
655 done := make(chan bool, 1)
656 errs := make(chan error, 1)
657 go func() {
658 allow, _, err := c.requestApproval(context.Background(), "forget", "", nil)
659 if err != nil {
660 errs <- err
661 return
662 }
663 done <- allow
664 }()
665
666 var approval event.Approval
667 select {
668 case approval = <-approvalRequests:
669 case <-time.After(30 * time.Second):
670 t.Fatal("memory approval request was not emitted")
671 }
672
673 c.SetAutoApproveTools(true)
674
675 select {
676 case err := <-errs:
677 t.Fatalf("requestApproval: %v", err)
678 case allow := <-done:
679 t.Fatalf("SetAutoApproveTools must not auto-answer pending memory approval; got allow=%v", allow)
680 case <-time.After(50 * time.Millisecond):
681 }
682
683 c.Approve(approval.ID, true, true, true)
684 select {
685 case err := <-errs:
686 t.Fatalf("requestApproval: %v", err)
687 case allow := <-done:
688 if !allow {
689 t.Fatal("manual approval should allow memory archive")
690 }
691 case <-time.After(30 * time.Second):
692 t.Fatal("memory approval stayed blocked after Approve")
693 }
694 }
695
696 // TestSetModeYoloDrainsPendingApproval is the SetMode-path twin of the
697 // SetAutoApproveTools case: applying YOLO atomically must also unblock an
698 // approval already waiting.
699 func TestSetModeYoloDrainsPendingApproval(t *testing.T) {
700 c, ids, _ := approvalIDs()
701
702 done := make(chan bool, 1)
703 go func() {
704 allow, _, _ := c.requestApproval(context.Background(), "multi_edit", "/tmp/file", nil)
705 done <- allow
706 }()
707
708 select {
709 case <-ids:
710 case <-time.After(30 * time.Second):
711 t.Fatal("approval request was not emitted")
712 }
713
714 c.SetMode(false, true)
715
716 select {
717 case allow := <-done:
718 if !allow {
719 t.Fatal("pending approval should be auto-allowed when SetMode turns YOLO on")
720 }
721 case <-time.After(30 * time.Second):
722 t.Fatal("pending approval stayed blocked after SetMode(false, true)")
723 }
724 }
725
726 // TestSetModeAppliesBothGates checks SetMode sets plan and tool auto-approval
727 // together so the composer never has to sequence two calls and risk a
728 // half-applied window.
729 func TestSetModeAppliesBothGates(t *testing.T) {
730 c, _, _ := approvalIDs()
731
732 c.SetMode(true, false)
733 if !c.PlanMode() || c.AutoApproveTools() {
734 t.Fatalf("plan mode: plan=%v autoApproveTools=%v, want true/false", c.PlanMode(), c.AutoApproveTools())
735 }
736
737 c.SetMode(false, true)
738 if c.PlanMode() || !c.AutoApproveTools() {
739 t.Fatalf("yolo mode: plan=%v autoApproveTools=%v, want false/true", c.PlanMode(), c.AutoApproveTools())
740 }
741
742 c.SetMode(true, true)
743 if !c.PlanMode() || !c.AutoApproveTools() {
744 t.Fatalf("plan + yolo mode: plan=%v autoApproveTools=%v, want true/true", c.PlanMode(), c.AutoApproveTools())
745 }
746
747 c.SetMode(false, false)
748 if c.PlanMode() || c.AutoApproveTools() {
749 t.Fatalf("normal mode: plan=%v autoApproveTools=%v, want false/false", c.PlanMode(), c.AutoApproveTools())
750 }
751 }
752
753 type planModeCountingRunner struct {
754 calls int
755 last bool
756 }
757
758 func (*planModeCountingRunner) Run(context.Context, string) error { return nil }
759 func (r *planModeCountingRunner) SetPlanMode(v bool) {
760 r.calls++
761 r.last = v
762 }
763
764 func TestApplyModeUsesRunnerPlanPropagationOnce(t *testing.T) {
765 runner := &planModeCountingRunner{}
766 c := New(Options{Runner: runner})
767 c.ApplyMode(true, true)
768 if runner.calls != 1 || !runner.last {
769 t.Fatalf("runner SetPlanMode calls=%d last=%v, want 1/true", runner.calls, runner.last)
770 }
771 if !c.PlanMode() || c.ToolApprovalMode() != ToolApprovalYolo {
772 t.Fatalf("controller plan=%v approval=%q, want true/yolo", c.PlanMode(), c.ToolApprovalMode())
773 }
774 c.SetPlanMode(false)
775 if runner.calls != 2 || runner.last {
776 t.Fatalf("SetPlanMode runner calls=%d last=%v, want 2/false", runner.calls, runner.last)
777 }
778 }
779
780 func TestApplyModePlanPropagationRunnerFallbacks(t *testing.T) {
781 for _, tc := range []struct {
782 name string
783 runner func(*agent.Agent) agent.Runner
784 }{
785 {name: "single agent", runner: func(executor *agent.Agent) agent.Runner { return executor }},
786 {name: "runner without setter", runner: func(*agent.Agent) agent.Runner {
787 return appendingRunner{session: agent.NewSession("runner")}
788 }},
789 {name: "nil runner", runner: func(*agent.Agent) agent.Runner { return nil }},
790 } {
791 t.Run(tc.name, func(t *testing.T) {
792 phaseCalls := 0
793 reg := tool.NewRegistry()
794 reg.Add(plannerUnsafeReadTool{calls: &phaseCalls})
795 prov := &scriptedTurns{turns: [][]provider.Chunk{
796 toolCallTurn("phase-1", "planner_phase_only", `{}`),
797 textTurn("done"),
798 }}
799 executor := agent.New(prov, reg, agent.NewSession("executor"), agent.Options{}, event.Discard)
800 c := New(Options{Runner: tc.runner(executor), Executor: executor})
801 c.ApplyMode(true, true)
802 if err := executor.Run(context.Background(), "try the execution-phase tool"); err != nil {
803 t.Fatalf("executor Run: %v", err)
804 }
805 if phaseCalls != 0 {
806 t.Fatalf("phase-opted-out tool executed %d times, want 0", phaseCalls)
807 }
808 })
809 }
810 }
811
812 type plannerUnsafeReadTool struct {
813 calls *int
814 }
815
816 func (plannerUnsafeReadTool) Name() string { return "planner_phase_only" }
817 func (plannerUnsafeReadTool) Description() string { return "planner phase test tool" }
818 func (plannerUnsafeReadTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
819 func (plannerUnsafeReadTool) ReadOnly() bool { return true }
820 func (plannerUnsafeReadTool) PlanModeSafe() bool { return false }
821 func (t plannerUnsafeReadTool) Execute(context.Context, json.RawMessage) (string, error) {
822 (*t.calls)++
823 return "executed", nil
824 }
825
826 func TestApplyModePropagatesPlanToCoordinatorPlannerAndKeepsYolo(t *testing.T) {
827 plannerCalls := 0
828 plannerTools := tool.NewRegistry()
829 plannerTools.Add(plannerUnsafeReadTool{calls: &plannerCalls})
830 planner := &scriptedTurns{turns: [][]provider.Chunk{
831 toolCallTurn("planner-tool", "planner_phase_only", `{}`),
832 textTurn("1. inspect the current behavior\n2. implement the fix"),
833 }}
834 execProvider := &scriptedTurns{turns: [][]provider.Chunk{textTurn("executor done")}}
835 executor := agent.New(execProvider, tool.NewRegistry(), agent.NewSession("exec"), agent.Options{}, event.Discard)
836 coordinator := agent.NewCoordinator(planner, agent.NewSession("planner"), nil, plannerTools, agent.Options{}, executor, 0, event.Discard, nil)
837 c := New(Options{Runner: coordinator, Executor: executor})
838
839 c.ApplyMode(true, true)
840 if err := c.Run(context.Background(), "prepare the change"); err != nil {
841 t.Fatalf("Run: %v", err)
842 }
843 if plannerCalls != 0 {
844 t.Fatalf("planner phase-only tool executed %d times, want 0 while Plan is active", plannerCalls)
845 }
846 if !c.PlanMode() || c.ToolApprovalMode() != ToolApprovalYolo {
847 t.Fatalf("after run plan=%v approval=%q, want true/yolo", c.PlanMode(), c.ToolApprovalMode())
848 }
849 }
850
851 type askCallResult struct {
852 answers []event.AskAnswer
853 err error
854 }
855
856 func sampleAskQuestions() []event.AskQuestion {
857 return []event.AskQuestion{
858 {
859 ID: "approach",
860 Header: "Approach",
861 Prompt: "Which path?",
862 Options: []event.AskOption{
863 {Label: "Recommended path"},
864 {Label: "Alternative path"},
865 },
866 },
867 {
868 ID: "scope",
869 Header: "Scope",
870 Prompt: "How broad?",
871 Options: []event.AskOption{
872 {Label: "Minimal"},
873 {Label: "Broad"},
874 },
875 Multi: true,
876 },
877 }
878 }
879
880 func askController(t *testing.T, c *Controller, questions []event.AskQuestion) <-chan askCallResult {
881 t.Helper()
882 done := make(chan askCallResult, 1)
883 go func() {
884 answers, err := c.Ask(context.Background(), questions)
885 done <- askCallResult{answers: answers, err: err}
886 }()
887 return done
888 }
889
890 func waitAskRequest(t *testing.T, askCh <-chan event.Ask) event.Ask {
891 t.Helper()
892 select {
893 case ask := <-askCh:
894 return ask
895 case <-time.After(30 * time.Second):
896 t.Fatal("Ask did not emit AskRequest")
897 }
898 return event.Ask{}
899 }
900
901 func waitAskResult(t *testing.T, done <-chan askCallResult) askCallResult {
902 t.Helper()
903 select {
904 case result := <-done:
905 if result.err != nil {
906 t.Fatalf("Ask: %v", result.err)
907 }
908 return result
909 case <-time.After(30 * time.Second):
910 t.Fatal("Ask stayed blocked")
911 }
912 return askCallResult{}
913 }
914
915 func assertAskAnswers(t *testing.T, got, want []event.AskAnswer) {
916 t.Helper()
917 if len(got) != len(want) {
918 t.Fatalf("answers len = %d, want %d: %#v", len(got), len(want), got)
919 }
920 for i := range want {
921 if got[i].QuestionID != want[i].QuestionID || len(got[i].Selected) != len(want[i].Selected) {
922 t.Fatalf("answers[%d] = %#v, want %#v", i, got[i], want[i])
923 }
924 for j := range want[i].Selected {
925 if got[i].Selected[j] != want[i].Selected[j] {
926 t.Fatalf("answers[%d] = %#v, want %#v", i, got[i], want[i])
927 }
928 }
929 }
930 }
931
932 func TestBypassDoesNotAutoAnswerAsk(t *testing.T) {
933 userAnswers := []event.AskAnswer{
934 {QuestionID: "approach", Selected: []string{"Alternative path"}},
935 {QuestionID: "scope", Selected: []string{"Broad"}},
936 }
937 askCh := make(chan event.Ask, 1)
938 c := New(Options{
939 Sink: event.FuncSink(func(e event.Event) {
940 if e.Kind == event.AskRequest {
941 askCh <- e.Ask
942 }
943 }),
944 })
945 c.SetBypass(true)
946
947 done := askController(t, c, sampleAskQuestions())
948 ask := waitAskRequest(t, askCh)
949
950 // Even with bypass/YOLO on, Ask must wait for the user's non-default choice.
951 c.AnswerQuestion(ask.ID, userAnswers)
952 result := waitAskResult(t, done)
953 assertAskAnswers(t, result.answers, userAnswers)
954 }
955
956 func TestAskPromptsAcrossInteractiveModes(t *testing.T) {
957 userAnswers := []event.AskAnswer{
958 {QuestionID: "approach", Selected: []string{"Alternative path"}},
959 {QuestionID: "scope", Selected: []string{"Broad"}},
960 }
961 tests := []struct {
962 name string
963 setup func(*Controller)
964 }{
965 {name: "normal"},
966 {name: "plan", setup: func(c *Controller) { c.SetMode(true, false) }},
967 {name: "yolo", setup: func(c *Controller) { c.SetMode(false, true) }},
968 }
969
970 for _, tt := range tests {
971 t.Run(tt.name, func(t *testing.T) {
972 askCh := make(chan event.Ask, 1)
973 c := New(Options{
974 Sink: event.FuncSink(func(e event.Event) {
975 if e.Kind == event.AskRequest {
976 askCh <- e.Ask
977 }
978 }),
979 })
980 if tt.setup != nil {
981 tt.setup(c)
982 }
983
984 done := askController(t, c, sampleAskQuestions())
985 ask := waitAskRequest(t, askCh)
986
987 // Answer with non-recommended options to prove this is the user's
988 // selection, not an automatic recommended-option fallback.
989 c.AnswerQuestion(ask.ID, userAnswers)
990 result := waitAskResult(t, done)
991 assertAskAnswers(t, result.answers, userAnswers)
992 })
993 }
994 }
995
996 func TestSetAutoApproveToolsDoesNotDrainPendingAsk(t *testing.T) {
997 askCh := make(chan event.Ask, 1)
998 c := New(Options{
999 Sink: event.FuncSink(func(e event.Event) {
1000 if e.Kind == event.AskRequest {
1001 askCh <- e.Ask
1002 }
1003 }),
1004 })
1005
1006 done := askController(t, c, sampleAskQuestions())
1007 ask := waitAskRequest(t, askCh)
1008
1009 c.SetAutoApproveTools(true)
1010
1011 select {
1012 case result := <-done:
1013 t.Fatalf("SetAutoApproveTools must not answer pending AskRequest; got %#v", result.answers)
1014 case <-time.After(50 * time.Millisecond):
1015 }
1016
1017 userAnswers := []event.AskAnswer{
1018 {QuestionID: "approach", Selected: []string{"Alternative path"}},
1019 {QuestionID: "scope", Selected: []string{"Broad"}},
1020 }
1021 c.AnswerQuestion(ask.ID, userAnswers)
1022 result := waitAskResult(t, done)
1023 assertAskAnswers(t, result.answers, userAnswers)
1024 }
1025
1026 func TestDismissedAskCancelsTurnWithoutModelContinuation(t *testing.T) {
1027 askCh := make(chan event.Ask, 1)
1028 turnDone := make(chan event.Event, 1)
1029 c := New(Options{
1030 Sink: event.FuncSink(func(e event.Event) {
1031 switch e.Kind {
1032 case event.AskRequest:
1033 askCh <- e.Ask
1034 case event.TurnDone:
1035 turnDone <- e
1036 }
1037 }),
1038 })
1039
1040 continued := false
1041 if got := c.runGuarded(func(ctx context.Context) error {
1042 _, err := c.Ask(ctx, sampleAskQuestions())
1043 if err == nil {
1044 continued = true
1045 }
1046 return err
1047 }); got != turnStarted {
1048 t.Fatalf("runGuarded = %v, want turnStarted", got)
1049 }
1050 ask := waitAskRequest(t, askCh)
1051 c.AnswerQuestion(ask.ID, nil)
1052
1053 select {
1054 case done := <-turnDone:
1055 if !done.Cancelled {
1056 t.Fatalf("dismissed Ask TurnDone = %+v, want Cancelled", done)
1057 }
1058 case <-time.After(30 * time.Second):
1059 t.Fatal("dismissed Ask did not finish the turn")
1060 }
1061 if continued {
1062 t.Fatal("dismissed Ask returned a model-facing result instead of stopping the turn")
1063 }
1064 }
1065
1066 func TestAskSerializesBehindPromptLockEvenWithAutoApproveTools(t *testing.T) {
1067 askCh := make(chan event.Ask, 1)
1068 c := New(Options{
1069 Sink: event.FuncSink(func(e event.Event) {
1070 if e.Kind == event.AskRequest {
1071 askCh <- e.Ask
1072 }
1073 }),
1074 })
1075 questions := []event.AskQuestion{{
1076 ID: "q1",
1077 Header: "Choice",
1078 Prompt: "Which path?",
1079 Options: []event.AskOption{
1080 {Label: "Recommended"},
1081 {Label: "Alternative"},
1082 },
1083 }}
1084
1085 c.approval.promptMu.Lock()
1086 started := make(chan struct{})
1087 done := make(chan []event.AskAnswer, 1)
1088 errs := make(chan error, 1)
1089 go func() {
1090 close(started)
1091 answers, err := c.Ask(context.Background(), questions)
1092 if err != nil {
1093 errs <- err
1094 return
1095 }
1096 done <- answers
1097 }()
1098 <-started
1099
1100 // Give the goroutine a chance to reach promptMu, then prove it did not emit
1101 // AskRequest while another prompt owns the user-decision slot.
1102 time.Sleep(20 * time.Millisecond)
1103 select {
1104 case ask := <-askCh:
1105 t.Fatalf("AskRequest emitted while promptMu was held: %#v", ask)
1106 default:
1107 }
1108
1109 // Enable tool auto-approval while Ask is queued behind promptMu.
1110 c.SetAutoApproveTools(true)
1111 select {
1112 case ask := <-askCh:
1113 t.Fatalf("tool auto-approval must not let Ask bypass promptMu; got %#v", ask)
1114 default:
1115 }
1116
1117 // Release the lock — Ask proceeds but must still emit an AskRequest.
1118 c.approval.promptMu.Unlock()
1119
1120 var ask event.Ask
1121 select {
1122 case err := <-errs:
1123 t.Fatalf("Ask: %v", err)
1124 case ask = <-askCh:
1125 case <-time.After(30 * time.Second):
1126 t.Fatal("Ask did not emit AskRequest after acquiring promptMu with tool auto-approval on")
1127 }
1128
1129 c.AnswerQuestion(ask.ID, []event.AskAnswer{
1130 {QuestionID: "q1", Selected: []string{"Alternative"}},
1131 })
1132
1133 var answers []event.AskAnswer
1134 select {
1135 case err := <-errs:
1136 t.Fatalf("Ask: %v", err)
1137 case answers = <-done:
1138 case <-time.After(30 * time.Second):
1139 t.Fatal("Ask stayed blocked after AnswerQuestion")
1140 }
1141 if len(answers) != 1 || answers[0].QuestionID != "q1" || len(answers[0].Selected) != 1 || answers[0].Selected[0] != "Alternative" {
1142 t.Fatalf("answers = %#v, want Alternative (user's choice, not auto-recommended)", answers)
1143 }
1144 }
1145
1146 func TestAskSerializesBehindPromptLockEvenWithBypass(t *testing.T) {
1147 askCh := make(chan event.Ask, 1)
1148 c := New(Options{
1149 Sink: event.FuncSink(func(e event.Event) {
1150 if e.Kind == event.AskRequest {
1151 askCh <- e.Ask
1152 }
1153 }),
1154 })
1155 questions := []event.AskQuestion{{
1156 ID: "q1",
1157 Header: "Choice",
1158 Prompt: "Which path?",
1159 Options: []event.AskOption{
1160 {Label: "Recommended"},
1161 {Label: "Alternative"},
1162 },
1163 }}
1164
1165 c.approval.promptMu.Lock()
1166 done := make(chan []event.AskAnswer, 1)
1167 errs := make(chan error, 1)
1168 go func() {
1169 answers, err := c.Ask(context.Background(), questions)
1170 if err != nil {
1171 errs <- err
1172 return
1173 }
1174 done <- answers
1175 }()
1176
1177 // Give the goroutine a chance to reach promptMu, then prove it did not emit
1178 // AskRequest while another prompt owns the user-decision slot.
1179 time.Sleep(20 * time.Millisecond)
1180 select {
1181 case ask := <-askCh:
1182 t.Fatalf("AskRequest emitted while promptMu was held: %#v", ask)
1183 default:
1184 }
1185
1186 // Enable bypass while Ask is queued behind promptMu.
1187 c.SetBypass(true)
1188 // Release the lock — Ask proceeds but must still emit an AskRequest.
1189 c.approval.promptMu.Unlock()
1190
1191 // Post-unlock assertion: Ask must emit AskRequest now that it holds the lock.
1192 var ask event.Ask
1193 select {
1194 case err := <-errs:
1195 t.Fatalf("Ask: %v", err)
1196 case ask = <-askCh:
1197 case <-time.After(30 * time.Second):
1198 t.Fatal("Ask did not emit AskRequest after acquiring promptMu with bypass on; bypass should not suppress ask")
1199 }
1200
1201 // Answer and verify we get the user's choice.
1202 c.AnswerQuestion(ask.ID, []event.AskAnswer{
1203 {QuestionID: "q1", Selected: []string{"Alternative"}},
1204 })
1205
1206 var answers []event.AskAnswer
1207 select {
1208 case err := <-errs:
1209 t.Fatalf("Ask: %v", err)
1210 case answers = <-done:
1211 case <-time.After(30 * time.Second):
1212 t.Fatal("Ask stayed blocked after AnswerQuestion")
1213 }
1214 if len(answers) != 1 || answers[0].QuestionID != "q1" || len(answers[0].Selected) != 1 || answers[0].Selected[0] != "Alternative" {
1215 t.Fatalf("answers = %#v, want Alternative (user's choice, not auto-recommended)", answers)
1216 }
1217 }
1218
1219 // TestApplyToolApprovalModeReportsDrainedIDs pins the drain-report contract
1220 // the desktop frontend relies on (#6432): a posture switch returns exactly
1221 // the pending approval ids it auto-allowed, so the UI dismisses those cards
1222 // and keeps the ones still pending here. Fresh user decisions (plan) never
1223 // drain, and auto keeps approvals an allow policy would not cover.
1224 func TestApplyToolApprovalModeReportsDrainedIDs(t *testing.T) {
1225 c := New(Options{
1226 Policy: permission.New("ask", nil, []string{"bash(git commit*)"}, nil),
1227 })
1228
1229 autoOKID, autoOKReply := c.approval.register("bash", "go test ./...", "")
1230 askRuleID, askRuleReply := c.approval.register("bash", "git commit -m x", "")
1231 planID, planReply := c.approval.registerDecision(planApprovalTool, "", "", true, false)
1232
1233 drained := c.ApplyToolApprovalMode(ToolApprovalAuto)
1234 if len(drained) != 1 || drained[0] != autoOKID {
1235 t.Fatalf("auto drained = %v, want [%s]", drained, autoOKID)
1236 }
1237 select {
1238 case r := <-autoOKReply:
1239 if !r.allow {
1240 t.Fatal("auto-drained approval must be auto-allowed")
1241 }
1242 default:
1243 t.Fatal("auto-drained approval reply not signaled")
1244 }
1245 select {
1246 case <-askRuleReply:
1247 t.Fatal("explicit ask-rule approval must stay pending under auto")
1248 default:
1249 }
1250
1251 drained = c.ApplyToolApprovalMode(ToolApprovalYolo)
1252 if len(drained) != 1 || drained[0] != askRuleID {
1253 t.Fatalf("yolo drained = %v, want [%s]", drained, askRuleID)
1254 }
1255 select {
1256 case r := <-askRuleReply:
1257 if !r.allow {
1258 t.Fatal("yolo-drained approval must be auto-allowed")
1259 }
1260 default:
1261 t.Fatal("yolo-drained approval reply not signaled")
1262 }
1263
1264 // The fresh plan decision survives both switches and stays pending.
1265 select {
1266 case <-planReply:
1267 t.Fatal("fresh plan approval must never drain on a posture switch")
1268 default:
1269 }
1270 if !c.approval.hasPending() {
1271 t.Fatalf("plan approval %s should still be pending", planID)
1272 }
1273 }
1274
1274 lines GO