internal/approvalgate/permission_asker_test.go
Rama main · réplica de lectura del repositorio original.
//go:build !nobundledharness
package approvalgate
import (
"context"
"testing"
"github.com/yeomyeonggeori/blueclaw/internal/mcpserver"
"github.com/yeomyeonggeori/blueclaw/internal/task"
"github.com/yeomyeonggeori/blueprotocol/agentcontract"
"github.com/yeomyeonggeori/blueprotocol/holdrecord"
)
type scriptedAsker struct {
answer agentcontract.ApprovalSignal
isAnswered bool
askedCount int
}
func (asker *scriptedAsker) AskPermission(context.Context, mcpserver.ApprovalRequest, PermissionQuestion) (ApprovalAnswer, AskStatus) {
asker.askedCount++
return ApprovalAnswer{Signal: asker.answer}, statusOfAnswered(asker.isAnswered)
}
func statusOfAnswered(isAnswered bool) AskStatus {
if isAnswered {
return AskAnswered
}
return AskInterrupted
}
func recordedEventNames(taskRunService *task.TaskRunService, taskRunID string) []string {
names := []string{}
for _, taskEvent := range taskRunService.ListTaskEvent(taskRunID) {
names = append(names, taskEvent.Name)
}
return names
}
func carriesEvent(names []string, wanted string) bool {
for _, name := range names {
if name == wanted {
return true
}
}
return false
}
func taskRunStatus(t *testing.T, taskRunService *task.TaskRunService, taskRunID string) agentcontract.TaskStatus {
t.Helper()
taskRun, isFound := taskRunService.FindTaskRun(taskRunID)
if !isFound {
t.Fatalf("no task run %s", taskRunID)
}
return taskRun.Status
}
func TestAnAnsweredCallRunsInsideTheTurnAndReadsTheSameOnTheLedger(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
asker := &scriptedAsker{answer: agentcontract.ApprovalSignalApprove, isAnswered: true}
gate.UsePermissionAsker(asker)
outcome, errorValue := gate.AwaitApproval(context.Background(), approvalRequestFixture(taskRun.TaskRunID))
if errorValue != nil {
t.Fatalf("await approval: %v", errorValue)
}
if outcome.Decision != mcpserver.ApprovalDecisionApproved {
t.Fatalf("the answered call decided %q, expected approved", outcome.Decision)
}
if asker.askedCount != 1 {
t.Fatalf("the person was asked %d times, expected once", asker.askedCount)
}
names := recordedEventNames(taskRunService, taskRun.TaskRunID)
for _, wanted := range []string{
agentcontract.TaskEventApprovalHoldOpened,
agentcontract.TaskEventConfirmationRequested,
agentcontract.TaskEventApprovalDecided,
agentcontract.TaskEventApprovalHoldSpent,
} {
if !carriesEvent(names, wanted) {
t.Fatalf("the ledger carries %v and not %s, so an acp-answered turn reads differently from a chat-answered one", names, wanted)
}
}
if taskRunStatus(t, taskRunService, taskRun.TaskRunID) == agentcontract.TaskStatusWaitingApproval {
t.Fatal("the run was paused for an approval that had already been answered")
}
if holds := holdrecord.Holds(taskRunService.ListTaskEvent(taskRun.TaskRunID)); len(holds) != 1 || outcome.HoldID != holds[0].ID {
t.Fatalf("the approved call is named by the hold that was answered, got %q for %+v", outcome.HoldID, holds)
}
}
func TestACallNobodyCanBeAskedAboutIsStillHeld(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
gate.UsePermissionAsker(&scriptedAsker{isAnswered: false})
outcome, errorValue := gate.AwaitApproval(context.Background(), approvalRequestFixture(taskRun.TaskRunID))
if errorValue != nil {
t.Fatalf("await approval: %v", errorValue)
}
if outcome.Decision != mcpserver.ApprovalDecisionHeld {
t.Fatalf("the unanswerable call decided %q, expected held", outcome.Decision)
}
if taskRunStatus(t, taskRunService, taskRun.TaskRunID) != agentcontract.TaskStatusWaitingApproval {
t.Fatal("a held call left the run running, so nobody can see it is waiting")
}
}
func TestADeclinedCallIsRejectedAndNotRecordedAsExecuted(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
gate.UsePermissionAsker(&scriptedAsker{answer: agentcontract.ApprovalSignalReject, isAnswered: true})
outcome, errorValue := gate.AwaitApproval(context.Background(), approvalRequestFixture(taskRun.TaskRunID))
if errorValue != nil {
t.Fatalf("await approval: %v", errorValue)
}
if outcome.Decision != mcpserver.ApprovalDecisionRejected {
t.Fatalf("the declined call decided %q, expected rejected", outcome.Decision)
}
if carriesEvent(recordedEventNames(taskRunService, taskRun.TaskRunID), agentcontract.TaskEventApprovalHoldSpent) {
t.Fatal("a declined call was recorded as executed")
}
}
func TestAGateWithNoAskerHoldsTheCallItAlwaysHeld(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
outcome, errorValue := gate.AwaitApproval(context.Background(), approvalRequestFixture(taskRun.TaskRunID))
if errorValue != nil {
t.Fatalf("await approval: %v", errorValue)
}
if outcome.Decision != mcpserver.ApprovalDecisionHeld {
t.Fatalf("the connectors path decided %q, expected held", outcome.Decision)
}
if taskRunStatus(t, taskRunService, taskRun.TaskRunID) != agentcontract.TaskStatusWaitingApproval {
t.Fatal("the connectors path stopped pausing the run it holds")
}
}
type inspectingAsker struct {
inspect func()
statusWhileAsking agentcontract.TaskStatus
eventsWhileAsking []string
}
func (asker *inspectingAsker) AskPermission(context.Context, mcpserver.ApprovalRequest, PermissionQuestion) (ApprovalAnswer, AskStatus) {
asker.inspect()
return ApprovalAnswer{Signal: agentcontract.ApprovalSignalApprove}, AskAnswered
}
func TestTheRunWaitsAndTheCallIsHeldWhileTheQuestionIsStillOutstanding(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
asker := &inspectingAsker{}
asker.inspect = func() {
asker.statusWhileAsking = taskRunStatus(t, taskRunService, taskRun.TaskRunID)
asker.eventsWhileAsking = recordedEventNames(taskRunService, taskRun.TaskRunID)
}
gate.UsePermissionAsker(asker)
if _, errorValue := gate.AwaitApproval(context.Background(), approvalRequestFixture(taskRun.TaskRunID)); errorValue != nil {
t.Fatalf("await approval: %v", errorValue)
}
if asker.statusWhileAsking != agentcontract.TaskStatusWaitingApproval {
t.Fatalf("the run read %q while the person was being asked, so a restart here finds nothing waiting", asker.statusWhileAsking)
}
if !carriesEvent(asker.eventsWhileAsking, agentcontract.TaskEventApprovalHoldOpened) {
t.Fatalf("the ledger carried %v while the person was being asked, so a restart here cannot say which call was held", asker.eventsWhileAsking)
}
}
type questionKeepingAsker struct {
question PermissionQuestion
}
func (asker *questionKeepingAsker) AskPermission(_ context.Context, _ mcpserver.ApprovalRequest, question PermissionQuestion) (ApprovalAnswer, AskStatus) {
asker.question = question
return ApprovalAnswer{Signal: agentcontract.ApprovalSignalApprove}, AskAnswered
}
func TestAHandoffTheRefusalNamesIsAskedAndKeptAsThatHandoff(t *testing.T) {
gate, taskRunService, taskRun := gateFixture(t)
asker := &questionKeepingAsker{}
gate.UsePermissionAsker(asker)
named := ApprovalTarget{InputField: "handoff", ID: "handoff-1", Title: "https://portal.example.com", HandoffID: "handoff-1"}
if _, errorValue := gate.AwaitApproval(WithRefusalTarget(context.Background(), named), approvalRequestFixture(taskRun.TaskRunID)); errorValue != nil {
t.Fatalf("await approval: %v", errorValue)
}
if asker.question.HandoffID != "handoff-1" {
t.Fatalf("the asker must know the question is a handoff, got %+v", asker.question)
}
holds := holdrecord.Holds(taskRunService.ListTaskEvent(taskRun.TaskRunID))
if len(holds) != 1 || holds[0].Call.HandoffID != "handoff-1" {
t.Fatalf("the ledger must keep the handoff so a restart can reopen it, got %+v", holds)
}
}