Files
kandev/apps/backend/internal/mcp/handlers/handlers.go
T
João Salavisa c8fcd6c5aa feat(mcp): add get_task_conversation tool (#756)
* feat(mcp): add get_task_conversation tool

* fix(mcp): correct conversation pagination cursor and limits

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-01 18:20:01 +01:00

1182 lines
45 KiB
Go

// Package handlers provides WebSocket handlers for MCP tool requests.
// These handlers are called by agentctl via the WS tunnel and execute
// operations against the backend services directly.
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/kandev/kandev/internal/agent/mcpconfig"
agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller"
"github.com/kandev/kandev/internal/clarification"
"github.com/kandev/kandev/internal/common/constants"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/orchestrator"
"github.com/kandev/kandev/internal/orchestrator/executor"
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
"github.com/kandev/kandev/internal/task/dto"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/service"
workflowctrl "github.com/kandev/kandev/internal/workflow/controller"
workflowsvc "github.com/kandev/kandev/internal/workflow/service"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
// ClarificationService defines the interface for clarification operations.
type ClarificationService interface {
CreateRequest(req *clarification.Request) string
WaitForResponse(ctx context.Context, pendingID string) (*clarification.Response, error)
CancelSession(sessionID string) []string
}
// MessageCreator creates messages for clarification requests.
type MessageCreator interface {
CreateClarificationRequestMessage(ctx context.Context, taskID, sessionID, pendingID string, question clarification.Question, clarificationContext string) (string, error)
}
// SessionRepository interface for updating session state.
type SessionRepository interface {
UpdateTaskSessionState(ctx context.Context, sessionID string, state models.TaskSessionState, errorMessage string) error
GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error)
}
// TaskRepository interface for updating task state.
type TaskRepository interface {
UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error
}
// EventBus interface for publishing events.
type EventBus interface {
Publish(ctx context.Context, topic string, event *bus.Event) error
}
// SessionLauncher provides session launch and prompt-dispatch capabilities.
// Both methods are implemented by *orchestrator.Service.
type SessionLauncher interface {
LaunchSession(ctx context.Context, req *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error)
PromptTask(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment) (*orchestrator.PromptResult, error)
StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode bool, attachments []v1.MessageAttachment) (*executor.TaskExecution, error)
ResumeTaskSession(ctx context.Context, taskID, sessionID string) (*executor.TaskExecution, error)
GetMessageQueue() *messagequeue.Service
}
// MessageQueuer queues a prompt message for delivery to a session on its next turn.
// TakeQueued is exposed so move_task can roll back the hand-off prompt when the
// underlying MoveTask call fails — without it, a queued "you were moved..."
// message would survive a failed move and be delivered on the next agent turn.
type MessageQueuer interface {
QueueMessage(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []messagequeue.MessageAttachment) (*messagequeue.QueuedMessage, error)
SetPendingMove(ctx context.Context, sessionID string, move *messagequeue.PendingMove)
TakeQueued(ctx context.Context, sessionID string) (*messagequeue.QueuedMessage, bool)
}
// Handlers provides MCP WebSocket handlers.
type Handlers struct {
taskSvc *service.Service
workflowCtrl *workflowctrl.Controller
clarificationSvc ClarificationService
messageCreator MessageCreator
sessionRepo SessionRepository
taskRepo TaskRepository
eventBus EventBus
planService *service.PlanService
sessionLauncher SessionLauncher
messageQueue MessageQueuer
logger *logger.Logger
// Config-mode dependencies (optional, set via SetConfigDeps)
workflowSvc *workflowsvc.Service
agentSettingsCtrl *agentsettingscontroller.Controller
mcpConfigSvc *mcpconfig.Service
}
// NewHandlers creates new MCP handlers.
func NewHandlers(
taskSvc *service.Service,
workflowCtrl *workflowctrl.Controller,
clarificationSvc ClarificationService,
messageCreator MessageCreator,
sessionRepo SessionRepository,
taskRepo TaskRepository,
eventBus EventBus,
planService *service.PlanService,
sessionLauncher SessionLauncher,
messageQueue MessageQueuer,
log *logger.Logger,
) *Handlers {
return &Handlers{
taskSvc: taskSvc,
workflowCtrl: workflowCtrl,
clarificationSvc: clarificationSvc,
messageCreator: messageCreator,
sessionRepo: sessionRepo,
taskRepo: taskRepo,
eventBus: eventBus,
planService: planService,
sessionLauncher: sessionLauncher,
messageQueue: messageQueue,
logger: log.WithFields(zap.String("component", "mcp-handlers")),
}
}
// SetConfigDeps sets the config-mode dependencies for agent-native configuration handlers.
// These are optional and only needed when config-mode MCP sessions are used.
func (h *Handlers) SetConfigDeps(
workflowSvc *workflowsvc.Service,
agentSettingsCtrl *agentsettingscontroller.Controller,
mcpConfigSvc *mcpconfig.Service,
) {
h.workflowSvc = workflowSvc
h.agentSettingsCtrl = agentSettingsCtrl
h.mcpConfigSvc = mcpConfigSvc
}
// RegisterHandlers registers all MCP handlers with the dispatcher.
func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) {
// Task-mode handlers (always registered)
d.RegisterFunc(ws.ActionMCPListWorkspaces, h.handleListWorkspaces)
d.RegisterFunc(ws.ActionMCPListWorkflows, h.handleListWorkflows)
d.RegisterFunc(ws.ActionMCPListWorkflowSteps, h.handleListWorkflowSteps)
d.RegisterFunc(ws.ActionMCPListTasks, h.handleListTasks)
d.RegisterFunc(ws.ActionMCPCreateTask, h.handleCreateTask)
d.RegisterFunc(ws.ActionMCPUpdateTask, h.handleUpdateTask)
d.RegisterFunc(ws.ActionMCPMessageTask, h.handleMessageTask)
d.RegisterFunc(ws.ActionMCPGetTaskConversation, h.handleGetTaskConversation)
d.RegisterFunc(ws.ActionMCPAskUserQuestion, h.handleAskUserQuestion)
d.RegisterFunc(ws.ActionMCPCreateTaskPlan, h.handleCreateTaskPlan)
d.RegisterFunc(ws.ActionMCPGetTaskPlan, h.handleGetTaskPlan)
d.RegisterFunc(ws.ActionMCPUpdateTaskPlan, h.handleUpdateTaskPlan)
d.RegisterFunc(ws.ActionMCPDeleteTaskPlan, h.handleDeleteTaskPlan)
d.RegisterFunc(ws.ActionMCPClarificationTimeout, h.handleClarificationTimeout)
count := 14
// Config-mode handlers (registered when config deps are set)
if h.workflowSvc != nil {
d.RegisterFunc(ws.ActionMCPCreateWorkflow, h.handleCreateWorkflow)
d.RegisterFunc(ws.ActionMCPUpdateWorkflow, h.handleUpdateWorkflow)
d.RegisterFunc(ws.ActionMCPDeleteWorkflow, h.handleDeleteWorkflow)
d.RegisterFunc(ws.ActionMCPCreateWorkflowStep, h.handleCreateWorkflowStep)
d.RegisterFunc(ws.ActionMCPUpdateWorkflowStep, h.handleUpdateWorkflowStep)
d.RegisterFunc(ws.ActionMCPDeleteWorkflowStep, h.handleDeleteWorkflowStep)
d.RegisterFunc(ws.ActionMCPReorderWorkflowStep, h.handleReorderWorkflowSteps)
count += 7
}
if h.agentSettingsCtrl != nil {
d.RegisterFunc(ws.ActionMCPListAgents, h.handleListAgents)
d.RegisterFunc(ws.ActionMCPUpdateAgent, h.handleUpdateAgent)
d.RegisterFunc(ws.ActionMCPListAgentProfiles, h.handleListAgentProfiles)
d.RegisterFunc(ws.ActionMCPCreateAgentProfile, h.handleCreateAgentProfile)
d.RegisterFunc(ws.ActionMCPUpdateAgentProfile, h.handleUpdateAgentProfile)
d.RegisterFunc(ws.ActionMCPDeleteAgentProfile, h.handleDeleteAgentProfile)
count += 6
}
// list_executor_profiles is always available (read-only, used in task mode for create_task)
if h.taskSvc != nil {
d.RegisterFunc(ws.ActionMCPListExecutorProfiles, h.handleListExecutorProfiles)
count++
}
if h.mcpConfigSvc != nil {
d.RegisterFunc(ws.ActionMCPGetMcpConfig, h.handleGetMcpConfig)
d.RegisterFunc(ws.ActionMCPUpdateMcpConfig, h.handleUpdateMcpConfig)
count += 2
}
if h.taskSvc != nil {
d.RegisterFunc(ws.ActionMCPMoveTask, h.handleMoveTask)
d.RegisterFunc(ws.ActionMCPDeleteTask, h.handleDeleteTask)
d.RegisterFunc(ws.ActionMCPArchiveTask, h.handleArchiveTask)
d.RegisterFunc(ws.ActionMCPUpdateTaskState, h.handleUpdateTaskState)
count += 4
// Executor mutation handlers (config-mode only)
if h.workflowSvc != nil {
d.RegisterFunc(ws.ActionMCPCreateExecutorProfile, h.handleCreateExecutorProfile)
d.RegisterFunc(ws.ActionMCPUpdateExecutorProfile, h.handleUpdateExecutorProfile)
d.RegisterFunc(ws.ActionMCPDeleteExecutorProfile, h.handleDeleteExecutorProfile)
count += 3
}
}
h.logger.Info("registered MCP handlers", zap.Int("count", count))
}
// handleListWorkspaces lists all workspaces.
func (h *Handlers) handleListWorkspaces(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
workspaces, err := h.taskSvc.ListWorkspaces(ctx)
if err != nil {
h.logger.Error("failed to list workspaces", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list workspaces", nil)
}
dtos := make([]dto.WorkspaceDTO, 0, len(workspaces))
for _, w := range workspaces {
dtos = append(dtos, dto.FromWorkspace(w))
}
return ws.NewResponse(msg.ID, msg.Action, dto.ListWorkspacesResponse{Workspaces: dtos, Total: len(dtos)})
}
// unmarshalStringField unmarshals a JSON payload and returns the value of a single string field.
func unmarshalStringField(payload json.RawMessage, fieldName string) (string, error) {
var m map[string]string
if err := json.Unmarshal(payload, &m); err != nil {
return "", err
}
return m[fieldName], nil
}
// handleListByField is a generic handler for listing resources identified by a single string field.
func (h *Handlers) handleListByField(
ctx context.Context, msg *ws.Message,
fieldName, logErrMsg, clientErrMsg string,
fn func(context.Context, string) (any, error),
) (*ws.Message, error) {
value, err := unmarshalStringField(msg.Payload, fieldName)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if value == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, fieldName+" is required", nil)
}
resp, err := fn(ctx, value)
if err != nil {
h.logger.Error(logErrMsg, zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, clientErrMsg, nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// handleDeleteByField is a generic handler for deleting a resource identified by a single string field.
func (h *Handlers) handleDeleteByField(
ctx context.Context, msg *ws.Message,
fieldName, logErrMsg, clientErrMsg string,
fn func(context.Context, string) error,
) (*ws.Message, error) {
value, err := unmarshalStringField(msg.Payload, fieldName)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if value == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, fieldName+" is required", nil)
}
if err := fn(ctx, value); err != nil {
h.logger.Error(logErrMsg, zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, clientErrMsg+": "+err.Error(), nil)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"success": true})
}
// handleListWorkflows lists workflows for a workspace.
func (h *Handlers) handleListWorkflows(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
return h.handleListByField(ctx, msg, "workspace_id", "failed to list workflows", "Failed to list workflows",
func(ctx context.Context, workspaceID string) (any, error) {
workflows, err := h.taskSvc.ListWorkflows(ctx, workspaceID, false)
if err != nil {
return nil, err
}
dtos := make([]dto.WorkflowDTO, 0, len(workflows))
for _, w := range workflows {
dtos = append(dtos, dto.FromWorkflow(w))
}
return dto.ListWorkflowsResponse{Workflows: dtos, Total: len(dtos)}, nil
})
}
// handleListWorkflowSteps lists workflow steps for a workflow.
func (h *Handlers) handleListWorkflowSteps(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
return h.handleListByField(ctx, msg, "workflow_id", "failed to list workflow steps", "Failed to list workflow steps",
func(ctx context.Context, workflowID string) (any, error) {
return h.workflowCtrl.ListStepsByWorkflow(ctx, workflowctrl.ListStepsRequest{WorkflowID: workflowID})
})
}
// handleListTasks lists tasks for a workflow.
func (h *Handlers) handleListTasks(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
return h.handleListByField(ctx, msg, "workflow_id", "failed to list tasks", "Failed to list tasks",
func(ctx context.Context, workflowID string) (any, error) {
tasks, err := h.taskSvc.ListTasks(ctx, workflowID)
if err != nil {
return nil, err
}
dtos := make([]dto.TaskDTO, 0, len(tasks))
for _, t := range tasks {
dtos = append(dtos, dto.FromTask(t))
}
return dto.ListTasksResponse{Tasks: dtos, Total: len(dtos)}, nil
})
}
// mcpRepositoryInput matches the repository input structure from MCP create_task
type mcpRepositoryInput struct {
RepositoryID string `json:"repository_id"`
LocalPath string `json:"local_path"`
GitHubURL string `json:"github_url"`
BaseBranch string `json:"base_branch"`
}
// handleCreateTask creates a new task and optionally auto-starts an agent session.
func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
// Use local struct with JSON tags since dto.CreateTaskRequest lacks them
var req struct {
ParentID string `json:"parent_id"`
SourceTaskID string `json:"source_task_id"`
WorkspaceID string `json:"workspace_id"`
WorkflowID string `json:"workflow_id"`
WorkflowStepID string `json:"workflow_step_id"`
Title string `json:"title"`
Description string `json:"description"`
AgentProfileID string `json:"agent_profile_id"`
ExecutorProfileID string `json:"executor_profile_id"`
StartAgent *bool `json:"start_agent"` // nil means default to true for backward compatibility
Repositories []mcpRepositoryInput `json:"repositories"` // explicit repositories for top-level tasks
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.Title == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil)
}
// Default start_agent to true for backward compatibility
startAgent := req.StartAgent == nil || *req.StartAgent
// Only require description for subtasks if we're starting an agent
if req.ParentID != "" && req.Description == "" && startAgent {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "description is required for subtasks: it is the sub-agent's initial prompt and the only context it receives to start working", nil)
}
// Resolve repositories and inherit workspace/workflow from parent if needed.
resolved, err := h.resolveTaskRepositories(ctx, req.ParentID, req.SourceTaskID, req.Repositories)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
}
repos := resolved.Repos
if req.WorkspaceID == "" {
req.WorkspaceID = resolved.WorkspaceID
}
if req.WorkflowID == "" {
req.WorkflowID = resolved.WorkflowID
}
// Auto-resolve workspace/workflow when not provided and there's exactly one option.
if req.WorkspaceID == "" && h.taskSvc != nil {
if workspaces, wsErr := h.taskSvc.ListWorkspaces(ctx); wsErr != nil {
h.logger.Warn("failed to auto-resolve workspace", zap.Error(wsErr))
} else if len(workspaces) == 1 {
req.WorkspaceID = workspaces[0].ID
}
}
if req.WorkspaceID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id is required", nil)
}
if req.WorkflowID == "" && h.taskSvc != nil {
if workflows, wfErr := h.taskSvc.ListWorkflows(ctx, req.WorkspaceID, false); wfErr != nil {
h.logger.Warn("failed to auto-resolve workflow", zap.String("workspace_id", req.WorkspaceID), zap.Error(wfErr))
} else if len(workflows) == 1 {
req.WorkflowID = workflows[0].ID
}
}
if req.WorkflowID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil)
}
task, err := h.taskSvc.CreateTask(ctx, &service.CreateTaskRequest{
ParentID: req.ParentID,
WorkspaceID: req.WorkspaceID,
WorkflowID: req.WorkflowID,
WorkflowStepID: req.WorkflowStepID,
Title: req.Title,
Description: req.Description,
Repositories: repos,
})
if err != nil {
h.logger.Error("failed to create task", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create task", nil)
}
// Auto-start agent session asynchronously only if requested
if startAgent {
h.autoStartTask(task, req.AgentProfileID, req.ExecutorProfileID, req.SourceTaskID)
}
return ws.NewResponse(msg.ID, msg.Action, dto.FromTask(task))
}
// taskRepoResult holds the output of resolveTaskRepositories.
type taskRepoResult struct {
Repos []service.TaskRepositoryInput
WorkspaceID string // inherited from parent, empty otherwise
WorkflowID string // inherited from parent, empty otherwise
}
// resolveTaskRepositories builds the repository list for a new task.
// Priority: explicit repositories > parent task repos > source task repos.
// When inheriting from a parent, it also returns the parent's workspace/workflow IDs.
func (h *Handlers) resolveTaskRepositories(
ctx context.Context,
parentID, sourceTaskID string,
explicit []mcpRepositoryInput,
) (taskRepoResult, error) {
if len(explicit) > 0 {
var repos []service.TaskRepositoryInput
for _, r := range explicit {
repos = append(repos, service.TaskRepositoryInput{
RepositoryID: r.RepositoryID,
LocalPath: r.LocalPath,
GitHubURL: r.GitHubURL,
BaseBranch: r.BaseBranch,
})
}
result := taskRepoResult{Repos: repos}
// Inherit workspace from source task so multi-workspace installs don't
// fail auto-resolution when the agent supplies an explicit repository.
if sourceTaskID != "" && h.taskSvc != nil {
src, srcErr := h.taskSvc.GetTask(ctx, sourceTaskID)
if srcErr != nil {
h.logger.Warn("source task lookup failed, skipping workspace inheritance",
zap.String("source_task_id", sourceTaskID), zap.Error(srcErr))
} else {
result.WorkspaceID = src.WorkspaceID
}
}
return result, nil
}
if parentID != "" {
parent, err := h.taskSvc.GetTask(ctx, parentID)
if err != nil {
return taskRepoResult{}, fmt.Errorf("invalid parent_id: %w", err)
}
if parent.IsEphemeral {
return taskRepoResult{}, fmt.Errorf("cannot create subtasks of an ephemeral task (quick chat); omit parent_id to create a top-level task")
}
var repos []service.TaskRepositoryInput
for _, r := range parent.Repositories {
repos = append(repos, service.TaskRepositoryInput{
RepositoryID: r.RepositoryID,
BaseBranch: r.BaseBranch,
CheckoutBranch: r.CheckoutBranch,
})
}
return taskRepoResult{
Repos: repos,
WorkspaceID: parent.WorkspaceID,
WorkflowID: parent.WorkflowID,
}, nil
}
// For top-level tasks, inherit repos and workspace from the calling agent's current task.
if sourceTaskID != "" {
sourceTask, err := h.taskSvc.GetTask(ctx, sourceTaskID)
if err != nil {
h.logger.Warn("source task not found, skipping inheritance",
zap.String("source_task_id", sourceTaskID), zap.Error(err))
return taskRepoResult{}, nil
}
var repos []service.TaskRepositoryInput
for _, r := range sourceTask.Repositories {
repos = append(repos, service.TaskRepositoryInput{
RepositoryID: r.RepositoryID,
BaseBranch: r.BaseBranch,
CheckoutBranch: r.CheckoutBranch,
})
}
return taskRepoResult{
Repos: repos,
WorkspaceID: sourceTask.WorkspaceID,
}, nil
}
return taskRepoResult{}, nil
}
// autoStartTask launches an agent session for a newly created task in the background.
// It resolves the agent profile: explicit > parent's session > source task's session > workspace default.
// It resolves the executor: explicit executor_profile_id > parent's executor_profile_id >
// source task's executor_profile_id > parent's executor_id > "exec-worktree" (default for MCP-created tasks).
func (h *Handlers) autoStartTask(task *models.Task, agentProfileID, executorProfileID, sourceTaskID string) {
if h.sessionLauncher == nil {
return
}
executorID := h.inheritFromParentSession(task.ParentID, &agentProfileID, &executorProfileID)
// For top-level tasks, inherit from the source task (the calling agent's task)
if task.ParentID == "" && sourceTaskID != "" {
sourceExecutorID := h.inheritFromParentSession(sourceTaskID, &agentProfileID, &executorProfileID)
if executorID == "" {
executorID = sourceExecutorID
}
}
// Fall back to workspace defaults for agent profile and worktree executor
if agentProfileID == "" {
workspace, err := h.taskSvc.GetWorkspace(context.Background(), task.WorkspaceID)
if err == nil && workspace.DefaultAgentProfileID != nil {
agentProfileID = *workspace.DefaultAgentProfileID
}
}
if executorID == "" && executorProfileID == "" {
executorID = models.ExecutorIDWorktree
}
if agentProfileID == "" {
h.logger.Warn("no agent profile available, skipping auto-start",
zap.String("task_id", task.ID))
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), constants.AgentLaunchTimeout)
defer cancel()
resp, err := h.sessionLauncher.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{
TaskID: task.ID,
Intent: orchestrator.IntentStart,
AgentProfileID: agentProfileID,
ExecutorID: executorID,
ExecutorProfileID: executorProfileID,
Prompt: task.Description,
})
if err != nil {
h.logger.Error("failed to auto-start task",
zap.String("task_id", task.ID), zap.Error(err))
return
}
h.logger.Info("auto-started agent for MCP-created task",
zap.String("task_id", task.ID),
zap.String("session_id", resp.SessionID))
}()
}
// inheritFromParentSession fills agentProfileID and executorProfileID from the parent
// task's primary session when not explicitly provided. It returns the parent's ExecutorID
// as a fallback for when the parent session has no executor profile (common for
// UI-created sessions). If ExecutorProfileID is resolved, ExecutorID is redundant
// since the profile already encodes the executor reference.
func (h *Handlers) inheritFromParentSession(parentID string, agentProfileID, executorProfileID *string) string {
if parentID == "" {
return ""
}
parent, err := h.taskSvc.GetPrimarySession(context.Background(), parentID)
if err != nil || parent == nil {
return ""
}
if *agentProfileID == "" {
*agentProfileID = parent.AgentProfileID
}
if *executorProfileID == "" {
*executorProfileID = parent.ExecutorProfileID
}
// Only return ExecutorID as fallback when no profile was resolved.
// An executor profile already encodes its executor reference.
if *executorProfileID == "" {
return parent.ExecutorID
}
return ""
}
// handleUpdateTask updates an existing task.
func (h *Handlers) handleUpdateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
// Use local struct with JSON tags since dto.UpdateTaskRequest lacks them
var req struct {
TaskID string `json:"task_id"`
Title *string `json:"title"`
Description *string `json:"description"`
State *string `json:"state"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.TaskID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
task, err := h.taskSvc.UpdateTask(ctx, req.TaskID, &service.UpdateTaskRequest{
Title: req.Title,
Description: req.Description,
})
if err != nil {
h.logger.Error("failed to update task", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task", nil)
}
return ws.NewResponse(msg.ID, msg.Action, dto.FromTask(task))
}
// handleMessageTask sends a prompt to an existing task. The dispatch path depends
// on the primary session's state: RUNNING/STARTING messages are queued and drained
// at turn end; idle sessions are prompted directly; CREATED sessions are started.
func (h *Handlers) handleMessageTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
Prompt string `json:"prompt"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.TaskID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
if req.Prompt == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "prompt is required", nil)
}
session, err := h.taskSvc.GetPrimarySession(ctx, req.TaskID)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "task not found or has no session: "+err.Error(), nil)
}
if session == nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "task has no active session — use create_task_kandev to start one", nil)
}
status, err := h.dispatchTaskMessage(ctx, req.TaskID, session, req.Prompt)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"task_id": req.TaskID,
"session_id": session.ID,
"status": status,
})
}
// handleGetTaskConversation returns paginated conversation history for a task.
// If session_id is omitted, it uses the task's primary session.
func (h *Handlers) handleGetTaskConversation(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
req, errResp := parseTaskConversationRequest(msg)
if errResp != nil {
return errResp, nil
}
session, errResp := h.resolveConversationSession(ctx, msg, req.TaskID, req.SessionID)
if errResp != nil {
return errResp, nil
}
messages, hasMore, err := h.taskSvc.ListMessagesPaginated(ctx, service.ListMessagesRequest{
TaskSessionID: session.ID,
Limit: conversationLimit(req.Limit),
Before: req.Before,
After: req.After,
Sort: req.Sort,
})
if err != nil {
h.logger.Error("failed to list task conversation", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list task conversation", nil)
}
result := filterAndConvertMessages(messages, req.Types)
cursor := conversationCursor(messages)
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"task_id": req.TaskID,
"session_id": session.ID,
"messages": result,
"total": len(result),
"has_more": hasMore,
"cursor": cursor,
})
}
type taskConversationRequest struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
Limit int `json:"limit"`
Before string `json:"before"`
After string `json:"after"`
Sort string `json:"sort"`
Types []string `json:"message_types"`
}
func parseTaskConversationRequest(msg *ws.Message) (*taskConversationRequest, *ws.Message) {
var req taskConversationRequest
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error())
}
if req.TaskID == "" {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required")
}
if req.Before != "" && req.After != "" {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeValidation, "only one of before or after can be set")
}
if req.Sort != "" && req.Sort != "asc" && req.Sort != "desc" {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeValidation, "sort must be asc or desc")
}
if req.Limit < 0 {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeValidation, "limit must be non-negative")
}
return &req, nil
}
func (h *Handlers) resolveConversationSession(ctx context.Context, msg *ws.Message, taskID, sessionID string) (*models.TaskSession, *ws.Message) {
if sessionID != "" {
session, err := h.taskSvc.GetTaskSession(ctx, sessionID)
if err != nil || session == nil {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "session not found")
}
if session.TaskID != taskID {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id does not belong to task_id")
}
return session, nil
}
session, err := h.taskSvc.GetPrimarySession(ctx, taskID)
if err != nil || session == nil {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "task has no session")
}
return session, nil
}
func wsError(id, action, code, message string) *ws.Message {
resp, _ := ws.NewError(id, action, code, message, nil)
return resp
}
func filterAndConvertMessages(messages []*models.Message, types []string) []*v1.Message {
filterTypes := make(map[string]struct{}, len(types))
for _, mt := range types {
if mt == "" {
continue
}
filterTypes[mt] = struct{}{}
}
result := make([]*v1.Message, 0, len(messages))
for _, message := range messages {
if len(filterTypes) > 0 {
if _, ok := filterTypes[string(message.Type)]; !ok {
continue
}
}
result = append(result, message.ToAPI())
}
return result
}
func conversationLimit(requested int) int {
if requested > 0 {
return requested
}
return service.DefaultMessagesPageSize
}
func conversationCursor(messages []*models.Message) string {
if len(messages) == 0 {
return ""
}
return messages[len(messages)-1].ID
}
// dispatchTaskMessage routes a message to the right delivery path based on session state.
// Returns the action taken: "queued", "sent", or "started".
func (h *Handlers) dispatchTaskMessage(ctx context.Context, taskID string, session *models.TaskSession, prompt string) (string, error) {
if h.sessionLauncher == nil {
return "", errors.New("orchestrator not available")
}
switch session.State {
case models.TaskSessionStateFailed, models.TaskSessionStateCancelled:
return "", fmt.Errorf("session is %s — cannot send message", session.State)
case models.TaskSessionStateRunning, models.TaskSessionStateStarting:
queue := h.sessionLauncher.GetMessageQueue()
if queue == nil {
return "", errors.New("message queue not available")
}
if _, err := queue.QueueMessage(ctx, session.ID, taskID, prompt, "", "agent", false, nil); err != nil {
return "", fmt.Errorf("failed to queue message: %w", err)
}
h.publishQueueStatusEvent(ctx, session.ID, queue)
return "queued", nil
case models.TaskSessionStateCreated:
if _, err := h.sessionLauncher.StartCreatedSession(ctx, taskID, session.ID, session.AgentProfileID, prompt, false, false, nil); err != nil {
return "", fmt.Errorf("failed to start session: %w", err)
}
return "started", nil
default: // WAITING_FOR_INPUT, COMPLETED, or any other promptable state
h.recordUserMessage(ctx, taskID, session.ID, prompt)
return h.promptWithAutoResume(ctx, taskID, session.ID, prompt)
}
}
// recordUserMessage writes the prompt to the task's chat as a user message so it
// is visible in the conversation. Mirrors the message.add path used by the UI.
func (h *Handlers) recordUserMessage(ctx context.Context, taskID, sessionID, prompt string) {
if h.taskSvc == nil {
return
}
if _, err := h.taskSvc.CreateMessage(ctx, &service.CreateMessageRequest{
TaskSessionID: sessionID,
TaskID: taskID,
Content: prompt,
AuthorType: "user",
}); err != nil {
h.logger.Warn("failed to record user message for message_task",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Error(err))
}
}
// promptWithAutoResume sends a prompt to a session and resumes the agent
// transparently if it has been torn down (mirrors message.add behaviour).
func (h *Handlers) promptWithAutoResume(ctx context.Context, taskID, sessionID, prompt string) (string, error) {
_, err := h.sessionLauncher.PromptTask(ctx, taskID, sessionID, prompt, "", false, nil)
if err == nil {
return "sent", nil
}
if !errors.Is(err, executor.ErrExecutionNotFound) {
return "", fmt.Errorf("failed to send prompt: %w", err)
}
if _, resumeErr := h.sessionLauncher.ResumeTaskSession(ctx, taskID, sessionID); resumeErr != nil {
return "", fmt.Errorf("failed to resume session: %w", resumeErr)
}
// ResumeTaskSession starts the agent asynchronously. Poll until the session
// is ready to accept prompts so the retry doesn't race the agent boot.
if waitErr := h.taskSvc.WaitForSessionReady(ctx, sessionID); waitErr != nil {
return "", fmt.Errorf("session not ready after resume: %w", waitErr)
}
if _, retryErr := h.sessionLauncher.PromptTask(ctx, taskID, sessionID, prompt, "", false, nil); retryErr != nil {
return "", fmt.Errorf("failed to send prompt after resume: %w", retryErr)
}
return "sent", nil
}
// publishQueueStatusEvent fires a queue.status_changed event so the frontend
// can update the queue indicator.
func (h *Handlers) publishQueueStatusEvent(ctx context.Context, sessionID string, queue *messagequeue.Service) {
if h.eventBus == nil {
return
}
status := queue.GetStatus(ctx, sessionID)
_ = h.eventBus.Publish(ctx, events.MessageQueueStatusChanged, bus.NewEvent(
events.MessageQueueStatusChanged,
"mcp-handlers",
map[string]interface{}{
"session_id": sessionID,
"is_queued": status.IsQueued,
"message": status.Message,
},
))
}
// handleAskUserQuestion creates a clarification request and blocks until the user responds.
// The agent's MCP tool call stays open (same turn) while waiting. If the agent times out,
// the event-based fallback in the orchestrator handles resuming with a new turn.
func (h *Handlers) handleAskUserQuestion(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
SessionID string `json:"session_id"`
TaskID string `json:"task_id"`
Question clarification.Question `json:"question"`
Context string `json:"context"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.SessionID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
}
if req.Question.Prompt == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "question.prompt is required", nil)
}
// Generate question ID if missing
if req.Question.ID == "" {
req.Question.ID = "q1"
}
// Generate option IDs if missing
for i := range req.Question.Options {
if req.Question.Options[i].ID == "" {
req.Question.Options[i].ID = generateOptionID(0, i)
}
}
// Look up task ID from session if not provided
taskID := req.TaskID
if taskID == "" {
session, err := h.sessionRepo.GetTaskSession(ctx, req.SessionID)
if err != nil {
h.logger.Warn("failed to look up task for session",
zap.String("session_id", req.SessionID),
zap.Error(err))
} else if session != nil {
taskID = session.TaskID
}
}
// Create the clarification request
clarificationReq := &clarification.Request{
SessionID: req.SessionID,
TaskID: taskID,
Question: req.Question,
Context: req.Context,
}
pendingID := h.clarificationSvc.CreateRequest(clarificationReq)
// Create the message in the database (triggers WS event to frontend)
if h.messageCreator != nil {
if _, err := h.messageCreator.CreateClarificationRequestMessage(
ctx, taskID, req.SessionID, pendingID, req.Question, req.Context,
); err != nil {
h.logger.Error("failed to create clarification request message",
zap.String("pending_id", pendingID),
zap.String("session_id", req.SessionID),
zap.Error(err))
}
}
// Update session and task states to waiting for input
h.setSessionWaitingForInput(ctx, taskID, req.SessionID)
h.logger.Info("clarification request created, waiting for user response",
zap.String("pending_id", pendingID),
zap.String("session_id", req.SessionID),
zap.String("task_id", taskID))
// Block until user responds or context is cancelled (agent MCP timeout).
// With MCP_TIMEOUT set to 2h for Claude Code, this will wait long enough.
// If the agent times out, the entry is cleaned up and the event-based
// fallback in the orchestrator handles resuming with a new turn.
resp, err := h.clarificationSvc.WaitForResponse(ctx, pendingID)
if err != nil {
h.logger.Warn("clarification wait ended without response",
zap.String("pending_id", pendingID),
zap.String("session_id", req.SessionID),
zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError,
"Clarification request timed out or was cancelled", nil)
}
// User responded — set session back to running
h.setSessionRunning(ctx, taskID, req.SessionID)
h.logger.Info("clarification answered, returning to agent",
zap.String("pending_id", pendingID),
zap.String("session_id", req.SessionID),
zap.Bool("rejected", resp.Rejected))
// Return response in format expected by agentctl's extractQuestionAnswer
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// setSessionRunning restores the session state to running after a clarification is answered.
func (h *Handlers) setSessionRunning(ctx context.Context, taskID, sessionID string) {
if err := h.sessionRepo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateRunning, ""); err != nil {
h.logger.Warn("failed to update session state to RUNNING",
zap.String("session_id", sessionID),
zap.Error(err))
}
if taskID != "" {
if err := h.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateInProgress); err != nil {
h.logger.Warn("failed to update task state to IN_PROGRESS",
zap.String("task_id", taskID),
zap.Error(err))
}
}
// Publish session state changed event
if h.eventBus != nil {
eventData := map[string]any{
"task_id": taskID,
"session_id": sessionID,
"new_state": string(models.TaskSessionStateRunning),
}
_ = h.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(
events.TaskSessionStateChanged,
"mcp-handlers",
eventData,
))
}
}
// setSessionWaitingForInput updates the session and task states to waiting for input
func (h *Handlers) setSessionWaitingForInput(ctx context.Context, taskID, sessionID string) {
// Update session state to WAITING_FOR_INPUT
if err := h.sessionRepo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateWaitingForInput, ""); err != nil {
h.logger.Warn("failed to update session state to WAITING_FOR_INPUT",
zap.String("session_id", sessionID),
zap.Error(err))
}
// Update task state to REVIEW
if taskID != "" {
if err := h.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateReview); err != nil {
h.logger.Warn("failed to update task state to REVIEW",
zap.String("task_id", taskID),
zap.Error(err))
}
}
// Publish session state changed event
if h.eventBus != nil {
eventData := map[string]interface{}{
"task_id": taskID,
"session_id": sessionID,
"new_state": string(models.TaskSessionStateWaitingForInput),
}
_ = h.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(
events.TaskSessionStateChanged,
"mcp-handlers",
eventData,
))
}
}
// generateOptionID generates an option ID for a question.
func generateOptionID(questionIndex, optionIndex int) string {
return fmt.Sprintf("q%d_opt%d", questionIndex+1, optionIndex+1)
}
// handleCreateTaskPlan creates a new task plan.
func (h *Handlers) handleCreateTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedBy string `json:"created_by"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.Content == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content is required", nil)
}
createdBy := req.CreatedBy
if createdBy == "" {
createdBy = "agent"
}
plan, err := h.planService.CreatePlan(ctx, service.CreatePlanRequest{
TaskID: req.TaskID,
Title: req.Title,
Content: req.Content,
CreatedBy: createdBy,
})
if err != nil {
if errors.Is(err, service.ErrTaskIDRequired) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create task plan: "+err.Error(), nil)
}
return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan))
}
// handleGetTaskPlan retrieves a task plan.
func (h *Handlers) handleGetTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
plan, err := h.planService.GetPlan(ctx, req.TaskID)
if err != nil {
if errors.Is(err, service.ErrTaskIDRequired) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get task plan", nil)
}
if plan == nil {
// Return empty object if no plan exists
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{})
}
return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan))
}
// handleUpdateTaskPlan updates an existing task plan.
func (h *Handlers) handleUpdateTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedBy string `json:"created_by"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.Content == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content is required", nil)
}
createdBy := req.CreatedBy
if createdBy == "" {
createdBy = "agent"
}
plan, err := h.planService.UpdatePlan(ctx, service.UpdatePlanRequest{
TaskID: req.TaskID,
Title: req.Title,
Content: req.Content,
CreatedBy: createdBy,
})
if err != nil {
if errors.Is(err, service.ErrTaskIDRequired) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
if errors.Is(err, service.ErrTaskPlanNotFound) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task plan not found", nil)
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task plan: "+err.Error(), nil)
}
return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan))
}
// handleDeleteTaskPlan deletes a task plan.
func (h *Handlers) handleDeleteTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
err := h.planService.DeletePlan(ctx, req.TaskID)
if err != nil {
if errors.Is(err, service.ErrTaskIDRequired) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
if errors.Is(err, service.ErrTaskPlanNotFound) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task plan not found", nil)
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete task plan: "+err.Error(), nil)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"success": true})
}
// handleClarificationTimeout is called by agentctl when the agent's MCP client
// disconnects while waiting for a clarification response. It cancels the pending
// clarification so the user's eventual answer goes through the event fallback path
// (new turn) instead of the primary path (which would be dropped).
func (h *Handlers) handleClarificationTimeout(_ context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
SessionID string `json:"session_id"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.SessionID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
}
cancelled := h.clarificationSvc.CancelSession(req.SessionID)
h.logger.Info("cancelled pending clarifications on agent MCP timeout",
zap.String("session_id", req.SessionID),
zap.Int("count", len(cancelled)))
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"ok": true, "cancelled": len(cancelled)})
}