Ctrl K
ComunidadGuías…

internal/approvalgate/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"
	"errors"
	"strings"
	"testing"

	"github.com/yeomyeonggeori/blueclaw/internal/mcpserver"
	"github.com/yeomyeonggeori/blueclaw/internal/task"
	"github.com/yeomyeonggeori/bluecollar/approval"
	"github.com/yeomyeonggeori/blueprotocol/agentcontract"
	"github.com/yeomyeonggeori/blueprotocol/holdrecord"
	"github.com/yeomyeonggeori/blueprotocol/model"
	"github.com/yeomyeonggeori/blueprotocol/toolcontract"
)

func gateFixture(t *testing.T) (*Gate, *task.TaskRunService, task.TaskRun) {
	t.Helper()
	taskRunService := task.NewTaskRunService(task.NewTaskEventService())
	taskRun := taskRunService.CreateTaskRun("person-1", "conversation-1", "내일 회의 지워줘")
	gate := New(taskRunService)
	gate.UsePermissionAsker(interruptedAsker{})
	return gate, taskRunService, taskRun
}

type interruptedAsker struct{}

func (interruptedAsker) AskPermission(context.Context, mcpserver.ApprovalRequest, PermissionQuestion) (ApprovalAnswer, AskStatus) {
	return ApprovalAnswer{}, AskInterrupted
}

func approvalRequestFixture(taskRunID string) mcpserver.ApprovalRequest {
	return mcpserver.ApprovalRequest{
		RequesterPersonID: "person-1",
		TaskRunID:         taskRunID,
		ToolName:          "event_delete",
		ToolInput:         json.RawMessage(`{"eventID":"event-1"}`),
		HarnessSession:    mcpserver.HarnessSession{HarnessName: "claude-code", SessionID: "session-uuid", IsResumable: true},
	}
}

func heldCallEventBody(t *testing.T, taskRunService *task.TaskRunService, taskRunID string) string {
	t.Helper()
	for _, taskEvent := range taskRunService.ListTaskEvent(taskRunID) {
		if taskEvent.Name == "approval.hold_opened" {
			return taskEvent.Body
		}
	}
	t.Fatal("expected a held call to be recorded")
	return ""
}

func TestAHeldCallIsRecordedWithTheConversationItWasHeldIn(t *testing.T) {
	gate, taskRunService, taskRun := gateFixture(t)

	outcome, errorValue := gate.AwaitApproval(context.Background(), approvalRequestFixture(taskRun.TaskRunID))
	if errorValue != nil {
		t.Fatalf("expected the gate to answer: %v", errorValue)
	}
	if outcome.Decision != mcpserver.ApprovalDecisionHeld {
		t.Fatalf("expected the call to be held, got %+v", outcome)
	}

	body := heldCallEventBody(t, taskRunService, taskRun.TaskRunID)
	for _, expectedFragment := range []string{"event_delete", "event-1", "claude-code", "session-uuid"} {
		if !strings.Contains(body, expectedFragment) {
			t.Fatalf("expected the held call to carry %q so it can be resumed, got %s", expectedFragment, body)
		}
	}
	pausedTaskRun, _ := taskRunService.FindTaskRun(taskRun.TaskRunID)
	if pausedTaskRun.Status != task.TaskStatusWaitingApproval {
		t.Fatalf("expected the task run to wait for the requester, got %q", pausedTaskRun.Status)
	}
}

func TestACallWithNoTaskRunToAnswerOnIsUnanswerableRatherThanHeld(t *testing.T) {
	taskRunService := task.NewTaskRunService(task.NewTaskEventService())
	gate := New(taskRunService)

	outcome, errorValue := gate.AwaitApproval(context.Background(), approvalRequestFixture(""))
	if errorValue != nil {
		t.Fatalf("expected the gate to answer: %v", errorValue)
	}
	if outcome.Decision != mcpserver.ApprovalDecisionUnanswerable {
		t.Fatalf("a call nobody can be asked about is not waiting for an answer, got %+v", outcome)
	}
}

func recordDecision(taskRunService *task.TaskRunService, taskRunID string, decision string) {
	SettleLatest(taskRunService, taskRunID, decision, "test")
}

