Ctrl K
ComunidadGuías…

internal/approvalgate/target_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/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)
	}
}