internal/approvalgate/turn_gate_test.go
Rama main · réplica de lectura del repositorio original.
//go:build !nobundledharness
package approvalgate
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"github.com/yeomyeonggeori/blueclaw/internal/mcpserver"
"github.com/yeomyeonggeori/blueclaw/internal/task"
"github.com/yeomyeonggeori/blueprotocol/toolcontract"
)
type recordingApprovalGate struct {
mutex sync.Mutex
outcome mcpserver.ApprovalOutcome
requests []mcpserver.ApprovalRequest
}
func (gate *recordingApprovalGate) ReviewToolCall(_ context.Context, toolInvocation toolcontract.ToolInvocation, toolDefinition toolcontract.ToolDefinition) (toolcontract.ToolCallReview, error) {
if !toolDefinition.RequiresApproval {
return toolcontract.ToolCallReview{MayProceed: true}, nil
}
gate.mutex.Lock()
defer gate.mutex.Unlock()
gate.requests = append(gate.requests, mcpserver.ApprovalRequest{
RequesterPersonID: "person-1",
ToolName: toolDefinition.Name,
ToolInput: toolInvocation.Input,
})
switch gate.outcome.Decision {
case mcpserver.ApprovalDecisionApproved:
return toolcontract.ToolCallReview{MayProceed: true}, nil
case mcpserver.ApprovalDecisionRejected:
return toolcontract.ToolCallReview{Result: rejectedCallResult(gate.outcome.Notice)}, nil
}
return toolcontract.ToolCallReview{Result: HeldCallResult(gate.outcome.Notice)}, nil
}
func (gate *recordingApprovalGate) received() []mcpserver.ApprovalRequest {
gate.mutex.Lock()
defer gate.mutex.Unlock()
return append([]mcpserver.ApprovalRequest{}, gate.requests...)
}
func approvalToolSet(t *testing.T, executed *[]string) *toolcontract.ToolSet {
t.Helper()
toolSet := toolcontract.NewToolSet([]string{"file_delete", "file_read"})
toolSet.AllowTestReplacement()
register := func(name string, requiresApproval bool) {
errorValue := toolSet.RegisterTool(toolcontract.ToolDefinition{
ID: "test:" + name,
Name: name,
Description: name,
Visibility: toolcontract.ToolVisibilityModel,
InputSchema: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`),
RequiresApproval: requiresApproval,
SideEffectClass: toolcontract.ToolSideEffectStateChange,
ResultContract: &toolcontract.ToolResultContract{Schema: json.RawMessage(`{"type":"object"}`)},
}, func(_ context.Context, invocation toolcontract.ToolInvocation) (toolcontract.ToolResult, error) {
*executed = append(*executed, invocation.ToolName)
return toolcontract.ToolSuccessData("done", json.RawMessage(`{}`)), nil
})
if errorValue != nil {
t.Fatalf("expected %s to register: %v", name, errorValue)
}
}
register("file_delete", true)
register("file_read", false)
return toolSet
}
func invokeThroughGate(t *testing.T, toolCallGate toolcontract.ToolCallGate, toolName string) (*[]string, toolcontract.ToolResult) {
t.Helper()
return invokeThroughGateInContext(t, context.Background(), toolCallGate, toolName)
}
func invokeThroughGateInContext(t *testing.T, invocationContext context.Context, toolCallGate toolcontract.ToolCallGate, toolName string) (*[]string, toolcontract.ToolResult) {
t.Helper()
executed := []string{}
toolSet := approvalToolSet(t, &executed)
toolSet.UseToolCallGate(toolCallGate)
result, errorValue := toolSet.Invoke(invocationContext, toolcontract.ToolInvocation{
ToolName: toolName,
Input: json.RawMessage(`{"path":"~/notes.md"}`),
})
if errorValue != nil {
t.Fatalf("expected the call to reach the tool set: %v", errorValue)
}
return &executed, result
}
func TestAToolNeedingApprovalDoesNotRunWithoutAGate(t *testing.T) {
var missingGate *Gate
executed, result := invokeThroughGate(t, missingGate.TurnGate(TurnContext{RequesterPersonID: "person-1"}), "file_delete")
if len(*executed) != 0 {
t.Fatalf("expected an approval-gated tool to refuse to run with no gate configured, it ran %+v", *executed)
}
if !result.Failed() {
t.Fatalf("expected the agent to be told the call did not run, got %+v", result)
}
}
func TestAToolNeedingApprovalRunsOnlyOnceApproved(t *testing.T) {
gate := &recordingApprovalGate{outcome: mcpserver.ApprovalOutcome{Decision: mcpserver.ApprovalDecisionApproved}}
executed, result := invokeThroughGate(t, gate, "file_delete")
if len(*executed) != 1 || (*executed)[0] != "file_delete" {
t.Fatalf("expected an approved call to run once, got %+v", *executed)
}
if result.Failed() {
t.Fatalf("expected an approved call to succeed, got %+v", result)
}
received := gate.received()
if len(received) != 1 || received[0].ToolName != "file_delete" {
t.Fatalf("expected the gate to be asked about this call, got %+v", received)
}
if !strings.Contains(string(received[0].ToolInput), "notes.md") {
t.Fatalf("expected the gate to see the exact call it is approving, got %s", received[0].ToolInput)
}
}
func TestAHeldOrRejectedCallNeverRuns(t *testing.T) {
for _, testCase := range []struct {
name string
decision mcpserver.ApprovalDecision
}{
{name: "held", decision: mcpserver.ApprovalDecisionHeld},
{name: "rejected", decision: mcpserver.ApprovalDecisionRejected},
} {
t.Run(testCase.name, func(t *testing.T) {
gate := &recordingApprovalGate{outcome: mcpserver.ApprovalOutcome{Decision: testCase.decision, Notice: "요청자 확인이 필요합니다"}}
executed, result := invokeThroughGate(t, gate, "file_delete")
if len(*executed) != 0 {
t.Fatalf("expected a %s call never to run, it ran %+v", testCase.name, *executed)
}
if !result.Failed() || !strings.Contains(result.UserSafeFailureSummary(), "요청자 확인이 필요합니다") {
t.Fatalf("expected the gate's own wording to reach the agent, got %+v", result)
}
})
}
}
func TestAToolThatNeedsNoApprovalNeverReachesTheGate(t *testing.T) {
gate := &recordingApprovalGate{outcome: mcpserver.ApprovalOutcome{Decision: mcpserver.ApprovalDecisionHeld}}
executed, result := invokeThroughGate(t, gate, "file_read")
if len(*executed) != 1 || result.Failed() {
t.Fatalf("expected an ungated tool to run, got executed=%+v result=%+v", *executed, result)
}
if len(gate.received()) != 0 {
t.Fatalf("expected the gate not to be consulted for a tool that needs no approval, got %+v", gate.received())
}
}
func TestADelegatedTurnIsDeniedRatherThanHeld(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
delegatedContext := toolcontract.WithDelegatedTurn(toolcontract.WithTaskRunID(context.Background(), taskRun.TaskRunID))
executed, result := invokeThroughGateInContext(t, delegatedContext, gate.TurnGate(TurnContext{RequesterPersonID: "person-1", ConversationID: "conversation-1"}), "file_delete")
if len(*executed) != 0 {
t.Fatalf("expected a denied call never to run, it ran %+v", *executed)
}
if !result.Failed() || result.Failure.RequiresApproval {
t.Fatalf("a call the child must stop waiting for is not a call that is still held: %+v", result)
}
currentTaskRun, _ := taskRunService.FindTaskRun(taskRun.TaskRunID)
if currentTaskRun.Status == task.TaskStatusWaitingApproval {
t.Fatal("a run left waiting for an approval nobody was asked for is a run a later approve can hijack")
}
for _, taskEvent := range taskRunService.ListTaskEvent(taskRun.TaskRunID) {
if taskEvent.Name == "approval.hold_opened" {
t.Fatalf("a denied call is not a held call, and the ledger must not offer one to resume: %s", taskEvent.Body)
}
}
}
func TestATopLevelTurnStillHoldsTheCallItNeedsApprovalFor(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
turnContext := toolcontract.WithTaskRunID(context.Background(), taskRun.TaskRunID)
executed, result := invokeThroughGateInContext(t, turnContext, gate.TurnGate(TurnContext{RequesterPersonID: "person-1", ConversationID: "conversation-1"}), "file_delete")
if len(*executed) != 0 {
t.Fatalf("expected a held call never to run, it ran %+v", *executed)
}
if !result.Failed() || !result.Failure.RequiresApproval {
t.Fatalf("the turn the requester is talking to is the one that may ask them: %+v", result)
}
heldTaskRun, _ := taskRunService.FindTaskRun(taskRun.TaskRunID)
if heldTaskRun.Status != task.TaskStatusWaitingApproval {
t.Fatalf("expected the run to wait for the requester, got %q", heldTaskRun.Status)
}
}