func TestTheSameCallRunsOnceTheRequesterHasApprovedIt(t *testing.T) {
	gate, taskRunService, taskRun := gateFixture(t)
	heldOutcome, _ := gate.AwaitApproval(context.Background(), approvalRequestFixture(taskRun.TaskRunID))
	if heldOutcome.Decision != mcpserver.ApprovalDecisionHeld {
		t.Fatalf("expected the first call to be held, got %+v", heldOutcome)
	}

	recordDecision(taskRunService, taskRun.TaskRunID, holdrecord.DecisionApprove)

	approvedOutcome, _ := gate.AwaitApproval(context.Background(), approvalRequestFixture(taskRun.TaskRunID))
	if approvedOutcome.Decision != mcpserver.ApprovalDecisionApproved {
		t.Fatalf("expected the approved call to run when the agent reissues it, got %+v", approvedOutcome)
	}
}

func TestAnApprovalIsSpentOnTheCallItAnsweredAndNotTheNextOne(t *testing.T) {
	gate, taskRunService, taskRun := gateFixture(t)
	gate.AwaitApproval(context.Background(), approvalRequestForEvent(taskRun.TaskRunID, `{"eventID":"event-1"}`))
	recordDecision(taskRunService, taskRun.TaskRunID, holdrecord.DecisionApprove)
	gate.AwaitApproval(context.Background(), approvalRequestForEvent(taskRun.TaskRunID, `{"eventID":"event-1"}`))

	repeatedOutcome, _ := gate.AwaitApproval(context.Background(), approvalRequestForEvent(taskRun.TaskRunID, `{"eventID":"event-1"}`))
	if repeatedOutcome.Decision == mcpserver.ApprovalDecisionApproved {
		t.Fatal("expected one approval to authorise one call, so a second identical call is asked about again")
	}
}

func approvalRequestForEvent(taskRunID string, toolInput string) mcpserver.ApprovalRequest {
	approvalRequest := approvalRequestFixture(taskRunID)
	approvalRequest.ToolInput = json.RawMessage(toolInput)
	return approvalRequest
}

func TestAnApprovalDoesNotCarryOverToACallTheRequesterNeverSaw(t *testing.T) {
	gate, taskRunService, taskRun := gateFixture(t)
	gate.AwaitApproval(context.Background(), approvalRequestForEvent(taskRun.TaskRunID, `{"eventID":"event-1"}`))
	recordDecision(taskRunService, taskRun.TaskRunID, holdrecord.DecisionApprove)

	substitutedOutcome, _ := gate.AwaitApproval(context.Background(), approvalRequestForEvent(taskRun.TaskRunID, `{"eventID":"event-2"}`))
	if substitutedOutcome.Decision == mcpserver.ApprovalDecisionApproved {
		t.Fatalf("expected approving one call to authorise that call alone, so a substituted target is asked about again, got %+v", substitutedOutcome)
	}
}

func TestAnApprovedCallIsStillRecognisedWhenTheAgentReordersItsInput(t *testing.T) {
	gate, taskRunService, taskRun := gateFixture(t)
	gate.AwaitApproval(context.Background(), approvalRequestForEvent(taskRun.TaskRunID, `{"eventID":"event-1","calendarID":"team"}`))
	recordDecision(taskRunService, taskRun.TaskRunID, holdrecord.DecisionApprove)

	reorderedOutcome, _ := gate.AwaitApproval(context.Background(), approvalRequestForEvent(taskRun.TaskRunID, `{"calendarID":"team","eventID":"event-1"}`))
	if reorderedOutcome.Decision != mcpserver.ApprovalDecisionApproved {
		t.Fatalf("expected the same call to be recognised through a reordered input rather than asked about twice, got %+v", reorderedOutcome)
	}
}

