635959f56d
Co-authored-by: Kandev Agent <agent@kandev.dev>
217 lines
8.4 KiB
Go
217 lines
8.4 KiB
Go
package backendapp
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/kandev/kandev/internal/clarification"
|
|
"github.com/kandev/kandev/internal/orchestrator"
|
|
"github.com/kandev/kandev/internal/plugins"
|
|
taskmodels "github.com/kandev/kandev/internal/task/models"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
type recordingPermissionResolver struct {
|
|
request orchestrator.ResolveAgentPermissionRequest
|
|
cancelled bool
|
|
err error
|
|
}
|
|
|
|
func (r *recordingPermissionResolver) ResolveAgentPermission(
|
|
_ context.Context, request orchestrator.ResolveAgentPermissionRequest,
|
|
) (*orchestrator.ResolveAgentPermissionResult, error) {
|
|
r.request = request
|
|
if r.err != nil {
|
|
return nil, r.err
|
|
}
|
|
return &orchestrator.ResolveAgentPermissionResult{PendingID: request.PendingID}, nil
|
|
}
|
|
|
|
func (r *recordingPermissionResolver) RespondToPermission(
|
|
_ context.Context, taskID, sessionID, requestID, pendingID, optionID string, cancelled, _ bool,
|
|
) error {
|
|
r.request = orchestrator.ResolveAgentPermissionRequest{
|
|
TaskID: taskID, SessionID: sessionID, RequestID: requestID,
|
|
PendingID: pendingID, OptionID: optionID,
|
|
}
|
|
r.cancelled = cancelled
|
|
return r.err
|
|
}
|
|
|
|
type recordingBundleResolver struct {
|
|
pendingID string
|
|
outcome clarification.Outcome
|
|
claimed bool
|
|
err error
|
|
}
|
|
|
|
func (r *recordingBundleResolver) ResolveBundle(
|
|
_ context.Context, pendingID string, outcome clarification.Outcome,
|
|
) (*clarification.Resolution, bool, error) {
|
|
r.pendingID, r.outcome = pendingID, outcome
|
|
if r.err != nil {
|
|
return nil, false, r.err
|
|
}
|
|
return &clarification.Resolution{}, r.claimed, nil
|
|
}
|
|
|
|
func TestPluginsInteractionResponderForwardsPermissionIdentity(t *testing.T) {
|
|
permissions := &recordingPermissionResolver{}
|
|
adapter := pluginsInteractionResponderAdapter{permissions: permissions}
|
|
|
|
if err := adapter.RespondToPermission(context.Background(), plugins.PluginPermissionResponse{
|
|
TaskID: "task-1", SessionID: "session-1", RequestID: "request-1",
|
|
PendingID: "pending-1", OptionID: "deny",
|
|
}); err != nil {
|
|
t.Fatalf("RespondToPermission: %v", err)
|
|
}
|
|
if permissions.request.TaskID != "task-1" || permissions.request.SessionID != "session-1" ||
|
|
permissions.request.RequestID != "request-1" || permissions.request.PendingID != "pending-1" ||
|
|
permissions.request.OptionID != "deny" {
|
|
t.Fatalf("resolver saw %+v", permissions.request)
|
|
}
|
|
// A plugin response must not be recorded as a browser one: the audit trail
|
|
// distinguishes sources, and mislabelling it would make a plugin's action
|
|
// indistinguishable from a person's after the fact.
|
|
if permissions.request.Source == taskmodels.PermissionSourceWeb {
|
|
t.Fatalf("source = %q, want a non-web source for a plugin response", permissions.request.Source)
|
|
}
|
|
}
|
|
|
|
// TestPluginsInteractionResponderDismissalUsesCancelPath keeps a dismissal off
|
|
// the option path: there is no option to select, and inventing one would
|
|
// report an outcome the agent never offered.
|
|
func TestPluginsInteractionResponderDismissalUsesCancelPath(t *testing.T) {
|
|
permissions := &recordingPermissionResolver{}
|
|
adapter := pluginsInteractionResponderAdapter{permissions: permissions}
|
|
|
|
if err := adapter.RespondToPermission(context.Background(), plugins.PluginPermissionResponse{
|
|
TaskID: "task-1", SessionID: "session-1", RequestID: "request-1",
|
|
PendingID: "pending-1", Cancelled: true,
|
|
}); err != nil {
|
|
t.Fatalf("RespondToPermission: %v", err)
|
|
}
|
|
if !permissions.cancelled {
|
|
t.Fatal("dismissal did not reach the cancel path")
|
|
}
|
|
if permissions.request.OptionID != "" {
|
|
t.Fatalf("option = %q, want empty on a dismissal", permissions.request.OptionID)
|
|
}
|
|
}
|
|
|
|
func TestPluginsInteractionResponderMapsPermissionOutcomes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in error
|
|
want codes.Code
|
|
}{
|
|
{"unknown permission", orchestrator.ErrPermissionNotFound, codes.NotFound},
|
|
{"unknown task or session", orchestrator.ErrPermissionTaskOrSessionNotFound, codes.NotFound},
|
|
{"option never offered", orchestrator.ErrPermissionOptionNotOffered, codes.InvalidArgument},
|
|
// Everything that means "this can no longer take your response"
|
|
// collapses to the one code terminal-once promises.
|
|
{"already resolved", orchestrator.ErrPermissionAlreadyResolved, codes.FailedPrecondition},
|
|
{"stale", orchestrator.ErrPermissionStale, codes.FailedPrecondition},
|
|
{"another responder mid-flight", orchestrator.ErrPermissionResolutionInProgress, codes.FailedPrecondition},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
adapter := pluginsInteractionResponderAdapter{
|
|
permissions: &recordingPermissionResolver{err: tc.in},
|
|
}
|
|
err := adapter.RespondToPermission(context.Background(), plugins.PluginPermissionResponse{
|
|
PendingID: "pending-1", OptionID: "allow",
|
|
})
|
|
if got := status.Code(err); got != tc.want {
|
|
t.Fatalf("code = %v (%v), want %v", got, err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPluginsInteractionResponderForwardsClarificationAnswers(t *testing.T) {
|
|
resolver := &recordingBundleResolver{claimed: true}
|
|
adapter := pluginsInteractionResponderAdapter{clarifications: resolver}
|
|
|
|
err := adapter.AnswerClarification(context.Background(), "pending-2", []plugins.PluginClarificationAnswer{
|
|
{QuestionID: "q1", SelectedOptions: []string{"a"}, CustomText: "note"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AnswerClarification: %v", err)
|
|
}
|
|
if resolver.pendingID != "pending-2" || len(resolver.outcome.Answers) != 1 {
|
|
t.Fatalf("resolver saw %q / %+v", resolver.pendingID, resolver.outcome)
|
|
}
|
|
if resolver.outcome.Rejected {
|
|
t.Fatal("an answer must not be delivered as a rejection")
|
|
}
|
|
}
|
|
|
|
// TestPluginsInteractionResponderDeclineUsesRejectOutcome pins the deliberate
|
|
// routing choice: the operator cancel route needs the in-memory pending entry
|
|
// and therefore fails after a restart, while the reject outcome is
|
|
// durable-claim based. A plugin reconciling a bundle whose waiter went away is
|
|
// exactly the caller that needs the second one.
|
|
func TestPluginsInteractionResponderDeclineUsesRejectOutcome(t *testing.T) {
|
|
resolver := &recordingBundleResolver{claimed: true}
|
|
adapter := pluginsInteractionResponderAdapter{clarifications: resolver}
|
|
|
|
if err := adapter.DeclineClarification(context.Background(), "pending-3", "user stepped away"); err != nil {
|
|
t.Fatalf("DeclineClarification: %v", err)
|
|
}
|
|
if !resolver.outcome.Rejected || resolver.outcome.RejectReason != "user stepped away" {
|
|
t.Fatalf("decline outcome = %+v", resolver.outcome)
|
|
}
|
|
if len(resolver.outcome.Answers) != 0 {
|
|
t.Fatalf("decline carried answers: %+v", resolver.outcome.Answers)
|
|
}
|
|
}
|
|
|
|
// TestPluginsInteractionResponderLostClaimIsFailedPrecondition covers the race
|
|
// the resolver reports as a non-claimed resolution: another responder settled
|
|
// the bundle first. It must read as the same code the host returns when it
|
|
// catches the terminal state itself.
|
|
func TestPluginsInteractionResponderLostClaimIsFailedPrecondition(t *testing.T) {
|
|
adapter := pluginsInteractionResponderAdapter{
|
|
clarifications: &recordingBundleResolver{claimed: false},
|
|
}
|
|
err := adapter.DeclineClarification(context.Background(), "pending-4", "")
|
|
if got := status.Code(err); got != codes.FailedPrecondition {
|
|
t.Fatalf("code = %v (%v), want FailedPrecondition", got, err)
|
|
}
|
|
}
|
|
|
|
func TestPluginsInteractionResponderMapsClarificationOutcomes(t *testing.T) {
|
|
adapter := pluginsInteractionResponderAdapter{
|
|
clarifications: &recordingBundleResolver{err: clarification.ErrBundleNotFound},
|
|
}
|
|
if got := status.Code(adapter.DeclineClarification(context.Background(), "pending-5", "")); got != codes.NotFound {
|
|
t.Fatalf("unknown bundle code = %v, want NotFound", got)
|
|
}
|
|
|
|
sentinel := errors.New("transport exploded")
|
|
adapter = pluginsInteractionResponderAdapter{
|
|
clarifications: &recordingBundleResolver{err: sentinel},
|
|
}
|
|
if got := status.Code(adapter.DeclineClarification(context.Background(), "pending-6", "")); got != codes.Internal {
|
|
t.Fatalf("unexpected failure code = %v, want Internal", got)
|
|
}
|
|
}
|
|
|
|
func TestPluginsInteractionResponderUnwiredIsUnimplemented(t *testing.T) {
|
|
adapter := pluginsInteractionResponderAdapter{}
|
|
ctx := context.Background()
|
|
|
|
if got := status.Code(adapter.RespondToPermission(ctx, plugins.PluginPermissionResponse{})); got != codes.Unimplemented {
|
|
t.Fatalf("permission code = %v", got)
|
|
}
|
|
if got := status.Code(adapter.AnswerClarification(ctx, "p", nil)); got != codes.Unimplemented {
|
|
t.Fatalf("answer code = %v", got)
|
|
}
|
|
if got := status.Code(adapter.DeclineClarification(ctx, "p", "")); got != codes.Unimplemented {
|
|
t.Fatalf("decline code = %v", got)
|
|
}
|
|
}
|