package control import ( "context" "encoding/json" "strings" "testing" "time" "reasonix/internal/agent" "reasonix/internal/event" "reasonix/internal/permission" "reasonix/internal/provider" "reasonix/internal/sandbox" "reasonix/internal/tool" ) // TestAutoApproveToolsStillAutoPlansAndRequiresPlanApproval drives the same // complex request that TestAutoPlanGateEndToEnd uses, but with YOLO/full access // on. Tool auto-approval skips tool approvals, not collaboration gates: a complex // task still drafts a plan and must wait for the user's plan approval. func TestAutoApproveToolsStillAutoPlansAndRequiresPlanApproval(t *testing.T) { prov := &scriptedTurns{turns: [][]provider.Chunk{ textTurn("Plan:\n1. Add the config field\n2. Wire it into boot\n3. Add tests"), textTurn("Done — implemented the approved plan."), }} ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) approvalRequests := make(chan event.Approval, 1) var seeded bool c := New(Options{ AutoPlan: "on", Runner: ag, Executor: ag, Sink: event.FuncSink(func(e event.Event) { switch e.Kind { case event.ApprovalRequest: approvalRequests <- e.Approval case event.ToolDispatch: if e.Tool.ID == "plan-seed" { seeded = true } } }), }) c.SetAutoApproveTools(true) input := "实现 issue #2395:新增配置项、自动判断复杂任务、补测试和文档" done := make(chan error, 1) go func() { done <- c.runTurnWithRaw(context.Background(), input, input) }() var approval event.Approval select { case approval = <-approvalRequests: case <-time.After(30 * time.Second): t.Fatal("tool auto-approval must not suppress plan approval") } if approval.Tool != planApprovalTool { t.Fatalf("approval tool = %q, want %q", approval.Tool, planApprovalTool) } if !c.PlanMode() { t.Fatal("controller should stay in plan mode while waiting for approval") } c.Approve(approval.ID, true, false, false) select { case err := <-done: if err != nil { t.Fatalf("runTurnWithRaw: %v", err) } case <-time.After(30 * time.Second): t.Fatal("approved plan did not continue into execution") } if got := agent.StripTransientUserBlocks(firstUserMessage(ag.Session().Messages)); !strings.HasPrefix(got, PlanModeMarker) { t.Fatalf("first model input = %q, want the auto-plan marker prefixed", got) } if c.PlanMode() { t.Fatal("plan mode should be off after approval") } if !c.AutoApproveTools() { t.Fatal("tool auto-approval should remain on after plan approval") } if !seeded { t.Fatal("approved plan should seed the task list") } if prov.call != 2 { t.Fatalf("provider called %d times, want 2 (plan + execution)", prov.call) } } // TestRequestApprovalHonorsAutoApproveTools guards the underlying gate: ordinary // tool approvals must return allow immediately without emitting anything under // tool auto-approval. func TestRequestApprovalHonorsAutoApproveTools(t *testing.T) { var approvalRequested bool c := New(Options{ Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.ApprovalRequest { approvalRequested = true } }), }) c.SetAutoApproveTools(true) done := make(chan bool, 1) go func() { allow, _, err := c.requestApproval(context.Background(), "multi_edit", "/tmp/file", nil) if err != nil { t.Errorf("requestApproval: %v", err) } done <- allow }() select { case allow := <-done: if !allow { t.Fatal("tool auto-approval should allow the approval") } case <-time.After(30 * time.Second): t.Fatal("requestApproval blocked under tool auto-approval") } if approvalRequested { t.Fatal("tool auto-approval must not emit an ApprovalRequest event") } } func TestMemoryApprovalIgnoresAutoApproveTools(t *testing.T) { approvalRequests := make(chan event.Approval, 1) c := New(Options{ Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.ApprovalRequest { approvalRequests <- e.Approval } }), }) c.SetAutoApproveTools(true) done := make(chan bool, 1) errs := make(chan error, 1) go func() { allow, _, err := c.requestApproval(context.Background(), "remember", "", nil) if err != nil { errs <- err return } done <- allow }() var approval event.Approval select { case approval = <-approvalRequests: case <-time.After(30 * time.Second): t.Fatal("memory approval request was not emitted under tool auto-approval") } if approval.Tool != "remember" { t.Fatalf("approval tool = %q, want remember", approval.Tool) } select { case err := <-errs: t.Fatalf("requestApproval: %v", err) case allow := <-done: t.Fatalf("memory approval must wait for manual approval, got allow=%v", allow) case <-time.After(50 * time.Millisecond): } c.Approve(approval.ID, true, true, true) select { case err := <-errs: t.Fatalf("requestApproval: %v", err) case allow := <-done: if !allow { t.Fatal("manual approval should allow memory write") } case <-time.After(30 * time.Second): t.Fatal("memory approval stayed blocked after Approve") } } func TestToolApprovalModeAutoKeepsAskRules(t *testing.T) { c := New(Options{ Policy: permission.New("ask", nil, []string{"bash(git commit*)"}, []string{"bash(rm*)"}), }) c.SetToolApprovalMode(ToolApprovalAuto) gate := c.newInteractiveGate() if got := gate.Policy.Decide("bash", false, json.RawMessage(`{"command":"go test ./..."}`)); got != permission.Allow { t.Fatalf("auto mode fallback = %v, want allow", got) } if got := gate.Policy.Decide("bash", false, json.RawMessage(`{"command":"git commit -m x"}`)); got != permission.Ask { t.Fatalf("explicit ask rule = %v, want ask", got) } if got := gate.Policy.Decide("bash", false, json.RawMessage(`{"command":"rm -rf build"}`)); got != permission.Deny { t.Fatalf("deny rule = %v, want deny", got) } if c.AutoApproveTools() { t.Fatal("auto approval must not report as YOLO") } } func TestToolApprovalModeAutoForcesMemoryAskRules(t *testing.T) { c := New(Options{}) c.SetToolApprovalMode(ToolApprovalAuto) gate := c.newInteractiveGate() for _, toolName := range []string{"remember", "forget"} { if got := gate.Policy.Decide(toolName, false, json.RawMessage(`{}`)); got != permission.Ask { t.Fatalf("%s under auto mode = %v, want ask", toolName, got) } } } func TestToolApprovalModeYoloForcesMemoryAskRules(t *testing.T) { c := New(Options{}) c.SetToolApprovalMode(ToolApprovalYolo) gate := c.newInteractiveGate() for _, toolName := range []string{"remember", "forget"} { if got := gate.Policy.Decide(toolName, false, json.RawMessage(`{}`)); got != permission.Ask { t.Fatalf("%s under yolo mode = %v, want ask", toolName, got) } } // Verify that regular tools ARE auto-allowed in YOLO (sanity check). if got := gate.Policy.Decide("bash", false, json.RawMessage(`{"command":"go test ./..."}`)); got != permission.Allow { t.Fatalf("regular tool under yolo mode = %v, want allow", got) } } func TestToolApprovalModeAutoDrainsPendingFallbackApproval(t *testing.T) { approvalRequests := make(chan event.Approval, 1) c := New(Options{ Policy: permission.New("ask", nil, nil, nil), Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.ApprovalRequest { approvalRequests <- e.Approval } }), }) done := make(chan bool, 1) errs := make(chan error, 1) go func() { allow, _, err := c.requestApproval(context.Background(), "multi_edit", "/tmp/file", nil) if err != nil { errs <- err return } done <- allow }() select { case <-approvalRequests: case <-time.After(30 * time.Second): t.Fatal("approval request was not emitted") } c.SetToolApprovalMode(ToolApprovalAuto) select { case err := <-errs: t.Fatalf("requestApproval: %v", err) case allow := <-done: if !allow { t.Fatal("pending fallback approval should be allowed when auto approval turns on") } case <-time.After(30 * time.Second): t.Fatal("pending fallback approval stayed blocked after auto approval turned on") } if c.AutoApproveTools() { t.Fatal("auto mode must not report as YOLO") } } func TestToolApprovalModeAutoDoesNotDrainPendingExplicitAsk(t *testing.T) { approvalRequests := make(chan event.Approval, 1) c := New(Options{ Policy: permission.New("ask", nil, []string{"bash(git commit*)"}, nil), Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.ApprovalRequest { approvalRequests <- e.Approval } }), }) done := make(chan bool, 1) errs := make(chan error, 1) go func() { allow, _, err := c.requestApproval(context.Background(), "bash", "git commit -m x", nil) if err != nil { errs <- err return } done <- allow }() var approval event.Approval select { case approval = <-approvalRequests: case <-time.After(30 * time.Second): t.Fatal("approval request was not emitted") } c.SetToolApprovalMode(ToolApprovalAuto) select { case err := <-errs: t.Fatalf("requestApproval: %v", err) case allow := <-done: t.Fatalf("auto mode must not answer explicit ask rules; got allow=%v", allow) case <-time.After(50 * time.Millisecond): } c.Approve(approval.ID, true, false, false) select { case err := <-errs: t.Fatalf("requestApproval: %v", err) case allow := <-done: if !allow { t.Fatal("manual approval should allow the explicit ask request") } case <-time.After(30 * time.Second): t.Fatal("explicit ask approval stayed blocked after manual Approve") } } func TestToolApprovalModeYoloBypassesApprovalPrompts(t *testing.T) { c := New(Options{}) c.SetToolApprovalMode(ToolApprovalYolo) if !c.AutoApproveTools() { t.Fatal("YOLO mode should satisfy legacy AutoApproveTools") } allow, remember, err := c.requestApproval(context.Background(), "bash", "go test ./...", nil) if err != nil || !allow || remember { t.Fatalf("requestApproval in YOLO = (%v,%v,%v), want allow without remember", allow, remember, err) } } func TestPlanApprovalIgnoresAutoApproveTools(t *testing.T) { approvalRequests := make(chan event.Approval, 1) c := New(Options{ Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.ApprovalRequest { approvalRequests <- e.Approval } }), }) c.SetAutoApproveTools(true) done := make(chan bool, 1) errs := make(chan error, 1) go func() { allow, _, err := c.requestApproval(context.Background(), planApprovalTool, "", nil) if err != nil { errs <- err return } done <- allow }() var approval event.Approval select { case approval = <-approvalRequests: case <-time.After(30 * time.Second): t.Fatal("plan approval must still prompt under tool auto-approval") } if approval.Tool != planApprovalTool { t.Fatalf("approval tool = %q, want %q", approval.Tool, planApprovalTool) } select { case allow := <-done: t.Fatalf("plan approval must wait for the user under tool auto-approval; got allow=%v", allow) case err := <-errs: t.Fatalf("requestApproval: %v", err) default: } c.Approve(approval.ID, true, false, false) select { case err := <-errs: t.Fatalf("requestApproval: %v", err) case allow := <-done: if !allow { t.Fatal("manual plan approval should allow") } case <-time.After(30 * time.Second): t.Fatal("plan approval stayed blocked after Approve") } } // TestSetAutoApproveToolsAllowsPendingApproval covers the desktop case where the // approval card is already visible, then the user switches to YOLO/full access. // Turning tool auto-approval on must unblock that pending tool gate too. func TestSetAutoApproveToolsAllowsPendingApproval(t *testing.T) { c, ids, _ := approvalIDs() done := make(chan bool, 1) errs := make(chan error, 1) go func() { allow, _, err := c.requestApproval(context.Background(), "multi_edit", "/tmp/file", nil) if err != nil { errs <- err return } done <- allow }() select { case <-ids: case <-time.After(30 * time.Second): t.Fatal("approval request was not emitted") } c.SetAutoApproveTools(true) select { case err := <-errs: t.Fatalf("requestApproval: %v", err) case allow := <-done: if !allow { t.Fatal("pending approval should be allowed when tool auto-approval turns on") } case <-time.After(30 * time.Second): t.Fatal("pending approval stayed blocked after tool auto-approval turned on") } if !c.AutoApproveTools() { t.Fatal("tool auto-approval should remain on after draining pending approvals") } } func TestSandboxEscapeApprovalIgnoresAutoApproveTools(t *testing.T) { approvalRequests := make(chan event.Approval, 1) c := New(Options{ Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.ApprovalRequest { approvalRequests <- e.Approval } }), }) c.SetAutoApproveTools(true) type escapeResult struct { allow bool reason string err error } done := make(chan escapeResult, 1) go func() { allow, reason, err := sandboxEscapeApprover{c}.ApproveSandboxEscape(context.Background(), sandbox.EscapeRequest{ Command: "go test ./...", Reason: "Windows sandbox failed. Run this command unconfined once?", }) done <- escapeResult{allow: allow, reason: reason, err: err} }() var approval event.Approval select { case approval = <-approvalRequests: case <-time.After(30 * time.Second): t.Fatal("sandbox escape approval request was not emitted") } if approval.Tool != SandboxEscapeApprovalTool { t.Fatalf("approval tool = %q, want %q", approval.Tool, SandboxEscapeApprovalTool) } c.SetAutoApproveTools(true) select { case got := <-done: t.Fatalf("tool auto-approval must not answer sandbox escape; got %+v", got) case <-time.After(50 * time.Millisecond): } c.Approve(approval.ID, true, true, true) select { case got := <-done: if got.err != nil || !got.allow || got.reason != "" { t.Fatalf("sandbox escape result = %+v, want allowed without reason/error", got) } case <-time.After(30 * time.Second): t.Fatal("sandbox escape approval stayed blocked after Approve") } if !(sandboxEscapeApprover{c}).SandboxEscapeSessionAllowed(context.Background(), sandbox.EscapeRequest{Command: "npm test"}) { t.Fatal("sandbox escape session checker = false, want true after session grant") } allow, reason, err := sandboxEscapeApprover{c}.ApproveSandboxEscape(context.Background(), sandbox.EscapeRequest{ Command: "npm test", Reason: "Windows sandbox failed. Run this command unconfined once?", }) if err != nil || !allow || reason != "" { t.Fatalf("sandbox escape session grant result = (%v,%q,%v), want allow", allow, reason, err) } select { case approval := <-approvalRequests: t.Fatalf("sandbox escape session grant emitted another approval: %+v", approval) default: } } func TestSetAutoApproveToolsDoesNotDrainPendingPlanApproval(t *testing.T) { approvalRequests := make(chan event.Approval, 1) c := New(Options{ Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.ApprovalRequest { approvalRequests <- e.Approval } }), }) done := make(chan bool, 1) errs := make(chan error, 1) go func() { allow, _, err := c.requestApproval(context.Background(), planApprovalTool, "", nil) if err != nil { errs <- err return } done <- allow }() var approval event.Approval select { case approval = <-approvalRequests: case <-time.After(30 * time.Second): t.Fatal("plan approval request was not emitted") } c.SetAutoApproveTools(true) select { case err := <-errs: t.Fatalf("requestApproval: %v", err) case allow := <-done: t.Fatalf("SetAutoApproveTools must not auto-answer pending plan approval; got allow=%v", allow) case <-time.After(50 * time.Millisecond): } if !c.AutoApproveTools() { t.Fatal("tool auto-approval should turn on while plan approval stays pending") } c.Approve(approval.ID, true, false, false) select { case err := <-errs: t.Fatalf("requestApproval: %v", err) case allow := <-done: if !allow { t.Fatal("manual plan approval should allow") } case <-time.After(30 * time.Second): t.Fatal("plan approval stayed blocked after Approve") } } func TestSetAutoApproveToolsDoesNotDrainPendingMCPReadOnlyTrust(t *testing.T) { approvalRequests := make(chan event.Approval, 1) c := New(Options{ Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.ApprovalRequest { approvalRequests <- e.Approval } }), }) type trustResult struct { allow bool reason string err error } done := make(chan trustResult, 1) req := agent.PlanModeReadOnlyTrustRequest{ ToolName: "mcp__github__issue_read", ServerName: "github", RawToolName: "issue/read", } go func() { allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req) done <- trustResult{allow: allow, reason: reason, err: err} }() var approval event.Approval select { case approval = <-approvalRequests: case <-time.After(30 * time.Second): t.Fatal("MCP read-only trust approval request was not emitted") } c.SetAutoApproveTools(true) select { case got := <-done: t.Fatalf("SetAutoApproveTools must not auto-answer MCP read-only trust; got %+v", got) case <-time.After(50 * time.Millisecond): } if !c.AutoApproveTools() { t.Fatal("tool auto-approval should turn on while MCP read-only trust stays pending") } c.Approve(approval.ID, true, false, false) select { case got := <-done: if got.err != nil || !got.allow || got.reason != "" { t.Fatalf("manual MCP read-only trust approval = %+v, want allow", got) } case <-time.After(30 * time.Second): t.Fatal("MCP read-only trust approval stayed blocked after Approve") } } func TestSetAutoApproveToolsDoesNotDrainPendingPlanModeReadOnlyCommandTrust(t *testing.T) { approvalRequests := make(chan event.Approval, 1) c := New(Options{ Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.ApprovalRequest { approvalRequests <- e.Approval } }), }) type trustResult struct { allow bool reason string err error } done := make(chan trustResult, 1) req := agent.PlanModeReadOnlyTrustRequest{ ToolName: agent.PlanModeReadOnlyCommandApprovalTool, Command: "gh issue view 5867", Prefix: "gh issue view", } go func() { allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req) done <- trustResult{allow: allow, reason: reason, err: err} }() var approval event.Approval select { case approval = <-approvalRequests: case <-time.After(30 * time.Second): t.Fatal("plan-mode bash read-only command trust approval request was not emitted") } if approval.Tool != agent.PlanModeReadOnlyCommandApprovalTool { t.Fatalf("approval tool = %q, want %q", approval.Tool, agent.PlanModeReadOnlyCommandApprovalTool) } c.SetAutoApproveTools(true) select { case got := <-done: t.Fatalf("SetAutoApproveTools must not auto-answer plan-mode bash read-only command trust; got %+v", got) case <-time.After(50 * time.Millisecond): } if !c.AutoApproveTools() { t.Fatal("tool auto-approval should turn on while plan-mode bash read-only command trust stays pending") } c.Approve(approval.ID, true, false, false) select { case got := <-done: if got.err != nil || !got.allow || got.reason != "" { t.Fatalf("manual plan-mode bash read-only command trust approval = %+v, want allow", got) } case <-time.After(30 * time.Second): t.Fatal("plan-mode bash read-only command trust approval stayed blocked after Approve") } } func TestSetAutoApproveToolsDoesNotDrainPendingMemoryApproval(t *testing.T) { approvalRequests := make(chan event.Approval, 1) c := New(Options{ Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.ApprovalRequest { approvalRequests <- e.Approval } }), }) done := make(chan bool, 1) errs := make(chan error, 1) go func() { allow, _, err := c.requestApproval(context.Background(), "forget", "", nil) if err != nil { errs <- err return } done <- allow }() var approval event.Approval select { case approval = <-approvalRequests: case <-time.After(30 * time.Second): t.Fatal("memory approval request was not emitted") } c.SetAutoApproveTools(true) select { case err := <-errs: t.Fatalf("requestApproval: %v", err) case allow := <-done: t.Fatalf("SetAutoApproveTools must not auto-answer pending memory approval; got allow=%v", allow) case <-time.After(50 * time.Millisecond): } c.Approve(approval.ID, true, true, true) select { case err := <-errs: t.Fatalf("requestApproval: %v", err) case allow := <-done: if !allow { t.Fatal("manual approval should allow memory archive") } case <-time.After(30 * time.Second): t.Fatal("memory approval stayed blocked after Approve") } } // TestSetModeYoloDrainsPendingApproval is the SetMode-path twin of the // SetAutoApproveTools case: applying YOLO atomically must also unblock an // approval already waiting. func TestSetModeYoloDrainsPendingApproval(t *testing.T) { c, ids, _ := approvalIDs() done := make(chan bool, 1) go func() { allow, _, _ := c.requestApproval(context.Background(), "multi_edit", "/tmp/file", nil) done <- allow }() select { case <-ids: case <-time.After(30 * time.Second): t.Fatal("approval request was not emitted") } c.SetMode(false, true) select { case allow := <-done: if !allow { t.Fatal("pending approval should be auto-allowed when SetMode turns YOLO on") } case <-time.After(30 * time.Second): t.Fatal("pending approval stayed blocked after SetMode(false, true)") } } // TestSetModeAppliesBothGates checks SetMode sets plan and tool auto-approval // together so the composer never has to sequence two calls and risk a // half-applied window. func TestSetModeAppliesBothGates(t *testing.T) { c, _, _ := approvalIDs() c.SetMode(true, false) if !c.PlanMode() || c.AutoApproveTools() { t.Fatalf("plan mode: plan=%v autoApproveTools=%v, want true/false", c.PlanMode(), c.AutoApproveTools()) } c.SetMode(false, true) if c.PlanMode() || !c.AutoApproveTools() { t.Fatalf("yolo mode: plan=%v autoApproveTools=%v, want false/true", c.PlanMode(), c.AutoApproveTools()) } c.SetMode(true, true) if !c.PlanMode() || !c.AutoApproveTools() { t.Fatalf("plan + yolo mode: plan=%v autoApproveTools=%v, want true/true", c.PlanMode(), c.AutoApproveTools()) } c.SetMode(false, false) if c.PlanMode() || c.AutoApproveTools() { t.Fatalf("normal mode: plan=%v autoApproveTools=%v, want false/false", c.PlanMode(), c.AutoApproveTools()) } } type askCallResult struct { answers []event.AskAnswer err error } func sampleAskQuestions() []event.AskQuestion { return []event.AskQuestion{ { ID: "approach", Header: "Approach", Prompt: "Which path?", Options: []event.AskOption{ {Label: "Recommended path"}, {Label: "Alternative path"}, }, }, { ID: "scope", Header: "Scope", Prompt: "How broad?", Options: []event.AskOption{ {Label: "Minimal"}, {Label: "Broad"}, }, Multi: true, }, } } func askController(t *testing.T, c *Controller, questions []event.AskQuestion) <-chan askCallResult { t.Helper() done := make(chan askCallResult, 1) go func() { answers, err := c.Ask(context.Background(), questions) done <- askCallResult{answers: answers, err: err} }() return done } func waitAskRequest(t *testing.T, askCh <-chan event.Ask) event.Ask { t.Helper() select { case ask := <-askCh: return ask case <-time.After(30 * time.Second): t.Fatal("Ask did not emit AskRequest") } return event.Ask{} } func waitAskResult(t *testing.T, done <-chan askCallResult) askCallResult { t.Helper() select { case result := <-done: if result.err != nil { t.Fatalf("Ask: %v", result.err) } return result case <-time.After(30 * time.Second): t.Fatal("Ask stayed blocked") } return askCallResult{} } func assertAskAnswers(t *testing.T, got, want []event.AskAnswer) { t.Helper() if len(got) != len(want) { t.Fatalf("answers len = %d, want %d: %#v", len(got), len(want), got) } for i := range want { if got[i].QuestionID != want[i].QuestionID || len(got[i].Selected) != len(want[i].Selected) { t.Fatalf("answers[%d] = %#v, want %#v", i, got[i], want[i]) } for j := range want[i].Selected { if got[i].Selected[j] != want[i].Selected[j] { t.Fatalf("answers[%d] = %#v, want %#v", i, got[i], want[i]) } } } } func TestBypassDoesNotAutoAnswerAsk(t *testing.T) { userAnswers := []event.AskAnswer{ {QuestionID: "approach", Selected: []string{"Alternative path"}}, {QuestionID: "scope", Selected: []string{"Broad"}}, } askCh := make(chan event.Ask, 1) c := New(Options{ Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.AskRequest { askCh <- e.Ask } }), }) c.SetBypass(true) done := askController(t, c, sampleAskQuestions()) ask := waitAskRequest(t, askCh) // Even with bypass/YOLO on, Ask must wait for the user's non-default choice. c.AnswerQuestion(ask.ID, userAnswers) result := waitAskResult(t, done) assertAskAnswers(t, result.answers, userAnswers) } func TestAskPromptsAcrossInteractiveModes(t *testing.T) { userAnswers := []event.AskAnswer{ {QuestionID: "approach", Selected: []string{"Alternative path"}}, {QuestionID: "scope", Selected: []string{"Broad"}}, } tests := []struct { name string setup func(*Controller) }{ {name: "normal"}, {name: "plan", setup: func(c *Controller) { c.SetMode(true, false) }}, {name: "yolo", setup: func(c *Controller) { c.SetMode(false, true) }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { askCh := make(chan event.Ask, 1) c := New(Options{ Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.AskRequest { askCh <- e.Ask } }), }) if tt.setup != nil { tt.setup(c) } done := askController(t, c, sampleAskQuestions()) ask := waitAskRequest(t, askCh) // Answer with non-recommended options to prove this is the user's // selection, not an automatic recommended-option fallback. c.AnswerQuestion(ask.ID, userAnswers) result := waitAskResult(t, done) assertAskAnswers(t, result.answers, userAnswers) }) } } func TestSetAutoApproveToolsDoesNotDrainPendingAsk(t *testing.T) { askCh := make(chan event.Ask, 1) c := New(Options{ Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.AskRequest { askCh <- e.Ask } }), }) done := askController(t, c, sampleAskQuestions()) ask := waitAskRequest(t, askCh) c.SetAutoApproveTools(true) select { case result := <-done: t.Fatalf("SetAutoApproveTools must not answer pending AskRequest; got %#v", result.answers) case <-time.After(50 * time.Millisecond): } userAnswers := []event.AskAnswer{ {QuestionID: "approach", Selected: []string{"Alternative path"}}, {QuestionID: "scope", Selected: []string{"Broad"}}, } c.AnswerQuestion(ask.ID, userAnswers) result := waitAskResult(t, done) assertAskAnswers(t, result.answers, userAnswers) } func TestAskSerializesBehindPromptLockEvenWithAutoApproveTools(t *testing.T) { askCh := make(chan event.Ask, 1) c := New(Options{ Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.AskRequest { askCh <- e.Ask } }), }) questions := []event.AskQuestion{{ ID: "q1", Header: "Choice", Prompt: "Which path?", Options: []event.AskOption{ {Label: "Recommended"}, {Label: "Alternative"}, }, }} c.approval.promptMu.Lock() started := make(chan struct{}) done := make(chan []event.AskAnswer, 1) errs := make(chan error, 1) go func() { close(started) answers, err := c.Ask(context.Background(), questions) if err != nil { errs <- err return } done <- answers }() <-started // Give the goroutine a chance to reach promptMu, then prove it did not emit // AskRequest while another prompt owns the user-decision slot. time.Sleep(20 * time.Millisecond) select { case ask := <-askCh: t.Fatalf("AskRequest emitted while promptMu was held: %#v", ask) default: } // Enable tool auto-approval while Ask is queued behind promptMu. c.SetAutoApproveTools(true) select { case ask := <-askCh: t.Fatalf("tool auto-approval must not let Ask bypass promptMu; got %#v", ask) default: } // Release the lock — Ask proceeds but must still emit an AskRequest. c.approval.promptMu.Unlock() var ask event.Ask select { case err := <-errs: t.Fatalf("Ask: %v", err) case ask = <-askCh: case <-time.After(30 * time.Second): t.Fatal("Ask did not emit AskRequest after acquiring promptMu with tool auto-approval on") } c.AnswerQuestion(ask.ID, []event.AskAnswer{ {QuestionID: "q1", Selected: []string{"Alternative"}}, }) var answers []event.AskAnswer select { case err := <-errs: t.Fatalf("Ask: %v", err) case answers = <-done: case <-time.After(30 * time.Second): t.Fatal("Ask stayed blocked after AnswerQuestion") } if len(answers) != 1 || answers[0].QuestionID != "q1" || len(answers[0].Selected) != 1 || answers[0].Selected[0] != "Alternative" { t.Fatalf("answers = %#v, want Alternative (user's choice, not auto-recommended)", answers) } } func TestAskSerializesBehindPromptLockEvenWithBypass(t *testing.T) { askCh := make(chan event.Ask, 1) c := New(Options{ Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.AskRequest { askCh <- e.Ask } }), }) questions := []event.AskQuestion{{ ID: "q1", Header: "Choice", Prompt: "Which path?", Options: []event.AskOption{ {Label: "Recommended"}, {Label: "Alternative"}, }, }} c.approval.promptMu.Lock() done := make(chan []event.AskAnswer, 1) errs := make(chan error, 1) go func() { answers, err := c.Ask(context.Background(), questions) if err != nil { errs <- err return } done <- answers }() // Give the goroutine a chance to reach promptMu, then prove it did not emit // AskRequest while another prompt owns the user-decision slot. time.Sleep(20 * time.Millisecond) select { case ask := <-askCh: t.Fatalf("AskRequest emitted while promptMu was held: %#v", ask) default: } // Enable bypass while Ask is queued behind promptMu. c.SetBypass(true) // Release the lock — Ask proceeds but must still emit an AskRequest. c.approval.promptMu.Unlock() // Post-unlock assertion: Ask must emit AskRequest now that it holds the lock. var ask event.Ask select { case err := <-errs: t.Fatalf("Ask: %v", err) case ask = <-askCh: case <-time.After(30 * time.Second): t.Fatal("Ask did not emit AskRequest after acquiring promptMu with bypass on; bypass should not suppress ask") } // Answer and verify we get the user's choice. c.AnswerQuestion(ask.ID, []event.AskAnswer{ {QuestionID: "q1", Selected: []string{"Alternative"}}, }) var answers []event.AskAnswer select { case err := <-errs: t.Fatalf("Ask: %v", err) case answers = <-done: case <-time.After(30 * time.Second): t.Fatal("Ask stayed blocked after AnswerQuestion") } if len(answers) != 1 || answers[0].QuestionID != "q1" || len(answers[0].Selected) != 1 || answers[0].Selected[0] != "Alternative" { t.Fatalf("answers = %#v, want Alternative (user's choice, not auto-recommended)", answers) } }