Files
kandev/apps/backend/internal/mcp/server/server_test.go
T

697 lines
28 KiB
Go

package mcp
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
mcplib "github.com/mark3labs/mcp-go/mcp"
mcpsrv "github.com/mark3labs/mcp-go/server"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMCPAttachmentObserverEmitsSafeConnectionEvidence(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
events := make(chan streams.MCPAttachmentEvidence, 4)
s.SetAttachmentReporter(func(evidence streams.MCPAttachmentEvidence) { events <- evidence })
s.SetAttachmentAttempt(streams.MCPAttachmentAttempt{AttemptID: "attempt-1"})
s.registerMCPConnection("agent session containing secret")
<-events
s.observeMCPConnection("agent session containing secret", streams.MCPAttachmentEvidenceInitializeObserved, 0, "")
s.observeMCPConnection("agent session containing secret", streams.MCPAttachmentEvidenceToolsListObserved, 7, "")
first := <-events
second := <-events
if first.AttemptID != "attempt-1" || first.ServerName != "kandev" || first.ConnectionID == "agent session containing secret" {
t.Fatalf("first evidence = %+v", first)
}
if second.Kind != streams.MCPAttachmentEvidenceToolsListObserved || second.ToolCount != 7 {
t.Fatalf("second evidence = %+v", second)
}
if first.OccurredAt.IsZero() || time.Since(first.OccurredAt) > time.Second {
t.Fatalf("timestamp = %v", first.OccurredAt)
}
}
func TestMCPAttachmentObserverKeepsConnectionAttemptAcrossRollover(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
events := make(chan streams.MCPAttachmentEvidence, 8)
s.SetAttachmentReporter(func(evidence streams.MCPAttachmentEvidence) { events <- evidence })
s.SetAttachmentAttempt(streams.MCPAttachmentAttempt{AttemptID: "attempt-old"})
s.registerMCPConnection("old")
if event := <-events; event.AttemptID != "attempt-old" {
t.Fatalf("old register attempt = %q", event.AttemptID)
}
s.SetAttachmentAttempt(streams.MCPAttachmentAttempt{AttemptID: "attempt-new"})
s.registerMCPConnection("new")
if event := <-events; event.AttemptID != "attempt-new" {
t.Fatalf("new register attempt = %q", event.AttemptID)
}
s.observeMCPConnection("old", streams.MCPAttachmentEvidenceToolsListObserved, 1, "")
if event := <-events; event.AttemptID != "attempt-old" {
t.Fatalf("old delayed event attempt = %q", event.AttemptID)
}
s.unregisterMCPConnection("old")
if event := <-events; event.AttemptID != "attempt-old" || event.Kind != streams.MCPAttachmentEvidenceConnectionClosed {
t.Fatalf("old close event = %+v", event)
}
}
func newTestLogger(t *testing.T) *logger.Logger {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
require.NoError(t, err)
return log
}
// getRegisteredToolNames returns the names of all tools registered on the MCP server.
func getRegisteredToolNames(s *Server) []string {
toolsMap := s.mcpServer.ListTools()
names := make([]string, 0, len(toolsMap))
for name := range toolsMap {
names = append(names, name)
}
return names
}
func TestServerModeTask_RegistersCorrectTools(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask, []string{"github", "gitlab"})
require.NotNil(t, s)
tools := getRegisteredToolNames(s)
// Task mode should have kanban tools
assert.Contains(t, tools, "list_workspaces_kandev")
assert.Contains(t, tools, "list_workflows_kandev")
assert.Contains(t, tools, "list_workflow_steps_kandev")
assert.Contains(t, tools, "list_tasks_kandev")
assert.Contains(t, tools, "create_task_kandev")
assert.Contains(t, tools, "update_task_kandev")
assert.Contains(t, tools, "move_task_kandev")
assert.Contains(t, tools, "message_task_kandev")
assert.Contains(t, tools, "stop_task_kandev")
assert.Contains(t, tools, "get_task_conversation_kandev")
assert.Contains(t, tools, "get_task_pr_automation_kandev")
assert.Contains(t, tools, "update_task_pr_automation_kandev")
assert.Contains(t, tools, "get_diagnostic_bundle_kandev")
assert.Contains(t, tools, "get_task_mr_automation_kandev")
assert.Contains(t, tools, "update_task_mr_automation_kandev")
// Task mode should have plan tools
assert.Contains(t, tools, "create_task_plan_kandev")
assert.Contains(t, tools, "get_task_plan_kandev")
assert.Contains(t, tools, "update_task_plan_kandev")
assert.Contains(t, tools, "delete_task_plan_kandev")
// Task mode should have interaction tools
assert.Contains(t, tools, "ask_user_question_kandev")
// Task mode should have profile listing tools (needed for create_task)
assert.Contains(t, tools, "list_agents_kandev")
assert.Contains(t, tools, "list_executor_profiles_kandev")
// Task mode keeps list_related_tasks_kandev (sibling discovery) but
// drops the task-document tools — those are office-only.
assert.Contains(t, tools, "list_related_tasks_kandev")
assert.NotContains(t, tools, "list_task_documents_kandev")
assert.NotContains(t, tools, "get_task_document_kandev")
assert.NotContains(t, tools, "write_task_document_kandev")
// Task mode exposes delete + archive so agents can clean up the tasks
// they fan out. Restore/unarchive is intentionally NOT exposed via MCP —
// it stays a user action in the UI.
assert.Contains(t, tools, "delete_task_kandev")
assert.Contains(t, tools, "archive_task_kandev")
assert.NotContains(t, tools, "restore_task_kandev")
// Task mode should NOT have config/mutation tools
assert.NotContains(t, tools, "create_workflow_kandev")
assert.NotContains(t, tools, "update_workflow_kandev")
assert.NotContains(t, tools, "delete_workflow_kandev")
assert.NotContains(t, tools, "create_workflow_step_kandev")
assert.NotContains(t, tools, "update_workflow_step_kandev")
assert.NotContains(t, tools, "update_agent_kandev")
assert.NotContains(t, tools, "create_agent_profile_kandev")
assert.NotContains(t, tools, "delete_agent_profile_kandev")
assert.NotContains(t, tools, "list_agent_profiles_kandev")
assert.NotContains(t, tools, "update_agent_profile_kandev")
assert.NotContains(t, tools, "get_mcp_config_kandev")
assert.NotContains(t, tools, "update_mcp_config_kandev")
assert.NotContains(t, tools, "list_executors_kandev")
assert.NotContains(t, tools, "create_executor_profile_kandev")
assert.NotContains(t, tools, "update_executor_profile_kandev")
assert.NotContains(t, tools, "delete_executor_profile_kandev")
assert.NotContains(t, tools, "update_task_state_kandev")
assert.NotContains(t, tools, "delete_workflow_step_kandev")
assert.NotContains(t, tools, "reorder_workflow_steps_kandev")
}
func TestServerModeConfig_RegistersCorrectTools(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeConfig)
require.NotNil(t, s)
tools := getRegisteredToolNames(s)
// Config mode should have workflow config tools
assert.Contains(t, tools, "list_workspaces_kandev")
assert.Contains(t, tools, "list_workflows_kandev")
assert.Contains(t, tools, "list_repositories_kandev")
assert.Contains(t, tools, "create_workflow_kandev")
assert.Contains(t, tools, "update_workflow_kandev")
assert.Contains(t, tools, "delete_workflow_kandev")
assert.Contains(t, tools, "import_workflow_kandev")
assert.Contains(t, tools, "list_workflow_steps_kandev")
assert.Contains(t, tools, "create_workflow_step_kandev")
assert.Contains(t, tools, "update_workflow_step_kandev")
assert.Contains(t, tools, "delete_workflow_step_kandev")
assert.Contains(t, tools, "reorder_workflow_steps_kandev")
// Config mode should have agent tools
assert.Contains(t, tools, "list_agents_kandev")
assert.Contains(t, tools, "update_agent_kandev")
assert.Contains(t, tools, "create_agent_profile_kandev")
assert.Contains(t, tools, "delete_agent_profile_kandev")
// Config mode should have MCP config tools
assert.Contains(t, tools, "list_agent_profiles_kandev")
assert.Contains(t, tools, "update_agent_profile_kandev")
assert.Contains(t, tools, "get_mcp_config_kandev")
assert.Contains(t, tools, "update_mcp_config_kandev")
// Config mode should have executor profile tools
assert.Contains(t, tools, "list_executors_kandev")
assert.Contains(t, tools, "list_executor_profiles_kandev")
assert.Contains(t, tools, "create_executor_profile_kandev")
assert.Contains(t, tools, "update_executor_profile_kandev")
assert.Contains(t, tools, "delete_executor_profile_kandev")
// Config mode should have task tools
assert.Contains(t, tools, "list_tasks_kandev")
assert.Contains(t, tools, "move_task_kandev")
assert.Contains(t, tools, "delete_task_kandev")
assert.Contains(t, tools, "archive_task_kandev")
assert.Contains(t, tools, "update_task_state_kandev")
assert.Contains(t, tools, "get_task_conversation_kandev")
// Config mode should have interaction tools
assert.Contains(t, tools, "ask_user_question_kandev")
// Config mode should NOT have plan tools
assert.NotContains(t, tools, "create_task_plan_kandev")
assert.NotContains(t, tools, "get_task_plan_kandev")
assert.NotContains(t, tools, "update_task_plan_kandev")
assert.NotContains(t, tools, "delete_task_plan_kandev")
// Config mode should NOT have task-mode kanban create/update tools
assert.NotContains(t, tools, "create_task_kandev")
assert.NotContains(t, tools, "update_task_kandev")
assert.NotContains(t, tools, "stop_task_kandev")
}
func TestServerModeDefault_DefaultsToTask(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "test-session", "test-task", 10005, log, "", false, "")
require.NotNil(t, s)
assert.Equal(t, ModeTask, s.mode)
tools := getRegisteredToolNames(s)
assert.Contains(t, tools, "create_task_kandev")
assert.Contains(t, tools, "create_task_plan_kandev")
assert.Contains(t, tools, "stop_task_kandev")
assert.NotContains(t, tools, "create_workflow_step_kandev")
}
func TestServerModeTask_AbsentProvidersFailClosedForReviewAutomation(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask)
tools := getRegisteredToolNames(s)
assert.NotContains(t, tools, "get_task_pr_automation_kandev")
assert.NotContains(t, tools, "update_task_pr_automation_kandev")
assert.NotContains(t, tools, "get_task_mr_automation_kandev")
assert.NotContains(t, tools, "update_task_mr_automation_kandev")
}
func TestServerModeTask_ProviderMembership(t *testing.T) {
log := newTestLogger(t)
tests := []struct {
name string
providers []string
wantPR bool
wantMR bool
}{
{name: "github only", providers: []string{" GITHUB "}, wantPR: true},
{name: "gitlab only", providers: []string{"gitlab"}, wantMR: true},
{name: "mixed", providers: []string{"gitlab", "github", "github"}, wantPR: true, wantMR: true},
{name: "empty", providers: []string{}, wantPR: false, wantMR: false},
{name: "unsupported", providers: []string{"local", "azure"}, wantPR: false, wantMR: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
backend := NewChannelBackendClient(log)
t.Cleanup(backend.Close)
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask, tt.providers)
tools := getRegisteredToolNames(s)
assert.Equal(t, tt.wantPR, containsTool(tools, "get_task_pr_automation_kandev"))
assert.Equal(t, tt.wantPR, containsTool(tools, "update_task_pr_automation_kandev"))
assert.Equal(t, tt.wantMR, containsTool(tools, "get_task_mr_automation_kandev"))
assert.Equal(t, tt.wantMR, containsTool(tools, "update_task_mr_automation_kandev"))
assert.Contains(t, tools, "stop_task_kandev")
})
}
}
func TestServerSetProvidersPreservesModeAndRebuildsTools(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTaskTitlePending, []string{"github"})
s.SetProviders([]string{"gitlab"})
tools := getRegisteredToolNames(s)
assert.Equal(t, ModeTaskTitlePending, s.mode)
assert.Contains(t, tools, "set_task_title_kandev")
assert.NotContains(t, tools, "get_task_pr_automation_kandev")
assert.Contains(t, tools, "get_task_mr_automation_kandev")
}
type providerRefreshTestSession struct {
id string
notifications chan mcplib.JSONRPCNotification
}
func (s *providerRefreshTestSession) Initialize() {}
func (s *providerRefreshTestSession) Initialized() bool { return true }
func (s *providerRefreshTestSession) NotificationChannel() chan<- mcplib.JSONRPCNotification {
return s.notifications
}
func (s *providerRefreshTestSession) SessionID() string { return s.id }
var _ mcpsrv.ClientSession = (*providerRefreshTestSession)(nil)
func TestServerSetProvidersNotifiesInitializedSessionOnceWithCompleteTools(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask, []string{"github"})
session := &providerRefreshTestSession{
id: "initialized-provider-refresh",
notifications: make(chan mcplib.JSONRPCNotification, 128),
}
require.NoError(t, s.mcpServer.RegisterSession(context.Background(), session))
s.SetProviders([]string{"gitlab"})
var notifications []mcplib.JSONRPCNotification
for {
select {
case notification := <-session.notifications:
notifications = append(notifications, notification)
default:
goto drained
}
}
drained:
require.Len(t, notifications, 1, "a live provider rebuild must emit one tools/list_changed notification")
require.Equal(t, mcplib.MethodNotificationToolsListChanged, notifications[0].Method)
tools := getRegisteredToolNames(s)
require.Len(t, tools, 32, "final registry should contain the complete GitLab-only task tool set")
assert.Contains(t, tools, "get_task_mr_automation_kandev")
assert.NotContains(t, tools, "get_task_pr_automation_kandev")
}
func TestServerSetProvidersAndModeSkipNormalizedNoOps(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask, []string{"github", "gitlab"})
session := &providerRefreshTestSession{
id: "initialized-noop-refresh",
notifications: make(chan mcplib.JSONRPCNotification, 128),
}
require.NoError(t, s.mcpServer.RegisterSession(context.Background(), session))
s.SetProviders([]string{" GITLAB ", "github", "github"})
s.SetMode("unknown-mode")
select {
case notification := <-session.notifications:
t.Fatalf("normalized no-op emitted notification %q", notification.Method)
default:
}
}
func containsTool(tools []string, name string) bool {
for _, tool := range tools {
if tool == name {
return true
}
}
return false
}
func TestServerModeConfig_DisableAskQuestion(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "test-session", "test-task", 10005, log, "", true, ModeConfig)
require.NotNil(t, s)
tools := getRegisteredToolNames(s)
assert.NotContains(t, tools, "ask_user_question_kandev")
assert.Contains(t, tools, "list_agents_kandev")
assert.Contains(t, tools, "create_workflow_step_kandev")
}
func TestServerModeTask_DisableAskQuestion(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "test-session", "test-task", 10005, log, "", true, ModeTask)
require.NotNil(t, s)
tools := getRegisteredToolNames(s)
assert.NotContains(t, tools, "ask_user_question_kandev")
assert.Contains(t, tools, "create_task_kandev")
assert.Contains(t, tools, "create_task_plan_kandev")
}
func TestServerModeTaskTitlePending_RegistersTitleToolOnlyForPendingTaskMode(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
pending := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTaskTitlePending)
titleTool, ok := pending.mcpServer.ListTools()["set_task_title_kandev"]
require.True(t, ok, "pending task mode must register set_task_title_kandev")
assert.Contains(t, titleTool.Tool.Description, "first action")
assert.Contains(t, titleTool.Tool.Description, "6 words")
assert.Contains(t, titleTool.Tool.Description, "sentence case")
assert.Contains(t, titleTool.Tool.Description, "Improve task title casing")
assert.Contains(t, titleTool.Tool.Description, "short title phrase")
assert.NotContains(t, titleTool.Tool.Description, "short noun phrase")
titleProperties := toolInputProperties(t, pending, "set_task_title_kandev")
titleProperty, ok := titleProperties["title"].(map[string]interface{})
require.True(t, ok, "title argument should have a schema property")
assert.Contains(t, titleProperty["description"], "targeting about 6 words")
assert.Contains(t, titleProperty["description"], "sentence-case")
ordinary := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask)
assert.NotContains(t, ordinary.mcpServer.ListTools(), "set_task_title_kandev")
for _, mode := range []string{ModeConfig, ModeOffice, ModeExternal} {
t.Run(mode, func(t *testing.T) {
restricted := New(backend, "test-session", "test-task", 10005, log, "", false, mode)
assert.NotContains(t, restricted.mcpServer.ListTools(), "set_task_title_kandev")
})
}
}
func TestServerSetTaskTitle_ForwardsBoundTaskAndSession(t *testing.T) {
backend := &testBackend{response: map[string]interface{}{
"accepted": true,
"task_id": "task-1",
"title": "Short title",
}}
log := newTestLogger(t)
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTaskTitlePending)
result := callTool(t, s, "set_task_title_kandev", map[string]interface{}{"title": "Short title"})
require.False(t, result.IsError)
assert.Equal(t, ws.ActionMCPSetTaskTitle, backend.lastAction)
assert.Equal(t, map[string]interface{}{
"task_id": "task-1",
"session_id": "session-1",
"title": "Short title",
}, backend.lastPayload)
text, ok := result.Content[0].(mcplib.TextContent)
require.True(t, ok)
assert.Contains(t, text.Text, `"accepted": true`)
}
func TestServerModeTask_ToolCount(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask, []string{"github", "gitlab"})
tools := getRegisteredToolNames(s)
// 19 kanban (incl. delete + archive task + stop_task + spawn_session + PR
// automation + MR automation) + 1 add_branch_to_task +
// 1 add_workspace_sources + 1 update_repository_base_branch +
// 1 step_complete (ADR 0015) + 1 interaction + 4 plan + 3 walkthrough +
// 1 publish_review_findings + 1 related-tasks + 1 diagnostic bundle = 34.
// Task-document tools (list/get/write) are office-only.
assert.Contains(t, tools, "step_complete_kandev", "ADR 0015 explicit-completion signal must be registered in task mode")
assert.Contains(t, tools, "show_walkthrough_kandev", "walkthrough tool must be registered in task mode")
assert.Contains(t, tools, "publish_review_findings_kandev", "native code-review publishing must be registered in task mode")
assert.Contains(t, tools, "spawn_session_kandev", "spawn_session must be registered in task mode")
assert.Contains(t, tools, "add_workspace_sources_kandev")
assert.Equal(t, 34, len(tools))
}
func TestServerStepCompleteTool_TaskOnlyAndDiscoverable(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
taskServer := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask)
stepComplete, ok := taskServer.mcpServer.ListTools()["step_complete_kandev"]
require.True(t, ok, "task mode must register step_complete_kandev")
serialized, err := json.Marshal(stepComplete.Tool)
require.NoError(t, err)
var tool map[string]json.RawMessage
require.NoError(t, json.Unmarshal(serialized, &tool))
assert.NotContains(t, tool, "_meta", "task tools should use normal client discovery")
for _, mode := range []string{ModeOffice, ModeConfig, ModeExternal} {
t.Run(mode, func(t *testing.T) {
restrictedServer := New(backend, "test-session", "test-task", 10005, log, "", false, mode)
assert.NotContains(t, restrictedServer.mcpServer.ListTools(), "step_complete_kandev")
})
}
}
func TestServerModeConfig_ToolCount(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeConfig)
tools := getRegisteredToolNames(s)
// 12 workflow (incl. list_repositories + import_workflow) + 4 agent + 4 mcp + 5 executor + 6 task + 1 interaction = 32
assert.NotContains(t, tools, "step_complete_kandev", "step_complete_kandev requires a live task session; must NOT register in config mode")
assert.Equal(t, 32, len(tools))
}
func TestServerModeConfig_ToolDescriptions(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeConfig)
toolsMap := s.mcpServer.ListTools()
assert.Contains(t, toolsMap["create_workflow_step_kandev"].Tool.Description, "Create a new workflow step")
assert.Contains(t, toolsMap["list_agents_kandev"].Tool.Description, "List all configured agents")
assert.Contains(t, toolsMap["get_mcp_config_kandev"].Tool.Description, "Get MCP server configuration")
}
func TestServerModeOffice_RegistersCorrectTools(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeOffice)
require.NotNil(t, s)
tools := getRegisteredToolNames(s)
// Office mode should have plan tools
assert.Contains(t, tools, "create_task_plan_kandev")
assert.Contains(t, tools, "get_task_plan_kandev")
assert.Contains(t, tools, "update_task_plan_kandev")
assert.Contains(t, tools, "delete_task_plan_kandev")
// Office mode should have interaction tools
assert.Contains(t, tools, "ask_user_question_kandev")
// delegate_task_kandev was removed from ModeOffice when the
// agentctl CLI started covering task creation/delegation via
// `agentctl kandev task create --parent $KANDEV_TASK_ID …`.
assert.NotContains(t, tools, "delegate_task_kandev")
// Office mode exposes the cross-task handoff tools.
assert.Contains(t, tools, "list_related_tasks_kandev")
assert.Contains(t, tools, "list_task_documents_kandev")
assert.Contains(t, tools, "get_task_document_kandev")
assert.Contains(t, tools, "write_task_document_kandev")
// Office mode should NOT have kanban tools
assert.NotContains(t, tools, "create_task_kandev")
assert.NotContains(t, tools, "list_tasks_kandev")
assert.NotContains(t, tools, "update_task_kandev")
assert.NotContains(t, tools, "list_workspaces_kandev")
assert.NotContains(t, tools, "list_workflows_kandev")
assert.NotContains(t, tools, "list_workflow_steps_kandev")
assert.NotContains(t, tools, "list_agents_kandev")
assert.NotContains(t, tools, "list_executor_profiles_kandev")
assert.NotContains(t, tools, "stop_task_kandev")
// Office mode should NOT have config tools
assert.NotContains(t, tools, "create_workflow_kandev")
assert.NotContains(t, tools, "update_workflow_kandev")
assert.NotContains(t, tools, "update_agent_kandev")
}
func TestServerModeOffice_ToolCount(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeOffice)
tools := getRegisteredToolNames(s)
// 4 plan + 1 interaction + 1 related-tasks + 3 task-documents = 9
// (delegate_task_kandev retired in favour of `agentctl kandev task create …`).
assert.NotContains(t, tools, "step_complete_kandev", "step_complete_kandev is kanban-task-only; office mode advances tasks via its own approval surface")
assert.Equal(t, 9, len(tools))
}
func TestServerModeOffice_DisableAskQuestion(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "test-session", "test-task", 10005, log, "", true, ModeOffice)
require.NotNil(t, s)
tools := getRegisteredToolNames(s)
assert.NotContains(t, tools, "ask_user_question_kandev")
assert.Contains(t, tools, "create_task_plan_kandev")
// delegate_task_kandev was retired from ModeOffice (now lives in
// the agentctl CLI as `agentctl kandev task create --parent …`).
assert.NotContains(t, tools, "delegate_task_kandev")
// 4 plan + 1 related-tasks + 3 task-documents = 8 (no ask_user_question, no delegate)
assert.Equal(t, 8, len(tools))
}
func TestServerModeConstants(t *testing.T) {
assert.Equal(t, "task", ModeTask)
assert.Equal(t, "task-title-pending", ModeTaskTitlePending)
assert.Equal(t, "config", ModeConfig)
assert.Equal(t, "external", ModeExternal)
assert.Equal(t, "office", ModeOffice)
}
func TestServerModeExternal_RegistersCorrectTools(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "", "", 0, log, "", true, ModeExternal)
require.NotNil(t, s)
tools := getRegisteredToolNames(s)
// External mode includes all config tools
assert.Contains(t, tools, "list_workspaces_kandev")
assert.Contains(t, tools, "list_repositories_kandev")
assert.Contains(t, tools, "create_workflow_kandev")
assert.Contains(t, tools, "list_agents_kandev")
assert.Contains(t, tools, "get_mcp_config_kandev")
assert.Contains(t, tools, "list_executors_kandev")
assert.Contains(t, tools, "move_task_kandev")
// External mode includes create_task_kandev so external agents can spawn tasks
assert.Contains(t, tools, "create_task_kandev")
createTask := s.mcpServer.ListTools()["create_task_kandev"]
assert.Contains(t, createTask.Tool.Description, "outranks an explicit agent_profile_id")
assert.Contains(t, createTask.Tool.Description, "current_task")
assert.Contains(t, createTask.Tool.Description, "workspace_default")
assert.Contains(t, createTask.Tool.Description, "workflow profiles first")
// External mode does NOT include session-scoped tools
assert.NotContains(t, tools, "ask_user_question_kandev")
assert.NotContains(t, tools, "create_task_plan_kandev")
assert.NotContains(t, tools, "get_task_plan_kandev")
assert.NotContains(t, tools, "update_task_plan_kandev")
assert.NotContains(t, tools, "delete_task_plan_kandev")
// External mode does NOT include kanban update_task_kandev (config has its own update_task_state)
assert.NotContains(t, tools, "update_task_kandev")
// External mode does NOT include message_task_kandev (no live session context)
assert.NotContains(t, tools, "message_task_kandev")
assert.NotContains(t, tools, "stop_task_kandev")
}
func TestServerModeExternal_ToolCount(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "", "", 0, log, "", true, ModeExternal)
tools := getRegisteredToolNames(s)
// 12 workflow (incl. list_repositories + import_workflow) + 4 agent + 4 mcp + 5 executor + 6 task + 1 create_task = 32.
// add_branch_to_task_kandev is task-mode only — external coding agents have no live session to attach a worktree to.
assert.Equal(t, 32, len(tools))
assert.NotContains(t, tools, "add_branch_to_task_kandev")
}
func TestNewExternal_Constructs(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := NewExternal(backend, log, "")
require.NotNil(t, s)
assert.Equal(t, ModeExternal, s.mode)
assert.True(t, s.disableAskQuestion)
assert.Empty(t, s.sessionID)
assert.Empty(t, s.taskID)
assert.NotNil(t, s.sseServer)
assert.NotNil(t, s.httpServer)
assert.Equal(t, "/mcp/message?sessionId=session-1", s.sseServer.GetMessageEndpointForClient(nil, "session-1"))
}