func TestAFreshCallAfterARejectionAsksAgain(t *testing.T) {
	gate, taskRunService, taskRun := gateFixture(t)
	gate.AwaitApproval(context.Background(), approvalRequestFixture(taskRun.TaskRunID))
	recordDecision(taskRunService, taskRun.TaskRunID, holdrecord.DecisionReject)

	freshOutcome, _ := gate.AwaitApproval(context.Background(), approvalRequestFixture(taskRun.TaskRunID))

	if freshOutcome.Decision != mcpserver.ApprovalDecisionHeld {
		t.Fatalf("a rejection answered the call it was given for and no other, got %+v", freshOutcome)
	}
}

func TestAReloadCancellationDoesNotAnswerALaterIdenticalCall(t *testing.T) {
	gate, taskRunService, taskRun := gateFixture(t)
	gate.AwaitApproval(context.Background(), approvalRequestFixture(taskRun.TaskRunID))
	SettleLatest(taskRunService, taskRun.TaskRunID, holdrecord.DecisionReject, "acp_permission_reload")

	freshOutcome, _ := gate.AwaitApproval(context.Background(), approvalRequestFixture(taskRun.TaskRunID))

	if freshOutcome.Decision != mcpserver.ApprovalDecisionHeld {
		t.Fatalf("a cancellation recorded by a reload refused a fresh call without asking, got %+v", freshOutcome)
	}
	if pendingCallCount(taskRunService, taskRun.TaskRunID) != 2 {
		t.Fatalf("the fresh call was not held on its own, got %v", recordedEventNames(taskRunService, taskRun.TaskRunID))
	}
}

func TestAnApprovalIsSpentByTheCallItAnswered(t *testing.T) {
	gate, taskRunService, taskRun := gateFixture(t)
	gate.AwaitApproval(context.Background(), approvalRequestForEvent(taskRun.TaskRunID, `{"eventID":"event-1"}`))
	recordDecision(taskRunService, taskRun.TaskRunID, holdrecord.DecisionApprove)

	firstOutcome, _ := gate.AwaitApproval(context.Background(), approvalRequestForEvent(taskRun.TaskRunID, `{"eventID":"event-1"}`))
	secondOutcome, _ := gate.AwaitApproval(context.Background(), approvalRequestForEvent(taskRun.TaskRunID, `{"eventID":"event-1"}`))

	if firstOutcome.Decision != mcpserver.ApprovalDecisionApproved || secondOutcome.Decision != mcpserver.ApprovalDecisionHeld {
		t.Fatalf("one approval covers one call, got %q then %q", firstOutcome.Decision, secondOutcome.Decision)
	}
}

func pendingCallCount(taskRunService *task.TaskRunService, taskRunID string) int {
	count := 0
	for _, eventName := range recordedEventNames(taskRunService, taskRunID) {
		if eventName == agentcontract.TaskEventApprovalHoldOpened {
			count++
		}
	}
	return count
}

func heldCallEventBodyNamed(t *testing.T, taskRunService *task.TaskRunService, taskRunID string, eventName string) string {
	t.Helper()
	for _, taskEvent := range taskRunService.ListTaskEvent(taskRunID) {
		if taskEvent.Name == eventName {
			return taskEvent.Body
		}
	}
	t.Fatalf("expected a %s event to be recorded, got %+v", eventName, taskRunService.ListTaskEvent(taskRunID))
	return ""
}

func TestAHeldCallTellsTheRequesterWhatTheyAreBeingAskedAbout(t *testing.T) {
	gate, taskRunService, taskRun := gateFixture(t)
	approvalRequest := approvalRequestFixture(taskRun.TaskRunID)
	approvalRequest.ResponseLanguage = "ko"
	approvalRequest.SideEffectClass = "external_send"

	gate.AwaitApproval(context.Background(), approvalRequest)

	confirmationBody := heldCallEventBodyNamed(t, taskRunService, taskRun.TaskRunID, "confirmation.requested")
	for _, expectedFragment := range []string{"userFacingMessage", "responseLanguage", "external_send"} {
		if !strings.Contains(confirmationBody, expectedFragment) {
			t.Fatalf("the connector reads this event to ask the requester, expected %q in %s", expectedFragment, confirmationBody)
		}
	}
}

type wordingLanguageModel struct {
	question    string
	failure     error
	lastRequest model.StructuredResponseRequest
}

