internal/approvalgate/target_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/toolcontract"
)
type recordingTargetResolver struct {
resolution ApprovalTargetResolution
failure error
receivedRequest ApprovalTargetRequest
callCount int
}
func (resolver *recordingTargetResolver) ResolveApprovalTarget(_ context.Context, request ApprovalTargetRequest) (ApprovalTargetResolution, error) {
resolver.receivedRequest = request
resolver.callCount++
if resolver.failure != nil {
return ApprovalTargetResolution{}, resolver.failure
}
return resolver.resolution, nil
}
func resolvedTargetResolver() *recordingTargetResolver {
return &recordingTargetResolver{resolution: ApprovalTargetResolution{Target: ApprovalTarget{
InputField: "eventHint",
ID: "event-1",
Title: "부산 공급사 미팅",
StartsAt: "2026-08-18T14:00:00+09:00",
}}}
}
func unresolvedTargetResolver() *recordingTargetResolver {
return &recordingTargetResolver{resolution: ApprovalTargetResolution{
Failure: toolcontract.ToolFailureWithOutput(
toolcontract.FailureNotFound,
toolcontract.FailureCodes.NotFound,
"target_resolution",
"no calendar event matched eventHint; the candidates list what exists, so use one of them or take another route",
json.RawMessage(`{"errorCode":"calendar_event_hint_unresolved","candidates":[{"eventID":"event-1","title":"부산 공급사 미팅"}]}`),
),
}}
}
func hintApprovalRequest(taskRunID string) mcpserver.ApprovalRequest {
approvalRequest := approvalRequestFixture(taskRunID)
approvalRequest.ToolInput = json.RawMessage(`{"eventHint":"NVIDIA·젯슨 공급 미팅"}`)
approvalRequest.RequesterEmail = "member@example.com"
return approvalRequest
}
func TestAnApprovalQuestionNamesTheTargetTheHintResolvedTo(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
languageModel := &wordingLanguageModel{question: "부산 공급사 미팅 일정을 삭제할까요?"}
gate.UseQuestionWorder(approval.NewWorder(languageModel))
gate.UseApprovalTargetResolver(resolvedTargetResolver())
outcome, errorValue := gate.AwaitApproval(context.Background(), hintApprovalRequest(taskRun.TaskRunID))
if errorValue != nil {
t.Fatalf("expected the gate to answer: %v", errorValue)
}
if outcome.Decision != mcpserver.ApprovalDecisionHeld {
t.Fatalf("a resolved target is still a destructive call the requester decides on, got %+v", outcome)
}
wordingContext := marshalRequestMessages(languageModel.lastRequest)
for _, expectedFragment := range []string{"부산 공급사 미팅", "2026-08-18T14:00:00+09:00"} {
if !strings.Contains(wordingContext, expectedFragment) {
t.Fatalf("the question is worded from the resolved target, expected %q in %s", expectedFragment, wordingContext)
}
}
if !strings.Contains(heldCallEventBodyNamed(t, taskRunService, taskRun.TaskRunID, "confirmation.requested"), "부산 공급사 미팅 일정을 삭제할까요?") {
t.Fatal("the requester is asked about the entity the hint resolved to")
}
}
func TestTheApprovalQuestionDoesNotRepeatTheSearchPhraseWhenATargetResolved(t *testing.T) {
gate, _, taskRun := gateFixture(t)
languageModel := &wordingLanguageModel{question: "부산 공급사 미팅 일정을 삭제할까요?"}
gate.UseQuestionWorder(approval.NewWorder(languageModel))
gate.UseApprovalTargetResolver(resolvedTargetResolver())
gate.AwaitApproval(context.Background(), hintApprovalRequest(taskRun.TaskRunID))
if wordingContext := marshalRequestMessages(languageModel.lastRequest); strings.Contains(wordingContext, "NVIDIA·젯슨 공급 미팅") {
t.Fatalf("the caller's search phrase names nothing once a target resolved, got %s", wordingContext)
}
}
func TestAHintThatResolvesToNothingIsReportedToTheAgentInsteadOfAskedAbout(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
gate.UseQuestionWorder(approval.NewWorder(&wordingLanguageModel{question: "삭제할까요?"}))
gate.UseApprovalTargetResolver(unresolvedTargetResolver())
outcome, errorValue := gate.AwaitApproval(context.Background(), hintApprovalRequest(taskRun.TaskRunID))
if errorValue != nil {
t.Fatalf("expected the gate to answer: %v", errorValue)
}
if outcome.Decision != mcpserver.ApprovalDecisionUnresolvedTarget {
t.Fatalf("a hint that denotes nothing is not a destructive action to approve, got %+v", outcome)
}
if outcome.Failure.Failure == nil || !strings.Contains(outcome.Failure.Failure.UserSafeSummary, "no calendar event matched eventHint") {
t.Fatalf("the agent gets the capability's own failure back, got %+v", outcome.Failure)
}
if !strings.Contains(string(outcome.Failure.Output.Data), "candidates") {
t.Fatalf("the candidates are what let the agent take another route, got %s", outcome.Failure.Output.Data)
}
for _, questionEventName := range []string{"approval.hold_opened", "confirmation.requested", "ask.requested"} {
if hasTaskEventNamed(taskRunService, taskRun.TaskRunID, questionEventName) {
t.Fatalf("nobody is asked about a target that does not exist, got %+v", taskEventNames(taskRunService, taskRun.TaskRunID))
}
}
}
func TestAnUnresolvedTargetLeavesTheTaskRunning(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
gate.UseApprovalTargetResolver(unresolvedTargetResolver())
gate.AwaitApproval(context.Background(), hintApprovalRequest(taskRun.TaskRunID))
pausedTaskRun, _ := taskRunService.FindTaskRun(taskRun.TaskRunID)
if pausedTaskRun.Status == task.TaskStatusWaitingApproval {
t.Fatal("a task with nothing to approve keeps working instead of waiting for an answer nobody was asked for")
}
}
func TestTheHeldCallCarriesBothTheRawHintAndTheIdentityItResolvedTo(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
gate.UseApprovalTargetResolver(resolvedTargetResolver())
gate.AwaitApproval(context.Background(), hintApprovalRequest(taskRun.TaskRunID))
heldCallBody := heldCallEventBody(t, taskRunService, taskRun.TaskRunID)
for _, expectedFragment := range []string{"NVIDIA·젯슨 공급 미팅", "event-1"} {
if !strings.Contains(heldCallBody, expectedFragment) {
t.Fatalf("the ledger shows what was asked and what it narrowed to, expected %q in %s", expectedFragment, heldCallBody)
}
}
}
func TestAnApprovedCallIsCarriedOutAgainstTheResolvedIdentity(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
gate.UseApprovalTargetResolver(resolvedTargetResolver())
gate.AwaitApproval(context.Background(), hintApprovalRequest(taskRun.TaskRunID))
recordDecision(taskRunService, taskRun.TaskRunID, "approve")
approvedCall, isApproved := ApprovedPendingCall(taskRunService.ListTaskEvent(taskRun.TaskRunID))
if !isApproved {
t.Fatal("expected the approved call to be carried out")
}
if string(approvedCall.ToolInput) != `{"eventHint":"event-1"}` {
t.Fatalf("consent binds to the entity the requester saw, not to the phrase, got %s", approvedCall.ToolInput)
}
}
func TestTheApprovalDecisionStillMatchesTheCallTheModelReissuesUnchanged(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
gate.UseApprovalTargetResolver(resolvedTargetResolver())
gate.AwaitApproval(context.Background(), hintApprovalRequest(taskRun.TaskRunID))
recordDecision(taskRunService, taskRun.TaskRunID, "approve")
reissuedOutcome, _ := gate.AwaitApproval(context.Background(), hintApprovalRequest(taskRun.TaskRunID))
if reissuedOutcome.Decision != mcpserver.ApprovalDecisionApproved {
t.Fatalf("the model reissues the call it made, so the recorded decision has to match that call, got %+v", reissuedOutcome)
}
}
func TestAToolWithNoResolvableTargetIsStillAskedAboutFromItsInput(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
gate.UseQuestionWorder(approval.NewWorder(&wordingLanguageModel{question: "메시지를 보낼까요?"}))
gate.UseApprovalTargetResolver(&recordingTargetResolver{})
outcome, _ := gate.AwaitApproval(context.Background(), hintApprovalRequest(taskRun.TaskRunID))
if outcome.Decision != mcpserver.ApprovalDecisionHeld {
t.Fatalf("a tool that resolves nothing ahead keeps the behaviour it has today, got %+v", outcome)
}
if !hasTaskEventNamed(taskRunService, taskRun.TaskRunID, "approval.hold_opened") {
t.Fatalf("expected the call to be held as before, got %+v", taskEventNames(taskRunService, taskRun.TaskRunID))
}
}
func TestAResolverThatCannotBeReachedAsksFromTheInputRatherThanBlockingTheDelete(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
gate.UseQuestionWorder(approval.NewWorder(&wordingLanguageModel{question: "삭제할까요?"}))
gate.UseApprovalTargetResolver(&recordingTargetResolver{failure: errors.New("capabilityd is unreachable")})
outcome, _ := gate.AwaitApproval(context.Background(), hintApprovalRequest(taskRun.TaskRunID))
if outcome.Decision != mcpserver.ApprovalDecisionHeld {
t.Fatalf("a capabilityd hiccup must not block every delete, got %+v", outcome)
}
if !hasTaskEventNamed(taskRunService, taskRun.TaskRunID, "approval.hold_opened") {
t.Fatalf("expected the call to be held from its input as before, got %+v", taskEventNames(taskRunService, taskRun.TaskRunID))
}
}
func TestTheResolverIsAskedAsTheRequesterWhoseCallItIs(t *testing.T) {
gate, _, taskRun := gateFixture(t)
resolver := resolvedTargetResolver()
gate.UseApprovalTargetResolver(resolver)
gate.AwaitApproval(context.Background(), hintApprovalRequest(taskRun.TaskRunID))
if resolver.receivedRequest.RequesterEmail != "member@example.com" {
t.Fatalf("resolution follows the same ownership tiebreak the call itself would, got %+v", resolver.receivedRequest)
}
if resolver.receivedRequest.ToolName != "event_delete" || string(resolver.receivedRequest.ToolInput) != `{"eventHint":"NVIDIA·젯슨 공급 미팅"}` {
t.Fatalf("the resolver is asked about the call the model made, got %+v", resolver.receivedRequest)
}
}
func TestAnUnresolvableTargetStopsTheCallWithoutHoldingIt(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
gate.UseApprovalTargetResolver(unresolvedTargetResolver())
turnGate := gate.TurnGate(TurnContext{RequesterPersonID: "person-1", RequesterEmail: "member@example.com"})
executed, result := invokeThroughGateInContext(t, toolcontract.WithTaskRunID(context.Background(), taskRun.TaskRunID), turnGate, "file_delete")
if len(*executed) != 0 {
t.Fatalf("a call whose target does not exist never runs, it ran %+v", *executed)
}
if result.Failure == nil || !strings.Contains(result.UserSafeFailureSummary(), "no calendar event matched eventHint") {
t.Fatalf("the agent is handed the capability's own failure so it can choose another route, got %+v", result)
}
if hasTaskEventNamed(taskRunService, taskRun.TaskRunID, "approval.hold_opened") {
t.Fatalf("no approval is spent on a target that does not exist, got %+v", taskEventNames(taskRunService, taskRun.TaskRunID))
}
}
func TestAnApprovedCallIsCarriedOutAgainstEveryIdentityAListResolvedTo(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
gate.UseApprovalTargetResolver(&recordingTargetResolver{resolution: ApprovalTargetResolution{Target: ApprovalTarget{
InputField: "personHints",
Title: "박예시, 이샘플",
IDs: []string{"person-yesi", "person-sample"},
}}})
approvalRequest := approvalRequestFixture(taskRun.TaskRunID)
approvalRequest.ToolInput = json.RawMessage(`{"personHints":["예시","샘플"],"message":"안녕"}`)
gate.AwaitApproval(context.Background(), approvalRequest)
recordDecision(taskRunService, taskRun.TaskRunID, "approve")
approvedCall, isApproved := ApprovedPendingCall(taskRunService.ListTaskEvent(taskRun.TaskRunID))
if !isApproved {
t.Fatal("expected the approved call to be carried out")
}
if string(approvedCall.ToolInput) != `{"message":"안녕","personHints":["person-yesi","person-sample"]}` {
t.Fatalf("consent binds to every identity the requester saw, got %s", approvedCall.ToolInput)
}
}
func TestARefusalThatNamesItsTargetIsAskedAboutAsNamed(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
languageModel := &wordingLanguageModel{question: "이 요청을 보낼까요?"}
gate.UseQuestionWorder(approval.NewWorder(languageModel))
resolver := resolvedTargetResolver()
gate.UseApprovalTargetResolver(resolver)
named := ApprovalTarget{InputField: "withheldRequest", ID: `{"requests":[{"method":"POST"}]}`, Title: "POST https://bank.example", Preview: "POST https://bank.example/transfer\nto: 박예시"}
approvalRequest := approvalRequestFixture(taskRun.TaskRunID)
approvalRequest.ToolInput = json.RawMessage(`{"target":"@e1"}`)
outcome, errorValue := gate.AwaitApproval(WithRefusalTarget(context.Background(), named), approvalRequest)
if errorValue != nil || outcome.Decision != mcpserver.ApprovalDecisionHeld {
t.Fatalf("expected the named call to be held, got %+v %v", outcome, errorValue)
}
if resolver.callCount != 0 {
t.Fatal("a refusal that names what it would do is not resolved a second time")
}
if wordingContext := marshalRequestMessages(languageModel.lastRequest); !strings.Contains(wordingContext, "to: 박예시") {
t.Fatalf("the question is worded from the named target, got %s", wordingContext)
}
recordDecision(taskRunService, taskRun.TaskRunID, "approve")
approvedCall, _ := ApprovedPendingCall(taskRunService.ListTaskEvent(taskRun.TaskRunID))
if string(approvedCall.ToolInput) != `{"target":"@e1","withheldRequest":"{\"requests\":[{\"method\":\"POST\"}]}"}` {
t.Fatalf("the approval carries exactly what the refusal named, got %s", approvedCall.ToolInput)
}
}
func messageSendApprovalRequest(taskRunID string) mcpserver.ApprovalRequest {
approvalRequest := approvalRequestFixture(taskRunID)
approvalRequest.ToolName = "message_send"
approvalRequest.ToolInput = json.RawMessage(`{"targetType":"directMessage","personHint":"이샘플","message":"요청하신 PDF입니다"}`)
approvalRequest.RequesterEmail = "sample@example.com"
approvalRequest.SideEffectClass = toolcontract.ToolSideEffectExternalSend
return approvalRequest
}
func recipientResolver(target ApprovalTarget) *recordingTargetResolver {
return &recordingTargetResolver{resolution: ApprovalTargetResolution{Target: target}}
}
func TestASendThatResolvesToTheRequesterAloneRunsWithoutAQuestion(t *testing.T) {
for _, target := range []ApprovalTarget{
{InputField: "personHint", ID: "person-1", Title: "이샘플 <sample@example.com>"},
{InputField: "personHint", ID: "Sample@Example.com", Title: "이샘플 <sample@example.com>"},
{InputField: "personHints", IDs: []string{"person-1"}, Title: "이샘플 <sample@example.com>"},
} {
gate, taskRunService, taskRun := gateFixture(t)
gate.UseApprovalTargetResolver(recipientResolver(target))
outcome, errorValue := gate.AwaitApproval(context.Background(), messageSendApprovalRequest(taskRun.TaskRunID))
if errorValue != nil || outcome.Decision != mcpserver.ApprovalDecisionApproved {
t.Fatalf("a message to the person who asked is no one else's to approve, got %+v %v for %+v", outcome, errorValue, target)
}
for _, taskEvent := range taskRunService.ListTaskEvent(taskRun.TaskRunID) {
if taskEvent.Name == "approval.hold_opened" {
t.Fatalf("nothing is held for a send to the requester, got %s", taskEvent.Body)
}
}
}
}
func TestASendThatReachesAnyoneElseIsStillAskedAbout(t *testing.T) {
for _, target := range []ApprovalTarget{
{InputField: "personHint", ID: "person-2", Title: "박예시 <example@example.com>"},
{InputField: "personHints", IDs: []string{"person-1", "person-2"}, Title: "이샘플; 박예시"},
} {
gate, _, taskRun := gateFixture(t)
gate.UseQuestionWorder(approval.NewWorder(&wordingLanguageModel{question: "박예시에게 보낼까요?"}))
gate.UseApprovalTargetResolver(recipientResolver(target))
outcome, _ := gate.AwaitApproval(context.Background(), messageSendApprovalRequest(taskRun.TaskRunID))
if outcome.Decision != mcpserver.ApprovalDecisionHeld {
t.Fatalf("a send that reaches someone besides the requester is theirs to approve, got %+v for %+v", outcome, target)
}
}
}
func TestOnlyASendIsLetThroughForTheRequester(t *testing.T) {
gate, _, taskRun := gateFixture(t)
gate.UseQuestionWorder(approval.NewWorder(&wordingLanguageModel{question: "계정을 바꿀까요?"}))
gate.UseApprovalTargetResolver(recipientResolver(ApprovalTarget{InputField: "personHint", ID: "person-1"}))
approvalRequest := messageSendApprovalRequest(taskRun.TaskRunID)
approvalRequest.ToolName = "person_update"
approvalRequest.SideEffectClass = toolcontract.ToolSideEffectStateChange
outcome, _ := gate.AwaitApproval(context.Background(), approvalRequest)
if outcome.Decision != mcpserver.ApprovalDecisionHeld {
t.Fatalf("a change to the requester's own record is still asked about, got %+v", outcome)
}
}