diff --git a/apps/backend/cmd/agentctl/main.go b/apps/backend/cmd/agentctl/main.go index 6b97f99366..9bbd59a703 100644 --- a/apps/backend/cmd/agentctl/main.go +++ b/apps/backend/cmd/agentctl/main.go @@ -180,7 +180,12 @@ func run(cfg *config.Config, log *logger.Logger) { mcpBackendClient := mcpserver.NewChannelBackendClient(instLog) // Create MCP server using the channel-based backend client - mcpSrv := mcpserver.New(mcpBackendClient, instCfg.SessionID, instCfg.TaskID, instCfg.Port, instLog, cfg.McpLogFile, instCfg.DisableAskQuestion, instCfg.McpMode, instCfg.McpProviders) + var mcpSrv *mcpserver.Server + if instCfg.McpProfile != nil { + mcpSrv = mcpserver.NewWithProfile(mcpBackendClient, instCfg.SessionID, instCfg.TaskID, instCfg.Port, instLog, cfg.McpLogFile, instCfg.DisableAskQuestion, *instCfg.McpProfile) + } else { + mcpSrv = mcpserver.New(mcpBackendClient, instCfg.SessionID, instCfg.TaskID, instCfg.Port, instLog, cfg.McpLogFile, instCfg.DisableAskQuestion, instCfg.McpMode, instCfg.McpProviders) + } mcpSrv.SetAttachmentReporter(procMgr.PublishMCPAttachment) instLog.Info("MCP server enabled (channel-based)", zap.String("session_id", instCfg.SessionID)) diff --git a/apps/backend/cmd/mock-agent/handler.go b/apps/backend/cmd/mock-agent/handler.go index 58d0ffbd70..d3528e6d93 100644 --- a/apps/backend/cmd/mock-agent/handler.go +++ b/apps/backend/cmd/mock-agent/handler.go @@ -227,12 +227,53 @@ func stripKandevSystem(prompt string) string { var kandevSystemRegex = regexp.MustCompile(`[\s\S]*?`) +var autopilotParentReplyRegex = regexp.MustCompile(`task_id=([0-9a-fA-F-]+), reply_to_question_id=([0-9a-fA-F-]+)`) + +// handleAutopilotParentQuestion makes the mock parent follow the production +// parent-question protocol. The prompt is generated by Kandev, so this keeps +// the E2E path deterministic without adding a second test-only API. +func handleAutopilotParentQuestion(e *emitter, prompt string) bool { + if !strings.Contains(prompt, "AUTOPILOT CHILD QUESTION") { + return false + } + matches := autopilotParentReplyRegex.FindStringSubmatch(prompt) + if len(matches) != 3 { + e.text("Mock parent could not parse the autopilot question reply route.") + return true + } + childTaskID, questionID := matches[1], matches[2] + // Keep the pending state visible long enough for the desktop and mobile + // specs to observe the question icon before the deterministic answer. + waitForDelay(e.ctx, 1_000) + toolID := nextToolID() + e.startTool(toolID, "message_task_kandev", acp.ToolKindOther, map[string]any{ + "task_id": childTaskID, + "reply_to_question_id": questionID, + }) + result, err := callMCPTool("kandev", "message_task_kandev", map[string]any{ + "task_id": childTaskID, + "prompt": "Use the first safe option and continue.", + "reply_to_question_id": questionID, + }) + if err != nil { + e.completeTool(toolID, map[string]any{"error": err.Error()}) + e.text("Mock parent could not answer the autopilot question.") + return true + } + e.completeTool(toolID, map[string]any{"result": result}) + e.text("Answered the autopilot child question.") + return true +} + // handlePrompt routes a user prompt to the appropriate sequence generator. func handlePrompt(e *emitter, prompt, model string) { prompt = strings.TrimSpace(prompt) // Extract the user-facing content for command routing. cmd := stripKandevSystem(prompt) + if handleAutopilotParentQuestion(e, cmd) { + return + } // Script mode: each line is a command (e2e:message, e2e:mcp:*, etc.) if isScriptMode(cmd) { diff --git a/apps/backend/config/prompts/kandev-context.md b/apps/backend/config/prompts/kandev-context.md index e4267c4991..2b189078b7 100644 --- a/apps/backend/config/prompts/kandev-context.md +++ b/apps/backend/config/prompts/kandev-context.md @@ -5,8 +5,10 @@ Kandev Task ID: {task_id} Kandev Session ID: {session_id} Use these IDs when calling tools that require task_id or session_id. +{autopilot_section} + Available tools: -- ask_user_question_kandev: Ask the user one or more clarifying questions in a single tool call. Use this whenever you need user input before proceeding. Required params: questions (array of 1-4 question objects; each object has prompt (string) and options (array of 2-6 {label, description})). Optional: context (string). +{question_tool_section} {step_complete_section}{task_title_section}- create_task_plan_kandev: Save an implementation plan for the current task. Required params: task_id, content (markdown). Optional: title. - get_task_plan_kandev: Retrieve the current plan for a task (includes any user edits). Required params: task_id. - update_task_plan_kandev: Update an existing plan. Required params: task_id, content (markdown). Optional: title. diff --git a/apps/backend/internal/agent/runtime/agentctl/control.go b/apps/backend/internal/agent/runtime/agentctl/control.go index 9b930ce77f..e0f8242700 100644 --- a/apps/backend/internal/agent/runtime/agentctl/control.go +++ b/apps/backend/internal/agent/runtime/agentctl/control.go @@ -13,6 +13,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/subproc" + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" "github.com/kandev/kandev/internal/task/models" "go.uber.org/zap" ) @@ -41,23 +42,24 @@ type McpServerConfig struct { // CreateInstanceRequest contains the parameters for creating a new agent instance. type CreateInstanceRequest struct { - ID string `json:"id,omitempty"` - WorkspacePath string `json:"workspace_path"` - AgentCommand string `json:"agent_command,omitempty"` - Protocol string `json:"protocol,omitempty"` // Protocol adapter to use (acp, rest, mcp, codex) - AgentType string `json:"agent_type,omitempty"` // Agent type ID for debug file naming (e.g., "codex", "auggie") - WorkspaceFlag string `json:"workspace_flag,omitempty"` // CLI flag for workspace path (e.g., "--workspace-root") - Env map[string]string `json:"env,omitempty"` - AutoStart bool `json:"auto_start,omitempty"` - AutoApprovePermissions *bool `json:"auto_approve_permissions,omitempty"` - McpServers []McpServerConfig `json:"mcp_servers,omitempty"` - SessionID string `json:"session_id,omitempty"` // Task session ID for MCP tool calls - TaskID string `json:"task_id,omitempty"` // Task ID for MCP plan tool calls (server-side injection) - DisableAskQuestion bool `json:"disable_ask_question,omitempty"` // Disable ask_user_question MCP tool (TUI agents) - AssumeMcpSse bool `json:"assume_mcp_sse,omitempty"` // Assume agent supports SSE MCP servers - AssumeMcpHttp bool `json:"assume_mcp_http,omitempty"` // Assume agent supports HTTP MCP servers - McpMode string `json:"mcp_mode,omitempty"` // MCP tool mode: "task" (default), "config", or "office" - McpProviders []string `json:"mcp_providers,omitempty"` // Supported review-automation providers + ID string `json:"id,omitempty"` + WorkspacePath string `json:"workspace_path"` + AgentCommand string `json:"agent_command,omitempty"` + Protocol string `json:"protocol,omitempty"` // Protocol adapter to use (acp, rest, mcp, codex) + AgentType string `json:"agent_type,omitempty"` // Agent type ID for debug file naming (e.g., "codex", "auggie") + WorkspaceFlag string `json:"workspace_flag,omitempty"` // CLI flag for workspace path (e.g., "--workspace-root") + Env map[string]string `json:"env,omitempty"` + AutoStart bool `json:"auto_start,omitempty"` + AutoApprovePermissions *bool `json:"auto_approve_permissions,omitempty"` + McpServers []McpServerConfig `json:"mcp_servers,omitempty"` + SessionID string `json:"session_id,omitempty"` // Task session ID for MCP tool calls + TaskID string `json:"task_id,omitempty"` // Task ID for MCP plan tool calls (server-side injection) + DisableAskQuestion bool `json:"disable_ask_question,omitempty"` // Disable ask_user_question MCP tool (TUI agents) + AssumeMcpSse bool `json:"assume_mcp_sse,omitempty"` // Assume agent supports SSE MCP servers + AssumeMcpHttp bool `json:"assume_mcp_http,omitempty"` // Assume agent supports HTTP MCP servers + McpMode string `json:"mcp_mode,omitempty"` // MCP tool mode: "task" (default), "config", or "office" + McpProviders []string `json:"mcp_providers,omitempty"` // Supported review-automation providers + McpProfile *mcpprofile.Context `json:"mcp_profile,omitempty"` // Backend-owned typed MCP tool profile // RequiresProcessKill tells agentctl to skip the graceful stdin-close wait // and reap the agent process group immediately. Required for agents whose // runtime keeps child processes (e.g. MCP servers) alive when stdin closes diff --git a/apps/backend/internal/agent/runtime/lifecycle/container.go b/apps/backend/internal/agent/runtime/lifecycle/container.go index 7827b3bb12..dbfb2454cd 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/container.go +++ b/apps/backend/internal/agent/runtime/lifecycle/container.go @@ -19,6 +19,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/gitconfigenv" "github.com/kandev/kandev/internal/githubauth" + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" "github.com/kandev/kandev/internal/task/models" ) @@ -50,6 +51,7 @@ type ContainerConfig struct { McpServers []McpServerConfig McpMode string McpProviders []string + McpProfile *mcpprofile.Context PrepareScript string // Script to run inside container before agent starts (e.g., clone repo) ImageTagOverride string // If set, replaces the agent runtime's default image (e.g. profile.config.image_tag) LocalClonePath string // Host path for file:// repository clone URLs; mounted read-only at the same path. @@ -101,6 +103,7 @@ func buildContainerCreateInstanceRequest( AssumeMcpHttp: assumeMcpHttp, McpMode: config.McpMode, McpProviders: config.McpProviders, + McpProfile: config.McpProfile, RequiresProcessKill: requiresProcessKill, StripEnv: stripEnv, BaseBranches: config.BaseBranches, diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go index e7fcd8e33f..5a6eb4597e 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go @@ -13,6 +13,7 @@ import ( agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/agentruntime" + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -364,8 +365,9 @@ type ExecutorCreateRequest struct { PreviousExecutionID string // Non-empty when reconnecting to a previous execution McpMode string // MCP tool mode: "task" (default), "config", or "office" McpProviders []string // Normalized provider capabilities attached to the task - AuthToken string // Previously handshaken agentctl token for reconnects - BootstrapNonce string // Stored nonce for re-handshake after container restart + McpProfile *mcpprofile.Context + AuthToken string // Previously handshaken agentctl token for reconnects + BootstrapNonce string // Stored nonce for re-handshake after container restart // OnProgress is an optional callback for streaming preparation progress. // Executors that perform multi-step setup (e.g. Sprites, remote Docker) can diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go index db9eaf52c4..12aca4df5e 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go @@ -267,6 +267,7 @@ func (r *DockerExecutor) buildContainerLaunchConfig(req *ExecutorCreateRequest) AutoApprovePermissionsOverride: req.AutoApprovePermissionsOverride, McpServers: req.McpServers, McpProviders: req.McpProviders, + McpProfile: req.McpProfile, PrepareScript: prepareScript, ImageTagOverride: getMetadataString(req.Metadata, MetadataKeyImageTagOverride), LocalClonePath: localCloneMountPath(req.Metadata), @@ -561,6 +562,7 @@ func buildReconnectCreateInstanceRequest(req *ExecutorCreateRequest, instanceID AutoStart: false, McpServers: req.McpServers, McpProviders: req.McpProviders, + McpProfile: req.McpProfile, SessionID: req.SessionID, TaskID: req.TaskID, DisableAskQuestion: disableAskQuestion, diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go index 6bd80481ab..b13243abf8 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go @@ -444,6 +444,7 @@ func spriteCreateInstanceRequest(req *ExecutorCreateRequest) agentctl.CreateInst McpServers: req.McpServers, McpMode: req.McpMode, McpProviders: req.McpProviders, + McpProfile: req.McpProfile, RequiresProcessKill: requiresProcessKillFromReq(req), StripEnv: stripEnvFromReq(req), BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go index bcec1fd2d4..55ceed91be 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go @@ -598,6 +598,7 @@ func buildSSHCreateInstanceRequest(req *ExecutorCreateRequest, workspacePath str McpServers: req.McpServers, McpMode: req.McpMode, McpProviders: req.McpProviders, + McpProfile: req.McpProfile, RequiresProcessKill: requiresProcessKillFromReq(req), StripEnv: stripEnvFromReq(req), BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go index 0493a20f45..c509ee99a3 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go @@ -109,6 +109,7 @@ func buildStandaloneCreateInstanceRequest( AssumeMcpHttp: assumeMcpHttp, McpMode: req.McpMode, McpProviders: req.McpProviders, + McpProfile: req.McpProfile, RequiresProcessKill: requiresProcessKill, StripEnv: stripEnv, BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index 8db439f410..3f58d05755 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -705,6 +705,7 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st PreviousExecutionID: reqWithWorktree.PreviousExecutionID, McpMode: reqWithWorktree.McpMode, McpProviders: reqWithWorktree.McpProviders, + McpProfile: reqWithWorktree.McpProfile, AuthToken: m.revealRuntimeSecret(ctx, metadata, MetadataKeyAuthTokenSecret), BootstrapNonce: m.revealRuntimeSecret(ctx, metadata, MetadataKeyBootstrapNonceSecret), OnProgress: onProgress, diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index 4a6ce8f740..56b6f17308 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -17,6 +17,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/agentruntime" "github.com/kandev/kandev/internal/common/ports" + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" "go.opentelemetry.io/otel/trace" @@ -689,6 +690,7 @@ type LaunchRequest struct { PreviousExecutionID string // Previous execution ID for runtime reconnect McpMode string // MCP tool mode: "task" (default), "config", or "office" McpProviders []string // Normalized provider capabilities attached to the task + McpProfile *mcpprofile.Context // Environment preparation SetupScript string // Setup script to run before agent starts diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index 9d843dbdf5..27e33ebb29 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -25,6 +25,7 @@ import ( "github.com/kandev/kandev/internal/gitconfigenv" "github.com/kandev/kandev/internal/githubauth" + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" mcpproviders "github.com/kandev/kandev/internal/mcp/providers" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/pkg/agent" @@ -230,6 +231,9 @@ type InstanceConfig struct { // McpProviders limits task-mode review automation tools to attached providers. McpProviders []string + // McpProfile is the backend-owned typed MCP tool profile. + McpProfile *mcpprofile.Context + // AuthToken is a shared secret for authenticating requests. // Inherited from the parent Config at instance creation time. AuthToken string @@ -434,6 +438,10 @@ func applyOverrides(cfg *InstanceConfig, overrides *InstanceOverrides) { if overrides.McpProviders != nil { cfg.McpProviders = mcpproviders.Normalize(overrides.McpProviders) } + if overrides.McpProfile != nil { + profileContext := *overrides.McpProfile + cfg.McpProfile = &profileContext + } if overrides.RequiresProcessKill { cfg.RequiresProcessKill = true } @@ -487,6 +495,7 @@ type InstanceOverrides struct { AssumeMcpHttp bool McpMode string McpProviders []string + McpProfile *mcpprofile.Context RequiresProcessKill bool StripEnv []string BaseBranches map[string]string diff --git a/apps/backend/internal/agentctl/server/instance/instance.go b/apps/backend/internal/agentctl/server/instance/instance.go index ff32f6dd3d..e0e26497bb 100644 --- a/apps/backend/internal/agentctl/server/instance/instance.go +++ b/apps/backend/internal/agentctl/server/instance/instance.go @@ -9,6 +9,7 @@ import ( "sync/atomic" "time" + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" "github.com/kandev/kandev/internal/task/models" ) @@ -165,7 +166,8 @@ type CreateRequest struct { McpMode string `json:"mcp_mode,omitempty"` // McpProviders limits task-mode review automation tools to attached providers. - McpProviders []string `json:"mcp_providers,omitempty"` + McpProviders []string `json:"mcp_providers,omitempty"` + McpProfile *mcpprofile.Context `json:"mcp_profile,omitempty"` // RequiresProcessKill forces the agent's process group to be killed on // shutdown instead of relying on stdin close. Required for agents whose diff --git a/apps/backend/internal/agentctl/server/instance/manager.go b/apps/backend/internal/agentctl/server/instance/manager.go index 4de6e454d5..a26e40c1dc 100644 --- a/apps/backend/internal/agentctl/server/instance/manager.go +++ b/apps/backend/internal/agentctl/server/instance/manager.go @@ -176,6 +176,7 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea AssumeMcpHttp: req.AssumeMcpHttp, McpMode: req.McpMode, McpProviders: req.McpProviders, + McpProfile: req.McpProfile, RequiresProcessKill: req.RequiresProcessKill, StripEnv: req.StripEnv, BaseBranches: req.BaseBranches, diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index ad78016f46..9469ca5fc5 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -206,6 +206,7 @@ func buildLifecycleLaunchRequest( PreviousExecutionID: req.PreviousExecutionID, McpMode: req.McpMode, McpProviders: req.McpProviders, + McpProfile: req.McpProfile, IsEphemeral: req.IsEphemeral, IsPassthrough: req.IsPassthrough, SetupScript: req.SetupScript, diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 2b6650c9fa..500a36e56e 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -355,6 +355,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPGetTaskConversation, h.handleGetTaskConversation) d.RegisterFunc(ws.ActionMCPListTaskSessions, h.handleListTaskSessions) d.RegisterFunc(ws.ActionMCPAskUserQuestion, h.handleAskUserQuestion) + d.RegisterFunc(ws.ActionMCPAskParentQuestion, h.handleAskParentQuestion) d.RegisterFunc(ws.ActionMCPCreateTaskPlan, h.handleCreateTaskPlan) d.RegisterFunc(ws.ActionMCPGetTaskPlan, h.handleGetTaskPlan) d.RegisterFunc(ws.ActionMCPUpdateTaskPlan, h.handleUpdateTaskPlan) @@ -569,6 +570,7 @@ func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.M WorkspaceMode string `json:"workspace_mode"` Title string `json:"title"` Description string `json:"description"` + Autopilot bool `json:"autopilot"` 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 @@ -699,6 +701,7 @@ func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.M WorkflowStepID: req.WorkflowStepID, Title: req.Title, Description: req.Description, + Autopilot: req.Autopilot, Repositories: repos, BlockedBy: req.BlockedBy, AssigneeAgentProfileID: req.AssigneeAgentProfileID, @@ -2037,12 +2040,13 @@ func (h *Handlers) publishStepCompletionEvent( // unattributed message. func (h *Handlers) handleMessageTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { - TaskID string `json:"task_id"` - SessionID string `json:"session_id"` - Prompt string `json:"prompt"` - SenderTaskID string `json:"sender_task_id"` - SenderSessionID string `json:"sender_session_id"` - DeliveryMode string `json:"delivery_mode"` + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + Prompt string `json:"prompt"` + SenderTaskID string `json:"sender_task_id"` + SenderSessionID string `json:"sender_session_id"` + DeliveryMode string `json:"delivery_mode"` + ReplyToQuestionID string `json:"reply_to_question_id"` } if err := json.Unmarshal(msg.Payload, &req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) @@ -2098,6 +2102,22 @@ func (h *Handlers) handleMessageTask(ctx context.Context, msg *ws.Message) (*ws. "failed to look up target task: "+err.Error(), nil) } + parentReply, err := h.validateParentQuestionReply(ctx, req.ReplyToQuestionID, req.TaskID, senderTask, targetTask) + if err != nil { + var parentQuestionErr *parentQuestionError + if errors.As(err, &parentQuestionErr) { + return ws.NewError(msg.ID, msg.Action, parentQuestionErr.code, parentQuestionErr.message, nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil) + } + if parentReply != nil && parentReply.alreadyAnswered { + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "task_id": req.TaskID, + "reply_to_question_id": req.ReplyToQuestionID, + "status": "already_answered", + }) + } + session, errResp := h.resolveMessageTargetSession(ctx, msg, req.TaskID, req.SessionID) if errResp != nil { return errResp, nil @@ -2106,6 +2126,10 @@ func (h *Handlers) handleMessageTask(ctx context.Context, msg *ws.Message) (*ws. prompt := h.appendPromptReferenceExpansionContext(ctx, req.Prompt) senderSessionName := h.lookupSenderSessionName(ctx, req.SenderTaskID, req.SenderSessionID) wrappedPrompt, senderMeta := wrapAgentMessage(prompt, senderTask, req.SenderSessionID, senderSessionName, req.SenderTaskID == req.TaskID) + if req.ReplyToQuestionID != "" { + senderMeta[models.MetaKeyParentQuestionID] = req.ReplyToQuestionID + senderMeta[models.MetaKeyParentQuestionResponse] = req.Prompt + } // Interrupt intent is explicit, never inferred: a parent/child // relationship alone no longer drives interruption (see @@ -2126,9 +2150,22 @@ func (h *Handlers) handleMessageTask(ctx context.Context, msg *ws.Message) (*ws. return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeForbidden, `delivery_mode="interrupt" is only allowed when the sender is the target task's direct parent`, nil) } + if parentReply != nil { + // Claim the durable question before dispatch. A failed status update after + // delivery would make a retry send the same answer a second time. + if err := h.markParentQuestionAnswered(ctx, parentReply.message, req.Prompt); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "parent question could not be claimed: "+err.Error(), nil) + } + } result, err := h.dispatchTaskMessage(ctx, req.TaskID, session, wrappedPrompt, senderMeta, wantsInterrupt, req.SessionID != "") if err != nil { + if parentReply != nil { + if restoreErr := h.restoreParentQuestionPending(ctx, parentReply.message); restoreErr != nil { + h.logger.Error("failed to restore parent question after answer dispatch failure", + zap.String("question_id", parentReply.message.ID), zap.Error(restoreErr)) + } + } var qfErr *queueFullDispatchError if errors.As(err, &qfErr) { return ws.NewError(msg.ID, msg.Action, messagequeue.QueueFullErrorCode, @@ -2137,7 +2174,6 @@ func (h *Handlers) handleMessageTask(ctx context.Context, msg *ws.Message) (*ws. } 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": result.sessionID, @@ -3403,12 +3439,27 @@ func (h *Handlers) setSessionWaitingForInput(ctx context.Context, taskID, sessio return } - // Update task state to REVIEW + // Update task state to REVIEW. Use the task service when available so the + // state change publishes the pending-action projection after the + // clarification message is persisted. The repository fallback keeps this + // helper usable in small handler tests and alternate integrations. if taskID != "" { - if err := h.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateReview); err != nil { + var stateErr error + if h.taskSvc != nil { + _, stateErr = h.taskSvc.UpdateTaskStateIfSessionState( + ctx, + taskID, + sessionID, + models.TaskSessionStateWaitingForInput, + v1.TaskStateReview, + ) + } else if h.taskRepo != nil { + stateErr = h.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateReview) + } + if stateErr != nil { h.logger.Warn("failed to update task state to REVIEW", zap.String("task_id", taskID), - zap.Error(err)) + zap.Error(stateErr)) } } diff --git a/apps/backend/internal/mcp/handlers/handlers_test.go b/apps/backend/internal/mcp/handlers/handlers_test.go index 4e1f82f69f..1e7f735354 100644 --- a/apps/backend/internal/mcp/handlers/handlers_test.go +++ b/apps/backend/internal/mcp/handlers/handlers_test.go @@ -606,6 +606,50 @@ func TestSetSessionRunning_PublishesTaskStateBeforeSession(t *testing.T) { assert.Equal(t, v1.TaskStateInProgress, task.State) } +func TestSetSessionWaitingForInput_PublishesPendingActionBeforeSession(t *testing.T) { + svc, repo, eventBus := newTestTaskServiceWithEventBus(t) + const taskID = "task-clarification-waiting-order" + const sessionID = "session-clarification-waiting-order" + seedMCPHandlerSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning) + + _, err := svc.CreateMessage(context.Background(), &service.CreateMessageRequest{ + TaskSessionID: sessionID, + TaskID: taskID, + Content: "Need a parent decision", + AuthorType: "agent", + Type: string(models.MessageTypeClarificationRequest), + Metadata: map[string]interface{}{"status": "pending"}, + RequestsInput: true, + }) + require.NoError(t, err) + + var published []*bus.Event + sub, err := eventBus.Subscribe(">", func(_ context.Context, event *bus.Event) error { + if event.Type == events.TaskStateChanged || event.Type == events.TaskSessionStateChanged { + published = append(published, event) + } + return nil + }) + require.NoError(t, err) + t.Cleanup(func() { _ = sub.Unsubscribe() }) + + h := &Handlers{ + taskSvc: svc, + sessionRepo: repo, + taskRepo: repo, + eventBus: eventBus, + logger: testLogger(t).WithFields(), + } + h.setSessionWaitingForInput(context.Background(), taskID, sessionID) + + require.Len(t, published, 2) + assert.Equal(t, events.TaskStateChanged, published[0].Type) + data, ok := published[0].Data.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, string(models.TaskPendingActionClarification), data["task_pending_action"]) + assert.Equal(t, events.TaskSessionStateChanged, published[1].Type) +} + type failingClarificationTaskRepository struct { repository.TaskRepository err error diff --git a/apps/backend/internal/mcp/handlers/parent_question.go b/apps/backend/internal/mcp/handlers/parent_question.go new file mode 100644 index 0000000000..608d37bb70 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/parent_question.go @@ -0,0 +1,273 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/clarification" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +const ( + parentQuestionStatusPending = "pending" + parentQuestionStatusAnswered = "answered" +) + +type parentQuestionRequest struct { + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + Questions []clarification.Question `json:"questions"` + Context string `json:"context"` +} + +type parentQuestionError struct { + code string + message string +} + +func (e *parentQuestionError) Error() string { + return e.message +} + +type parentQuestionTarget struct { + childSession *models.TaskSession + childTask *models.Task + parentTask *models.Task + parentSession *models.TaskSession +} + +func parentQuestionRequestFromMessage(msg *ws.Message) (parentQuestionRequest, *parentQuestionError) { + var req parentQuestionRequest + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return req, &parentQuestionError{ws.ErrorCodeBadRequest, "Invalid payload: " + err.Error()} + } + if req.SessionID == "" { + return req, &parentQuestionError{ws.ErrorCodeValidation, "session_id is required"} + } + if errMsg := clarification.NormalizeAndValidateQuestions(req.Questions); errMsg != "" { + return req, &parentQuestionError{ws.ErrorCodeValidation, errMsg} + } + return req, nil +} + +func (h *Handlers) loadParentQuestionTarget(ctx context.Context, req parentQuestionRequest) (*parentQuestionTarget, *parentQuestionError) { + if h.taskSvc == nil || h.sessionRepo == nil || h.taskRepo == nil { + return nil, &parentQuestionError{ws.ErrorCodeInternalError, "task services are not available"} + } + childSession, err := h.taskSvc.GetTaskSession(ctx, req.SessionID) + if err != nil || childSession == nil { + return nil, &parentQuestionError{ws.ErrorCodeNotFound, "child session not found"} + } + taskID := req.TaskID + if taskID == "" { + taskID = childSession.TaskID + } + if childSession.TaskID != taskID { + return nil, &parentQuestionError{ws.ErrorCodeValidation, "session_id does not belong to task_id"} + } + childTask, err := h.taskSvc.GetTask(ctx, taskID) + if err != nil || childTask == nil { + return nil, &parentQuestionError{ws.ErrorCodeNotFound, "child task not found"} + } + parentTask, parentSession, parentErr := h.loadParentQuestionParent(ctx, childTask) + if parentErr != nil { + return nil, parentErr + } + return &parentQuestionTarget{ + childSession: childSession, + childTask: childTask, + parentTask: parentTask, + parentSession: parentSession, + }, nil +} + +func (h *Handlers) loadParentQuestionParent(ctx context.Context, childTask *models.Task) (*models.Task, *models.TaskSession, *parentQuestionError) { + if !childTask.Autopilot { + return nil, nil, &parentQuestionError{ws.ErrorCodeValidation, "ask_parent_question_kandev is only available to autopilot tasks"} + } + if childTask.ParentID == "" { + return nil, nil, &parentQuestionError{ws.ErrorCodeValidation, "an autopilot root task has no parent to ask"} + } + parentTask, err := h.taskSvc.GetTask(ctx, childTask.ParentID) + if err != nil || parentTask == nil { + return nil, nil, &parentQuestionError{ws.ErrorCodeNotFound, "direct parent task not found"} + } + parentSession, err := h.taskSvc.GetPrimarySession(ctx, parentTask.ID) + if err != nil || parentSession == nil { + return nil, nil, &parentQuestionError{ws.ErrorCodeNotFound, "direct parent task has no primary session"} + } + return parentTask, parentSession, nil +} + +// handleAskParentQuestion records a durable question for an autopilot child, +// sends the question to its direct parent, and pauses the child turn. It does +// not wait for the answer: the parent answers with message_task_kandev and the +// child starts a new turn through the normal message dispatch path. +func (h *Handlers) handleAskParentQuestion(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + req, requestErr := parentQuestionRequestFromMessage(msg) + if requestErr != nil { + return ws.NewError(msg.ID, msg.Action, requestErr.code, requestErr.message, nil) + } + target, targetErr := h.loadParentQuestionTarget(ctx, req) + if targetErr != nil { + return ws.NewError(msg.ID, msg.Action, targetErr.code, targetErr.message, nil) + } + + questionID := uuid.NewString() + questionMetadata := parentQuestionMetadata(questionID, target.parentTask, target.childTask, req.Questions, req.Context) + question, err := h.taskSvc.CreateMessageWithID(ctx, questionID, &service.CreateMessageRequest{ + TaskSessionID: target.childSession.ID, + TaskID: target.childTask.ID, + Content: req.Questions[0].Prompt, + AuthorType: "agent", + Type: string(models.MessageTypeClarificationRequest), + Metadata: questionMetadata, + RequestsInput: true, + }) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to persist parent question: "+err.Error(), nil) + } + + parentPrompt := formatParentQuestionPrompt(questionID, target.parentTask, target.childTask, req.Questions, req.Context) + parentMetadata := map[string]interface{}{ + models.MetaKeyParentQuestionID: questionID, + models.MetaKeyParentQuestion: true, + models.MetaKeyParentQuestionParentID: target.parentTask.ID, + models.MetaKeyParentQuestionChildID: target.childTask.ID, + "sender_task_id": target.childTask.ID, + "sender_task_title": target.childTask.Title, + "sender_session_id": target.childSession.ID, + } + if _, dispatchErr := h.dispatchTaskMessage(ctx, target.parentTask.ID, target.parentSession, parentPrompt, parentMetadata, false, false); dispatchErr != nil { + _ = h.taskSvc.DeleteMessage(ctx, question.ID) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to send question to direct parent: "+dispatchErr.Error(), nil) + } + + // Mark the child as waiting before asking the orchestrator to cancel the + // active turn. The pause is best effort because the durable message and + // parent delivery are already committed; a later lifecycle watchdog can + // still reconcile the session if the process exits during the race. + h.setSessionWaitingForInput(ctx, target.childTask.ID, target.childSession.ID) + if h.inputPauser != nil { + if _, pauseErr := h.inputPauser.PauseForClarificationInput(context.WithoutCancel(ctx), target.childSession.ID); pauseErr != nil { + h.logger.Warn("failed to pause autopilot child after parent question", + zap.String("question_id", questionID), zap.String("session_id", target.childSession.ID), zap.Error(pauseErr)) + } + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "question_id": questionID, + "status": "waiting_for_parent", + "parent_task_id": target.parentTask.ID, + }) +} + +// parentQuestionReply is the result of validating a reply_to_question_id. +// A terminal answer is returned as alreadyAnswered so retries do not dispatch +// the same answer twice. +type parentQuestionReply struct { + message *models.Message + alreadyAnswered bool +} + +func (h *Handlers) validateParentQuestionReply(ctx context.Context, questionID, targetTaskID string, senderTask, targetTask *models.Task) (*parentQuestionReply, error) { + if strings.TrimSpace(questionID) == "" { + return nil, nil + } + if h.taskSvc == nil { + return nil, errors.New("task service is not available") + } + question, err := h.taskSvc.GetMessage(ctx, questionID) + if err != nil || question == nil { + return nil, fmt.Errorf("parent question %q not found", questionID) + } + if err := validateParentQuestionOwnership(question, targetTaskID, senderTask, targetTask); err != nil { + return nil, err + } + metadata := question.Metadata + status, _ := metadata[models.MetaKeyParentQuestionStatus].(string) + if status == parentQuestionStatusAnswered { + return &parentQuestionReply{message: question, alreadyAnswered: true}, nil + } + if status != parentQuestionStatusPending { + return nil, fmt.Errorf("parent question %q is no longer pending", questionID) + } + return &parentQuestionReply{message: question}, nil +} + +func validateParentQuestionOwnership(question *models.Message, targetTaskID string, senderTask, targetTask *models.Task) error { + if question.Type != models.MessageTypeClarificationRequest || question.TaskID != targetTaskID || targetTask == nil || senderTask == nil { + return errors.New("reply_to_question_id does not belong to the target child task") + } + metadata := question.Metadata + parentMarker, _ := metadata[models.MetaKeyParentQuestion].(bool) + parentID, _ := metadata[models.MetaKeyParentQuestionParentID].(string) + childID, _ := metadata[models.MetaKeyParentQuestionChildID].(string) + if !parentMarker || parentID == "" || childID == "" || parentID != senderTask.ID || targetTask.ParentID != senderTask.ID || childID != targetTask.ID { + return errors.New("only the direct parent task can answer this question") + } + return nil +} + +func (h *Handlers) markParentQuestionAnswered(ctx context.Context, question *models.Message, answer string) error { + if question == nil || h.taskSvc == nil { + return errors.New("parent question is not available") + } + metadata := cloneTaskMessageMetadataMap(question.Metadata) + metadata["status"] = parentQuestionStatusAnswered + metadata[models.MetaKeyParentQuestionStatus] = parentQuestionStatusAnswered + metadata[models.MetaKeyParentQuestionResponse] = answer + metadata["parent_question_answered_at"] = time.Now().UTC().Format(time.RFC3339Nano) + question.Metadata = metadata + question.RequestsInput = false + return h.taskSvc.UpdateMessage(ctx, question) +} + +func (h *Handlers) restoreParentQuestionPending(ctx context.Context, question *models.Message) error { + if question == nil || h.taskSvc == nil { + return errors.New("parent question is not available") + } + metadata := cloneTaskMessageMetadataMap(question.Metadata) + metadata["status"] = parentQuestionStatusPending + metadata[models.MetaKeyParentQuestionStatus] = parentQuestionStatusPending + delete(metadata, models.MetaKeyParentQuestionResponse) + delete(metadata, "parent_question_answered_at") + question.Metadata = metadata + question.RequestsInput = true + return h.taskSvc.UpdateMessage(ctx, question) +} + +func parentQuestionMetadata(questionID string, parentTask, childTask *models.Task, questions []clarification.Question, questionContext string) map[string]interface{} { + questionData := make([]clarification.Question, len(questions)) + copy(questionData, questions) + return map[string]interface{}{ + "status": parentQuestionStatusPending, + "pending_id": questionID, + "question_id": questionID, + "questions": questionData, + "question": questions[0], + "context": questionContext, + models.MetaKeyParentQuestionID: questionID, + models.MetaKeyParentQuestion: true, + models.MetaKeyParentQuestionParentID: parentTask.ID, + models.MetaKeyParentQuestionChildID: childTask.ID, + models.MetaKeyParentQuestionStatus: parentQuestionStatusPending, + } +} + +func formatParentQuestionPrompt(questionID string, parentTask, childTask *models.Task, questions []clarification.Question, questionContext string) string { + data, _ := json.MarshalIndent(questions, "", " ") + contextLine := strings.TrimSpace(questionContext) + if contextLine == "" { + contextLine = "(no additional context)" + } + return fmt.Sprintf("AUTOPILOT CHILD QUESTION\n\nChild task: %s (%s)\nParent task: %s (%s)\nQuestion ID: %s\nContext: %s\n\nQuestions:\n%s\n\nReply to this child with message_task_kandev using task_id=%s, reply_to_question_id=%s, and your answer as the prompt. Do not use ask_user_question_kandev for this child question. The child turn is paused until you answer.", childTask.Title, childTask.ID, parentTask.Title, parentTask.ID, questionID, contextLine, data, childTask.ID, questionID) +} diff --git a/apps/backend/internal/mcp/handlers/parent_question_test.go b/apps/backend/internal/mcp/handlers/parent_question_test.go new file mode 100644 index 0000000000..96188578a0 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/parent_question_test.go @@ -0,0 +1,189 @@ +package handlers + +import ( + "context" + "encoding/json" + "testing" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" + v1 "github.com/kandev/kandev/pkg/api/v1" + ws "github.com/kandev/kandev/pkg/websocket" + "github.com/stretchr/testify/require" +) + +func seedParentQuestionScenario(t *testing.T, svc *service.Service, repo seedRepo) (*models.Task, *models.Task, *models.TaskSession, *models.TaskSession) { + t.Helper() + ctx := context.Background() + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-parent-question", Name: "Parent questions"})) + require.NoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-parent-question", WorkspaceID: "ws-parent-question", Name: "Board"})) + parentResult, err := svc.CreateTask(ctx, &service.CreateTaskRequest{ + WorkspaceID: "ws-parent-question", + WorkflowID: "wf-parent-question", + Title: "Parent task", + }) + require.NoError(t, err) + parent := parentResult.Task + childResult, err := svc.CreateTask(ctx, &service.CreateTaskRequest{ + WorkspaceID: "ws-parent-question", + WorkflowID: "wf-parent-question", + ParentID: parent.ID, + Title: "Autopilot child", + Autopilot: true, + }) + require.NoError(t, err) + child := childResult.Task + parentSession := &models.TaskSession{ID: "parent-question-parent-session", TaskID: parent.ID, IsPrimary: true, State: models.TaskSessionStateRunning} + childSession := &models.TaskSession{ID: "parent-question-child-session", TaskID: child.ID, IsPrimary: true, State: models.TaskSessionStateRunning} + require.NoError(t, repo.CreateTaskSession(ctx, parentSession)) + require.NoError(t, repo.CreateTaskSession(ctx, childSession)) + return parent, child, parentSession, childSession +} + +func parentQuestionMessage(t *testing.T, taskID, sessionID string) *ws.Message { + t.Helper() + return makeWSMessage(t, ws.ActionMCPAskParentQuestion, map[string]interface{}{ + "task_id": taskID, + "session_id": sessionID, + "questions": []map[string]interface{}{{ + "id": "database", + "prompt": "Which database should I use?", + "options": []map[string]interface{}{ + {"label": "SQLite", "description": "Use the embedded database"}, + {"label": "Postgres", "description": "Use the hosted database"}, + }, + }}, + "context": "The migration needs a database choice.", + }) +} + +func TestHandleAskParentQuestion_PersistsRoutesAndPauses(t *testing.T) { + svc, repo := newTestTaskService(t) + parent, child, parentSession, childSession := seedParentQuestionScenario(t, svc, repo) + h, orch := newMessageTaskHandler(t, svc, repo) + pauser := &recordingClarificationInputPauser{} + h.inputPauser = pauser + + resp, err := h.handleAskParentQuestion(context.Background(), parentQuestionMessage(t, child.ID, childSession.ID)) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Payload, &payload)) + questionID, ok := payload["question_id"].(string) + require.True(t, ok) + require.Equal(t, "waiting_for_parent", payload["status"]) + require.Equal(t, parent.ID, payload["parent_task_id"]) + require.Equal(t, []string{childSession.ID}, pauser.sessions) + + question, err := svc.GetMessage(context.Background(), questionID) + require.NoError(t, err) + require.Equal(t, models.MessageTypeClarificationRequest, question.Type) + require.True(t, question.RequestsInput) + require.Equal(t, true, question.Metadata[models.MetaKeyParentQuestion]) + require.Equal(t, "pending", question.Metadata[models.MetaKeyParentQuestionStatus]) + require.Equal(t, parent.ID, question.Metadata[models.MetaKeyParentQuestionParentID]) + require.Equal(t, child.ID, question.Metadata[models.MetaKeyParentQuestionChildID]) + + childSessionAfter, err := repo.GetTaskSession(context.Background(), childSession.ID) + require.NoError(t, err) + require.Equal(t, models.TaskSessionStateWaitingForInput, childSessionAfter.State) + childTaskAfter, err := svc.GetTask(context.Background(), child.ID) + require.NoError(t, err) + require.Equal(t, v1.TaskStateReview, childTaskAfter.State) + + status := orch.queue.GetStatus(context.Background(), parentSession.ID) + require.Len(t, status.Entries, 1) + require.Equal(t, questionID, status.Entries[0].Metadata[models.MetaKeyParentQuestionID]) + require.Contains(t, status.Entries[0].Content, questionID) + require.Contains(t, status.Entries[0].Content, "Which database should I use?") +} + +func TestHandleMessageTask_AnswersParentQuestionIdempotently(t *testing.T) { + svc, repo := newTestTaskService(t) + parent, child, _, childSession := seedParentQuestionScenario(t, svc, repo) + h, _ := newMessageTaskHandler(t, svc, repo) + h.inputPauser = &recordingClarificationInputPauser{} + + questionResp, err := h.handleAskParentQuestion(context.Background(), parentQuestionMessage(t, child.ID, childSession.ID)) + require.NoError(t, err) + var questionPayload map[string]interface{} + require.NoError(t, json.Unmarshal(questionResp.Payload, &questionPayload)) + questionID := questionPayload["question_id"].(string) + + answer := senderPayload(child.ID, "Use Postgres.", parent.ID) + answer["reply_to_question_id"] = questionID + answerResp, err := h.handleMessageTask(context.Background(), makeWSMessage(t, ws.ActionMCPMessageTask, answer)) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, answerResp.Type) + + question, err := svc.GetMessage(context.Background(), questionID) + require.NoError(t, err) + require.Equal(t, "answered", question.Metadata[models.MetaKeyParentQuestionStatus]) + require.Equal(t, "Use Postgres.", question.Metadata[models.MetaKeyParentQuestionResponse]) + + answerAgain, err := h.handleMessageTask(context.Background(), makeWSMessage(t, ws.ActionMCPMessageTask, answer)) + require.NoError(t, err) + var answerAgainPayload map[string]interface{} + require.NoError(t, json.Unmarshal(answerAgain.Payload, &answerAgainPayload)) + require.Equal(t, "already_answered", answerAgainPayload["status"]) +} + +func TestHandleAskParentQuestion_RejectsRootTask(t *testing.T) { + svc, repo := newTestTaskService(t) + parent, _, _, _ := seedParentQuestionScenario(t, svc, repo) + rootSession := &models.TaskSession{ID: "parent-question-root-session", TaskID: parent.ID, IsPrimary: true, State: models.TaskSessionStateRunning} + require.NoError(t, repo.CreateTaskSession(context.Background(), rootSession)) + h, _ := newMessageTaskHandler(t, svc, repo) + + resp, err := h.handleAskParentQuestion(context.Background(), parentQuestionMessage(t, parent.ID, rootSession.ID)) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeValidation) +} + +func TestHandleAskParentQuestion_RejectsNonAutopilotTask(t *testing.T) { + svc, repo := newTestTaskService(t) + parent, _, _, _ := seedParentQuestionScenario(t, svc, repo) + normalChildResult, err := svc.CreateTask(context.Background(), &service.CreateTaskRequest{ + WorkspaceID: "ws-parent-question", + WorkflowID: "wf-parent-question", + ParentID: parent.ID, + Title: "Normal child", + }) + require.NoError(t, err) + normalChild := normalChildResult.Task + normalSession := &models.TaskSession{ID: "parent-question-normal-session", TaskID: normalChild.ID, IsPrimary: true, State: models.TaskSessionStateRunning} + require.NoError(t, repo.CreateTaskSession(context.Background(), normalSession)) + h, _ := newMessageTaskHandler(t, svc, repo) + + resp, err := h.handleAskParentQuestion(context.Background(), parentQuestionMessage(t, normalChild.ID, normalSession.ID)) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeValidation) +} + +func TestHandleMessageTask_RejectsParentQuestionReplyFromUnrelatedTask(t *testing.T) { + svc, repo := newTestTaskService(t) + parent, child, _, childSession := seedParentQuestionScenario(t, svc, repo) + strangerResult, err := svc.CreateTask(context.Background(), &service.CreateTaskRequest{ + WorkspaceID: "ws-parent-question", + WorkflowID: "wf-parent-question", + Title: "Unrelated task", + }) + require.NoError(t, err) + stranger := strangerResult.Task + strangerSession := &models.TaskSession{ID: "parent-question-stranger-session", TaskID: stranger.ID, IsPrimary: true, State: models.TaskSessionStateRunning} + require.NoError(t, repo.CreateTaskSession(context.Background(), strangerSession)) + h, _ := newMessageTaskHandler(t, svc, repo) + h.inputPauser = &recordingClarificationInputPauser{} + + questionResp, err := h.handleAskParentQuestion(context.Background(), parentQuestionMessage(t, child.ID, childSession.ID)) + require.NoError(t, err) + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(questionResp.Payload, &payload)) + answer := senderPayload(child.ID, "Use SQLite.", stranger.ID) + answer["reply_to_question_id"] = payload["question_id"] + resp, err := h.handleMessageTask(context.Background(), makeWSMessage(t, ws.ActionMCPMessageTask, answer)) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeValidation) + _ = parent +} diff --git a/apps/backend/internal/mcp/profile/profile.go b/apps/backend/internal/mcp/profile/profile.go new file mode 100644 index 0000000000..0815c8ad6b --- /dev/null +++ b/apps/backend/internal/mcp/profile/profile.go @@ -0,0 +1,111 @@ +// Package profile defines the backend-owned MCP tool profile contract. +// Profiles describe a small base surface plus additive capability groups. An +// agent can receive a profile, but it cannot request arbitrary tool names. +package profile + +import "slices" + +type Surface string + +const ( + SurfaceKanbanTask Surface = "kanban-task" + SurfaceOfficeTask Surface = "office-task" + SurfaceConfiguration Surface = "configuration" + SurfaceExternal Surface = "external" +) + +type Capability string + +const ( + CapabilityUserQuestion Capability = "user-question" + CapabilityParentQuestion Capability = "parent-question" + CapabilityTaskTitle Capability = "task-title" + CapabilityGitHubPR Capability = "github-pr" + CapabilityGitLabMR Capability = "gitlab-mr" +) + +// Context is the complete, backend-resolved MCP profile for one agent +// instance. Surface selects the base tool set. Capabilities add small +// context-specific groups. Providers add provider-specific automation tools. +type Context struct { + Surface Surface `json:"surface"` + Capabilities []Capability `json:"capabilities,omitempty"` + Providers []string `json:"providers,omitempty"` +} + +func New(surface Surface, capabilities []Capability, providers []string) Context { + ctx := Context{Surface: normalizeSurface(surface)} + for _, capability := range capabilities { + ctx = ctx.WithCapability(capability) + } + ctx.Providers = normalizeStrings(providers) + return ctx +} + +func (c Context) HasCapability(capability Capability) bool { + return slices.Contains(c.Capabilities, capability) +} + +func (c Context) WithCapability(capability Capability) Context { + if capability == "" || c.HasCapability(capability) { + return c + } + c.Capabilities = append(c.Capabilities, capability) + return c +} + +func (c Context) WithoutCapability(capability Capability) Context { + // Clone before filtering so changing a derived profile never overwrites the + // backing array owned by the source profile. + capabilities := slices.Clone(c.Capabilities) + capabilities = slices.DeleteFunc(capabilities, func(value Capability) bool { return value == capability }) + c.Capabilities = capabilities + return c +} + +// Legacy maps the current mode/boolean API to the typed profile contract. +// Keep this adapter while older runtime callers migrate to Context. +func Legacy(mode string, disableAskQuestion bool, providers []string) Context { + surface := SurfaceKanbanTask + capabilities := []Capability{} + switch mode { + case "office": + surface = SurfaceOfficeTask + case "config": + surface = SurfaceConfiguration + case "external": + surface = SurfaceExternal + case "task-title-pending": + capabilities = append(capabilities, CapabilityTaskTitle) + } + if !disableAskQuestion && surface != SurfaceExternal { + capabilities = append(capabilities, CapabilityUserQuestion) + } + return New(surface, capabilities, providers) +} + +func normalizeSurface(surface Surface) Surface { + switch surface { + case SurfaceKanbanTask, SurfaceOfficeTask, SurfaceConfiguration, SurfaceExternal: + return surface + default: + return SurfaceKanbanTask + } +} + +func normalizeStrings(values []string) []string { + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + slices.Sort(result) + return result +} diff --git a/apps/backend/internal/mcp/profile/profile_test.go b/apps/backend/internal/mcp/profile/profile_test.go new file mode 100644 index 0000000000..b4f29533d3 --- /dev/null +++ b/apps/backend/internal/mcp/profile/profile_test.go @@ -0,0 +1,46 @@ +package profile + +import "testing" + +func TestLegacyTaskTitleProfile(t *testing.T) { + ctx := Legacy("task-title-pending", false, []string{"gitlab", "gitlab", "github"}) + if ctx.Surface != SurfaceKanbanTask { + t.Fatalf("surface = %q, want %q", ctx.Surface, SurfaceKanbanTask) + } + if !ctx.HasCapability(CapabilityTaskTitle) || !ctx.HasCapability(CapabilityUserQuestion) { + t.Fatalf("capabilities = %#v, want title and user question", ctx.Capabilities) + } + if got, want := ctx.Providers, []string{"github", "gitlab"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("providers = %#v, want %#v", got, want) + } +} + +func TestAutopilotQuestionCapabilitiesCanBeSwapped(t *testing.T) { + ctx := New(SurfaceKanbanTask, []Capability{CapabilityParentQuestion}, nil) + if !ctx.HasCapability(CapabilityParentQuestion) { + t.Fatal("parent-question capability missing") + } + ctx = ctx.WithoutCapability(CapabilityParentQuestion).WithCapability(CapabilityUserQuestion) + if ctx.HasCapability(CapabilityParentQuestion) || !ctx.HasCapability(CapabilityUserQuestion) { + t.Fatalf("capabilities = %#v, want only user-question", ctx.Capabilities) + } +} + +func TestWithoutCapabilityDoesNotMutateSourceProfile(t *testing.T) { + source := New(SurfaceKanbanTask, []Capability{CapabilityParentQuestion, CapabilityTaskTitle}, nil) + derived := source.WithoutCapability(CapabilityParentQuestion) + + if !source.HasCapability(CapabilityParentQuestion) || !source.HasCapability(CapabilityTaskTitle) { + t.Fatalf("source capabilities = %#v, want both capabilities preserved", source.Capabilities) + } + if derived.HasCapability(CapabilityParentQuestion) || !derived.HasCapability(CapabilityTaskTitle) { + t.Fatalf("derived capabilities = %#v, want only task title", derived.Capabilities) + } +} + +func TestLegacyExternalHasNoQuestionCapability(t *testing.T) { + ctx := Legacy("external", false, nil) + if ctx.HasCapability(CapabilityUserQuestion) || ctx.HasCapability(CapabilityParentQuestion) { + t.Fatalf("external capabilities = %#v, want no question capability", ctx.Capabilities) + } +} diff --git a/apps/backend/internal/mcp/server/handlers.go b/apps/backend/internal/mcp/server/handlers.go index 949d3d2828..f62567803b 100644 --- a/apps/backend/internal/mcp/server/handlers.go +++ b/apps/backend/internal/mcp/server/handlers.go @@ -173,6 +173,7 @@ func (s *Server) createTaskHandler() server.ToolHandlerFunc { "workspace_mode": req.GetString("workspace_mode", ""), "title": title, "description": req.GetString("prompt", ""), + "autopilot": req.GetBool("autopilot", false), "agent_profile_id": req.GetString("agent_profile_id", ""), "executor_profile_id": req.GetString("executor_profile_id", ""), "source_task_id": s.taskID, @@ -385,6 +386,7 @@ func (s *Server) messageTaskHandler() server.ToolHandlerFunc { } copyOptionalStringArg(payload, req, "delivery_mode") copyOptionalStringArg(payload, req, "session_id") + copyOptionalStringArg(payload, req, "reply_to_question_id") var result map[string]interface{} if err := s.backend.RequestPayload(ctx, ws.ActionMCPMessageTask, payload, &result); err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -573,6 +575,27 @@ func (s *Server) askUserQuestionHandler() server.ToolHandlerFunc { } } +func (s *Server) askParentQuestionHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + questions, errResult := parseQuestions(req) + if errResult != nil { + return errResult, nil + } + payload := map[string]interface{}{ + "task_id": s.taskID, + "session_id": s.sessionID, + questionsArg: questions, + "context": req.GetString("context", ""), + } + var result map[string]interface{} + if err := s.backend.RequestPayload(ctx, ws.ActionMCPAskParentQuestion, payload, &result); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + data, _ := json.MarshalIndent(result, "", " ") + return mcp.NewToolResultText(string(data)), nil + } +} + // emitKeepAlivePings invokes send on every interval tick until stop is closed or // ctx is cancelled. It is the transport-agnostic core of the ask_user_question // keepalive, split out so the timing loop is unit-testable without a live MCP diff --git a/apps/backend/internal/mcp/server/handlers_test.go b/apps/backend/internal/mcp/server/handlers_test.go index 7d990e9b12..1fb9277159 100644 --- a/apps/backend/internal/mcp/server/handlers_test.go +++ b/apps/backend/internal/mcp/server/handlers_test.go @@ -50,7 +50,7 @@ func TestCreateTask_ToolSchema_HasParentID(t *testing.T) { assert.Contains(t, props, "prompt") assert.ElementsMatch(t, []string{ "parent_id", "workspace_id", "workflow_id", "workflow_step_id", "workspace_mode", - "title", "prompt", "agent_profile_id", "executor_profile_id", "start_agent", + "title", "prompt", "autopilot", "agent_profile_id", "executor_profile_id", "start_agent", "repository_id", "local_path", "repository_url", "base_branch", "external_id", }, propertyNames(props), "unexpected change to the advertised create_task_kandev schema") assert.NotContains(t, props, "description", "legacy alias must not increase the advertised schema") @@ -109,6 +109,24 @@ func TestCreateTask_ToolSchema_HasParentID(t *testing.T) { assert.False(t, requiredSet["workflow_id"], "workflow_id should not be required") } +func TestCreateTask_AutopilotPayload(t *testing.T) { + backend := &testBackend{ + response: map[string]interface{}{"id": "subtask-1", "parent_id": "task-current", "autopilot": true}, + } + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "create_task_kandev", map[string]interface{}{ + "title": "Run independently", + "parent_id": "self", + "autopilot": true, + }) + + assert.False(t, result.IsError) + payload, ok := backend.lastPayload.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, true, payload["autopilot"]) +} + func propertyNames(properties map[string]interface{}) []string { names := make([]string, 0, len(properties)) for name := range properties { @@ -646,10 +664,11 @@ func TestMessageTask_ForwardsToBackend(t *testing.T) { s := newTaskModeServer(t, backend, "task-current") result := callTool(t, s, "message_task_kandev", map[string]interface{}{ - "task_id": "task-target", - "session_id": "sess-target", - "prompt": "follow up", - "delivery_mode": "interrupt", + "task_id": "task-target", + "session_id": "sess-target", + "prompt": "follow up", + "delivery_mode": "interrupt", + "reply_to_question_id": "question-1", }) assert.False(t, result.IsError) @@ -661,6 +680,7 @@ func TestMessageTask_ForwardsToBackend(t *testing.T) { assert.Equal(t, "sess-target", payload["session_id"]) assert.Equal(t, "follow up", payload["prompt"]) assert.Equal(t, "interrupt", payload["delivery_mode"]) + assert.Equal(t, "question-1", payload["reply_to_question_id"]) assert.Equal(t, "task-current", payload["sender_task_id"]) assert.Equal(t, "test-session", payload["sender_session_id"]) } @@ -678,6 +698,7 @@ func TestMessageTask_DescriptionExplainsQueueInterruptAndStop(t *testing.T) { assert.Contains(t, description, `delivery_mode="interrupt"`) assert.Contains(t, description, "stop_task_kandev") assert.Contains(t, description, `safely falls back to "queued"`) + assert.Contains(t, description, "reply_to_question_id") } func TestMessageTask_MissingTaskID_ReturnsError(t *testing.T) { diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 2f7722ac91..5937d91e18 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -14,6 +14,7 @@ import ( "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" mcpproviders "github.com/kandev/kandev/internal/mcp/providers" "github.com/kandev/kandev/internal/task/service" ws "github.com/kandev/kandev/pkg/websocket" @@ -94,6 +95,7 @@ type Server struct { disableAskQuestion bool mode string // "task" (default), "task-title-pending", "config", or "office" mcpProviders []string + profile mcpprofile.Context mcpServer *server.MCPServer sseServer *server.SSEServer httpServer *server.StreamableHTTPServer @@ -117,7 +119,7 @@ func New(backend BackendClient, sessionID, taskID string, port int, log *logger. if len(mcpProviders) > 0 { providers = mcpProviders[0] } - s := newServer(backend, sessionID, taskID, log, mcpLogFile, disableAskQuestion, mcpMode, providers) + s := newServerWithProfile(backend, sessionID, taskID, log, mcpLogFile, mcpprofile.Legacy(mcpMode, disableAskQuestion, providers)) // Create SSE server for Claude Desktop, Cursor, etc. // WithBaseURL ensures the SSE endpoint event includes the full message URL @@ -134,12 +136,30 @@ func New(backend BackendClient, sessionID, taskID string, port int, log *logger. return s } +// NewWithProfile creates an MCP server from the backend-owned typed profile. +// The profile keeps base surfaces and additive capability groups separate so +// callers can add or remove one context-specific group without copying a full +// mode branch. +func NewWithProfile(backend BackendClient, sessionID, taskID string, port int, log *logger.Logger, mcpLogFile string, disableAskQuestion bool, profileContext mcpprofile.Context) *Server { + if disableAskQuestion { + profileContext = profileContext.WithoutCapability(mcpprofile.CapabilityUserQuestion) + } + s := newServerWithProfile(backend, sessionID, taskID, log, mcpLogFile, profileContext) + s.sseServer = server.NewSSEServer(s.mcpServer, + server.WithBaseURL(fmt.Sprintf("http://localhost:%d", port)), + ) + s.httpServer = server.NewStreamableHTTPServer(s.mcpServer, + server.WithEndpointPath("/mcp"), + ) + return s +} + // NewExternal creates an MCP server for the Kandev backend's external endpoint. // External coding agents (Claude Code, Cursor, etc.) connect here to manage Kandev // configuration and create tasks. Routes are mounted under /mcp on the backend. func NewExternal(backend BackendClient, log *logger.Logger, mcpLogFile string) *Server { // External mode has no live session, so disable ask-question and use empty IDs. - s := newServer(backend, "", "", log, mcpLogFile, true, ModeExternal, nil) + s := newServerWithProfile(backend, "", "", log, mcpLogFile, mcpprofile.Legacy(ModeExternal, true, nil)) // SSE handlers are mounted at /mcp/sse and /mcp/message — the static base path // makes the SSE endpoint event emit /mcp/message. Keeping the message endpoint @@ -162,14 +182,19 @@ func NewExternal(backend BackendClient, log *logger.Logger, mcpLogFile string) * // Callers are responsible for constructing sseServer and httpServer with the // transport configuration appropriate for their hosting environment. func newServer(backend BackendClient, sessionID, taskID string, log *logger.Logger, mcpLogFile string, disableAskQuestion bool, mcpMode string, mcpProviders []string) *Server { - mcpMode = normalizeMode(mcpMode) + return newServerWithProfile(backend, sessionID, taskID, log, mcpLogFile, mcpprofile.Legacy(mcpMode, disableAskQuestion, mcpProviders)) +} + +func newServerWithProfile(backend BackendClient, sessionID, taskID string, log *logger.Logger, mcpLogFile string, profileContext mcpprofile.Context) *Server { + profileContext = mcpprofile.New(profileContext.Surface, profileContext.Capabilities, profileContext.Providers) s := &Server{ backend: backend, sessionID: sessionID, taskID: taskID, - disableAskQuestion: disableAskQuestion, - mode: mcpMode, - mcpProviders: mcpproviders.Normalize(mcpProviders), + disableAskQuestion: !profileContext.HasCapability(mcpprofile.CapabilityUserQuestion), + mode: modeForProfile(profileContext), + mcpProviders: mcpproviders.Normalize(profileContext.Providers), + profile: profileContext, logger: log.WithFields(zap.String("component", "mcp-server")), attachmentAttempts: make(map[string]streams.MCPAttachmentAttempt), } @@ -218,6 +243,24 @@ func newServer(backend BackendClient, sessionID, taskID string, log *logger.Logg return s } +func modeForProfile(profileContext mcpprofile.Context) string { + switch profileContext.Surface { + case mcpprofile.SurfaceConfiguration: + return ModeConfig + case mcpprofile.SurfaceExternal: + return ModeExternal + case mcpprofile.SurfaceOfficeTask: + return ModeOffice + case mcpprofile.SurfaceKanbanTask: + if profileContext.HasCapability(mcpprofile.CapabilityTaskTitle) { + return ModeTaskTitlePending + } + return ModeTask + default: + return ModeTask + } +} + // SetAttachmentReporter routes safe MCP observations to the instance's // existing agent update stream. It accepts a concrete callback to keep the MCP // package independent of process-manager implementation details. @@ -439,9 +482,28 @@ func (s *Server) SetMode(mode string) { return } s.mode = normalizedMode + s.profile = mcpprofile.New(surfaceForMode(normalizedMode), s.profile.Capabilities, s.mcpProviders) + if normalizedMode == ModeTaskTitlePending { + s.profile = s.profile.WithCapability(mcpprofile.CapabilityTaskTitle) + } else { + s.profile = s.profile.WithoutCapability(mcpprofile.CapabilityTaskTitle) + } s.rebuildTools() } +func surfaceForMode(mode string) mcpprofile.Surface { + switch mode { + case ModeConfig: + return mcpprofile.SurfaceConfiguration + case ModeExternal: + return mcpprofile.SurfaceExternal + case ModeOffice: + return mcpprofile.SurfaceOfficeTask + default: + return mcpprofile.SurfaceKanbanTask + } +} + // SetProviders replaces the provider capabilities advertised by task mode. // The MCP mode itself is preserved while the effective tool registry is rebuilt. func (s *Server) SetProviders(providerValues []string) { @@ -453,9 +515,52 @@ func (s *Server) SetProviders(providerValues []string) { return } s.mcpProviders = normalizedProviders + s.profile.Providers = normalizedProviders s.rebuildTools() } +// Profile returns a copy of the effective backend-owned MCP profile. +func (s *Server) Profile() mcpprofile.Context { + s.mu.RLock() + defer s.mu.RUnlock() + return mcpprofile.New(s.profile.Surface, s.profile.Capabilities, s.profile.Providers) +} + +// SetProfile replaces the complete profile and rebuilds the tool registry in +// one atomic operation. This is the preferred runtime update seam. Legacy +// SetMode and SetProviders remain available for older agentctl callers. +func (s *Server) SetProfile(profileContext mcpprofile.Context) { + s.mu.Lock() + defer s.mu.Unlock() + + profileContext = mcpprofile.New(profileContext.Surface, profileContext.Capabilities, profileContext.Providers) + if sameProfile(s.profile, profileContext) { + return + } + s.profile = profileContext + s.mode = modeForProfile(profileContext) + s.disableAskQuestion = !profileContext.HasCapability(mcpprofile.CapabilityUserQuestion) + s.mcpProviders = mcpproviders.Normalize(profileContext.Providers) + s.rebuildTools() +} + +func sameProfile(left, right mcpprofile.Context) bool { + if left.Surface != right.Surface || len(left.Capabilities) != len(right.Capabilities) || len(left.Providers) != len(right.Providers) { + return false + } + for i := range left.Capabilities { + if left.Capabilities[i] != right.Capabilities[i] { + return false + } + } + for i := range left.Providers { + if left.Providers[i] != right.Providers[i] { + return false + } + } + return true +} + func (s *Server) rebuildTools() { // Build against an isolated registry so the live server remains unchanged // until the complete replacement is ready. mcp-go emits one notification @@ -495,109 +600,76 @@ func (s *Server) assembleTools() []server.ServerTool { return tools } -// registerTools registers MCP tools based on the server mode. +type profileToolGroup struct { + name string + enabled func(mcpprofile.Context) bool + register func(*Server) +} + +func surfaceEnabled(surface mcpprofile.Surface) func(mcpprofile.Context) bool { + return func(ctx mcpprofile.Context) bool { return ctx.Surface == surface } +} + +func capabilityEnabled(capability mcpprofile.Capability) func(mcpprofile.Context) bool { + return func(ctx mcpprofile.Context) bool { return ctx.HasCapability(capability) } +} + +func andProfilePredicates(predicates ...func(mcpprofile.Context) bool) func(mcpprofile.Context) bool { + return func(ctx mcpprofile.Context) bool { + for _, predicate := range predicates { + if !predicate(ctx) { + return false + } + } + return true + } +} + +func (s *Server) profileToolGroups() []profileToolGroup { + config := surfaceEnabled(mcpprofile.SurfaceConfiguration) + external := surfaceEnabled(mcpprofile.SurfaceExternal) + office := surfaceEnabled(mcpprofile.SurfaceOfficeTask) + kanban := surfaceEnabled(mcpprofile.SurfaceKanbanTask) + return []profileToolGroup{ + {name: "configuration-workflows", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigWorkflowTools() }}, + {name: "configuration-agents", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigAgentTools() }}, + {name: "configuration-mcp", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigMcpTools() }}, + {name: "configuration-executors", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigExecutorTools() }}, + {name: "configuration-tasks", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigTaskTools() }}, + {name: "external-create-task", enabled: external, register: func(s *Server) { s.registerCreateTaskTool() }}, + {name: "kanban-task", enabled: kanban, register: func(s *Server) { s.registerKanbanTools() }}, + {name: "github-pr", enabled: andProfilePredicates(kanban, func(ctx mcpprofile.Context) bool { return mcpproviders.Contains(ctx.Providers, mcpproviders.GitHub) }), register: func(s *Server) { s.registerPRAutomationTools() }}, + {name: "gitlab-mr", enabled: andProfilePredicates(kanban, func(ctx mcpprofile.Context) bool { return mcpproviders.Contains(ctx.Providers, mcpproviders.GitLab) }), register: func(s *Server) { s.registerMRAutomationTools() }}, + {name: "user-question", enabled: capabilityEnabled(mcpprofile.CapabilityUserQuestion), register: func(s *Server) { s.registerInteractionTools() }}, + {name: "parent-question", enabled: andProfilePredicates(kanban, capabilityEnabled(mcpprofile.CapabilityParentQuestion)), register: func(s *Server) { s.registerParentQuestionTool() }}, + {name: "plan", enabled: func(ctx mcpprofile.Context) bool { return kanban(ctx) || office(ctx) }, register: func(s *Server) { s.registerPlanTools() }}, + {name: "walkthrough", enabled: kanban, register: func(s *Server) { s.registerWalkthroughTools() }}, + {name: "review", enabled: kanban, register: func(s *Server) { s.registerReviewTools() }}, + {name: "related-tasks", enabled: func(ctx mcpprofile.Context) bool { return kanban(ctx) || office(ctx) }, register: func(s *Server) { s.registerRelatedTasksTool() }}, + {name: "office-documents", enabled: office, register: func(s *Server) { s.registerTaskDocumentTools() }}, + {name: "task-branch-sources", enabled: kanban, register: func(s *Server) { + s.registerAddBranchToTaskTool() + s.registerAddWorkspaceSourcesTool() + s.registerUpdateRepositoryBaseBranchTool() + }}, + {name: "step-completion", enabled: kanban, register: func(s *Server) { s.registerStepCompleteTool() }}, + {name: "task-title", enabled: andProfilePredicates(kanban, capabilityEnabled(mcpprofile.CapabilityTaskTitle)), register: func(s *Server) { s.registerSetTaskTitleTool() }}, + {name: "diagnostics", enabled: kanban, register: func(s *Server) { s.registerDiagnosticBundleTool() }}, + } +} + +// registerTools registers MCP tools from the declarative profile registry. +// Each group owns one additive capability or base surface. The registry is +// backend-owned: an agent receives the result, not an arbitrary tool list. func (s *Server) registerTools() { - count := 0 - switch s.mode { - case ModeConfig: - s.registerConfigWorkflowTools() - count += 12 - s.registerConfigAgentTools() - count += 4 - s.registerConfigMcpTools() - count += 4 - s.registerConfigExecutorTools() - count += 5 - s.registerConfigTaskTools() - count += 7 - if !s.disableAskQuestion { - s.registerInteractionTools() - count++ + for _, group := range s.profileToolGroups() { + if group.enabled(s.profile) { + group.register(s) } - case ModeExternal: - // External coding agents get config tools plus create_task_kandev so - // they can both manage Kandev configuration and spawn new tasks. - // No interaction or plan tools (no live session to attach them to). - s.registerConfigWorkflowTools() - count += 12 - s.registerConfigAgentTools() - count += 4 - s.registerConfigMcpTools() - count += 4 - s.registerConfigExecutorTools() - count += 5 - s.registerConfigTaskTools() - count += 7 - s.registerCreateTaskTool() - count++ - case ModeOffice: - // Office agents use `agentctl kandev …` subcommands for every - // office mutation (create task, delegate, comment, …). The MCP - // surface for office mode only keeps: - // - ask_user_question — interactive prompt path - // - plan tools — structured plan capture - // - related-tasks — discover parent/child/sibling IDs - // - task-document tools — parent/child coordination docs - // delegate_task was removed in favour of - // `agentctl kandev tasks create --parent $KANDEV_TASK_ID …`. - if !s.disableAskQuestion { - s.registerInteractionTools() - count++ - } - s.registerPlanTools() - count += 4 - s.registerRelatedTasksTool() - count++ - s.registerTaskDocumentTools() - count += 3 - case ModeTask, ModeTaskTitlePending: - // Kanban tasks get list_related_tasks_kandev (useful for finding - // a sibling to message_task_kandev) but NOT the task-document - // tools — those are office coordination plumbing. - s.registerKanbanTools() - count += 16 - if mcpproviders.Contains(s.mcpProviders, mcpproviders.GitHub) { - s.registerPRAutomationTools() - count += 2 - } - if mcpproviders.Contains(s.mcpProviders, mcpproviders.GitLab) { - s.registerMRAutomationTools() - count += 2 - } - if !s.disableAskQuestion { - s.registerInteractionTools() - count++ - } - s.registerPlanTools() - count += 4 - s.registerWalkthroughTools() - count += 3 - s.registerReviewTools() - count++ - s.registerRelatedTasksTool() - count++ - // Task-mode only: requires a live session to attach the new - // (repository, branch) to. External mode has no such context. - s.registerAddBranchToTaskTool() - s.registerAddWorkspaceSourcesTool() - s.registerUpdateRepositoryBaseBranchTool() - count += 3 - // Task-mode only: ADR 0015 explicit step-completion signal. The - // tool targets the current (task, session, step) the MCP server - // was bound to, so it has no meaningful semantics outside a task - // session. - s.registerStepCompleteTool() - count++ - if s.mode == ModeTaskTitlePending { - s.registerSetTaskTitleTool() - count++ - } - s.registerDiagnosticBundleTool() - count++ } s.logger.Info("registered MCP tools", zap.String("mode", s.mode), - zap.Int("count", count), + zap.Int("count", len(s.mcpServer.ListTools())), zap.Bool("disable_ask_question", s.disableAskQuestion)) s.rebuildToolArgumentValidators() } @@ -716,6 +788,10 @@ Behaviour by session state: - Created (not yet started): the agent is started with this message as its first prompt (delivery_mode has no effect). - Failed/cancelled: an error is returned (use create_task_kandev to start fresh). +For an autopilot child question, pass reply_to_question_id with the question_id +from the child message. The direct parent answer is recorded against that +question and retries are idempotent. + Returns the dispatch status: "queued", "sent", or "started".`), mcp.WithString("task_id", mcp.Required(), mcp.Description("The target task's full UUID (not a truncated prefix)")), mcp.WithString("session_id", mcp.Description("Optional target session ID (must belong to task_id). Omit to message the task's primary session. Required when messaging a sibling session on your OWN task (task_id may then be your own task ID) — e.g. a session you spawned with spawn_session_kandev.")), @@ -725,6 +801,7 @@ Returns the dispatch status: "queued", "sent", or "started".`), mcp.DefaultString("queued"), mcp.Description(`How to deliver this message if the target is currently running/starting. "queued" (default): wait for the current turn to finish, like any other peer message. "interrupt": cancel the target's current turn now and deliver this message immediately instead — only allowed when you are the target task's direct parent; requesting "interrupt" as a non-parent is rejected with an error rather than silently queued.`), ), + mcp.WithString("reply_to_question_id", mcp.Description("Optional question ID from an autopilot child. When set, the direct parent answer is recorded against that pending question and the delivery is idempotent.")), ), s.wrapHandler("message_task_kandev", s.messageTaskHandler()), ) @@ -870,6 +947,7 @@ IDEMPOTENCY (external_id): mcp.WithString("workspace_mode", mcp.Description("Subtask materialized-workspace mode: inherit_parent reuses the parent's worktree/materialized workspace (default for subtasks); new_workspace launches the subtask in its own workspace/worktree.")), mcp.WithString("title", mcp.Required(), mcp.MaxLength(service.TaskTitleMaxLength), mcp.Description("A concise, few-word task title (maximum 60 characters).")), mcp.WithString("prompt", mcp.Description("The initial prompt for the sub-agent. This is the ONLY context the agent receives when it starts — treat it as the agent's first user message. For auto-started subtasks, provide a specific and detailed prompt; omitting it starts the sub-agent without task-specific context.")), + mcp.WithBoolean("autopilot", mcp.Description("Start this task in autopilot mode. Default: false. The value is fixed at creation and is not inherited by subtasks. The agent does not ask the user directly; it asks its direct parent only for critical decisions.")), mcp.WithString("agent_profile_id", mcp.Description(agentProfileDesc)), mcp.WithString("executor_profile_id", mcp.Description("Executor profile ID to use (determines the runtime environment: local, worktree, docker, etc.). For subtasks, inherited from the parent session. For top-level tasks, ask the user which executor profile they want if not already known.")), mcp.WithBoolean("start_agent", mcp.Description("Whether to auto-start an agent on the created task. Default: true — leave it true unless you specifically want a placeholder task with no agent running. Setting false leaves the task waiting for the user to click 'Start agent' in the UI; the prompt is preserved but no work happens automatically.")), @@ -1285,6 +1363,24 @@ Example rejection: ) } +func (s *Server) registerParentQuestionTool() { + s.mcpServer.AddTool( + mcp.NewTool("ask_parent_question_kandev", + mcp.WithDescription(`Ask the direct parent task one or more questions when an autopilot task reaches a critical decision that cannot be inferred safely. + +This tool is available only to autopilot child tasks. It sends a durable question message to the direct parent, pauses this child turn, and returns without waiting for the answer. The parent answers with message_task_kandev using the returned question_id as reply_to_question_id. Do not use ask_user_question_kandev for this task, and do not ask unless the decision is truly blocking.`), + mcp.WithArray(questionsArg, mcp.Required(), + mcp.Description(`Array of 1-4 question objects. Each question must have a "prompt" (the question text) and an "options" array (2-6 entries with label + description). Optional fields: "id" (stable identifier), "title" (≤12 chars short label).`), + mcp.MinItems(1), + mcp.MaxItems(4), + mcp.Items(buildQuestionSchemaItem()), + ), + mcp.WithString("context", mcp.Description("Optional shared background information for the direct parent.")), + ), + s.wrapHandler("ask_parent_question_kandev", s.askParentQuestionHandler()), + ) +} + func (s *Server) registerPlanTools() { s.mcpServer.AddTool( mcp.NewTool("create_task_plan_kandev", diff --git a/apps/backend/internal/mcp/server/server_test.go b/apps/backend/internal/mcp/server/server_test.go index affcd003df..a38600f0d0 100644 --- a/apps/backend/internal/mcp/server/server_test.go +++ b/apps/backend/internal/mcp/server/server_test.go @@ -8,6 +8,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" ws "github.com/kandev/kandev/pkg/websocket" mcplib "github.com/mark3labs/mcp-go/mcp" mcpsrv "github.com/mark3labs/mcp-go/server" @@ -164,6 +165,25 @@ func TestServerModeTask_RegistersCorrectTools(t *testing.T) { assert.NotContains(t, tools, "reorder_workflow_steps_kandev") } +func TestServerProfile_AutopilotChildHasOnlyParentQuestion(t *testing.T) { + log := newTestLogger(t) + backend := NewChannelBackendClient(log) + defer backend.Close() + + profile := mcpprofile.New(mcpprofile.SurfaceKanbanTask, []mcpprofile.Capability{mcpprofile.CapabilityParentQuestion}, nil) + s := NewWithProfile(backend, "child-session", "child-task", 10005, log, "", false, profile) + tools := getRegisteredToolNames(s) + + assert.Contains(t, tools, "ask_parent_question_kandev") + assert.NotContains(t, tools, "ask_user_question_kandev") + + rootProfile := mcpprofile.New(mcpprofile.SurfaceKanbanTask, nil, nil) + root := NewWithProfile(backend, "root-session", "root-task", 10006, log, "", false, rootProfile) + rootTools := getRegisteredToolNames(root) + assert.NotContains(t, rootTools, "ask_parent_question_kandev") + assert.NotContains(t, rootTools, "ask_user_question_kandev") +} + func TestServerModeConfig_RegistersCorrectTools(t *testing.T) { log := newTestLogger(t) backend := NewChannelBackendClient(log) @@ -382,6 +402,46 @@ func TestServerSetProvidersAndModeSkipNormalizedNoOps(t *testing.T) { } } +func TestServerSetProfileSkipsEquivalentProfile(t *testing.T) { + log := newTestLogger(t) + backend := NewChannelBackendClient(log) + defer backend.Close() + + profile := mcpprofile.New(mcpprofile.SurfaceKanbanTask, []mcpprofile.Capability{mcpprofile.CapabilityUserQuestion}, []string{"github"}) + s := NewWithProfile(backend, "test-session", "test-task", 10005, log, "", false, profile) + session := &providerRefreshTestSession{ + id: "initialized-profile-noop", + notifications: make(chan mcplib.JSONRPCNotification, 128), + } + require.NoError(t, s.mcpServer.RegisterSession(context.Background(), session)) + + s.SetProfile(mcpprofile.New(mcpprofile.SurfaceKanbanTask, []mcpprofile.Capability{mcpprofile.CapabilityUserQuestion}, []string{"github"})) + + select { + case notification := <-session.notifications: + t.Fatalf("equivalent profile emitted notification %q", notification.Method) + default: + } +} + +func TestServerSetModePreservesAdditiveCapabilities(t *testing.T) { + log := newTestLogger(t) + backend := NewChannelBackendClient(log) + defer backend.Close() + + profile := mcpprofile.New(mcpprofile.SurfaceKanbanTask, []mcpprofile.Capability{mcpprofile.CapabilityParentQuestion}, nil) + s := NewWithProfile(backend, "child-session", "child-task", 10005, log, "", false, profile) + + s.SetMode(ModeConfig) + assert.True(t, s.Profile().HasCapability(mcpprofile.CapabilityParentQuestion)) + assert.NotContains(t, getRegisteredToolNames(s), "ask_parent_question_kandev") + + s.SetMode(ModeTask) + assert.True(t, s.Profile().HasCapability(mcpprofile.CapabilityParentQuestion)) + assert.Contains(t, getRegisteredToolNames(s), "ask_parent_question_kandev") + assert.NotContains(t, getRegisteredToolNames(s), "ask_user_question_kandev") +} + func containsTool(tools []string, name string) bool { for _, tool := range tools { if tool == name { diff --git a/apps/backend/internal/office/repository/sqlite/base_migrations.go b/apps/backend/internal/office/repository/sqlite/base_migrations.go index 6ca8866131..dbe14e4978 100644 --- a/apps/backend/internal/office/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/office/repository/sqlite/base_migrations.go @@ -322,6 +322,7 @@ func (r *Repository) runTaskPriorityRecreate() error { _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN wip_admitted INTEGER NOT NULL DEFAULT 1`) _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN queued_for_step_id TEXT NOT NULL DEFAULT ''`) _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN queued_at TIMESTAMP`) + _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN autopilot_enabled INTEGER NOT NULL DEFAULT 0`) // Same defensive add for external_id/external_id_settled_at // (docs/specs/tasks/external-id-idempotency): real installs already have // these from task/repository/sqlite/base.go runMigrations(), but older @@ -370,6 +371,7 @@ func taskPriorityMigrationStatements() []string { metadata TEXT DEFAULT '{}', is_ephemeral INTEGER NOT NULL DEFAULT 0, parent_id TEXT DEFAULT '', + autopilot_enabled INTEGER NOT NULL DEFAULT 0, archived_at TIMESTAMP, archived_by_cascade_id TEXT DEFAULT '', created_at TIMESTAMP NOT NULL, @@ -396,7 +398,7 @@ func taskPriorityMigrationStatements() []string { // meaningful "no identity" state. `INSERT INTO tasks_priority_new ( id, workspace_id, workflow_id, workflow_step_id, title, description, - state, priority, position, wip_admitted, queued_for_step_id, queued_at, metadata, is_ephemeral, parent_id, + state, priority, position, wip_admitted, queued_for_step_id, queued_at, metadata, is_ephemeral, parent_id, autopilot_enabled, archived_at, archived_by_cascade_id, created_at, updated_at, origin, project_id, labels, identifier, @@ -408,7 +410,7 @@ func taskPriorityMigrationStatements() []string { COALESCE(state,'TODO'), 'medium', COALESCE(position,0), COALESCE(wip_admitted,1), COALESCE(queued_for_step_id,''), queued_at, COALESCE(metadata,'{}'), COALESCE(is_ephemeral,0), - COALESCE(parent_id,''), archived_at, + COALESCE(parent_id,''), COALESCE(autopilot_enabled,0), archived_at, COALESCE(archived_by_cascade_id,''), created_at, updated_at, COALESCE(origin,'manual'), diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 5c74217620..5938071bb2 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -1677,6 +1677,7 @@ func (s *Service) autoStartStepPrompt( recordedPrompt := agentPrompt titleOwner := false isOfficeTask := false + var taskForPrompt *models.Task if session.State == models.TaskSessionStateCreated { var officeErr error isOfficeTask, officeErr = s.lookupOfficeTask(ctx, taskID) @@ -1684,6 +1685,13 @@ func (s *Service) autoStartStepPrompt( requeueTaken() return fmt.Errorf("resolve MCP mode for workflow auto-start: %w", officeErr) } + if !isOfficeTask { + taskForPrompt, officeErr = s.repo.GetTask(ctx, taskID) + if officeErr != nil { + requeueTaken() + return fmt.Errorf("load task for autopilot prompt: %w", officeErr) + } + } configMode, _ := session.Metadata["config_mode"].(bool) if !configMode && !isOfficeTask { var claimErr error @@ -1705,6 +1713,9 @@ func (s *Service) autoStartStepPrompt( RequiresCompletionSignal: requiresSignal, IncludeCoordinatorTaskControls: !configMode, IncludeTaskTitleTool: !configMode && titleOwner, + Autopilot: taskForPrompt != nil && taskForPrompt.Autopilot, + IncludeUserQuestionTool: taskForPrompt == nil || !taskForPrompt.Autopilot, + IncludeParentQuestionTool: taskForPrompt != nil && taskForPrompt.Autopilot && taskForPrompt.ParentID != "", }, referenceContext) } } diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 2e77927c91..068bbef560 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -18,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/agentruntime" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/integrations/cloneauth" + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -346,15 +347,16 @@ type LaunchAgentRequest struct { // every managed runtime value and can perform the final strict resolution. EnvironmentDefinitions []runtimeenv.Definition EnvironmentResolutionRequired bool - ACPSessionID string // ACP session ID to resume, if available - ModelOverride string // If set, use this model instead of the profile's model - ExecutorType string // Executor type (e.g., "local", "worktree", "local_docker") - determines runtime - ExecutorConfig map[string]string // Executor config (docker_host, git_token, etc.) - PreviousExecutionID string // Previous execution ID for runtime reconnect - McpMode string // MCP tool mode: "task" (default), "config", or "office" - McpProviders []string // Normalized provider capabilities attached to the task - IsEphemeral bool // Ephemeral task (quick chat) — enables fallback workspace creation - WorkspacePath string // Optional host folder for repo-less tasks (overrides scratch fallback) + ACPSessionID string // ACP session ID to resume, if available + ModelOverride string // If set, use this model instead of the profile's model + ExecutorType string // Executor type (e.g., "local", "worktree", "local_docker") - determines runtime + ExecutorConfig map[string]string // Executor config (docker_host, git_token, etc.) + PreviousExecutionID string // Previous execution ID for runtime reconnect + McpMode string // MCP tool mode: "task" (default), "config", or "office" + McpProviders []string // Normalized provider capabilities attached to the task + McpProfile *mcpprofile.Context // Backend-owned base surface and additive MCP capabilities + IsEphemeral bool // Ephemeral task (quick chat) — enables fallback workspace creation + WorkspacePath string // Optional host folder for repo-less tasks (overrides scratch fallback) // IsPassthrough is the session's mode snapshot (TaskSession.IsPassthrough) // at session-creation time. Forwarded to the lifecycle manager so @@ -469,6 +471,7 @@ type LaunchOptions struct { WorkflowStepID string StartAgent bool McpMode string // MCP tool mode: empty task default, McpModeTaskTitlePending, McpModeConfig, or McpModeOffice + McpProfile *mcpprofile.Context Attachments []v1.MessageAttachment Env map[string]string // RouteOverride carries a provider-routing override resolved by the diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 9993f5055e..2df2143644 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -14,6 +14,7 @@ import ( "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/agent/runtime/routingerr" "github.com/kandev/kandev/internal/common/subproc" + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" "github.com/kandev/kandev/internal/orchestrator/sessionstate" "github.com/kandev/kandev/internal/repoclone" "github.com/kandev/kandev/internal/sysprompt" @@ -53,6 +54,44 @@ func (e *Executor) resolveTaskSessionMCPMode(ctx context.Context, taskID string, return "", nil } +func (e *Executor) resolveTaskSessionMCPProfile(ctx context.Context, taskID string, session *models.TaskSession, allowTitleTool bool) (mcpprofile.Context, error) { + if isConfigModeSession(session) { + capabilities := []mcpprofile.Capability{mcpprofile.CapabilityUserQuestion} + if session.IsPassthrough { + capabilities = nil + } + return mcpprofile.New(mcpprofile.SurfaceConfiguration, capabilities, nil), nil + } + task, err := e.repo.GetTask(ctx, taskID) + if err != nil { + return mcpprofile.Context{}, fmt.Errorf("load task for MCP profile: %w", err) + } + if task == nil { + // A few lifecycle paths can prepare a request from a session snapshot + // before the task row is visible (and older executor fakes model that + // state). Keep the legacy kanban profile in that narrow case; production + // task launches resolve the persisted task above and therefore still get + // the exact office/autopilot capability set. + return mcpprofile.Legacy("", session != nil && session.IsPassthrough, nil), nil + } + surface := mcpprofile.SurfaceKanbanTask + if task.IsFromOffice { + surface = mcpprofile.SurfaceOfficeTask + } + capabilities := make([]mcpprofile.Capability, 0, 2) + if task.Autopilot { + if task.ParentID != "" && !session.IsPassthrough { + capabilities = append(capabilities, mcpprofile.CapabilityParentQuestion) + } + } else if !session.IsPassthrough { + capabilities = append(capabilities, mcpprofile.CapabilityUserQuestion) + } + if allowTitleTool && surface == mcpprofile.SurfaceKanbanTask && models.IsAgentTitleOwner(task.Metadata, session.ID) { + capabilities = append(capabilities, mcpprofile.CapabilityTaskTitle) + } + return mcpprofile.New(surface, capabilities, nil), nil +} + // isContainerizedExecutor returns true for executor types that run agents in // containers or remote sandboxes (Docker variants + Sprites). These are the // same executors that need explicitly configured remote credentials and the @@ -887,6 +926,13 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses return nil, err } } + if opts.McpProfile == nil { + profileContext, profileErr := e.resolveTaskSessionMCPProfile(ctx, task.ID, session, opts.StartAgent) + if profileErr != nil { + return nil, profileErr + } + opts.McpProfile = &profileContext + } running, _ := e.repo.GetExecutorRunningBySessionID(ctx, sessionID) if running != nil && running.ExecutionProfileID != "" && @@ -986,6 +1032,11 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses if opts.McpMode != "" { req.McpMode = opts.McpMode } + if opts.McpProfile != nil { + profileContext := *opts.McpProfile + profileContext.Providers = deriveMCPProviders(allRepos) + req.McpProfile = &profileContext + } // Carry the prior ACP session id forward so the agent CLI resumes the // existing conversation (session/load) instead of opening a fresh one. diff --git a/apps/backend/internal/orchestrator/executor/executor_interaction.go b/apps/backend/internal/orchestrator/executor/executor_interaction.go index bcc6f5aa56..9e6adcd098 100644 --- a/apps/backend/internal/orchestrator/executor/executor_interaction.go +++ b/apps/backend/internal/orchestrator/executor/executor_interaction.go @@ -721,6 +721,11 @@ func (e *Executor) buildSwitchModelRequest(ctx context.Context, task *models.Tas return nil, err } req.McpMode = mcpMode + profileContext, err := e.resolveTaskSessionMCPProfile(ctx, task.ID, session, true) + if err != nil { + return nil, err + } + req.McpProfile = &profileContext repositoryPath, err := e.applyRepositoryToSwitchRequest(ctx, req, session, execConfig) if err != nil { @@ -737,6 +742,8 @@ func (e *Executor) buildSwitchModelRequest(ctx context.Context, task *models.Tas if err != nil { return nil, err } + req.McpProviders = deriveMCPProviders(allRepos) + profileContext.Providers = req.McpProviders if err := e.resolveLaunchEnvironment(ctx, req, execConfig.ProfileEnvVars, allRepos); err != nil { return nil, err } diff --git a/apps/backend/internal/orchestrator/executor/executor_mcp_mode_test.go b/apps/backend/internal/orchestrator/executor/executor_mcp_mode_test.go index ee87677ac2..88168a6a29 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mcp_mode_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mcp_mode_test.go @@ -4,10 +4,107 @@ import ( "context" "testing" + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" "github.com/kandev/kandev/internal/task/models" "github.com/stretchr/testify/require" ) +func TestResolveTaskSessionMCPProfile_SelectsSurfaceAndQuestionCapability(t *testing.T) { + tests := []struct { + name string + task *models.Task + session *models.TaskSession + allowTitle bool + wantSurface mcpprofile.Surface + wantUserQuestion bool + wantParentQuestion bool + wantTaskTitle bool + }{ + { + name: "normal kanban task", + task: &models.Task{ID: "task"}, + session: &models.TaskSession{ID: "session", TaskID: "task"}, + wantSurface: mcpprofile.SurfaceKanbanTask, + wantUserQuestion: true, + wantParentQuestion: false, + }, + { + name: "normal passthrough task", + task: &models.Task{ID: "task"}, + session: &models.TaskSession{ID: "session", TaskID: "task", IsPassthrough: true}, + wantSurface: mcpprofile.SurfaceKanbanTask, + wantUserQuestion: false, + wantParentQuestion: false, + }, + { + name: "autopilot root", + task: &models.Task{ID: "task", Autopilot: true}, + session: &models.TaskSession{ID: "session", TaskID: "task"}, + wantSurface: mcpprofile.SurfaceKanbanTask, + wantUserQuestion: false, + wantParentQuestion: false, + }, + { + name: "autopilot child", + task: &models.Task{ID: "task", ParentID: "parent", Autopilot: true}, + session: &models.TaskSession{ID: "session", TaskID: "task"}, + wantSurface: mcpprofile.SurfaceKanbanTask, + wantUserQuestion: false, + wantParentQuestion: true, + }, + { + name: "autopilot passthrough child", + task: &models.Task{ID: "task", ParentID: "parent", Autopilot: true}, + session: &models.TaskSession{ID: "session", TaskID: "task", IsPassthrough: true}, + wantSurface: mcpprofile.SurfaceKanbanTask, + wantUserQuestion: false, + wantParentQuestion: false, + }, + { + name: "office task", + task: &models.Task{ID: "task", IsFromOffice: true}, + session: &models.TaskSession{ID: "session", TaskID: "task"}, + wantSurface: mcpprofile.SurfaceOfficeTask, + wantUserQuestion: true, + wantParentQuestion: false, + }, + { + name: "configuration session", + task: &models.Task{ID: "task"}, + session: &models.TaskSession{ID: "session", TaskID: "task", Metadata: map[string]interface{}{"config_mode": true}}, + wantSurface: mcpprofile.SurfaceConfiguration, + wantUserQuestion: true, + wantParentQuestion: false, + }, + { + name: "title owner adds title capability", + task: &models.Task{ID: "task", Metadata: map[string]interface{}{models.MetaKeyAgentTitlePending: true, models.MetaKeyAgentTitleOwnerSessionID: "session"}}, + session: &models.TaskSession{ID: "session", TaskID: "task"}, + allowTitle: true, + wantSurface: mcpprofile.SurfaceKanbanTask, + wantUserQuestion: true, + wantParentQuestion: false, + wantTaskTitle: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := newMockRepository() + repo.tasks[tt.task.ID] = tt.task + repo.sessions[tt.session.ID] = tt.session + exec := newTestExecutor(t, &mockAgentManager{}, repo) + + profile, err := exec.resolveTaskSessionMCPProfile(context.Background(), tt.task.ID, tt.session, tt.allowTitle) + require.NoError(t, err) + require.Equal(t, tt.wantSurface, profile.Surface) + require.Equal(t, tt.wantUserQuestion, profile.HasCapability(mcpprofile.CapabilityUserQuestion)) + require.Equal(t, tt.wantParentQuestion, profile.HasCapability(mcpprofile.CapabilityParentQuestion)) + require.Equal(t, tt.wantTaskTitle, profile.HasCapability(mcpprofile.CapabilityTaskTitle)) + }) + } +} + func TestResolveTaskSessionMCPMode_TitlePendingIsTaskModeVariant(t *testing.T) { ctx := context.Background() repo := newMockRepository() diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index a91d0c477a..c54254ca87 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -855,6 +855,12 @@ func (e *Executor) buildResumeRequestAtCredentialBoundary( if err != nil { return nil, "", execConfig, nil, nil, err } + profileContext, err := e.resolveTaskSessionMCPProfile(ctx, task.ID, session, true) + if err != nil { + return nil, "", execConfig, nil, nil, err + } + profileContext.Providers = req.McpProviders + req.McpProfile = &profileContext existingRunning := e.applyRunningRecordToResumeRequest(ctx, req, task, session, startAgent) if err := e.applyResumeWorkspaceFolders(ctx, task.ID, req); err != nil { diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index eaacbce532..00ff4f928d 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -570,6 +570,9 @@ func (s *Service) wrapCreatedSessionPrompt( RequiresCompletionSignal: s.WorkflowStepRequiresCompletionSignal(ctx, dbTask.WorkflowStepID), IncludeCoordinatorTaskControls: !configMode, IncludeTaskTitleTool: !configMode && titleOwner, + Autopilot: dbTask.Autopilot, + IncludeUserQuestionTool: !dbTask.Autopilot && !session.IsPassthrough, + IncludeParentQuestionTool: dbTask.Autopilot && dbTask.ParentID != "", }, referenceContext, promptReferenceContext) } } @@ -965,15 +968,17 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s // while the task is already bound to a signal-gated step in the DB. if effectivePrompt != "" || len(attachments) > 0 { effectivePrompt = s.applyLaunchPromptContext(ctx, launchPromptContext{ - prompt: effectivePrompt, - taskID: task.ID, - sessionID: sessionID, - isOfficeTask: isOfficeTask, - isPassthrough: skipKandevMCPWrap, - configMode: configMode, - referenceContext: promptReferenceContext, - includeTaskTitleTool: !configMode && titleOwner, - spawnOrigin: opts.SpawnOrigin, + prompt: effectivePrompt, + taskID: task.ID, + sessionID: sessionID, + isOfficeTask: isOfficeTask, + isPassthrough: skipKandevMCPWrap, + configMode: configMode, + referenceContext: promptReferenceContext, + includeTaskTitleTool: !configMode && titleOwner, + autopilot: task.Autopilot, + includeParentQuestionTool: task.Autopilot && task.ParentID != "", + spawnOrigin: opts.SpawnOrigin, }) } @@ -1043,15 +1048,17 @@ func (s *Service) applyWorkflowSessionConfigBeforeLaunchForStep( // launchPromptContext carries what the first turn of a launch needs in order to // compose its system context. type launchPromptContext struct { - prompt string - taskID string - sessionID string - isOfficeTask bool - isPassthrough bool - configMode bool - includeTaskTitleTool bool - referenceContext string - spawnOrigin *SpawnOrigin + prompt string + taskID string + sessionID string + isOfficeTask bool + isPassthrough bool + configMode bool + includeTaskTitleTool bool + autopilot bool + includeParentQuestionTool bool + referenceContext string + spawnOrigin *SpawnOrigin } // applyLaunchPromptContext prepends the first-turn system context to a launch @@ -1082,6 +1089,9 @@ func (s *Service) applyLaunchPromptContext(ctx context.Context, p launchPromptCo RequiresCompletionSignal: s.StepRequiresCompletionSignal(ctx, p.taskID), IncludeCoordinatorTaskControls: !p.configMode, IncludeTaskTitleTool: p.includeTaskTitleTool, + Autopilot: p.autopilot, + IncludeUserQuestionTool: !p.autopilot && !p.isPassthrough, + IncludeParentQuestionTool: p.autopilot && p.includeParentQuestionTool, }, p.referenceContext, spawnContext) } diff --git a/apps/backend/internal/sysprompt/sysprompt.go b/apps/backend/internal/sysprompt/sysprompt.go index 1c40aa8322..630cf043a3 100644 --- a/apps/backend/internal/sysprompt/sysprompt.go +++ b/apps/backend/internal/sysprompt/sysprompt.go @@ -121,16 +121,27 @@ func PlanMode() string { return prompts.Get("plan-mode") } // KandevContext returns the task-mode system prompt template that provides // Kandev-specific instructions and session context to agents. Contains -// {task_id}, {session_id}, {step_complete_section}, and {task_title_section} -// placeholders — use +// task, session, capability, and question placeholders — use // [FormatKandevContext] to inject values. func KandevContext() string { return Resolve("kandev-context", map[string]string{ "coordinator_task_control_section": coordinatorTaskControlSection, "task_title_section": "", + "autopilot_section": "", + "question_tool_section": userQuestionSection, }) } +const userQuestionSection = `- ask_user_question_kandev: Ask the user one or more clarifying questions in a single tool call. Use this whenever you need user input to proceed. Required params: questions (array of 1-4 question objects; each object has prompt (string) and options (array of 2-6 {label, description})). Optional: context (string). +` + +const parentQuestionSection = `- ask_parent_question_kandev: Ask the direct parent task one or more critical questions. Use this only when you cannot continue safely without a parent decision. The question is sent to the parent task, and this call MUST end your turn; do not use an operator-question tool. +` + +const autopilotSection = `AUTOPILOT MODE: +This task runs in autopilot mode. Continue independently and make reasonable decisions without asking the operator. Ask for help only when a decision is critical, unsafe, or cannot be inferred from the task context. If a direct parent exists, use the available parent-question tool for that critical question. The question tool ends the current turn, so do not make another tool call or continue working after you ask it. If there is no parent, do not ask a question; choose the safest reversible path and report the limitation in your final response. +` + // stepCompleteSection is the description + instruction block for the // step_complete_kandev MCP tool. Only injected when the current workflow step // has `auto_advance_requires_signal = true` (ADR 0015). Agents on legacy @@ -156,6 +167,7 @@ const stepCompleteSection = "- step_complete_kandev: Signal that every user-stat // in the template to avoid broadening this feature into a cleanup of older mode // mismatches. const coordinatorTaskControlSection = " Optional: session_id, delivery_mode. " + + "For an autopilot child question, also pass reply_to_question_id with the question ID from the child message. " + "Use delivery_mode=\"queued\" or omit it for information that can wait. " + "Use delivery_mode=\"interrupt\" for urgent replacement work on a running direct child; " + "if immediate cancel-and-dispatch cannot be confirmed safely, the message remains queued. " + @@ -190,6 +202,9 @@ type KandevContextOptions struct { RequiresCompletionSignal bool IncludeCoordinatorTaskControls bool IncludeTaskTitleTool bool + Autopilot bool + IncludeUserQuestionTool bool + IncludeParentQuestionTool bool } // FormatKandevContext returns the Kandev context prompt with task and session IDs injected. @@ -219,12 +234,25 @@ func FormatKandevContextWithOptions(taskID, sessionID string, options KandevCont if options.IncludeTaskTitleTool { taskTitle = taskTitleSection } + questionTool := "" + switch { + case options.IncludeParentQuestionTool: + questionTool = parentQuestionSection + case options.IncludeUserQuestionTool || !options.Autopilot: + questionTool = userQuestionSection + } + autopilot := "" + if options.Autopilot { + autopilot = autopilotSection + } return Resolve("kandev-context", map[string]string{ "task_id": taskID, "session_id": sessionID, "step_complete_section": section, "task_title_section": taskTitle, "coordinator_task_control_section": coordinatorControls, + "autopilot_section": autopilot, + "question_tool_section": questionTool, }) } diff --git a/apps/backend/internal/sysprompt/sysprompt_test.go b/apps/backend/internal/sysprompt/sysprompt_test.go index 7364ea1194..e6d9c44445 100644 --- a/apps/backend/internal/sysprompt/sysprompt_test.go +++ b/apps/backend/internal/sysprompt/sysprompt_test.go @@ -180,6 +180,29 @@ func TestFormatKandevContext_CoordinatorTaskControlsFollowCapability(t *testing. } } +func TestFormatKandevContext_AutopilotChildUsesParentQuestionOnly(t *testing.T) { + result := FormatKandevContextWithOptions("task-child", "session-child", KandevContextOptions{ + Autopilot: true, + IncludeParentQuestionTool: true, + IncludeUserQuestionTool: false, + IncludeCoordinatorTaskControls: true, + }) + assert.Contains(t, result, "AUTOPILOT MODE") + assert.Contains(t, result, "ask_parent_question_kandev") + assert.NotContains(t, result, "ask_user_question_kandev") + assert.Contains(t, result, "end your turn") +} + +func TestFormatKandevContext_AutopilotRootHasNoQuestionTool(t *testing.T) { + result := FormatKandevContextWithOptions("task-root", "session-root", KandevContextOptions{ + Autopilot: true, + IncludeUserQuestionTool: false, + }) + assert.Contains(t, result, "AUTOPILOT MODE") + assert.NotContains(t, result, "ask_parent_question_kandev") + assert.NotContains(t, result, "ask_user_question_kandev") +} + func TestFormatKandevContext_InjectsIDs(t *testing.T) { result := FormatKandevContext("task-abc", "session-xyz", false) assert.Contains(t, result, "Kandev Task ID: task-abc") diff --git a/apps/backend/internal/task/dto/converters_test.go b/apps/backend/internal/task/dto/converters_test.go index f3cc780f6f..a7c2a33e45 100644 --- a/apps/backend/internal/task/dto/converters_test.go +++ b/apps/backend/internal/task/dto/converters_test.go @@ -62,6 +62,24 @@ func TestFromTaskSerializesWorkspaceFolders(t *testing.T) { } } +func TestFromTaskSerializesAutopilot(t *testing.T) { + got := FromTask(&models.Task{ID: "task-autopilot", Autopilot: true}) + if !got.Autopilot { + t.Fatal("Autopilot = false, want true") + } + payload, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal task DTO: %v", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(payload, &fields); err != nil { + t.Fatalf("decode task DTO: %v", err) + } + if raw, ok := fields["autopilot"]; !ok || string(raw) != "true" { + t.Fatalf("autopilot field = %s, want true", raw) + } +} + func TestFromWorkflowStep_PreservesWIPFields(t *testing.T) { step := &wfmodels.WorkflowStep{ ID: "step-1", diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index f5fff831c8..28eff2a8f5 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -178,6 +178,7 @@ type TaskDTO struct { ActiveSubagentCount int `json:"active_subagent_count"` IsRemoteExecutor bool `json:"is_remote_executor,omitempty"` ParentID string `json:"parent_id,omitempty"` + Autopilot bool `json:"autopilot"` ArchivedAt *time.Time `json:"archived_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -762,6 +763,7 @@ func FromTaskWithSessionInfo( PrimarySessionPendingAction: primarySessionPendingAction, IsRemoteExecutor: primaryExecutorType != nil && models.IsRemoteExecutorType(models.ExecutorType(*primaryExecutorType)), ParentID: task.ParentID, + Autopilot: task.Autopilot, ArchivedAt: task.ArchivedAt, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index bcd622e132..afb49d1d35 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -287,6 +287,7 @@ type CreateTaskRequest struct { Labels string ParentID string BlockedBy []string + Autopilot bool } type UpdateTaskRequest struct { diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index d87b0631eb..956dc15ec4 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -446,6 +446,9 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w RequiresCompletionSignal: requiresSignal, IncludeCoordinatorTaskControls: !configMode, IncludeTaskTitleTool: !configMode && titleOwner, + Autopilot: task.Autopilot, + IncludeUserQuestionTool: !task.Autopilot && !sessionResp.Session.IsPassthrough, + IncludeParentQuestionTool: task.Autopilot && task.ParentID != "", }, referenceContext) } } diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index cc36d6a885..6a78443543 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -735,6 +735,7 @@ type httpCreateTaskRequest struct { Title string `json:"title"` Description string `json:"description,omitempty"` AutoTitle bool `json:"auto_title,omitempty"` + Autopilot bool `json:"autopilot,omitempty"` Priority string `json:"priority,omitempty"` State *v1.TaskState `json:"state,omitempty"` Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` @@ -906,6 +907,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { Title: title, Description: description, AutoTitle: body.AutoTitle, + Autopilot: body.Autopilot, Priority: body.Priority, State: body.State, Repositories: convertToServiceRepos(repos), diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index 7abc6eb75d..effd3c6405 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -95,6 +95,7 @@ type wsCreateTaskRequest struct { WorkflowStepID string `json:"workflow_step_id"` Title string `json:"title"` Description string `json:"description,omitempty"` + Autopilot bool `json:"autopilot,omitempty"` Priority string `json:"priority,omitempty"` State *v1.TaskState `json:"state,omitempty"` Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` @@ -181,6 +182,7 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M WorkflowStepID: req.WorkflowStepID, Title: title, Description: description, + Autopilot: req.Autopilot, Priority: req.Priority, State: req.State, Repositories: convertToServiceRepos(repos), diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index ac8dedf3cc..175aabf46e 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -125,6 +125,16 @@ const ( // MetaKeyPortForwardingEnabled controls whether a task exposes its // session port-forwarding controls in the task UI. MetaKeyPortForwardingEnabled = "port_forwarding_enabled" + // Parent-question message metadata is durable state for an autopilot child + // waiting for a decision from its direct parent. The question ID is also + // the child clarification message ID, so a parent reply can be idempotent + // without a second persistence table. + MetaKeyParentQuestionID = "parent_question_id" + MetaKeyParentQuestion = "parent_question" + MetaKeyParentQuestionParentID = "parent_task_id" + MetaKeyParentQuestionChildID = "child_task_id" + MetaKeyParentQuestionStatus = "parent_question_status" + MetaKeyParentQuestionResponse = "parent_question_response" ) // IsAgentTitlePending reports whether task metadata contains the durable @@ -576,6 +586,7 @@ type Task struct { WorkspaceFolders []*TaskWorkspaceFolder `json:"workspace_folders,omitempty"` IsEphemeral bool `json:"is_ephemeral"` // Ephemeral tasks are not shown in kanban, used for quick chat ParentID string `json:"parent_id,omitempty"` // FK to parent task for subtasks + Autopilot bool `json:"autopilot"` // Fixed at task creation ArchivedAt *time.Time `json:"archived_at,omitempty"` // ArchivedByCascadeID is set when the task was archived as part of an // office task-handoffs cascade (phase 6). UnarchiveTaskByCascade uses @@ -1861,6 +1872,7 @@ func (t *Task) ToAPI() *v1.Task { Interrupted: t.Metadata[MetaKeyInterruptedAt] != nil, IsEphemeral: t.IsEphemeral, ParentID: t.ParentID, + Autopilot: t.Autopilot, } if t.Identifier != "" { result.Identifier = t.Identifier diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index 713e496af3..1cf3301d25 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -73,6 +73,7 @@ func (r *Repository) runMigrations() error { r.migrate.Apply("workspaces.default_config_agent_profile_id", `ALTER TABLE workspaces ADD COLUMN default_config_agent_profile_id TEXT DEFAULT ''`) r.migrate.Apply("task_sessions.task_environment_id", `ALTER TABLE task_sessions ADD COLUMN task_environment_id TEXT DEFAULT ''`) r.migrate.Apply("tasks.parent_id", `ALTER TABLE tasks ADD COLUMN parent_id TEXT DEFAULT ''`) + r.migrate.Apply("tasks.autopilot_enabled", `ALTER TABLE tasks ADD COLUMN autopilot_enabled INTEGER NOT NULL DEFAULT 0`) // Remove FK constraint on workflow_id to allow ephemeral tasks without workflows if err := r.migrateTasksRemoveWorkflowFK(); err != nil { return err @@ -517,13 +518,14 @@ func (r *Repository) migrateTasksRemoveWorkflowFK() error { metadata TEXT DEFAULT '{}', is_ephemeral INTEGER NOT NULL DEFAULT 0, parent_id TEXT DEFAULT '', + autopilot_enabled INTEGER NOT NULL DEFAULT 0, archived_at TIMESTAMP, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL )`, `INSERT INTO tasks_new SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, - state, priority, position, wip_admitted, queued_for_step_id, queued_at, metadata, is_ephemeral, parent_id, archived_at, created_at, updated_at + state, priority, position, wip_admitted, queued_for_step_id, queued_at, metadata, is_ephemeral, parent_id, autopilot_enabled, archived_at, created_at, updated_at FROM tasks`, `DROP TABLE tasks`, `ALTER TABLE tasks_new RENAME TO tasks`, diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index fe7dc1fb4b..a353ae81a0 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -304,6 +304,7 @@ func (r *Repository) initTaskSchema() error { queued_for_step_id TEXT NOT NULL DEFAULT '', queued_at TIMESTAMP, metadata TEXT DEFAULT '{}', + autopilot_enabled INTEGER NOT NULL DEFAULT 0, archived_at TIMESTAMP, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index b1aa989317..70de82703c 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -59,6 +59,7 @@ var taskScanColumns = []taskScanColumn{ {name: "metadata"}, {name: "is_ephemeral"}, {name: "parent_id"}, + {name: "autopilot_enabled"}, {name: "archived_at"}, {name: "archived_by_cascade_id", selectExpr: func(alias string) string { return `COALESCE(` + alias + `.archived_by_cascade_id, '') AS archived_by_cascade_id` @@ -325,9 +326,9 @@ func (r *Repository) insertTaskTx(ctx context.Context, tx *sql.Tx, task *models. externalID = task.ExternalID } _, err = tx.ExecContext(ctx, r.db.Rebind(` - INSERT INTO tasks (id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, wip_admitted, queued_for_step_id, queued_at, metadata, is_ephemeral, parent_id, created_at, updated_at, origin, project_id, labels, identifier, external_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `), task.ID, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), dialect.BoolToInt(task.IsEphemeral), task.ParentID, task.CreatedAt, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, externalID) + INSERT INTO tasks (id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, wip_admitted, queued_for_step_id, queued_at, metadata, is_ephemeral, parent_id, autopilot_enabled, created_at, updated_at, origin, project_id, labels, identifier, external_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), task.ID, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), dialect.BoolToInt(task.IsEphemeral), task.ParentID, dialect.BoolToInt(task.Autopilot), task.CreatedAt, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, externalID) if err != nil { if isExternalIDUniqueViolation(err) { return fmt.Errorf("%w: %w", ErrExternalIDConflict, err) @@ -1493,7 +1494,7 @@ func (r *Repository) scanSingleTask(row *sql.Row) (*models.Task, error) { &task.ID, &task.WorkspaceID, &task.WorkflowID, &task.WorkflowStepID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &task.WIPAdmitted, &task.QueuedForStepID, &queuedAt, - &metadata, &task.IsEphemeral, &task.ParentID, &archivedAt, &task.ArchivedByCascadeID, + &metadata, &task.IsEphemeral, &task.ParentID, &task.Autopilot, &archivedAt, &task.ArchivedByCascadeID, &task.CreatedAt, &task.UpdatedAt, &task.AssigneeAgentProfileID, &task.Origin, &task.ProjectID, &task.Labels, &identifier, &externalID, &externalIDSettledAt, &task.IsFromOffice, @@ -1535,7 +1536,7 @@ func (r *Repository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { &task.ID, &task.WorkspaceID, &task.WorkflowID, &task.WorkflowStepID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &task.WIPAdmitted, &task.QueuedForStepID, &queuedAt, - &metadata, &task.IsEphemeral, &task.ParentID, &archivedAt, &task.ArchivedByCascadeID, + &metadata, &task.IsEphemeral, &task.ParentID, &task.Autopilot, &archivedAt, &task.ArchivedByCascadeID, &task.CreatedAt, &task.UpdatedAt, &task.AssigneeAgentProfileID, &task.Origin, &task.ProjectID, &task.Labels, &identifier, &externalID, &externalIDSettledAt, &task.IsFromOffice, diff --git a/apps/backend/internal/task/repository/task_repository_test.go b/apps/backend/internal/task/repository/task_repository_test.go index 4ebbdf2b4e..fad24a78dc 100644 --- a/apps/backend/internal/task/repository/task_repository_test.go +++ b/apps/backend/internal/task/repository/task_repository_test.go @@ -77,6 +77,51 @@ func TestSQLiteRepository_TaskCRUD(t *testing.T) { } } +func TestSQLiteRepository_TaskAutopilotRoundTrip(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-autopilot", Name: "Workspace"}); err != nil { + t.Fatalf("create workspace: %v", err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-autopilot", WorkspaceID: "ws-autopilot", Name: "Workflow"}); err != nil { + t.Fatalf("create workflow: %v", err) + } + + task := &models.Task{ + WorkspaceID: "ws-autopilot", + WorkflowID: "wf-autopilot", + WorkflowStepID: "step-autopilot", + Title: "Autopilot task", + State: v1.TaskStateTODO, + Autopilot: true, + } + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("create autopilot task: %v", err) + } + + reloaded, err := repo.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("get autopilot task: %v", err) + } + if !reloaded.Autopilot { + t.Fatal("reloaded autopilot = false, want true") + } + + reloaded.Autopilot = false + if err := repo.UpdateTask(ctx, reloaded); err != nil { + t.Fatalf("update autopilot task: %v", err) + } + reloaded, err = repo.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("get updated autopilot task: %v", err) + } + if !reloaded.Autopilot { + t.Fatal("updated autopilot = false, want immutable true") + } +} + func TestSQLiteRepository_TaskNotFound(t *testing.T) { repo, cleanup := createTestSQLiteRepo(t) defer cleanup() diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 19fd3bb4b3..05c88e3fbc 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -380,6 +380,7 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas "created_at": task.CreatedAt.Format(time.RFC3339), "updated_at": task.UpdatedAt.Format(time.RFC3339), "is_ephemeral": task.IsEphemeral, + "autopilot": task.Autopilot, // Consumers that restore quick-chat tabs filter on origin, so it has to // travel with the event and not just the HTTP DTO. "origin": task.Origin, diff --git a/apps/backend/internal/task/service/service_events_test.go b/apps/backend/internal/task/service/service_events_test.go index 861d2d0dc8..665acf01f9 100644 --- a/apps/backend/internal/task/service/service_events_test.go +++ b/apps/backend/internal/task/service/service_events_test.go @@ -531,6 +531,18 @@ func TestPublishTaskUpdated_FallbackRepositoryID(t *testing.T) { } } +func TestPublishTaskUpdated_EmitsAutopilot(t *testing.T) { + svc, eventBus, _ := createTestService(t) + svc.PublishTaskUpdated(context.Background(), &models.Task{ + ID: "task-autopilot", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Autopilot: true, + }) + + data := singlePublishedEventData(t, eventBus) + if got, ok := data["autopilot"].(bool); !ok || !got { + t.Fatalf("autopilot payload = %#v, want true", data["autopilot"]) + } +} + func TestPublishWorkspaceSourcesAdoptedPublishesSessionScopedPayload(t *testing.T) { svc, eventBus, _ := createTestService(t) eventBus.ClearEvents() diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 7eddfc71f6..3b9fec6ef6 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -58,6 +58,7 @@ type CreateTaskRequest struct { PlanMode bool `json:"plan_mode,omitempty"` IsEphemeral bool `json:"is_ephemeral,omitempty"` // Ephemeral tasks are hidden from kanban, used for quick chat ParentID string `json:"parent_id,omitempty"` + Autopilot bool `json:"autopilot,omitempty"` WorkspacePath string `json:"workspace_path,omitempty"` // Optional host folder for repo-less tasks // ExternalID is a caller-supplied identity used for create-idempotency diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 87eae2307c..863a26d4ef 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -634,6 +634,7 @@ func (s *Service) buildTask(req *CreateTaskRequest, workflowStepID string) *mode Metadata: metadata, IsEphemeral: req.IsEphemeral, ParentID: req.ParentID, + Autopilot: req.Autopilot, AssigneeAgentProfileID: req.AssigneeAgentProfileID, Origin: origin, ProjectID: req.ProjectID, diff --git a/apps/backend/internal/task/service/service_tasks_test.go b/apps/backend/internal/task/service/service_tasks_test.go index 516ed4e871..578207ad08 100644 --- a/apps/backend/internal/task/service/service_tasks_test.go +++ b/apps/backend/internal/task/service/service_tasks_test.go @@ -86,6 +86,38 @@ func TestCreateTaskAutoTitlePersistsPendingMarker(t *testing.T) { } } +func TestCreateTaskPersistsAutopilot(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-autopilot-service", Name: "Workspace"}); err != nil { + t.Fatalf("create workspace: %v", err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-autopilot-service", WorkspaceID: "ws-autopilot-service", Name: "Workflow"}); err != nil { + t.Fatalf("create workflow: %v", err) + } + + taskResult, err := svc.CreateTask(ctx, &CreateTaskRequest{ + WorkspaceID: "ws-autopilot-service", + WorkflowID: "wf-autopilot-service", + Title: "Autopilot service task", + Autopilot: true, + }) + if err != nil { + t.Fatalf("create autopilot task: %v", err) + } + task := taskResult.Task + if !task.Autopilot { + t.Fatal("created autopilot = false, want true") + } + reloaded, err := svc.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("reload autopilot task: %v", err) + } + if !reloaded.Autopilot { + t.Fatal("reloaded autopilot = false, want true") + } +} + func TestCreateTaskAutoTitleSupportsSubtasks(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go index 0b9bfd21e6..3ab040dc6f 100644 --- a/apps/backend/pkg/api/v1/task.go +++ b/apps/backend/pkg/api/v1/task.go @@ -155,6 +155,7 @@ type Task struct { Interrupted bool `json:"interrupted,omitempty"` IsEphemeral bool `json:"is_ephemeral"` // Ephemeral tasks are not shown in kanban, used for quick chat ParentID string `json:"parent_id,omitempty"` // FK to parent task for subtasks + Autopilot bool `json:"autopilot"` Identifier string `json:"identifier,omitempty"` } diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index b4aeb639c0..2f70807988 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -383,6 +383,7 @@ const ( ActionMCPUpdateRepositoryBaseBranch = "mcp.update_repository_base_branch" ActionMCPStepComplete = "mcp.step_complete" // ADR 0015: agent-emitted explicit completion signal ActionMCPAskUserQuestion = "mcp.ask_user_question" + ActionMCPAskParentQuestion = "mcp.ask_parent_question" ActionMCPCreateTaskPlan = "mcp.create_task_plan" ActionMCPGetTaskPlan = "mcp.get_task_plan" ActionMCPUpdateTaskPlan = "mcp.update_task_plan" diff --git a/apps/web/components/task-autopilot-toggle.test.tsx b/apps/web/components/task-autopilot-toggle.test.tsx new file mode 100644 index 0000000000..7f01ca7397 --- /dev/null +++ b/apps/web/components/task-autopilot-toggle.test.tsx @@ -0,0 +1,25 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { TaskAutopilotToggle } from "./task-autopilot-toggle"; + +afterEach(cleanup); + +describe("TaskAutopilotToggle", () => { + it("shows additional autopilot guidance from the compact info control", async () => { + render( + + + , + ); + + const infoButton = screen.getByRole("button", { + name: "More information about autopilot", + }); + fireEvent.click(infoButton); + + expect((await screen.findByRole("tooltip")).textContent).toContain( + "The agent works independently and asks the parent task only when a critical decision blocks progress.", + ); + }); +}); diff --git a/apps/web/components/task-autopilot-toggle.tsx b/apps/web/components/task-autopilot-toggle.tsx new file mode 100644 index 0000000000..42a38a076c --- /dev/null +++ b/apps/web/components/task-autopilot-toggle.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState } from "react"; +import { IconInfoCircle, IconRobot } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Switch } from "@kandev/ui/switch"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; + +export function TaskAutopilotToggle({ + checked, + onCheckedChange, + disabled, +}: { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled: boolean; +}) { + const { t } = useTranslation(); + const [helpOpen, setHelpOpen] = useState(false); + + return ( +
+
+
+ +
+ ); +} diff --git a/apps/web/components/task-create-dialog-form-body.test.tsx b/apps/web/components/task-create-dialog-form-body.test.tsx index 5efa6347a3..884acc0f68 100644 --- a/apps/web/components/task-create-dialog-form-body.test.tsx +++ b/apps/web/components/task-create-dialog-form-body.test.tsx @@ -63,6 +63,8 @@ describe("WorkflowSection", () => { function makeFs(): DialogFormState { return { taskName: "", + autopilot: false, + setAutopilot: () => {}, setTaskName: () => {}, hasTitle: false, setHasTitle: () => {}, diff --git a/apps/web/components/task-create-dialog-helpers.test.ts b/apps/web/components/task-create-dialog-helpers.test.ts index fab51fbad0..7cbf5f265b 100644 --- a/apps/web/components/task-create-dialog-helpers.test.ts +++ b/apps/web/components/task-create-dialog-helpers.test.ts @@ -172,4 +172,38 @@ describe("auto-title creation helpers", () => { expect(payload).toMatchObject({ auto_title: true }); expect(payload).not.toHaveProperty("title"); }); + + it("includes autopilot only when the create form opts in", () => { + const payload = buildCreateTaskPayload({ + workspaceId: "ws-1", + effectiveWorkflowId: "wf-1", + trimmedTitle: "Autonomous task", + trimmedDescription: "Run the migration", + repositoriesPayload: [], + agentProfileId: "agent-1", + executorId: "executor-1", + executorProfileId: "profile-1", + withAgent: true, + autopilot: true, + }); + + expect(payload.autopilot).toBe(true); + }); + + it("keeps autopilot off when the create form does not opt in", () => { + const payload = buildCreateTaskPayload({ + workspaceId: "ws-1", + effectiveWorkflowId: "wf-1", + trimmedTitle: "Manual task", + trimmedDescription: "Run the migration", + repositoriesPayload: [], + agentProfileId: "agent-1", + executorId: "executor-1", + executorProfileId: "profile-1", + withAgent: true, + autopilot: false, + }); + + expect(payload.autopilot).toBeUndefined(); + }); }); diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index a0d8cc951f..379849fc1f 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -166,6 +166,7 @@ export type BuildCreatePayloadArgs = { attachments?: MessageAttachment[]; parentId?: string; workspacePath?: string; + autopilot?: boolean; }; export function buildCreateTaskPayload(args: BuildCreatePayloadArgs): CreateTaskParams { @@ -185,6 +186,7 @@ export function buildCreateTaskPayload(args: BuildCreatePayloadArgs): CreateTask attachments: args.attachments, parent_id: args.parentId || undefined, workspace_path: args.workspacePath || undefined, + autopilot: args.autopilot || undefined, }; } diff --git a/apps/web/components/task-create-dialog-setup.ts b/apps/web/components/task-create-dialog-setup.ts index fd7fe5960b..65b0701d41 100644 --- a/apps/web/components/task-create-dialog-setup.ts +++ b/apps/web/components/task-create-dialog-setup.ts @@ -136,6 +136,7 @@ function useSubmitHandlersWiring({ isSessionMode, isEditMode, autoTitle, + autopilot: fs.autopilot, isPassthroughProfile: computed.isPassthroughProfile, taskName: fs.taskName, workspaceId, diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index 510efe62f5..3d778b0c3d 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -54,6 +54,7 @@ type FormResetters = { setUseRemote: (v: boolean) => void; setNoRepository: (v: boolean) => void; setWorkspacePath: (v: string) => void; + setAutopilot: (v: boolean) => void; setGitHubUrlError: (v: string | null) => void; setFreshBranchEnabled: (v: boolean) => void; setCurrentLocalBranch: (v: string) => void; @@ -199,6 +200,7 @@ function resetTaskForm( resetters.setExecutorProfileId(""); resetters.setSelectedWorkflowId(workflowId); resetters.setFetchedSteps(null); + resetters.setAutopilot(false); } /** Resets repository discovery state */ @@ -344,6 +346,7 @@ function useFormStateValues(workflowId: string | null) { // means kandev creates a scratch workspace. const [noRepository, setNoRepository] = useState(false); const [workspacePath, setWorkspacePath] = useState(""); + const [autopilot, setAutopilot] = useState(false); return { taskName, setTaskName, @@ -379,6 +382,8 @@ function useFormStateValues(workflowId: string | null) { setNoRepository, workspacePath, setWorkspacePath, + autopilot, + setAutopilot, }; } @@ -447,6 +452,7 @@ export function useDialogFormState( setCurrentLocalBranch: freshBranch.setCurrentLocalBranch, setNoRepository: form.setNoRepository, setWorkspacePath: form.setWorkspacePath, + setAutopilot: form.setAutopilot, }, }); diff --git a/apps/web/components/task-create-dialog-submit.test.tsx b/apps/web/components/task-create-dialog-submit.test.tsx index afaef11858..c4f4164033 100644 --- a/apps/web/components/task-create-dialog-submit.test.tsx +++ b/apps/web/components/task-create-dialog-submit.test.tsx @@ -119,6 +119,7 @@ function makeDeps(overrides: Partial): SubmitHandlersDeps { return { isSessionMode: false, isEditMode: false, + autopilot: false, isPassthroughProfile: false, taskName: "My CLI task", workspaceId: "ws-1", diff --git a/apps/web/components/task-create-dialog-submit.tsx b/apps/web/components/task-create-dialog-submit.tsx index 06930ddb42..9d4d1f776e 100644 --- a/apps/web/components/task-create-dialog-submit.tsx +++ b/apps/web/components/task-create-dialog-submit.tsx @@ -62,6 +62,7 @@ export function useTaskSubmitHandlers({ isSessionMode, isEditMode, autoTitle = false, + autopilot = false, isPassthroughProfile, taskName, workspaceId, @@ -386,6 +387,7 @@ export function useTaskSubmitHandlers({ // case and keeps "no path provided" semantically distinct from // "empty path string" on the wire. workspacePath: noRepository ? workspacePath.trim() || undefined : undefined, + autopilot, }); submittedPayload = payload; return payload; @@ -422,6 +424,7 @@ export function useTaskSubmitHandlers({ executorProfileId, isPassthroughProfile, parentTaskId, + autopilot, noRepository, workspacePath, onSuccess, @@ -647,6 +650,7 @@ export function useTaskSubmitHandlers({ withAgent: false, attachments, workspacePath: noRepository ? workspacePath.trim() || undefined : undefined, + autopilot, }); p.workflow_step_id = requirements.workflowStepId; submittedPayload = p; @@ -679,6 +683,7 @@ export function useTaskSubmitHandlers({ executorId, executorProfileId, noRepository, + autopilot, workspacePath, validateForCreate, checkRemoteDuplicates, diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index c961a0e360..c9d576bd8a 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -327,6 +327,9 @@ export type DialogFormState = { /** Optional host folder for repo-less tasks; empty means scratch workspace. */ workspacePath: string; setWorkspacePath: (v: string) => void; + /** Create-mode opt-in. Autopilot is immutable after task creation. */ + autopilot: boolean; + setAutopilot: (v: boolean) => void; }; export type SubmitHandlersDeps = { @@ -334,6 +337,8 @@ export type SubmitHandlersDeps = { isEditMode: boolean; /** Create-mode opt-in: derive a provisional title from the prompt. */ autoTitle?: boolean; + /** Create-mode opt-in. The backend fixes this value at task creation. */ + autopilot: boolean; isPassthroughProfile: boolean; taskName: string; workspaceId: string | null; diff --git a/apps/web/components/task-create-dialog.test.tsx b/apps/web/components/task-create-dialog.test.tsx index 699ebab231..6f3dc983ab 100644 --- a/apps/web/components/task-create-dialog.test.tsx +++ b/apps/web/components/task-create-dialog.test.tsx @@ -231,6 +231,8 @@ vi.mock("@/components/task-create-dialog-state", () => ({ function buildMockFs(initialDescription = ORIGINAL_PROMPT): DialogFormState { return { taskName: "Task title", + autopilot: false, + setAutopilot: () => undefined, setTaskName: () => undefined, hasTitle: true, setHasTitle: () => undefined, diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index d292b3aaef..9b8ad8f07a 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -106,21 +106,12 @@ function CreateModeBody(props: DialogFormBodyProps) { workspaceId, onJiraImport, onLinearImport, - agentProfileOptions, - executorProfileOptions, - agentProfiles, - agentProfilesLoading, - executorsLoading, - isCreatingSession, fs, onTaskNameChange, onRowRepositoryChange, onRowBranchChange, - onAgentProfileChange, - onExecutorProfileChange, onToggleRemote, onToggleFreshBranch, - workflowAgentLocked, repositories, onRefreshRepositories, repositoriesRefreshing, @@ -177,26 +168,32 @@ function CreateModeBody(props: DialogFormBodyProps) { autoFocusDescription={!isTaskStarted && !(showTaskName && taskNameAutoFocus)} onVoiceAutoSend={props.onVoiceAutoSend} /> - + {props.bottomSlot} ); } +function CreateModeAgentSelectors(props: DialogFormBodyProps) { + return ( + + ); +} + function SessionModeBody(props: DialogFormBodyProps) { return ( <> diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index dc2b089397..644580842a 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -42,6 +42,7 @@ import { cn } from "@/lib/utils"; import { resolveComposerWorkspaceId } from "./composer-workspace"; import { useTranslation } from "react-i18next"; import { t } from "@/lib/i18n"; +import { AutopilotChatChip, useTaskAutopilot } from "./task-autopilot-chat-chip"; const PLAN_CONTEXT_PATH = "plan:context"; @@ -396,6 +397,7 @@ function ChatStatusBar({ const { t } = useTranslation(); const showTodos = todoItems.length > 0; const showProceed = !!nextStepName && !isAgentBusy; + const autopilot = useTaskAutopilot(taskId); const showAutoScrollControl = useAppStore( (state) => state.userSettings.showTranscriptAutoScrollControl, ); @@ -425,6 +427,7 @@ function ChatStatusBar({ className="flex items-center gap-1.5 py-1 text-xs text-muted-foreground" > {showTodos && } + {autopilot && } {queueChip} diff --git a/apps/web/components/task/chat/task-autopilot-chat-chip.test.tsx b/apps/web/components/task/chat/task-autopilot-chat-chip.test.tsx new file mode 100644 index 0000000000..0076960356 --- /dev/null +++ b/apps/web/components/task/chat/task-autopilot-chat-chip.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; + +type MockTask = { id: string; autopilot?: boolean }; +type MockState = { + kanban: { tasks: MockTask[] }; + kanbanMulti: { snapshots: Record }; +}; + +let mockState: MockState = { + kanban: { tasks: [] }, + kanbanMulti: { snapshots: {} }, +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockState) => boolean) => selector(mockState), +})); + +import { useTaskAutopilot } from "./task-autopilot-chat-chip"; + +describe("useTaskAutopilot", () => { + it("falls back to a snapshot when the direct task omits autopilot", () => { + mockState = { + kanban: { tasks: [{ id: "task-1" }] }, + kanbanMulti: { snapshots: { workflow: { tasks: [{ id: "task-1", autopilot: true }] } } }, + }; + + const { result } = renderHook(() => useTaskAutopilot("task-1")); + + expect(result.current).toBe(true); + }); + + it("lets an explicit direct false override a stale snapshot", () => { + mockState = { + kanban: { tasks: [{ id: "task-1", autopilot: false }] }, + kanbanMulti: { snapshots: { workflow: { tasks: [{ id: "task-1", autopilot: true }] } } }, + }; + + const { result } = renderHook(() => useTaskAutopilot("task-1")); + + expect(result.current).toBe(false); + }); +}); diff --git a/apps/web/components/task/chat/task-autopilot-chat-chip.tsx b/apps/web/components/task/chat/task-autopilot-chat-chip.tsx new file mode 100644 index 0000000000..127fd23a0f --- /dev/null +++ b/apps/web/components/task/chat/task-autopilot-chat-chip.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { IconRobot } from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useTranslation } from "react-i18next"; +import { useAppStore } from "@/components/state-provider"; + +export function useTaskAutopilot(taskId: string | null): boolean { + return useAppStore((state) => { + if (!taskId) return false; + const direct = state.kanban.tasks.find((task) => task.id === taskId); + if (direct?.autopilot === true) return true; + if (direct?.autopilot === false) return false; + return Object.values(state.kanbanMulti.snapshots).some((snapshot) => + snapshot.tasks.some((task) => task.id === taskId && task.autopilot === true), + ); + }); +} + +export function AutopilotChatChip() { + const { t } = useTranslation(); + return ( + + + + + + {t("task:autopilotChatDescription")} + + ); +} diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts index ebe87c9906..2f655809f5 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts @@ -26,6 +26,10 @@ function task(overrides: Partial = {}): SheetTask { } describe("toSheetItem", () => { + it("carries the autopilot marker onto the mobile sheet row", () => { + expect(toSheetItem(task({ autopilot: true }), emptyCtx()).autopilot).toBe(true); + }); + // The mobile task-switcher row must read the same task-level most-active-wins // aggregate the desktop sidebar and board card read, so a background-running // secondary session is caught on mobile too. diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts index e54beb5e85..bceafecc4b 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts @@ -107,6 +107,7 @@ export function toSheetItem( return { id: task.id, title: task.title, + autopilot: task.autopilot, // Carry the parent link so the mobile task switcher nests subtasks the same // way the desktop sidebar does (applyView/TaskSwitcher read parentTaskId). parentTaskId: task.parentTaskId ?? undefined, diff --git a/apps/web/components/task/new-subtask-dialog.tsx b/apps/web/components/task/new-subtask-dialog.tsx index bd747bac47..b5ddaedcae 100644 --- a/apps/web/components/task/new-subtask-dialog.tsx +++ b/apps/web/components/task/new-subtask-dialog.tsx @@ -422,6 +422,7 @@ function NewSubtaskForm({ resolvePrompt: promptZone.resolvePrompt, title, autoTitle, + autopilot: fs.autopilot, setIsCreating, onClose, workspaceMode, @@ -432,6 +433,7 @@ function NewSubtaskForm({ title, setTitle, autoTitle, + autopilot: fs.autopilot, workspaceId, availableRepositories, parentRepositoryId, @@ -515,6 +517,16 @@ export function NewSubtaskDialog({ { + if (!autoTitle) return; + const content = event.currentTarget as HTMLElement | null; + const prompt = content?.querySelector( + '[data-testid="subtask-prompt-input"]', + ); + if (!prompt) return; + event.preventDefault(); + prompt.focus(); + }} className="w-full h-full min-w-0 max-w-full max-h-full overflow-hidden rounded-none sm:w-[800px] sm:h-auto sm:max-w-none sm:max-h-[85vh] sm:rounded-lg flex flex-col" > diff --git a/apps/web/components/task/new-subtask-form-parts.tsx b/apps/web/components/task/new-subtask-form-parts.tsx index 7ec65b46bb..499d062c41 100644 --- a/apps/web/components/task/new-subtask-form-parts.tsx +++ b/apps/web/components/task/new-subtask-form-parts.tsx @@ -27,6 +27,7 @@ import { } from "./session-dialog-shared"; import { ContextZone } from "./chat/context-items/context-zone"; import { clampTaskTitleInput } from "@/lib/task-title"; +import { TaskAutopilotToggle } from "@/components/task-autopilot-toggle"; export function WorktreeBadge({ show, branch }: { show: boolean; branch: string | null }) { const { t } = useTranslation(); @@ -258,6 +259,7 @@ type SubtaskFormBodyProps = { title: string; setTitle: (v: string) => void; autoTitle?: boolean; + autopilot: boolean; workspaceId: string | null; availableRepositories: Repository[]; parentRepositoryId: string | null; @@ -404,6 +406,7 @@ export function SubtaskFormBody({ title, setTitle, autoTitle = false, + autopilot, workspaceId, availableRepositories, parentRepositoryId, @@ -473,13 +476,23 @@ export function SubtaskFormBody({ disabled={isCreating} hideExecutor={inheritParent} /> - +
+ + +
{promptZone}