internal/approvalgate/gate_test.go
Rama main · réplica de lectura del repositorio original.
//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)
}
}