//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) } }