func (languageModel *wordingLanguageModel) GenerateResponse(context.Context, string) (string, error) {
	return "", errors.New("the approval gate only asks for structured output")
}

func (languageModel *wordingLanguageModel) GenerateStructuredResponse(_ context.Context, request model.StructuredResponseRequest) (model.StructuredResponse, error) {
	languageModel.lastRequest = request
	if languageModel.failure != nil {
		return model.StructuredResponse{}, languageModel.failure
	}
	return model.StructuredResponse{Content: `{"question":"` + languageModel.question + `"}`}, nil
}

func TestTheRequesterIsAskedInWordsTheModelChose(t *testing.T) {
	gate, taskRunService, taskRun := gateFixture(t)
	languageModel := &wordingLanguageModel{question: "내일 팀 회의를 캘린더에서 지울까요?"}
	gate.UseQuestionWorder(approval.NewWorder(languageModel))

	gate.AwaitApproval(context.Background(), approvalRequestFixture(taskRun.TaskRunID))

	if !strings.Contains(heldCallEventBodyNamed(t, taskRunService, taskRun.TaskRunID, "confirmation.requested"), "내일 팀 회의를 캘린더에서 지울까요?") {
		t.Fatal("the requester has to be asked in words a model wrote, not in a sentence assembled from a tool name")
	}
	if !strings.Contains(marshalRequestMessages(languageModel.lastRequest), "event_delete") {
		t.Fatalf("the model needs the pending call to word the question, got %s", marshalRequestMessages(languageModel.lastRequest))
	}
}

func TestAnUnwordableCallStillReachesTheRequesterAsTheCallItself(t *testing.T) {
	gate, taskRunService, taskRun := gateFixture(t)
	gate.UseQuestionWorder(approval.NewWorder(&wordingLanguageModel{failure: errors.New("the language model is unreachable")}))

	heldOutcome, _ := gate.AwaitApproval(context.Background(), approvalRequestFixture(taskRun.TaskRunID))

	if heldOutcome.Decision != mcpserver.ApprovalDecisionHeld {
		t.Fatalf("a call nobody could word still has to be held rather than run, got %+v", heldOutcome)
	}
	confirmationBody := heldCallEventBodyNamed(t, taskRunService, taskRun.TaskRunID, "confirmation.requested")
	for _, expectedFragment := range []string{"event_delete", "event-1"} {
		if !strings.Contains(confirmationBody, expectedFragment) {
			t.Fatalf("with no wording the requester gets the raw call, expected %q in %s", expectedFragment, confirmationBody)
		}
	}
}

func TestAnUnwordableCallRecordsWhyNoModelWordedIt(t *testing.T) {
	gate, taskRunService, taskRun := gateFixture(t)
	gate.UseQuestionWorder(approval.NewWorder(&wordingLanguageModel{failure: errors.New("the language model is unreachable")}))

	gate.AwaitApproval(context.Background(), approvalRequestFixture(taskRun.TaskRunID))

	failureBody := heldCallEventBodyNamed(t, taskRunService, taskRun.TaskRunID, TaskEventApprovalWordingFailed)
	for _, expectedFragment := range []string{"event_delete", "the language model is unreachable"} {
		if !strings.Contains(failureBody, expectedFragment) {
			t.Fatalf("a requester asked in a raw call must leave the reason no model worded it, expected %q in %s", expectedFragment, failureBody)
		}
	}
}

func marshalRequestMessages(request model.StructuredResponseRequest) string {
	messages := []string{}
	for _, message := range request.Messages {
		messages = append(messages, message.Content)
	}
	return strings.Join(messages, "\n")
}

func taskEventNames(taskRunService *task.TaskRunService, taskRunID string) []string {
	names := []string{}
	for _, taskEvent := range taskRunService.ListTaskEvent(taskRunID) {
		names = append(names, taskEvent.Name)
	}
	return names
}

func hasTaskEventNamed(taskRunService *task.TaskRunService, taskRunID string, eventName string) bool {
	for _, name := range taskEventNames(taskRunService, taskRunID) {
		if name == eventName {
			return true
		}
	}
	return false
}

