//go:build !nobundledharness package approvalgate import ( "context" "encoding/json" "testing" "time" "github.com/yeomyeonggeori/blueclaw/internal/mcpserver" "github.com/yeomyeonggeori/blueclaw/internal/task" "github.com/yeomyeonggeori/blueprotocol/agentcontract" "github.com/yeomyeonggeori/blueprotocol/holdrecord" "github.com/yeomyeonggeori/blueprotocol/toolcontract" ) const laterStartsAt = "2099-10-03T03:00:00+09:00" type choosingAsker struct { answer ApprovalAnswer offeredChoices []holdrecord.Choice offeredQuestion string } func (asker *choosingAsker) AskPermission(_ context.Context, _ mcpserver.ApprovalRequest, question PermissionQuestion) (ApprovalAnswer, AskStatus) { asker.offeredChoices = question.Choices asker.offeredQuestion = question.Confirmation return asker.answer, AskAnswered } type recordingScheduler struct { requests []task.ApprovedCallScheduleRequest } func (scheduler *recordingScheduler) ScheduleApprovedCall(_ context.Context, request task.ApprovedCallScheduleRequest) (task.Schedule, error) { scheduler.requests = append(scheduler.requests, request) schedule, errorValue := task.BuildApprovedCallSchedule(request) schedule.ScheduleID = "schedule-1" return schedule, errorValue } func (scheduler *recordingScheduler) ScheduleByID(string) (task.Schedule, bool, error) { return task.Schedule{}, false, nil } func hostUpdateRequest(taskRunID string) mcpserver.ApprovalRequest { return mcpserver.ApprovalRequest{ RequesterPersonID: "person-admin", TaskRunID: taskRunID, ToolName: "host_update", ToolInput: json.RawMessage(`{}`), Prompt: "update yourself", Platform: "buzz", ConversationID: "conversation-1", ReplyTargetID: "message-1", } } func choiceResolver() *recordingTargetResolver { return &recordingTargetResolver{resolution: ApprovalTargetResolution{ Target: ApprovalTarget{InputField: "targetVersion", ID: "v2026.10.02.090000", Title: "v2026.10.01.203142 → v2026.10.02.090000"}, Choices: []holdrecord.Choice{ {Key: "offHours", StartsAt: laterStartsAt}, {Key: "now"}, }, }} } func choiceGate(t *testing.T, answer ApprovalAnswer) (*Gate, *task.TaskRunService, task.TaskRun, *choosingAsker, *recordingScheduler) { t.Helper() gate, taskRunService, taskRun := gateFixture(t) gate.UseApprovalTargetResolver(choiceResolver()) asker := &choosingAsker{answer: answer} gate.UsePermissionAsker(asker) scheduler := &recordingScheduler{} gate.UseSchedules(scheduler) return gate, taskRunService, taskRun, asker, scheduler } func TestTheRequesterIsOfferedEveryChoiceTheTargetCarriesInItsOrder(t *testing.T) { gate, taskRunService, taskRun, asker, _ := choiceGate(t, ApprovalAnswer{Signal: agentcontract.ApprovalSignalApprove, ChoiceKey: "now"}) if _, errorValue := gate.AwaitApproval(context.Background(), hostUpdateRequest(taskRun.TaskRunID)); errorValue != nil { t.Fatalf("await approval: %v", errorValue) } if len(asker.offeredChoices) != 2 || asker.offeredChoices[0].Key != "offHours" || asker.offeredChoices[1].Key != "now" { t.Fatalf("the requester was offered %+v, expected the later time first and now second", asker.offeredChoices) } recorded := OfferedChoices(taskRunService.ListTaskEvent(taskRun.TaskRunID)) if len(recorded) != 2 || recorded[0].StartsAt != laterStartsAt { t.Fatalf("the ledger kept %+v, so a reply read after a restart is read against nothing", recorded) } } func TestChoosingNowRunsTheCallInsideTheTurn(t *testing.T) { gate, _, taskRun, _, scheduler := choiceGate(t, ApprovalAnswer{Signal: agentcontract.ApprovalSignalApprove, ChoiceKey: "now"}) outcome, errorValue := gate.AwaitApproval(context.Background(), hostUpdateRequest(taskRun.TaskRunID)) if errorValue != nil { t.Fatalf("await approval: %v", errorValue) } if outcome.Decision != mcpserver.ApprovalDecisionApproved { t.Fatalf("choosing now decided %q, expected approved", outcome.Decision) } if len(scheduler.requests) != 0 { t.Fatalf("choosing now also scheduled %d calls", len(scheduler.requests)) } } func TestChoosingALaterTimeBindsTheApprovalToThatCallAndThatRequester(t *testing.T) { gate, taskRunService, taskRun, _, scheduler := choiceGate(t, ApprovalAnswer{Signal: agentcontract.ApprovalSignalApprove, ChoiceKey: "offHours"}) outcome, errorValue := gate.AwaitApproval(context.Background(), hostUpdateRequest(taskRun.TaskRunID)) if errorValue != nil { t.Fatalf("await approval: %v", errorValue) } if outcome.Decision != mcpserver.ApprovalDecisionDeferred { t.Fatalf("choosing a later time decided %q, expected deferred", outcome.Decision) } if len(scheduler.requests) != 1 { t.Fatalf("choosing a later time scheduled %d calls, expected one", len(scheduler.requests)) } scheduled := scheduler.requests[0] if scheduled.Call.ToolName != "host_update" || scheduled.ApproverPersonID != "person-admin" { t.Fatalf("the schedule carries %+v, expected host_update approved by person-admin", scheduled.Call) } if string(scheduled.Call.ToolInput) != `{"targetVersion":"v2026.10.02.090000"}` { t.Fatalf("the schedule carries input %s, expected the version the requester was shown", scheduled.Call.ToolInput) } if scheduled.StartsAt.Format(time.RFC3339) != laterStartsAt { t.Fatalf("the schedule starts at %s, expected %s", scheduled.StartsAt.Format(time.RFC3339), laterStartsAt) } if scheduled.Delivery.ConversationID != "conversation-1" { t.Fatalf("the schedule reports to %q, expected the conversation the approval was given in", scheduled.Delivery.ConversationID) } if _, isApproved := ApprovedPendingCall(taskRunService.ListTaskEvent(taskRun.TaskRunID)); isApproved { t.Fatal("the deferred call still reads as approved to run now, so a continuation would run it immediately") } if _, isHeld := PendingHeldCall(taskRunService.ListTaskEvent(taskRun.TaskRunID)); isHeld { t.Fatal("the deferred call still reads as waiting for an answer") } } func TestCancellingRunsNothingAndSchedulesNothing(t *testing.T) { gate, _, taskRun, _, scheduler := choiceGate(t, ApprovalAnswer{Signal: agentcontract.ApprovalSignalReject}) outcome, errorValue := gate.AwaitApproval(context.Background(), hostUpdateRequest(taskRun.TaskRunID)) if errorValue != nil { t.Fatalf("await approval: %v", errorValue) } if outcome.Decision != mcpserver.ApprovalDecisionRejected || len(scheduler.requests) != 0 { t.Fatalf("cancelling decided %q and scheduled %d calls", outcome.Decision, len(scheduler.requests)) } } func TestTheDeferredCallReachesTheModelAsAScheduleWithNothingRunYet(t *testing.T) { gate, _, taskRun, _, _ := choiceGate(t, ApprovalAnswer{Signal: agentcontract.ApprovalSignalApprove, ChoiceKey: "offHours"}) review, errorValue := gate.TurnGate(TurnContext{RequesterPersonID: "person-admin", Platform: "buzz", ConversationID: "conversation-1", ReplyTargetID: "message-1"}).ReviewToolCall( toolcontract.WithTaskRunID(context.Background(), taskRun.TaskRunID), toolcontract.ToolInvocation{ToolName: "host_update", Input: json.RawMessage(`{}`)}, toolcontract.ToolDefinition{Name: "host_update", RequiresApproval: true}, ) if errorValue != nil { t.Fatalf("review: %v", errorValue) } if review.MayProceed { t.Fatal("a call deferred to a later time went ahead now") } if review.Result.Failure != nil || len(review.Result.Effects) != 1 || review.Result.Effects[0].ObjectType != "schedule" { t.Fatalf("the model was told %+v, expected one created schedule", review.Result) } }