From 1dc06e829837f750e49184460fa32b8a5dc80b2b Mon Sep 17 00:00:00 2001 From: Ivan Turkovic Date: Tue, 1 Sep 2026 00:39:24 +0000 Subject: [PATCH] fix(agentctl): accept the automation MCP mode at the instance API (#3193) Co-authored-by: Carlos Florencio --- .../agent/runtime/agentctl/control.go | 2 +- .../runtime/lifecycle/executor_backend.go | 2 +- .../internal/agent/runtime/lifecycle/types.go | 2 +- .../agentctl/server/api/mcp_mode_test.go | 28 ++++++- .../internal/agentctl/server/api/server.go | 7 +- .../internal/agentctl/server/config/config.go | 3 +- .../agentctl/server/instance/instance.go | 3 +- apps/backend/internal/common/mcpmode/mode.go | 38 +++++++++ .../internal/common/mcpmode/mode_test.go | 15 ++++ apps/backend/internal/mcp/profile/profile.go | 16 ++-- apps/backend/internal/mcp/server/server.go | 61 ++++++++++---- .../mcp/server/server_automation_mode_test.go | 38 +++++++++ .../orchestrator/executor/executor.go | 11 +-- .../executor/executor_mcp_mode_test.go | 79 +++++++++++++++++++ 14 files changed, 269 insertions(+), 36 deletions(-) create mode 100644 apps/backend/internal/common/mcpmode/mode.go create mode 100644 apps/backend/internal/common/mcpmode/mode_test.go create mode 100644 apps/backend/internal/mcp/server/server_automation_mode_test.go diff --git a/apps/backend/internal/agent/runtime/agentctl/control.go b/apps/backend/internal/agent/runtime/agentctl/control.go index 72dc4b262a..1a9d8e18db 100644 --- a/apps/backend/internal/agent/runtime/agentctl/control.go +++ b/apps/backend/internal/agent/runtime/agentctl/control.go @@ -57,7 +57,7 @@ type CreateInstanceRequest struct { 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" + McpMode string `json:"mcp_mode,omitempty"` // MCP tool mode: "task" (default), "task-title-pending", "config", "office", or "automation" 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 diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go index c489af5a2c..1a5e573c19 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go @@ -467,7 +467,7 @@ type ExecutorCreateRequest struct { // the final command. ManagedRuntimeVersion string PreviousExecutionID string // Non-empty when reconnecting to a previous execution - McpMode string // MCP tool mode: "task" (default), "config", or "office" + McpMode string // MCP tool mode: "task" (default), "task-title-pending", "config", "office", or "automation" McpProviders []string // Normalized provider capabilities attached to the task McpProfile *mcpprofile.Context AuthToken string // Previously handshaken agentctl token for reconnects diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index c2c7fcf212..dca08d1a79 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -901,7 +901,7 @@ type LaunchRequest struct { 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" + McpMode string // MCP tool mode: "task" (default), "task-title-pending", "config", "office", or "automation" McpProviders []string // Normalized provider capabilities attached to the task McpProfile *mcpprofile.Context diff --git a/apps/backend/internal/agentctl/server/api/mcp_mode_test.go b/apps/backend/internal/agentctl/server/api/mcp_mode_test.go index 49699f6ed0..0b320ec7c9 100644 --- a/apps/backend/internal/agentctl/server/api/mcp_mode_test.go +++ b/apps/backend/internal/agentctl/server/api/mcp_mode_test.go @@ -10,6 +10,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/server/config" "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/common/mcpmode" "github.com/kandev/kandev/internal/mcp/plugintools" mcpserver "github.com/kandev/kandev/internal/mcp/server" ) @@ -57,7 +58,7 @@ func setMcpProviders(t *testing.T, s *Server, providers []string) *httptest.Resp func TestHandleSetMcpMode_AcceptsSupportedModes(t *testing.T) { s := newTestServerWithMCP(t) - for _, mode := range []string{mcpserver.ModeTask, mcpserver.ModeTaskTitlePending, mcpserver.ModeConfig, mcpserver.ModeOffice} { + for _, mode := range mcpmode.InstanceModes() { t.Run(mode, func(t *testing.T) { rec := setMcpMode(t, s, mode) if rec.Code != http.StatusOK { @@ -77,6 +78,31 @@ func TestHandleSetMcpMode_AcceptsSupportedModes(t *testing.T) { } } +// orchestratorEmittableModes is every non-default value the backend's +// orchestrator/executor resolveTaskSessionMCPMode can put on this route. The +// values come from the shared wire contract. The producer half of the pin is +// executor.TestMcpModeConstants_MatchTheAgentctlWireValues. +// +// handleSetMcpMode and that resolver are two allowlists over one field. +// v0.92.1 taught the resolver "automation" and left this one alone; the +// existing-workspace launch path calls SetMcpMode before starting the agent, +// so every automation-origin task failed with HTTP 400 at launch. +var orchestratorEmittableModes = []string{ + mcpmode.Config, mcpmode.Office, mcpmode.TaskTitlePending, mcpmode.Automation, +} + +func TestHandleSetMcpMode_AcceptsEveryModeTheOrchestratorCanEmit(t *testing.T) { + s := newTestServerWithMCP(t) + + for _, mode := range orchestratorEmittableModes { + t.Run(mode, func(t *testing.T) { + if rec := setMcpMode(t, s, mode); rec.Code != http.StatusOK { + t.Fatalf("mode %q: status = %d, want %d; body=%s", mode, rec.Code, http.StatusOK, rec.Body.String()) + } + }) + } +} + func TestHandleSetMcpMode_RejectsUnsupportedMode(t *testing.T) { s := newTestServerWithMCP(t) diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index cb9f5d685f..d39cd35abc 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -18,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/server/utility" "github.com/kandev/kandev/internal/common/httpmw" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/common/mcpmode" lspinstaller "github.com/kandev/kandev/internal/lsp/installer" "github.com/kandev/kandev/internal/mcp/plugintools" mcpproviders "github.com/kandev/kandev/internal/mcp/providers" @@ -328,8 +329,10 @@ func (s *Server) handleSetMcpMode(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if req.Mode != mcp.ModeTask && req.Mode != mcp.ModeTaskTitlePending && req.Mode != mcp.ModeConfig && req.Mode != mcp.ModeOffice { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mode: must be 'task', 'task-title-pending', 'config', or 'office'"}) + // ModeExternal stays rejected on purpose: it belongs to the backend's own + // MCP endpoint for external coding agents, and no launch path can emit it. + if !mcpmode.IsInstanceMode(req.Mode) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mode: must be 'task', 'task-title-pending', 'config', 'office', or 'automation'"}) return } s.mcpServer.SetMode(req.Mode) diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index cb55d6ecc5..b0a289ed50 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -244,7 +244,8 @@ type InstanceConfig struct { AssumeMcpHttp bool // McpMode controls which MCP tools are registered for this instance. - // "task" (default), "config", and "office" select distinct tool surfaces. + // "task" (default), "task-title-pending", "config", "office", and + // "automation" select distinct tool surfaces. McpMode string // McpProviders limits task-mode review automation tools to attached providers. diff --git a/apps/backend/internal/agentctl/server/instance/instance.go b/apps/backend/internal/agentctl/server/instance/instance.go index e7f34f8b29..bf4125730c 100644 --- a/apps/backend/internal/agentctl/server/instance/instance.go +++ b/apps/backend/internal/agentctl/server/instance/instance.go @@ -162,7 +162,8 @@ type CreateRequest struct { // AssumeMcpHttp overrides MCP capability filtering to assume HTTP support. AssumeMcpHttp bool `json:"assume_mcp_http,omitempty"` - // McpMode controls which MCP tools are registered: "task" (default), "config", or "office". + // McpMode controls which MCP tools are registered: "task" (default), + // "task-title-pending", "config", "office", or "automation". McpMode string `json:"mcp_mode,omitempty"` // McpProviders limits task-mode review automation tools to attached providers. diff --git a/apps/backend/internal/common/mcpmode/mode.go b/apps/backend/internal/common/mcpmode/mode.go new file mode 100644 index 0000000000..5cab44bdf9 --- /dev/null +++ b/apps/backend/internal/common/mcpmode/mode.go @@ -0,0 +1,38 @@ +// Package mcpmode defines the MCP mode values shared by the orchestrator and +// agentctl. Keeping the wire contract here prevents the two process tiers from +// accepting different values. +package mcpmode + +const ( + Task = "task" + TaskTitlePending = "task-title-pending" + Config = "config" + External = "external" + Office = "office" + Automation = "automation" +) + +var instanceModes = [...]string{ + Task, + TaskTitlePending, + Config, + Office, + Automation, +} + +// InstanceModes returns the modes accepted by the agentctl instance API. +// External is intentionally excluded because it belongs to the backend MCP +// endpoint, not to a live agentctl instance. +func InstanceModes() []string { + return append([]string(nil), instanceModes[:]...) +} + +// IsInstanceMode reports whether mode is valid for the agentctl instance API. +func IsInstanceMode(mode string) bool { + for _, accepted := range instanceModes { + if mode == accepted { + return true + } + } + return false +} diff --git a/apps/backend/internal/common/mcpmode/mode_test.go b/apps/backend/internal/common/mcpmode/mode_test.go new file mode 100644 index 0000000000..0794bcae52 --- /dev/null +++ b/apps/backend/internal/common/mcpmode/mode_test.go @@ -0,0 +1,15 @@ +package mcpmode + +import "testing" + +func TestInstanceModes(t *testing.T) { + for _, mode := range InstanceModes() { + if !IsInstanceMode(mode) { + t.Errorf("IsInstanceMode(%q) = false, want true", mode) + } + } + + if IsInstanceMode(External) { + t.Errorf("IsInstanceMode(%q) = true, want false", External) + } +} diff --git a/apps/backend/internal/mcp/profile/profile.go b/apps/backend/internal/mcp/profile/profile.go index 2d07c6d3c1..5f15593984 100644 --- a/apps/backend/internal/mcp/profile/profile.go +++ b/apps/backend/internal/mcp/profile/profile.go @@ -3,7 +3,11 @@ // agent can receive a profile, but it cannot request arbitrary tool names. package profile -import "slices" +import ( + "slices" + + "github.com/kandev/kandev/internal/common/mcpmode" +) type Surface string @@ -77,15 +81,15 @@ func Legacy(mode string, disableAskQuestion bool, providers []string) Context { surface := SurfaceKanbanTask capabilities := []Capability{} switch mode { - case "office": + case mcpmode.Office: surface = SurfaceOfficeTask - case "config": + case mcpmode.Config: surface = SurfaceConfiguration - case "external": + case mcpmode.External: surface = SurfaceExternal - case "automation": + case mcpmode.Automation: surface = SurfaceAutomation - case "task-title-pending": + case mcpmode.TaskTitlePending: capabilities = append(capabilities, CapabilityTaskTitle) } if !disableAskQuestion && surface != SurfaceExternal && surface != SurfaceAutomation { diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 6c9c301ff8..4ac6cb24ab 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/json" "fmt" + "slices" "strings" "sync" "time" @@ -14,6 +15,7 @@ import ( "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/common/mcpmode" "github.com/kandev/kandev/internal/mcp/plugintools" mcpprofile "github.com/kandev/kandev/internal/mcp/profile" mcpproviders "github.com/kandev/kandev/internal/mcp/providers" @@ -37,22 +39,22 @@ type BackendClient interface { // MCP mode constants control which tools are registered. const ( // ModeTask registers kanban, plan, and interaction tools (default for task-solving agents). - ModeTask = "task" + ModeTask = mcpmode.Task // ModeTaskTitlePending registers the task-mode tools plus the one-shot // title tool used while a prompt-first task still has its provisional title. - ModeTaskTitlePending = "task-title-pending" + ModeTaskTitlePending = mcpmode.TaskTitlePending // ModeConfig registers configuration tools for workflows, agents, and MCP servers. - ModeConfig = "config" + ModeConfig = mcpmode.Config // ModeExternal registers config tools plus create_task_kandev for external coding agents // (Claude Code, Cursor, etc.) that connect to the backend's MCP endpoint. // No session-scoped tools (plan, ask_user_question) since there is no live session. - ModeExternal = "external" + ModeExternal = mcpmode.External // ModeOffice registers plan and interaction tools for office agents. // Kanban tools are excluded because office agents use CLI commands instead. - ModeOffice = "office" + ModeOffice = mcpmode.Office // ModeAutomation registers the fixed workspace coordinator catalog for // scheduled automation agents. - ModeAutomation = "automation" + ModeAutomation = mcpmode.Automation ) const pluginToolArgumentsKey = "arguments" @@ -103,9 +105,10 @@ type Server struct { sessionID string taskID string disableAskQuestion bool - mode string // "task" (default), "task-title-pending", "config", or "office" + mode string // "task" (default), "task-title-pending", "config", "external", "office", or "automation" mcpProviders []string profile mcpprofile.Context + legacyModeCapabilities []mcpprofile.Capability namespacesMCPToolsByServer bool mcpServer *server.MCPServer sseServer *server.SSEServer @@ -214,15 +217,16 @@ func newServer(backend BackendClient, sessionID, taskID string, log *logger.Logg func newServerWithProfile(backend BackendClient, sessionID, taskID string, log *logger.Logger, mcpLogFile string, profileContext mcpprofile.Context, options ...ServerOption) *Server { profileContext = mcpprofile.New(profileContext.Surface, profileContext.Capabilities, profileContext.Providers) s := &Server{ - backend: backend, - sessionID: sessionID, - taskID: taskID, - 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), + backend: backend, + sessionID: sessionID, + taskID: taskID, + disableAskQuestion: !profileContext.HasCapability(mcpprofile.CapabilityUserQuestion), + mode: modeForProfile(profileContext), + mcpProviders: mcpproviders.Normalize(profileContext.Providers), + profile: profileContext, + legacyModeCapabilities: slices.Clone(profileContext.Capabilities), + logger: log.WithFields(zap.String("component", "mcp-server")), + attachmentAttempts: make(map[string]streams.MCPAttachmentAttempt), } for _, option := range options { if option != nil { @@ -322,6 +326,8 @@ func modeForProfile(profileContext mcpprofile.Context) string { return ModeExternal case mcpprofile.SurfaceOfficeTask: return ModeOffice + case mcpprofile.SurfaceAutomation: + return ModeAutomation case mcpprofile.SurfaceKanbanTask: if profileContext.HasCapability(mcpprofile.CapabilityTaskTitle) { return ModeTaskTitlePending @@ -637,13 +643,27 @@ func (s *Server) SetMode(mode string) { if s.mode == normalizedMode { return } + previousMode := s.mode + capabilities := s.profile.Capabilities + if normalizedMode == ModeAutomation { + s.legacyModeCapabilities = slices.Clone(capabilities) + // The automation surface is a fixed coordinator catalog and never + // carries task-local capabilities. The snapshot lets a later legacy + // mode change restore the profile that was active before automation. + capabilities = nil + } else if previousMode == ModeAutomation { + capabilities = slices.Clone(s.legacyModeCapabilities) + } s.mode = normalizedMode - s.profile = mcpprofile.New(surfaceForMode(normalizedMode), s.profile.Capabilities, s.mcpProviders) + s.profile = mcpprofile.New(surfaceForMode(normalizedMode), capabilities, s.mcpProviders) if normalizedMode == ModeTaskTitlePending { s.profile = s.profile.WithCapability(mcpprofile.CapabilityTaskTitle) } else { s.profile = s.profile.WithoutCapability(mcpprofile.CapabilityTaskTitle) } + if normalizedMode != ModeAutomation { + s.legacyModeCapabilities = slices.Clone(s.profile.Capabilities) + } s.rebuildTools() } @@ -695,6 +715,13 @@ func (s *Server) SetProfile(profileContext mcpprofile.Context) { if sameProfile(s.profile, profileContext) { return } + if profileContext.Surface == mcpprofile.SurfaceAutomation { + if s.profile.Surface != mcpprofile.SurfaceAutomation { + s.legacyModeCapabilities = slices.Clone(s.profile.Capabilities) + } + } else { + s.legacyModeCapabilities = slices.Clone(profileContext.Capabilities) + } s.profile = profileContext s.mode = modeForProfile(profileContext) s.disableAskQuestion = !profileContext.HasCapability(mcpprofile.CapabilityUserQuestion) diff --git a/apps/backend/internal/mcp/server/server_automation_mode_test.go b/apps/backend/internal/mcp/server/server_automation_mode_test.go new file mode 100644 index 0000000000..550f973f3e --- /dev/null +++ b/apps/backend/internal/mcp/server/server_automation_mode_test.go @@ -0,0 +1,38 @@ +package mcp + +import ( + "testing" + + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestModeForProfile_MapsTheAutomationSurface(t *testing.T) { + require.Equal(t, ModeAutomation, modeForProfile(mcpprofile.NewAutomation())) +} + +// SetMode(automation) must land on exactly the fixed coordinator catalog a +// cold automation launch gets. SetMode carries the previous profile's +// capabilities across the surface change, and the user-question group is +// capability-gated rather than surface-gated, so without the drop a task-mode +// instance switched at runtime kept CapabilityUserQuestion and advertised +// ask_user_question_kandev on a surface whose execution-time allowlist +// refuses it. +func TestSetMode_AutomationDropsTaskLocalQuestionCapabilities(t *testing.T) { + log := newTestLogger(t) + backend := NewChannelBackendClient(log) + defer backend.Close() + + s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask) + require.Contains(t, getRegisteredToolNames(s), "ask_user_question_kandev") + + s.SetMode(ModeAutomation) + + cold := NewWithProfile(backend, "test-session", "test-task", 10006, log, "", false, mcpprofile.NewAutomation()) + assert.ElementsMatch(t, getRegisteredToolNames(cold), getRegisteredToolNames(s)) + assert.NotContains(t, getRegisteredToolNames(s), "ask_user_question_kandev") + + s.SetMode(ModeTask) + assert.Contains(t, getRegisteredToolNames(s), "ask_user_question_kandev") +} diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index f20d2c45c0..d8d09721d0 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.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/logger" + "github.com/kandev/kandev/internal/common/mcpmode" "github.com/kandev/kandev/internal/integrations/cloneauth" mcpprofile "github.com/kandev/kandev/internal/mcp/profile" "github.com/kandev/kandev/internal/repoclone" @@ -401,7 +402,7 @@ type LaunchAgentRequest struct { 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" + McpMode string // MCP tool mode: "task" (default), "task-title-pending", "config", "office", or "automation" 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 @@ -521,21 +522,21 @@ type RepoSpec struct { // McpModeConfig activates config-mode MCP tools (workflow steps, agents, MCP // config, tasks). Used when plan_mode is enabled on a session. -const McpModeConfig = "config" +const McpModeConfig = mcpmode.Config // McpModeTaskTitlePending exposes the task-mode MCP surface plus the one-shot // title tool while a prompt-first task still has its provisional title. -const McpModeTaskTitlePending = "task-title-pending" +const McpModeTaskTitlePending = mcpmode.TaskTitlePending // McpModeOffice restricts the MCP toolset for office (autonomous) agents to // interaction + plan tools. Office agents manage tasks via the kandev CLI // (exposed through agentctl + $KANDEV_CLI), not MCP — see // docs/specs/office/system-design/agents-03.md. -const McpModeOffice = "office" +const McpModeOffice = mcpmode.Office // McpModeAutomation selects the fixed coordinator MCP surface for tasks // created by a user-configured automation. -const McpModeAutomation = "automation" +const McpModeAutomation = mcpmode.Automation // LaunchOptions contains optional parameters for LaunchPreparedSession. type LaunchOptions struct { 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 c3f6b1d811..7d7ad83244 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mcp_mode_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mcp_mode_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/kandev/kandev/internal/common/mcpmode" mcpprofile "github.com/kandev/kandev/internal/mcp/profile" "github.com/kandev/kandev/internal/task/models" "github.com/stretchr/testify/require" @@ -160,3 +161,81 @@ func TestResolveTaskSessionMCPMode_TitlePendingDoesNotOverrideRestrictedModes(t require.NoError(t, err) require.Equal(t, McpModeOffice, mode) } + +// The executor names the agentctl wire values a second time — this package +// must not import the agentctl-side MCP server. Pin the literals so a rename +// or a new mode on either side is a test failure here rather than an HTTP 400 +// at launch. The consumer half of the pin is +// api.TestHandleSetMcpMode_AcceptsEveryModeTheOrchestratorCanEmit. +func TestMcpModeConstants_MatchTheAgentctlWireValues(t *testing.T) { + require.Equal(t, mcpmode.Config, McpModeConfig) + require.Equal(t, mcpmode.TaskTitlePending, McpModeTaskTitlePending) + require.Equal(t, mcpmode.Office, McpModeOffice) + require.Equal(t, mcpmode.Automation, McpModeAutomation) +} + +// Every branch of resolveTaskSessionMCPMode, asserted against the exact set +// the agentctl validator accepts. The "automation" row is the v0.92.1 +// regression: task.Origin == automation_run resolved to a mode the instance +// API used to answer with 400 "invalid mode". +func TestResolveTaskSessionMCPMode_EmitsOnlyAgentctlAcceptedModes(t *testing.T) { + agentctlAccepted := mcpmode.InstanceModes() + + tests := []struct { + name string + task *models.Task + session *models.TaskSession + wantMode string + }{ + { + name: "config-mode session", + task: &models.Task{ID: "task"}, + session: &models.TaskSession{ID: "session", TaskID: "task", Metadata: map[string]interface{}{"config_mode": true}}, + wantMode: McpModeConfig, + }, + { + name: "automation-origin task", + task: &models.Task{ID: "task", Origin: models.TaskOriginAutomationRun}, + session: &models.TaskSession{ID: "session", TaskID: "task"}, + wantMode: McpModeAutomation, + }, + { + name: "office task", + task: &models.Task{ID: "task", IsFromOffice: true}, + session: &models.TaskSession{ID: "session", TaskID: "task"}, + wantMode: McpModeOffice, + }, + { + name: "title-owner session", + task: &models.Task{ID: "task", Metadata: map[string]interface{}{ + models.MetaKeyAgentTitlePending: true, + models.MetaKeyAgentTitleOwnerSessionID: "session", + }}, + session: &models.TaskSession{ID: "session", TaskID: "task"}, + wantMode: McpModeTaskTitlePending, + }, + { + name: "plain task", + task: &models.Task{ID: "task"}, + session: &models.TaskSession{ID: "session", TaskID: "task"}, + wantMode: "", + }, + } + + 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) + + mode, err := exec.resolveTaskSessionMCPMode(context.Background(), tt.task.ID, tt.session, true) + require.NoError(t, err) + require.Equal(t, tt.wantMode, mode) + if mode != "" { + require.Contains(t, agentctlAccepted, mode, + "resolveTaskSessionMCPMode emitted a mode the agentctl instance API rejects") + } + }) + } +}