Ctrl K
ComunidadGuías…

internal/approvalgate/turn_gate_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//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)
	}
}