返回 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: planThenExecuteTurns(
22 "Plan:\n1. Add the config field\n2. Wire it into boot\n3. Add tests",
23 "Done — implemented the approved plan.",
24 )}
25 ag := newPlanTestAgent(prov)
26
27 approvalRequests := make(chan event.Approval, 1)
28 c := newOwnedTestController(t, Options{
29 Runner: ag,
30 Executor: ag,
31 Sink: event.FuncSink(func(e event.Event) {
32 switch e.Kind {
33 case event.ApprovalRequest:
34 approvalRequests <- e.Approval
35 }
36 }),
37 })
38 c.SetToolApprovalMode(ToolApprovalDangerFullAccess)
39 c.SetPlanMode(true)
40
41 input := "实现 issue #2395:新增配置项、自动判断复杂任务、补测试和文档"
42 done := make(chan error, 1)
43 go func() { done <- c.runTurnWithRaw(context.Background(), input, input) }()
44
45 var approval event.Approval
46 select {
47 case approval = <-approvalRequests:
48 case <-time.After(30 * time.Second):
49 t.Fatal("tool auto-approval must not suppress plan approval")
50 }
51 if approval.Tool != planApprovalTool {
52 t.Fatalf("approval tool = %q, want %q", approval.Tool, planApprovalTool)
53 }
54
55 if !c.PlanMode() {
56 t.Fatal("controller should stay in plan mode while waiting for approval")
57 }
58 c.Approve(approval.ID, true, false, false)
59
60 select {
61 case err := <-done:
62 if err != nil {
63 t.Fatalf("runTurnWithRaw: %v", err)
64 }
65 case <-time.After(30 * time.Second):
66 t.Fatal("approved plan did not continue into execution")
67 }
68 if got := agent.StripTransientUserBlocks(firstUserMessage(ag.Session().Messages)); !strings.HasPrefix(got, PlanModeMarker) {
69 t.Fatalf("first model input = %q, want the plan marker prefixed", got)
70 }
71 if c.PlanMode() {
72 t.Fatal("plan mode should be off after approval")
73 }
74 if !c.AutoApproveTools() {
75 t.Fatal("tool auto-approval should remain on after plan approval")
76 }
77 if got := c.Todos(); len(got) != 0 {
78 t.Fatalf("approved plan seeded todo state: %+v", got)
79 }
80 if prov.call != 3 {
81 t.Fatalf("provider called %d times, want 3 (plan + read + answer)", prov.call)
82 }
83 }
84
85 // TestRequestApprovalHonorsAutoApproveTools guards the underlying gate: ordinary
86 // tool approvals must return allow immediately without emitting anything under
87 // tool auto-approval.
88 func TestRequestApprovalHonorsAutoApproveTools(t *testing.T) {
89 var approvalRequested bool
90 c := newOwnedTestController(t, Options{
91 Sink: event.FuncSink(func(e event.Event) {
92 if e.Kind == event.ApprovalRequest {
93 approvalRequested = true
94 }
95 }),
96 })
97 c.SetToolApprovalMode(ToolApprovalDangerFullAccess)
98
99 done := make(chan bool, 1)
100 go func() {
101 allow, _, err := c.requestApproval(context.Background(), "multi_edit", "/tmp/file", nil)
102 if err != nil {
103 t.Errorf("requestApproval: %v", err)
104 }
105 done <- allow
106 }()
107
108 select {
109 case allow := <-done:
110 if !allow {
111 t.Fatal("tool auto-approval should allow the approval")
112 }
113 case <-time.After(30 * time.Second):
114 t.Fatal("requestApproval blocked under tool auto-approval")
115 }
116
117 if approvalRequested {
118 t.Fatal("tool auto-approval must not emit an ApprovalRequest event")
119 }
120 }
121
122 func TestToolApprovalModeAutoKeepsAskRules(t *testing.T) {
123 c := newOwnedTestController(t, Options{
124 Policy: permission.New("ask", nil, []string{"bash(git commit*)"}, []string{"bash(rm*)"}),
125 })
126 c.SetToolApprovalMode(ToolApprovalAuto)
127
128 gate := c.newInteractiveGate()
129 if got := gate.Policy.Decide("bash", false, json.RawMessage(`{"command":"go test ./..."}`)); got != permission.Allow {
130 t.Fatalf("auto mode fallback = %v, want allow", got)
131 }
132 if got := gate.Policy.Decide("bash", false, json.RawMessage(`{"command":"git commit -m x"}`)); got != permission.Ask {
133 t.Fatalf("explicit ask rule = %v, want ask", got)
134 }
135 if got := gate.Policy.Decide("bash", false, json.RawMessage(`{"command":"rm -rf build"}`)); got != permission.Deny {
136 t.Fatalf("deny rule = %v, want deny", got)
137 }
138 if c.AutoApproveTools() {
139 t.Fatal("auto approval must not report as YOLO")
140 }
141 }
142
143 func TestToolApprovalModeDontAskDeniesWithoutPrompt(t *testing.T) {
144 requests := 0
145 c := newOwnedTestController(t, Options{
146 Policy: permission.New("ask", nil, []string{"bash(git commit*)"}, nil).
147 WithSessionAllow([]string{"bash(go test*)"}),
148 Sink: event.FuncSink(func(e event.Event) {
149 if e.Kind == event.ApprovalRequest {
150 requests++
151 }
152 }),
153 })
154 c.SetToolApprovalMode(ToolApprovalDontAsk)
155 gate := c.newInteractiveGate()
156
157 allow, _, err := gate.Check(context.Background(), "bash", json.RawMessage(`{"command":"go test ./..."}`), false)
158 if err != nil || !allow {
159 t.Fatalf("session-allowed call = (%v, %v), want allow", allow, err)
160 }
161 allow, _, err = gate.Check(context.Background(), "bash", json.RawMessage(`{"command":"git commit -m x"}`), false)
162 if err != nil || allow {
163 t.Fatalf("explicit ask under dontAsk = (%v, %v), want deny", allow, err)
164 }
165 allow, _, err = gate.Check(context.Background(), "write_file", json.RawMessage(`{"path":"x.txt"}`), false)
166 if err != nil || allow {
167 t.Fatalf("fallback under dontAsk = (%v, %v), want deny", allow, err)
168 }
169 if requests != 0 {
170 t.Fatalf("dontAsk emitted %d approval requests, want 0", requests)
171 }
172 }
173
174 func TestLegacyAutoMigrationDoesNotApprovePendingFallback(t *testing.T) {
175 approvalRequests := make(chan event.Approval, 1)
176 c := newOwnedTestController(t, Options{
177 Policy: permission.New("ask", nil, nil, nil),
178 Sink: event.FuncSink(func(e event.Event) {
179 if e.Kind == event.ApprovalRequest {
180 approvalRequests <- e.Approval
181 }
182 }),
183 })
184
185 done := make(chan bool, 1)
186 errs := make(chan error, 1)
187 go func() {
188 allow, _, err := c.requestApproval(context.Background(), "multi_edit", "/tmp/file", nil)
189 if err != nil {
190 errs <- err
191 return
192 }
193 done <- allow
194 }()
195
196 var approval event.Approval
197 select {
198 case approval = <-approvalRequests:
199 case <-time.After(30 * time.Second):
200 t.Fatal("approval request was not emitted")
201 }
202
203 c.SetToolApprovalMode(ToolApprovalAuto)
204
205 select {
206 case err := <-errs:
207 t.Fatalf("requestApproval returned unexpectedly: %v", err)
208 case allow := <-done:
209 t.Fatalf("legacy auto migration resolved pending approval unexpectedly: allow=%v", allow)
210 case <-time.After(50 * time.Millisecond):
211 }
212 c.Approve(approval.ID, true, true, false)
213 select {
214 case err := <-errs:
215 t.Fatalf("requestApproval: %v", err)
216 case allow := <-done:
217 if !allow {
218 t.Fatal("manual session approval returned deny")
219 }
220 case <-time.After(30 * time.Second):
221 t.Fatal("pending fallback approval stayed blocked after manual approval")
222 }
223 if c.AutoApproveTools() {
224 t.Fatal("auto mode must not report as YOLO")
225 }
226 }
227
228 func TestToolApprovalModeAutoDoesNotDrainPendingExplicitAsk(t *testing.T) {
229 approvalRequests := make(chan event.Approval, 1)
230 c := newOwnedTestController(t, Options{
231 Policy: permission.New("ask", nil, []string{"bash(git commit*)"}, nil),
232 Sink: event.FuncSink(func(e event.Event) {
233 if e.Kind == event.ApprovalRequest {
234 approvalRequests <- e.Approval
235 }
236 }),
237 })
238
239 done := make(chan bool, 1)
240 errs := make(chan error, 1)
241 go func() {
242 allow, _, err := c.requestApproval(context.Background(), "bash", "git commit -m x", nil)
243 if err != nil {
244 errs <- err
245 return
246 }
247 done <- allow
248 }()
249
250 var approval event.Approval
251 select {
252 case approval = <-approvalRequests:
253 case <-time.After(30 * time.Second):
254 t.Fatal("approval request was not emitted")
255 }
256
257 c.SetToolApprovalMode(ToolApprovalAuto)
258
259 select {
260 case err := <-errs:
261 t.Fatalf("requestApproval: %v", err)
262 case allow := <-done:
263 t.Fatalf("auto mode must not answer explicit ask rules; got allow=%v", allow)
264 case <-time.After(50 * time.Millisecond):
265 }
266
267 c.Approve(approval.ID, true, false, false)
268
269 select {
270 case err := <-errs:
271 t.Fatalf("requestApproval: %v", err)
272 case allow := <-done:
273 if !allow {
274 t.Fatal("manual approval should allow the explicit ask request")
275 }
276 case <-time.After(30 * time.Second):
277 t.Fatal("explicit ask approval stayed blocked after manual Approve")
278 }
279 }
280
281 func TestToolApprovalModeYoloBypassesApprovalPrompts(t *testing.T) {
282 c := newOwnedTestController(t, Options{})
283 c.SetToolApprovalMode(ToolApprovalYolo)
284 if !c.AutoApproveTools() {
285 t.Fatal("YOLO mode should satisfy legacy AutoApproveTools")
286 }
287 allow, remember, err := c.requestApproval(context.Background(), "bash", "go test ./...", nil)
288 if err != nil || !allow || remember {
289 t.Fatalf("requestApproval in YOLO = (%v,%v,%v), want allow without remember", allow, remember, err)
290 }
291 }
292
293 func TestPlanApprovalIgnoresAutoApproveTools(t *testing.T) {
294 approvalRequests := make(chan event.Approval, 1)
295 c := newOwnedTestController(t, Options{
296 Sink: event.FuncSink(func(e event.Event) {
297 if e.Kind == event.ApprovalRequest {
298 approvalRequests <- e.Approval
299 }
300 }),
301 })
302 c.SetToolApprovalMode(ToolApprovalDangerFullAccess)
303
304 done := make(chan bool, 1)
305 errs := make(chan error, 1)
306 go func() {
307 allow, _, err := c.requestApproval(context.Background(), planApprovalTool, "", nil)
308 if err != nil {
309 errs <- err
310 return
311 }
312 done <- allow
313 }()
314
315 var approval event.Approval
316 select {
317 case approval = <-approvalRequests:
318 case <-time.After(30 * time.Second):
319 t.Fatal("plan approval must still prompt under tool auto-approval")
320 }
321 if approval.Tool != planApprovalTool {
322 t.Fatalf("approval tool = %q, want %q", approval.Tool, planApprovalTool)
323 }
324 select {
325 case allow := <-done:
326 t.Fatalf("plan approval must wait for the user under tool auto-approval; got allow=%v", allow)
327 case err := <-errs:
328 t.Fatalf("requestApproval: %v", err)
329 default:
330 }
331
332 c.Approve(approval.ID, true, false, false)
333
334 select {
335 case err := <-errs:
336 t.Fatalf("requestApproval: %v", err)
337 case allow := <-done:
338 if !allow {
339 t.Fatal("manual plan approval should allow")
340 }
341 case <-time.After(30 * time.Second):
342 t.Fatal("plan approval stayed blocked after Approve")
343 }
344 }
345
346 // Legacy SetAutoApproveTools conservatively maps to workspace access and must
347 // not answer an approval created under an older permission revision.
348 func TestSetAutoApproveToolsDoesNotResolvePendingApproval(t *testing.T) {
349 c, ids, _ := approvalIDs(t)
350
351 done := make(chan bool, 1)
352 errs := make(chan error, 1)
353 go func() {
354 allow, _, err := c.requestApproval(context.Background(), "multi_edit", "/tmp/file", nil)
355 if err != nil {
356 errs <- err
357 return
358 }
359 done <- allow
360 }()
361
362 var approvalID string
363 select {
364 case approvalID = <-ids:
365 case <-time.After(30 * time.Second):
366 t.Fatal("approval request was not emitted")
367 }
368
369 c.SetAutoApproveTools(true)
370
371 select {
372 case err := <-errs:
373 t.Fatalf("requestApproval: %v", err)
374 case allow := <-done:
375 t.Fatalf("legacy mode change answered pending approval: allow=%v", allow)
376 case <-time.After(50 * time.Millisecond):
377 }
378 if c.AutoApproveTools() || c.ToolApprovalMode() != ToolApprovalWorkspaceWrite {
379 t.Fatalf("legacy mode = %q full=%v, want workspace-write without full access", c.ToolApprovalMode(), c.AutoApproveTools())
380 }
381 c.Approve(approvalID, true, false, false)
382 if allow := <-done; !allow {
383 t.Fatal("manual approval should allow")
384 }
385 }
386
387 func TestSandboxEscapeApprovalIgnoresAutoApproveTools(t *testing.T) {
388 approvalRequests := make(chan event.Approval, 1)
389 c := newOwnedTestController(t, Options{
390 Sink: event.FuncSink(func(e event.Event) {
391 if e.Kind == event.ApprovalRequest {
392 approvalRequests <- e.Approval
393 }
394 }),
395 })
396 c.SetToolApprovalMode(ToolApprovalDangerFullAccess)
397
398 type escapeResult struct {
399 allow bool
400 reason string
401 err error
402 }
403 done := make(chan escapeResult, 1)
404 go func() {
405 allow, reason, err := sandboxEscapeApprover{c}.ApproveSandboxEscape(context.Background(), sandbox.EscapeRequest{
406 Command: "go test ./...",
407 Reason: "Windows sandbox failed. Run this command unconfined once?",
408 })
409 done <- escapeResult{allow: allow, reason: reason, err: err}
410 }()
411
412 var approval event.Approval
413 select {
414 case approval = <-approvalRequests:
415 case <-time.After(30 * time.Second):
416 t.Fatal("sandbox escape approval request was not emitted")
417 }
418 if approval.Tool != SandboxEscapeApprovalTool {
419 t.Fatalf("approval tool = %q, want %q", approval.Tool, SandboxEscapeApprovalTool)
420 }
421
422 c.SetAutoApproveTools(true)
423 select {
424 case got := <-done:
425 t.Fatalf("tool auto-approval must not answer sandbox escape; got %+v", got)
426 case <-time.After(50 * time.Millisecond):
427 }
428
429 c.Approve(approval.ID, true, true, false)
430 select {
431 case got := <-done:
432 if got.err != nil || !got.allow || got.reason != "" {
433 t.Fatalf("sandbox escape result = %+v, want allowed without reason/error", got)
434 }
435 case <-time.After(30 * time.Second):
436 t.Fatal("sandbox escape approval stayed blocked after Approve")
437 }
438
439 if !(sandboxEscapeApprover{c}).SandboxEscapeSessionAllowed(context.Background(), sandbox.EscapeRequest{Command: "npm test"}) {
440 t.Fatal("sandbox escape session checker = false, want true after session grant")
441 }
442 allow, reason, err := sandboxEscapeApprover{c}.ApproveSandboxEscape(context.Background(), sandbox.EscapeRequest{
443 Command: "npm test",
444 Reason: "Windows sandbox failed. Run this command unconfined once?",
445 })
446 if err != nil || !allow || reason != "" {
447 t.Fatalf("sandbox escape session grant result = (%v,%q,%v), want allow", allow, reason, err)
448 }
449 select {
450 case approval := <-approvalRequests:
451 t.Fatalf("sandbox escape session grant emitted another approval: %+v", approval)
452 default:
453 }
454 }
455
456 func TestSetAutoApproveToolsDoesNotDrainPendingPlanApproval(t *testing.T) {
457 approvalRequests := make(chan event.Approval, 1)
458 c := newOwnedTestController(t, Options{
459 Sink: event.FuncSink(func(e event.Event) {
460 if e.Kind == event.ApprovalRequest {
461 approvalRequests <- e.Approval
462 }
463 }),
464 })
465
466 done := make(chan bool, 1)
467 errs := make(chan error, 1)
468 go func() {
469 allow, _, err := c.requestApproval(context.Background(), planApprovalTool, "", nil)
470 if err != nil {
471 errs <- err
472 return
473 }
474 done <- allow
475 }()
476
477 var approval event.Approval
478 select {
479 case approval = <-approvalRequests:
480 case <-time.After(30 * time.Second):
481 t.Fatal("plan approval request was not emitted")
482 }
483
484 c.SetAutoApproveTools(true)
485
486 select {
487 case err := <-errs:
488 t.Fatalf("requestApproval: %v", err)
489 case allow := <-done:
490 t.Fatalf("SetAutoApproveTools must not auto-answer pending plan approval; got allow=%v", allow)
491 case <-time.After(50 * time.Millisecond):
492 }
493 if c.AutoApproveTools() || c.ToolApprovalMode() != ToolApprovalWorkspaceWrite {
494 t.Fatalf("legacy mode = %q full=%v, want workspace-write", c.ToolApprovalMode(), c.AutoApproveTools())
495 }
496
497 c.Approve(approval.ID, true, false, false)
498
499 select {
500 case err := <-errs:
501 t.Fatalf("requestApproval: %v", err)
502 case allow := <-done:
503 if !allow {
504 t.Fatal("manual plan approval should allow")
505 }
506 case <-time.After(30 * time.Second):
507 t.Fatal("plan approval stayed blocked after Approve")
508 }
509 }
510
511 func TestSetAutoApproveToolsDoesNotDrainPendingPlanModeReadOnlyCommandTrust(t *testing.T) {
512 approvalRequests := make(chan event.Approval, 1)
513 c := newOwnedTestController(t, Options{
514 Sink: event.FuncSink(func(e event.Event) {
515 if e.Kind == event.ApprovalRequest {
516 approvalRequests <- e.Approval
517 }
518 }),
519 })
520
521 type trustResult struct {
522 allow bool
523 reason string
524 err error
525 }
526 done := make(chan trustResult, 1)
527 req := agent.PlanModeReadOnlyTrustRequest{
528 ToolName: agent.PlanModeReadOnlyCommandApprovalTool,
529 Command: "gh issue view 5867",
530 Prefix: "gh issue view",
531 }
532 go func() {
533 allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req)
534 done <- trustResult{allow: allow, reason: reason, err: err}
535 }()
536
537 var approval event.Approval
538 select {
539 case approval = <-approvalRequests:
540 case <-time.After(30 * time.Second):
541 t.Fatal("plan-mode bash read-only command trust approval request was not emitted")
542 }
543 if approval.Tool != agent.PlanModeReadOnlyCommandApprovalTool {
544 t.Fatalf("approval tool = %q, want %q", approval.Tool, agent.PlanModeReadOnlyCommandApprovalTool)
545 }
546
547 c.SetAutoApproveTools(true)
548
549 select {
550 case got := <-done:
551 t.Fatalf("SetAutoApproveTools must not auto-answer plan-mode bash read-only command trust; got %+v", got)
552 case <-time.After(50 * time.Millisecond):
553 }
554 if c.AutoApproveTools() || c.ToolApprovalMode() != ToolApprovalWorkspaceWrite {
555 t.Fatalf("legacy mode = %q full=%v, want workspace-write", c.ToolApprovalMode(), c.AutoApproveTools())
556 }
557
558 c.Approve(approval.ID, true, false, false)
559 select {
560 case got := <-done:
561 if got.err != nil || !got.allow || got.reason != "" {
562 t.Fatalf("manual plan-mode bash read-only command trust approval = %+v, want allow", got)
563 }
564 case <-time.After(30 * time.Second):
565 t.Fatal("plan-mode bash read-only command trust approval stayed blocked after Approve")
566 }
567 }
568
569 // The legacy combined mode API no longer grants full access and cannot answer
570 // an approval already waiting under another snapshot.
571 func TestSetModeLegacyPermissionDoesNotResolvePendingApproval(t *testing.T) {
572 c, ids, _ := approvalIDs(t)
573
574 done := make(chan bool, 1)
575 go func() {
576 allow, _, _ := c.requestApproval(context.Background(), "multi_edit", "/tmp/file", nil)
577 done <- allow
578 }()
579
580 var approvalID string
581 select {
582 case approvalID = <-ids:
583 case <-time.After(30 * time.Second):
584 t.Fatal("approval request was not emitted")
585 }
586
587 c.SetMode(false, true)
588
589 select {
590 case allow := <-done:
591 t.Fatalf("legacy SetMode answered pending approval: allow=%v", allow)
592 case <-time.After(50 * time.Millisecond):
593 }
594 c.Approve(approvalID, true, false, false)
595 if allow := <-done; !allow {
596 t.Fatal("manual approval should allow")
597 }
598 }
599
600 func TestSetModeLegacyAppliesPlanAndWorkspacePermission(t *testing.T) {
601 c, _, _ := approvalIDs(t)
602
603 c.SetMode(true, false)
604 if !c.PlanMode() || c.AutoApproveTools() {
605 t.Fatalf("plan mode: plan=%v autoApproveTools=%v, want true/false", c.PlanMode(), c.AutoApproveTools())
606 }
607
608 c.SetMode(false, true)
609 if c.PlanMode() || c.AutoApproveTools() || c.ToolApprovalMode() != ToolApprovalWorkspaceWrite {
610 t.Fatalf("legacy write mode: plan=%v permission=%q", c.PlanMode(), c.ToolApprovalMode())
611 }
612
613 c.SetMode(true, true)
614 if !c.PlanMode() || c.AutoApproveTools() || c.ToolApprovalMode() != ToolApprovalWorkspaceWrite {
615 t.Fatalf("legacy plan mode: plan=%v permission=%q", c.PlanMode(), c.ToolApprovalMode())
616 }
617
618 c.SetMode(false, false)
619 if c.PlanMode() || c.AutoApproveTools() {
620 t.Fatalf("normal mode: plan=%v autoApproveTools=%v, want false/false", c.PlanMode(), c.AutoApproveTools())
621 }
622 }
623
624 type planModeCountingRunner struct {
625 calls int
626 last bool
627 }
628
629 func (*planModeCountingRunner) Run(context.Context, string) error { return nil }
630 func (r *planModeCountingRunner) SetPlanMode(v bool) {
631 r.calls++
632 r.last = v
633 }
634
635 func TestApplyModeUsesRunnerPlanPropagationOnce(t *testing.T) {
636 runner := &planModeCountingRunner{}
637 c := newOwnedTestController(t, Options{Runner: runner})
638 c.ApplyMode(true, true)
639 if runner.calls != 1 || !runner.last {
640 t.Fatalf("runner SetPlanMode calls=%d last=%v, want 1/true", runner.calls, runner.last)
641 }
642 if !c.PlanMode() || c.ToolApprovalMode() != ToolApprovalWorkspaceWrite {
643 t.Fatalf("controller plan=%v approval=%q, want true/workspace-write after legacy migration", c.PlanMode(), c.ToolApprovalMode())
644 }
645 c.SetPlanMode(false)
646 if runner.calls != 2 || runner.last {
647 t.Fatalf("SetPlanMode runner calls=%d last=%v, want 2/false", runner.calls, runner.last)
648 }
649 }
650
651 func TestApplyModePlanPropagationRunnerFallbacks(t *testing.T) {
652 for _, tc := range []struct {
653 name string
654 runner func(*agent.Agent) agent.Runner
655 }{
656 {name: "single agent", runner: func(executor *agent.Agent) agent.Runner { return executor }},
657 {name: "runner without setter", runner: func(*agent.Agent) agent.Runner {
658 return appendingRunner{session: agent.NewSession("runner")}
659 }},
660 {name: "nil runner", runner: func(*agent.Agent) agent.Runner { return nil }},
661 } {
662 t.Run(tc.name, func(t *testing.T) {
663 phaseCalls := 0
664 reg := tool.NewRegistry()
665 reg.Add(plannerUnsafeReadTool{calls: &phaseCalls})
666 prov := &scriptedTurns{turns: [][]provider.Chunk{
667 toolCallTurn("phase-1", "planner_phase_only", `{}`),
668 textTurn("done"),
669 }}
670 executor := agent.New(prov, reg, agent.NewSession("executor"), agent.Options{}, event.Discard)
671 c := newOwnedTestController(t, Options{Runner: tc.runner(executor), Executor: executor})
672 c.ApplyMode(true, true)
673 if err := executor.Run(context.Background(), "try the execution-phase tool"); err != nil {
674 t.Fatalf("executor Run: %v", err)
675 }
676 if phaseCalls != 0 {
677 t.Fatalf("phase-opted-out tool executed %d times, want 0", phaseCalls)
678 }
679 })
680 }
681 }
682
683 type plannerUnsafeReadTool struct {
684 calls *int
685 }
686
687 func (plannerUnsafeReadTool) Name() string { return "planner_phase_only" }
688 func (plannerUnsafeReadTool) Description() string { return "planner phase test tool" }
689 func (plannerUnsafeReadTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
690 func (plannerUnsafeReadTool) ReadOnly() bool { return true }
691 func (plannerUnsafeReadTool) PlanModeSafe() bool { return false }
692 func (t plannerUnsafeReadTool) Execute(context.Context, json.RawMessage) (string, error) {
693 (*t.calls)++
694 return "executed", nil
695 }
696
697 func TestApplyModePropagatesPlanToCoordinatorPlannerAndMigratesLegacyYolo(t *testing.T) {
698 plannerCalls := 0
699 plannerTools := agent.PlannerToolRegistry(tool.NewRegistry())
700 plannerTools.Add(plannerUnsafeReadTool{calls: &plannerCalls})
701 planner := &scriptedTurns{turns: [][]provider.Chunk{
702 toolCallTurn("planner-tool", "planner_phase_only", `{}`),
703 planTurn("1. inspect the current behavior\n2. implement the fix"),
704 }}
705 execProvider := &scriptedTurns{turns: [][]provider.Chunk{textTurn("executor done")}}
706 executor := agent.New(execProvider, tool.NewRegistry(), agent.NewSession("exec"), agent.Options{}, event.Discard)
707 coordinator := agent.NewCoordinator(planner, agent.NewSession("planner"), nil, plannerTools, agent.Options{}, executor, 0, event.Discard, nil)
708 c := newOwnedTestController(t, Options{Runner: coordinator, Executor: executor})
709
710 c.ApplyMode(true, true)
711 if err := c.Run(context.Background(), "prepare the change"); err != nil {
712 t.Fatalf("Run: %v", err)
713 }
714 if plannerCalls != 0 {
715 t.Fatalf("planner phase-only tool executed %d times, want 0 while Plan is active", plannerCalls)
716 }
717 if !c.PlanMode() || c.ToolApprovalMode() != ToolApprovalWorkspaceWrite {
718 t.Fatalf("after run plan=%v approval=%q, want true/workspace-write", c.PlanMode(), c.ToolApprovalMode())
719 }
720 }
721
722 type askCallResult struct {
723 answers []event.AskAnswer
724 err error
725 }
726
727 func sampleAskQuestions() []event.AskQuestion {
728 return []event.AskQuestion{
729 {
730 ID: "approach",
731 Header: "Approach",
732 Prompt: "Which path?",
733 Options: []event.AskOption{
734 {Label: "Recommended path"},
735 {Label: "Alternative path"},
736 },
737 },
738 {
739 ID: "scope",
740 Header: "Scope",
741 Prompt: "How broad?",
742 Options: []event.AskOption{
743 {Label: "Minimal"},
744 {Label: "Broad"},
745 },
746 Multi: true,
747 },
748 }
749 }
750
751 func askController(t *testing.T, c *Controller, questions []event.AskQuestion) <-chan askCallResult {
752 t.Helper()
753 done := make(chan askCallResult, 1)
754 go func() {
755 answers, err := c.Ask(context.Background(), questions)
756 done <- askCallResult{answers: answers, err: err}
757 }()
758 return done
759 }
760
761 func waitAskRequest(t *testing.T, askCh <-chan event.Ask) event.Ask {
762 t.Helper()
763 select {
764 case ask := <-askCh:
765 return ask
766 case <-time.After(30 * time.Second):
767 t.Fatal("Ask did not emit AskRequest")
768 }
769 return event.Ask{}
770 }
771
772 func waitAskResult(t *testing.T, done <-chan askCallResult) askCallResult {
773 t.Helper()
774 select {
775 case result := <-done:
776 if result.err != nil {
777 t.Fatalf("Ask: %v", result.err)
778 }
779 return result
780 case <-time.After(30 * time.Second):
781 t.Fatal("Ask stayed blocked")
782 }
783 return askCallResult{}
784 }
785
786 func assertAskAnswers(t *testing.T, got, want []event.AskAnswer) {
787 t.Helper()
788 if len(got) != len(want) {
789 t.Fatalf("answers len = %d, want %d: %#v", len(got), len(want), got)
790 }
791 for i := range want {
792 if got[i].QuestionID != want[i].QuestionID || len(got[i].Selected) != len(want[i].Selected) {
793 t.Fatalf("answers[%d] = %#v, want %#v", i, got[i], want[i])
794 }
795 for j := range want[i].Selected {
796 if got[i].Selected[j] != want[i].Selected[j] {
797 t.Fatalf("answers[%d] = %#v, want %#v", i, got[i], want[i])
798 }
799 }
800 }
801 }
802
803 func TestBypassDoesNotAutoAnswerAsk(t *testing.T) {
804 userAnswers := []event.AskAnswer{
805 {QuestionID: "approach", Selected: []string{"Alternative path"}},
806 {QuestionID: "scope", Selected: []string{"Broad"}},
807 }
808 askCh := make(chan event.Ask, 1)
809 c := newOwnedTestController(t, Options{
810 Sink: event.FuncSink(func(e event.Event) {
811 if e.Kind == event.AskRequest {
812 askCh <- e.Ask
813 }
814 }),
815 })
816 c.SetBypass(true)
817
818 done := askController(t, c, sampleAskQuestions())
819 ask := waitAskRequest(t, askCh)
820
821 // Even with bypass/YOLO on, Ask must wait for the user's non-default choice.
822 c.AnswerQuestion(ask.ID, userAnswers)
823 result := waitAskResult(t, done)
824 assertAskAnswers(t, result.answers, userAnswers)
825 }
826
827 func TestAskPromptsAcrossInteractiveModes(t *testing.T) {
828 userAnswers := []event.AskAnswer{
829 {QuestionID: "approach", Selected: []string{"Alternative path"}},
830 {QuestionID: "scope", Selected: []string{"Broad"}},
831 }
832 tests := []struct {
833 name string
834 setup func(*Controller)
835 }{
836 {name: "normal"},
837 {name: "plan", setup: func(c *Controller) { c.SetMode(true, false) }},
838 {name: "yolo", setup: func(c *Controller) { c.SetMode(false, true) }},
839 }
840
841 for _, tt := range tests {
842 t.Run(tt.name, func(t *testing.T) {
843 askCh := make(chan event.Ask, 1)
844 c := newOwnedTestController(t, Options{
845 Sink: event.FuncSink(func(e event.Event) {
846 if e.Kind == event.AskRequest {
847 askCh <- e.Ask
848 }
849 }),
850 })
851 if tt.setup != nil {
852 tt.setup(c)
853 }
854
855 done := askController(t, c, sampleAskQuestions())
856 ask := waitAskRequest(t, askCh)
857
858 // Answer with non-recommended options to prove this is the user's
859 // selection, not an automatic recommended-option fallback.
860 c.AnswerQuestion(ask.ID, userAnswers)
861 result := waitAskResult(t, done)
862 assertAskAnswers(t, result.answers, userAnswers)
863 })
864 }
865 }
866
867 func TestSetAutoApproveToolsDoesNotDrainPendingAsk(t *testing.T) {
868 askCh := make(chan event.Ask, 1)
869 c := newOwnedTestController(t, Options{
870 Sink: event.FuncSink(func(e event.Event) {
871 if e.Kind == event.AskRequest {
872 askCh <- e.Ask
873 }
874 }),
875 })
876
877 done := askController(t, c, sampleAskQuestions())
878 ask := waitAskRequest(t, askCh)
879
880 c.SetToolApprovalMode(ToolApprovalDangerFullAccess)
881
882 select {
883 case result := <-done:
884 t.Fatalf("SetAutoApproveTools must not answer pending AskRequest; got %#v", result.answers)
885 case <-time.After(50 * time.Millisecond):
886 }
887
888 userAnswers := []event.AskAnswer{
889 {QuestionID: "approach", Selected: []string{"Alternative path"}},
890 {QuestionID: "scope", Selected: []string{"Broad"}},
891 }
892 c.AnswerQuestion(ask.ID, userAnswers)
893 result := waitAskResult(t, done)
894 assertAskAnswers(t, result.answers, userAnswers)
895 }
896
897 func TestDismissedAskCancelsTurnWithoutModelContinuation(t *testing.T) {
898 askCh := make(chan event.Ask, 1)
899 turnDone := make(chan event.Event, 1)
900 c := newOwnedTestController(t, Options{
901 Sink: event.FuncSink(func(e event.Event) {
902 switch e.Kind {
903 case event.AskRequest:
904 askCh <- e.Ask
905 case event.TurnDone:
906 turnDone <- e
907 }
908 }),
909 })
910
911 continued := false
912 if got := c.runGuarded(func(ctx context.Context) error {
913 _, err := c.Ask(ctx, sampleAskQuestions())
914 if err == nil {
915 continued = true
916 }
917 return err
918 }); got != turnStarted {
919 t.Fatalf("runGuarded = %v, want turnStarted", got)
920 }
921 ask := waitAskRequest(t, askCh)
922 c.AnswerQuestion(ask.ID, nil)
923
924 select {
925 case done := <-turnDone:
926 if !done.Cancelled {
927 t.Fatalf("dismissed Ask TurnDone = %+v, want Cancelled", done)
928 }
929 case <-time.After(30 * time.Second):
930 t.Fatal("dismissed Ask did not finish the turn")
931 }
932 if continued {
933 t.Fatal("dismissed Ask returned a model-facing result instead of stopping the turn")
934 }
935 }
936
937 // TestApplyToolApprovalModeDoesNotAuthorizePendingApprovals pins the preset
938 // revision contract: changing the boundary never turns an older prompt into
939 // an authorization. New calls evaluate the new preset from a fresh snapshot.
940 func TestApplyToolApprovalModeDoesNotAuthorizePendingApprovals(t *testing.T) {
941 c := newOwnedTestController(t, Options{
942 Policy: permission.New("ask", nil, []string{"bash(git commit*)"}, nil),
943 })
944
945 autoOKID, autoOKReply := c.approval.register("bash", "go test ./...", "")
946 askRuleID, askRuleReply := c.approval.register("bash", "git commit -m x", "")
947 planID, planReply := c.approval.registerDecision(planApprovalTool, "", "", true, false)
948
949 drained := c.ApplyToolApprovalMode(ToolApprovalAuto)
950 if len(drained) != 0 {
951 t.Fatalf("workspace preset resolved old approvals: %v", drained)
952 }
953 select {
954 case r := <-autoOKReply:
955 t.Fatalf("workspace preset resolved old approval: %+v", r)
956 default:
957 }
958 select {
959 case <-askRuleReply:
960 t.Fatal("explicit ask-rule approval must stay pending under auto")
961 default:
962 }
963
964 drained = c.ApplyToolApprovalMode(ToolApprovalYolo)
965 if len(drained) != 0 {
966 t.Fatalf("full-access preset resolved old approvals: %v", drained)
967 }
968 select {
969 case r := <-askRuleReply:
970 t.Fatalf("full-access preset resolved old approval: %+v", r)
971 default:
972 }
973
974 // The fresh plan decision survives both switches and stays pending.
975 select {
976 case <-planReply:
977 t.Fatal("fresh plan approval must never drain on a posture switch")
978 default:
979 }
980 if !c.approval.hasPending() {
981 t.Fatalf("plan approval %s should still be pending", planID)
982 }
983 // Clean up the synthetic pending approvals without granting them.
984 c.Approve(autoOKID, false, false, false)
985 c.Approve(askRuleID, false, false, false)
986 c.Approve(planID, false, false, false)
987 }
988
988 lines GO