func TestAHeldCallIsRecordedOnTheTaskRunTheCallIsRunningIn(t *testing.T) {
	gate, taskRunService, abandonedTaskRun := gateFixture(t)
	runningTaskRun := taskRunService.CreateTaskRun("person-1", "conversation-1", "다시 해봐")
	turnGate := gate.TurnGate(TurnContext{RequesterPersonID: "person-1"})

	invokeThroughGateInContext(t, toolcontract.WithTaskRunID(context.Background(), runningTaskRun.TaskRunID), turnGate, "file_delete")

	for _, expectedEventName := range []string{"approval.hold_opened", "confirmation.requested", "ask.requested"} {
		if !hasTaskEventNamed(taskRunService, runningTaskRun.TaskRunID, expectedEventName) {
			t.Fatalf("expected %q on the run the call is executing in, got %+v", expectedEventName, taskEventNames(taskRunService, runningTaskRun.TaskRunID))
		}
		if hasTaskEventNamed(taskRunService, abandonedTaskRun.TaskRunID, expectedEventName) {
			t.Fatalf("expected %q never to reach the run the turn left behind, got %+v", expectedEventName, taskEventNames(taskRunService, abandonedTaskRun.TaskRunID))
		}
	}
	parkedTaskRun, _ := taskRunService.FindTaskRun(runningTaskRun.TaskRunID)
	if parkedTaskRun.Status != task.TaskStatusWaitingApproval {
		t.Fatalf("expected the run the call is executing in to be the one parked, got %q", parkedTaskRun.Status)
	}
	untouchedTaskRun, _ := taskRunService.FindTaskRun(abandonedTaskRun.TaskRunID)
	if untouchedTaskRun.Status == task.TaskStatusWaitingApproval {
		t.Fatal("expected the run the turn left behind never to be parked, since nothing will ever deliver its question")
	}
}

func TestACallCarryingNoTaskRunParksNothingAtAll(t *testing.T) {
	gate, taskRunService, taskRun := gateFixture(t)
	turnGate := gate.TurnGate(TurnContext{RequesterPersonID: "person-1"})

	executed, result := invokeThroughGateInContext(t, context.Background(), turnGate, "file_delete")

	if len(*executed) != 0 || !result.Failed() {
		t.Fatalf("expected a call nobody can be asked about never to run, got executed=%+v result=%+v", *executed, result)
	}
	if hasTaskEventNamed(taskRunService, taskRun.TaskRunID, "approval.hold_opened") {
		t.Fatalf("expected a call with no run of its own to reach no run at all, got %+v", taskEventNames(taskRunService, taskRun.TaskRunID))
	}
}

func TestARunThatCannotBeParkedIsNeverToldItWasAsked(t *testing.T) {
	gate, taskRunService, taskRun := gateFixture(t)
	if _, errorValue := taskRunService.CompleteTaskRun(taskRun.TaskRunID, "done"); errorValue != nil {
		t.Fatalf("expected the fixture run to complete: %v", errorValue)
	}

	outcome, errorValue := gate.AwaitApproval(context.Background(), approvalRequestFixture(taskRun.TaskRunID))
	if errorValue != nil {
		t.Fatalf("expected the gate to answer: %v", errorValue)
	}

	if outcome.Decision != mcpserver.ApprovalDecisionUnanswerable {
		t.Fatalf("a call nobody can be asked about is not waiting for an answer, got %+v", outcome)
	}
	for _, questionEventName := range []string{"approval.hold_opened", "confirmation.requested", "ask.requested"} {
		if hasTaskEventNamed(taskRunService, taskRun.TaskRunID, questionEventName) {
			t.Fatalf("a question that was never asked leaves no record saying it was, got %+v", taskEventNames(taskRunService, taskRun.TaskRunID))
		}
	}
	unparkedTaskRun, _ := taskRunService.FindTaskRun(taskRun.TaskRunID)
	if unparkedTaskRun.Status == task.TaskStatusWaitingApproval {
		t.Fatalf("expected a run that could not be parked never to report waiting, got %q", unparkedTaskRun.Status)
	}
}