Files
kandev/apps/backend/internal/integration/test_server_test.go
T
Luan Martins 1a467b969f fix(web): wire permission approval UI for Kandev MCP tools (#1037)
* fix(web): wire permission approval UI for Kandev MCP tools

KandevToolMessage adapter ignored the permissionMessage prop, so MCP tools
that block on a permission request rendered the result row but no Approve/
Deny buttons — users could not unblock the agent. Extract the shared
permission handlers into use-permission-handlers.ts and reuse them from
both ToolCallMessage and KandevToolMessage. KandevRow now also shows an
amber pending clock icon and hides the result-count summary while waiting
for approval so the row reads correctly before the result arrives.

Adds a mock-agent scenarioKandevMCPPermission emitting
mcp__kandev__list_workspaces_kandev plus a Playwright e2e and unit tests
covering the pending UI and the absence of "0 workspaces" while pending.

* fix(web): collapse tool + permission status into resolved Kandev row

After approve/reject the orchestrator stamps the decision onto the
permission_request message immediately, but the tool_call's own
meta.status stays "pending" until the next tool_call update lands.
KandevToolMessage previously passed meta.status straight to the renderer,
so the row kept the amber pending clock and the hidden-summary state for
the brief window between the two events. resolveKandevStatus now prefers
the permission decision once present, mapping approved -> complete and
rejected -> error.

Drops a redundant Playwright `.first()` after a toHaveCount(1) assertion
on the same locator. Adds unit coverage for the resolved-decision state.

* fix(web): keep Kandev tool status independent of permission decision

The previous fix collapsed an approved permission into tool status
"complete" and a rejected permission into "error". That was wrong on two
counts: an approved tool that's still running rendered as already done
(green check + visible result summary built from an empty payload), and
any later tool_call error was silently masked because the status had
already been forced to "complete".

Replace the status override with a separate display overlay. KandevRow
now reads a `KandevPermissionUIContext` ("pending" | "rejected" |
undefined) and uses it only to drive the waiting-on-user affordances —
amber clock, hidden summary — and a rejection mark that wins over the
tool's own success state. The renderer always receives the real
tool_call status, so a tool that's approved-but-pending still reads as
pending and a tool that errors after approval still renders the red X.

Adds explicit regression tests for the two race-window failure modes
plus the new rejected overlay.

* test(web): tolerate duplicate-render backend race in Kandev MCP e2e

CI shard 1 sometimes renders two approve buttons for the same Kandev MCP
permission because the orchestrator can create a second generic tool_call
message for the same tool_call_id alongside the kandev-routed one. Both
buttons resolve the same pending_id, so clicking the first unblocks the
agent either way.

The bug the test guards against is "no approve button at all" — the
strict toHaveCount(1) assertion was tying the test to that separate
backend issue. Relax to at-least-one and click the first.

* fix: persist Deny clicks as rejected, not approved

Backend RespondToPermission previously derived status purely from cancelled:
cancelled=true -> "rejected", otherwise "approved". Deny clicks sent
cancelled=false with a reject_* option_id, so they were recorded as
"approved". Add an explicit rejected flag from the WS request so the
backend can record "rejected" without taking the cancellation path (which
races against permission_cancelled -> "expired").

Also refactor permission-request-message.tsx to consume the shared
usePermissionResponseHandlers hook (the duplicated inline handler had the
same bug) and introduce a typed models.PermissionStatus enum on the
backend so status strings are not scattered literals.

* test(web): scope Kandev MCP permission e2e to the Kandev row

Previously the test clicked the first matching permission-approve button,
which could be a duplicate generic ToolCallMessage fallback rendered for
the same pending_id by a backend race -- a green test no longer proved the
custom Kandev approval UI rendered.

Add a `data-testid="kandev-tool-permission"` wrapper around the Kandev
action row and a `kandevPermissionApproveButtons` page-object locator that
scopes through it, then assert `toHaveCount(1)` so the test only passes
when the Kandev custom row actually mounts.

* chore(orchestrator): log rejected flag in RespondToPermission

Debug log was missing the new rejected param, making it impossible to
distinguish explicit Deny from dialog cancellation when triaging
permission-flow issues.
2026-05-26 00:22:45 +01:00

424 lines
15 KiB
Go

// Package integration provides end-to-end integration tests for the Kandev backend.
package integration
import (
"context"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/events/bus"
gateways "github.com/kandev/kandev/internal/gateway/websocket"
"github.com/kandev/kandev/internal/orchestrator"
orchestratorhandlers "github.com/kandev/kandev/internal/orchestrator/handlers"
taskhandlers "github.com/kandev/kandev/internal/task/handlers"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/internal/workflow"
workflowcontroller "github.com/kandev/kandev/internal/workflow/controller"
workflowhandlers "github.com/kandev/kandev/internal/workflow/handlers"
workflowservice "github.com/kandev/kandev/internal/workflow/service"
"github.com/kandev/kandev/internal/worktree"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// OrchestratorTestServer extends TestServer with orchestrator components
type OrchestratorTestServer struct {
Server *httptest.Server
Gateway *gateways.Gateway
TaskRepo *sqliterepo.Repository
TaskSvc *taskservice.Service
WorkflowSvc *workflowservice.Service
EventBus bus.EventBus
OrchestratorSvc *orchestrator.Service
AgentManager *SimulatedAgentManagerClient
Logger *logger.Logger
ctx context.Context
cancelFunc context.CancelFunc
}
// taskRepositoryAdapter adapts the task repository for the orchestrator
type taskRepositoryAdapter struct {
repo *sqliterepo.Repository
svc *taskservice.Service
}
func (a *taskRepositoryAdapter) GetTask(ctx context.Context, taskID string) (*v1.Task, error) {
task, err := a.repo.GetTask(ctx, taskID)
if err != nil {
return nil, err
}
return task.ToAPI(), nil
}
func (a *taskRepositoryAdapter) UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error {
_, err := a.svc.UpdateTaskState(ctx, taskID, state)
return err
}
// testMessageCreatorAdapter adapts the task service to the orchestrator.MessageCreator interface for tests
type testMessageCreatorAdapter struct {
svc *taskservice.Service
}
func (a *testMessageCreatorAdapter) CreateAgentMessage(ctx context.Context, taskID, content, agentSessionID, turnID string) error {
_, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
TaskSessionID: agentSessionID,
TaskID: taskID,
TurnID: turnID,
Content: content,
AuthorType: "agent",
})
return err
}
func (a *testMessageCreatorAdapter) CreateUserMessage(ctx context.Context, taskID, content, agentSessionID, turnID string, metadata map[string]interface{}) error {
_, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
TaskSessionID: agentSessionID,
TaskID: taskID,
TurnID: turnID,
Content: content,
AuthorType: "user",
Metadata: metadata,
})
return err
}
func (a *testMessageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, title, status, agentSessionID, turnID string, normalized *streams.NormalizedPayload) error {
metadata := map[string]interface{}{
"tool_call_id": toolCallID,
"title": title,
"status": status,
}
if parentToolCallID != "" {
metadata["parent_tool_call_id"] = parentToolCallID
}
if normalized != nil {
metadata["normalized"] = normalized
}
_, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
TaskSessionID: agentSessionID,
TaskID: taskID,
TurnID: turnID,
Content: title,
AuthorType: "agent",
Type: "tool_call",
Metadata: metadata,
})
return err
}
func (a *testMessageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, status, result, agentSessionID, title, turnID, msgType string, normalized *streams.NormalizedPayload) error {
return a.svc.UpdateToolCallMessageWithCreate(ctx, agentSessionID, toolCallID, parentToolCallID, status, result, title, normalized, taskID, turnID, msgType)
}
func (a *testMessageCreatorAdapter) CreateSessionMessage(ctx context.Context, taskID, content, agentSessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error {
_, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
TaskSessionID: agentSessionID,
TaskID: taskID,
TurnID: turnID,
Content: content,
AuthorType: "agent",
Type: messageType,
Metadata: metadata,
RequestsInput: requestsInput,
})
return err
}
func (a *testMessageCreatorAdapter) CreatePermissionRequestMessage(ctx context.Context, taskID, sessionID, pendingID, toolCallID, title, turnID string, options []map[string]interface{}, actionType string, actionDetails map[string]interface{}) (string, error) {
metadata := map[string]interface{}{
"pending_id": pendingID,
"tool_call_id": toolCallID,
"options": options,
"action_type": actionType,
"action_details": actionDetails,
}
msg, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
TaskSessionID: sessionID,
TaskID: taskID,
TurnID: turnID,
Content: title,
AuthorType: "agent",
Type: "permission_request",
Metadata: metadata,
})
if err != nil {
return "", err
}
return msg.ID, nil
}
func (a *testMessageCreatorAdapter) UpdatePermissionMessage(ctx context.Context, sessionID, pendingID string, status models.PermissionStatus) error {
return a.svc.UpdatePermissionMessage(ctx, sessionID, pendingID, status)
}
func (a *testMessageCreatorAdapter) CreateAgentMessageStreaming(ctx context.Context, messageID, taskID, content, agentSessionID, turnID string) error {
_, err := a.svc.CreateMessageWithID(ctx, messageID, &taskservice.CreateMessageRequest{
TaskSessionID: agentSessionID,
TaskID: taskID,
TurnID: turnID,
Content: content,
AuthorType: "agent",
Type: "content",
})
return err
}
func (a *testMessageCreatorAdapter) AppendAgentMessage(ctx context.Context, messageID, additionalContent string) error {
return a.svc.AppendMessageContent(ctx, messageID, additionalContent)
}
func (a *testMessageCreatorAdapter) CreateThinkingMessageStreaming(ctx context.Context, messageID, taskID, content, agentSessionID, turnID string) error {
_, err := a.svc.CreateMessageWithID(ctx, messageID, &taskservice.CreateMessageRequest{
TaskSessionID: agentSessionID,
TaskID: taskID,
TurnID: turnID,
Content: "",
AuthorType: "agent",
Type: "thinking",
Metadata: map[string]interface{}{
"thinking": content,
},
})
return err
}
func (a *testMessageCreatorAdapter) AppendThinkingMessage(ctx context.Context, messageID, additionalContent string) error {
return a.svc.AppendThinkingContent(ctx, messageID, additionalContent)
}
func (a *testMessageCreatorAdapter) InvalidateModelCache(string) {}
// testTurnServiceAdapter adapts the task service to the orchestrator.TurnService interface for tests
type testTurnServiceAdapter struct {
svc *taskservice.Service
}
func (a *testTurnServiceAdapter) StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) {
return a.svc.StartTurn(ctx, sessionID)
}
func (a *testTurnServiceAdapter) CompleteTurn(ctx context.Context, turnID string) error {
return a.svc.CompleteTurn(ctx, turnID)
}
func (a *testTurnServiceAdapter) GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) {
return a.svc.GetActiveTurn(ctx, sessionID)
}
func (a *testTurnServiceAdapter) AbandonOpenTurns(ctx context.Context, sessionID string) error {
return a.svc.AbandonOpenTurns(ctx, sessionID)
}
// NewOrchestratorTestServer creates a test server with full orchestrator support
func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
t.Helper()
// Initialize logger (quiet for tests)
log, err := logger.NewLogger(logger.LoggingConfig{
Level: "error",
Format: "console",
})
require.NoError(t, err)
// Create context
ctx, cancel := context.WithCancel(context.Background())
// Initialize event bus
eventBus := bus.NewMemoryEventBus(log)
tmpDir := t.TempDir()
dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db"))
require.NoError(t, err)
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
taskRepoImpl, cleanup, err := repository.Provide(sqlxDB, sqlxDB, nil)
require.NoError(t, err)
taskRepo := taskRepoImpl
t.Cleanup(func() {
if err := sqlxDB.Close(); err != nil {
t.Errorf("failed to close sqlite db: %v", err)
}
if cleanup != nil {
if err := cleanup(); err != nil {
t.Errorf("failed to close task repo: %v", err)
}
}
})
if _, err := worktree.NewSQLiteStore(sqlxDB, sqlxDB); err != nil {
t.Fatalf("failed to init worktree store: %v", err)
}
// Initialize workflow service
_, workflowSvc, _, err := workflow.Provide(sqlxDB, sqlxDB, log)
require.NoError(t, err)
// Initialize task service and wire workflow step creator
taskSvc := taskservice.NewService(taskservice.Repos{
Workspaces: taskRepo, Tasks: taskRepo, TaskRepos: taskRepo,
Workflows: taskRepo, Messages: taskRepo, Turns: taskRepo,
Sessions: taskRepo, GitSnapshots: taskRepo, RepoEntities: taskRepo,
Executors: taskRepo, Environments: taskRepo, TaskEnvironments: taskRepo,
Reviews: taskRepo,
}, eventBus, log, taskservice.RepositoryDiscoveryConfig{})
taskSvc.SetWorkflowStepCreator(workflowSvc)
// Create simulated agent manager
agentManager := NewSimulatedAgentManager(eventBus, log)
// Create task repository adapter
taskRepoAdapter := &taskRepositoryAdapter{repo: taskRepo, svc: taskSvc}
// Create orchestrator service
cfg := orchestrator.DefaultServiceConfig()
cfg.Scheduler.ProcessInterval = 50 * time.Millisecond // Faster for tests
orchestratorSvc := orchestrator.NewService(cfg, eventBus, agentManager, taskRepoAdapter, taskRepo, nil, nil, nil, log)
// Wire message creator for message persistence (similar to cmd/kandev/orchestrator.go)
msgCreator := &testMessageCreatorAdapter{svc: taskSvc}
orchestratorSvc.SetMessageCreator(msgCreator)
orchestratorSvc.SetTurnService(&testTurnServiceAdapter{svc: taskSvc})
// Create WebSocket gateway
gateway := gateways.NewGateway(log)
// Register orchestrator handlers (Pattern A)
orchestratorHandlers := orchestratorhandlers.NewHandlers(orchestratorSvc, log)
orchestratorHandlers.RegisterHandlers(gateway.Dispatcher)
// Start hub
go gateway.Hub.Run(ctx)
// Register task notifications to broadcast events to WebSocket clients
gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log)
// Start orchestrator
require.NoError(t, orchestratorSvc.Start(ctx))
// Create router
gin.SetMode(gin.TestMode)
router := gin.New()
gateway.SetupRoutes(router)
// Register handlers (HTTP + WS)
workflowCtrl := workflowcontroller.NewController(workflowSvc)
taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, taskSvc, log)
taskhandlers.RegisterWorkflowRoutes(router, gateway.Dispatcher, taskSvc, workflowSvc, log)
planService := taskservice.NewPlanService(taskRepo, eventBus, log)
taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskSvc, nil, taskRepo, planService, log)
taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, taskSvc, log)
taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, taskSvc, log)
taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, taskSvc, log)
workflowhandlers.RegisterRoutes(router, gateway.Dispatcher, workflowCtrl, log)
// Create test server
server := httptest.NewServer(router)
return &OrchestratorTestServer{
Server: server,
Gateway: gateway,
TaskRepo: taskRepo,
TaskSvc: taskSvc,
WorkflowSvc: workflowSvc,
EventBus: eventBus,
OrchestratorSvc: orchestratorSvc,
AgentManager: agentManager,
Logger: log,
ctx: ctx,
cancelFunc: cancel,
}
}
// Close shuts down the test server
func (ts *OrchestratorTestServer) Close() {
if err := ts.OrchestratorSvc.Stop(); err != nil {
ts.Logger.Warn("failed to stop orchestrator", zap.Error(err))
}
ts.AgentManager.Close()
ts.cancelFunc()
ts.Server.Close()
if err := ts.TaskRepo.Close(); err != nil {
ts.Logger.Warn("failed to close task repo", zap.Error(err))
}
ts.EventBus.Close()
}
// CreateTestTask creates a task for testing.
func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID string, priority int) string {
t.Helper()
workspace, err := ts.TaskSvc.CreateWorkspace(context.Background(), &taskservice.CreateWorkspaceRequest{
Name: "Test Workspace",
})
require.NoError(t, err)
// Create workflow first (workflow steps are created automatically via default template)
defaultTemplateID := "simple"
wf, err := ts.TaskSvc.CreateWorkflow(context.Background(), &taskservice.CreateWorkflowRequest{
WorkspaceID: workspace.ID,
Name: "Test Workflow",
Description: "Test workflow for orchestrator",
WorkflowTemplateID: &defaultTemplateID,
})
require.NoError(t, err)
// Get first workflow step from workflow
steps, err := ts.WorkflowSvc.ListStepsByWorkflow(context.Background(), wf.ID)
require.NoError(t, err)
require.NotEmpty(t, steps, "workflow should have workflow steps")
workflowStepID := steps[0].ID
// Create task with agent profile ID
repository, err := ts.TaskSvc.CreateRepository(context.Background(), &taskservice.CreateRepositoryRequest{
WorkspaceID: workspace.ID,
Name: "Test Repo",
LocalPath: createTempRepoDir(t),
})
require.NoError(t, err)
task, err := ts.TaskSvc.CreateTask(context.Background(), &taskservice.CreateTaskRequest{
WorkspaceID: workspace.ID,
WorkflowID: wf.ID,
WorkflowStepID: workflowStepID,
Title: "Test Task",
Description: "This is a test task for the orchestrator",
Priority: intPriorityForTest(priority),
Repositories: []taskservice.TaskRepositoryInput{
{
RepositoryID: repository.ID,
BaseBranch: "main",
},
},
})
require.NoError(t, err)
return task.ID
}
// intPriorityForTest maps a legacy integer priority to the TEXT priority
// label form used after the priority column migration.
func intPriorityForTest(p int) string {
switch {
case p >= 8:
return "critical"
case p >= 4:
return "high"
case p >= 2:
return "medium"
case p >= 1:
return "low"
default:
return "medium"
}
}