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

1187 lines
51 KiB
Go

package mcp
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/mcp/plugintools"
mcpprofile "github.com/kandev/kandev/internal/mcp/profile"
"github.com/kandev/kandev/internal/mcp/tooltokens"
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"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestMCPAttachmentObserverEmitsSafeConnectionEvidence(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
t.Cleanup(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 TestMCPAttachmentObserverPublishesToolSummaries(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
t.Cleanup(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("connection-1")
<-events
tool := mcplib.Tool{
Name: "create_task_kandev",
Description: "Create a task",
RawInputSchema: []byte(`{"type":"object","properties":{"secret":{"type":"string"}}}`),
Meta: &mcplib.Meta{AdditionalFields: map[string]any{"private_note": "must not persist"}},
}
s.observeMCPToolsList("connection-1", []mcplib.Tool{tool})
evidence := <-events
if evidence.Kind != streams.MCPAttachmentEvidenceToolsListObserved || evidence.ToolCount != 1 {
t.Fatalf("evidence = %+v, want tools-list count", evidence)
}
if len(evidence.Tools) != 1 || evidence.Tools[0].Name != "create_task_kandev" || evidence.Tools[0].Description != "Create a task" {
t.Fatalf("tool summaries = %+v, want name and description", evidence.Tools)
}
encoded, err := json.Marshal(evidence)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"input_schema":{"type":"object","properties":{"secret":{"type":"string"}}}`)
assert.Contains(t, string(encoded), `"tool_token_estimator":"o200k_base:mcp-tool-json-v1"`)
assert.NotContains(t, string(encoded), "must not persist")
definition, err := json.Marshal(tool)
require.NoError(t, err)
wantTokens, err := tooltokens.EstimateToolJSON(definition)
require.NoError(t, err)
assert.Equal(t, wantTokens, evidence.Tools[0].EstimatedTokens)
}
func TestMCPAttachmentObserverPublishesStructuredInputSchema(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
t.Cleanup(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("connection-1")
<-events
s.observeMCPToolsList("connection-1", []mcplib.Tool{{
Name: "structured",
InputSchema: mcplib.ToolInputSchema{
Type: "object",
Properties: map[string]any{"title": map[string]any{"type": "string"}},
Required: []string{"title"},
},
}})
evidence := <-events
require.Len(t, evidence.Tools, 1)
assert.JSONEq(t, `{"type":"object","properties":{"title":{"type":"string"}},"required":["title"]}`, string(evidence.Tools[0].InputSchema))
assert.Positive(t, evidence.Tools[0].EstimatedTokens)
}
func TestMCPAttachmentObserverKeepsConnectionAttemptAcrossRollover(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
t.Cleanup(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
}
func TestSetPluginToolsReplacesRuntimeRegistry(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
t.Cleanup(backend.Close)
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
definition := plugintools.Definition{
PluginID: "task-tags.v1", LocalName: "add_tag", ExposedName: plugintools.ExposedName("task-tags.v1", "add_tag"),
Description: "Add a tag", InputSchema: []byte(`{"type":"object","properties":{"tag":{"type":"string"}}}`),
Surfaces: []string{plugintools.SurfaceKanban},
}
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition}}))
if _, ok := s.mcpServer.ListTools()[definition.ExposedName]; !ok {
t.Fatalf("plugin tool was not registered")
}
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 2}))
if _, ok := s.mcpServer.ListTools()[definition.ExposedName]; ok {
t.Fatalf("plugin tool was not removed on replacement")
}
}
func TestSetPluginToolsRebuildsArgumentValidators(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
t.Cleanup(backend.Close)
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
definition := plugintools.Definition{
PluginID: "task-tags.v1", LocalName: "add_tag", ExposedName: plugintools.ExposedName("task-tags.v1", "add_tag"),
Description: "Add a tag", InputSchema: []byte(`{"type":"object","properties":{"tag":{"type":"string"}},"required":["tag"]}`),
Surfaces: []string{plugintools.SurfaceKanban},
}
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition}}))
req := mcplib.CallToolRequest{Params: mcplib.CallToolParams{Name: definition.ExposedName, Arguments: map[string]any{"tag": "urgent"}}}
if _, err := s.validateToolArguments(definition.ExposedName, req); err != nil {
t.Fatalf("validateToolArguments() error = %v", err)
}
}
func TestSetPluginToolsRejectsMalformedSnapshotAndPreservesRegistry(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
t.Cleanup(backend.Close)
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
definition := plugintools.Definition{
PluginID: "echo", LocalName: "echo", ExposedName: plugintools.ExposedName("echo", "echo"),
Description: "Echo", InputSchema: []byte(`{"type":"object"}`),
Surfaces: []string{plugintools.SurfaceKanban},
}
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{
Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition},
}))
invalid := definition
invalid.InputSchema = []byte(`{"type":`)
err := s.SetPluginTools(plugintools.Snapshot{
Generation: "g", Revision: 2, Tools: []plugintools.Definition{invalid},
})
require.ErrorContains(t, err, "input schema")
require.Contains(t, s.mcpServer.ListTools(), definition.ExposedName)
require.Equal(t, uint64(1), s.pluginTools.Revision)
}
func TestSetPluginToolsPublishesDeclaredOutputSchema(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
t.Cleanup(backend.Close)
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
definition := plugintools.Definition{
PluginID: "echo", LocalName: "echo", ExposedName: plugintools.ExposedName("echo", "echo"),
Description: "Echo", InputSchema: []byte(`{"type":"object"}`),
OutputSchema: []byte(`{"type":"object","properties":{"value":{"type":"string"}}}`),
Surfaces: []string{plugintools.SurfaceKanban},
}
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{
Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition},
}))
tool := s.mcpServer.ListTools()[definition.ExposedName]
require.JSONEq(t, string(definition.OutputSchema), string(tool.Tool.RawOutputSchema))
}
func TestSetPluginToolsSkipsEquivalentEffectiveRegistry(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
t.Cleanup(backend.Close)
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
session := &providerRefreshTestSession{
id: "initialized-plugin-noop", notifications: make(chan mcplib.JSONRPCNotification, 4),
}
require.NoError(t, s.mcpServer.RegisterSession(context.Background(), session))
definition := plugintools.Definition{
PluginID: "echo", LocalName: "echo", ExposedName: plugintools.ExposedName("echo", "echo"),
Description: "Echo", InputSchema: []byte(`{"type":"object"}`),
Surfaces: []string{plugintools.SurfaceKanban},
}
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{
Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition},
}))
<-session.notifications
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{
Generation: "g", Revision: 2, Tools: []plugintools.Definition{definition},
}))
select {
case notification := <-session.notifications:
t.Fatalf("equivalent plugin catalog emitted notification %q", notification.Method)
default:
}
require.Equal(t, uint64(2), s.pluginTools.Revision)
}
func TestPluginToolCallDoesNotLogArguments(t *testing.T) {
core, observed := observer.New(zap.DebugLevel)
log, err := logger.NewFromZap(zap.New(core))
require.NoError(t, err)
backend := &testBackend{response: map[string]any{"text": "ok"}}
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
definition := plugintools.Definition{
PluginID: "echo", LocalName: "echo", ExposedName: plugintools.ExposedName("echo", "echo"),
Description: "Echo", InputSchema: []byte(`{"type":"object","properties":{"token":{"type":"string"}}}`),
Surfaces: []string{plugintools.SurfaceKanban},
}
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition}}))
callTool(t, s, definition.ExposedName, map[string]interface{}{"token": "secret-value"})
entries := observed.FilterMessage("MCP tool call").All()
require.Len(t, entries, 1)
_, loggedArgs := entries[0].ContextMap()["args"]
require.False(t, loggedArgs, "plugin arguments must not be attached to logs")
}
func TestPluginToolErrorDoesNotLogResult(t *testing.T) {
core, observed := observer.New(zap.DebugLevel)
log, err := logger.NewFromZap(zap.New(core))
require.NoError(t, err)
backend := &testBackend{response: map[string]any{"text": "secret-result", "is_error": true}}
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
definition := plugintools.Definition{
PluginID: "echo", LocalName: "echo", ExposedName: plugintools.ExposedName("echo", "echo"),
Description: "Echo", InputSchema: []byte(`{"type":"object"}`), Surfaces: []string{plugintools.SurfaceKanban},
}
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition}}))
callTool(t, s, definition.ExposedName, map[string]interface{}{})
entries := observed.FilterMessage("MCP tool returned error").All()
require.Len(t, entries, 1)
_, loggedResult := entries[0].ContextMap()["result"]
require.False(t, loggedResult, "plugin results must not be attached to logs")
}
type staticPluginBackend struct{}
func (staticPluginBackend) RequestPayload(_ context.Context, _ string, _, result interface{}) error {
response, ok := result.(*struct {
Text string `json:"text"`
StructuredContent map[string]any `json:"structured_content,omitempty"`
IsError bool `json:"is_error"`
})
if ok {
response.Text = "ok"
}
return nil
}
func TestPluginToolInvocationProfileAccessIsRaceFree(t *testing.T) {
log := newTestLogger(t)
s := New(staticPluginBackend{}, "session-1", "task-1", 10005, log, "", false, ModeTask)
definition := plugintools.Definition{
PluginID: "echo", LocalName: "echo", ExposedName: plugintools.ExposedName("echo", "echo"),
Description: "Echo", InputSchema: []byte(`{"type":"object"}`),
Surfaces: []string{plugintools.SurfaceKanban, plugintools.SurfaceOffice},
}
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition}}))
tool := s.mcpServer.ListTools()[definition.ExposedName]
require.NotNil(t, tool)
req := mcplib.CallToolRequest{Params: mcplib.CallToolParams{Name: definition.ExposedName, Arguments: map[string]any{}}}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < 100; i++ {
s.SetProfile(mcpprofile.New(mcpprofile.SurfaceKanbanTask, nil, nil))
s.SetProfile(mcpprofile.New(mcpprofile.SurfaceOfficeTask, nil, nil))
}
}()
go func() {
defer wg.Done()
for i := 0; i < 100; i++ {
_, _ = tool.Handler(context.Background(), req)
}
}()
wg.Wait()
}
func TestSetPluginToolsIgnoresStaleRevision(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
t.Cleanup(backend.Close)
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
definition := plugintools.Definition{
PluginID: "plugin", LocalName: "current", ExposedName: plugintools.ExposedName("plugin", "current"),
Description: "Current", InputSchema: []byte(`{"type":"object"}`),
Surfaces: []string{plugintools.SurfaceKanban},
}
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 2, Tools: []plugintools.Definition{definition}}))
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 1}))
_, ok := s.mcpServer.ListTools()[definition.ExposedName]
if !ok {
t.Fatal("stale revision replaced the current registry")
}
}
func TestSetPluginToolsIgnoresDivergentSnapshotAtCurrentRevision(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
t.Cleanup(backend.Close)
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
current := plugintools.Definition{
PluginID: "plugin", LocalName: "current", ExposedName: plugintools.ExposedName("plugin", "current"),
Description: "Current", InputSchema: []byte(`{"type":"object"}`),
Surfaces: []string{plugintools.SurfaceKanban},
}
replacement := plugintools.Definition{
PluginID: "plugin", LocalName: "replacement", ExposedName: plugintools.ExposedName("plugin", "replacement"),
Description: "Replacement", InputSchema: []byte(`{"type":"object"}`),
Surfaces: []string{plugintools.SurfaceKanban},
}
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{
Generation: "g", Revision: 2, Tools: []plugintools.Definition{current},
}))
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{
Generation: "g", Revision: 2, Tools: []plugintools.Definition{replacement},
}))
tools := s.mcpServer.ListTools()
require.Contains(t, tools, current.ExposedName)
require.NotContains(t, tools, replacement.ExposedName)
}
func TestSetPluginToolsPreservesCatalogAcrossSurfaceChange(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
t.Cleanup(backend.Close)
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
kanban := plugintools.Definition{
PluginID: "plugin", LocalName: "kanban", ExposedName: plugintools.ExposedName("plugin", "kanban"),
Description: "Kanban", InputSchema: []byte(`{"type":"object"}`), Surfaces: []string{plugintools.SurfaceKanban},
}
office := plugintools.Definition{
PluginID: "plugin", LocalName: "office", ExposedName: plugintools.ExposedName("plugin", "office"),
Description: "Office", InputSchema: []byte(`{"type":"object"}`), Surfaces: []string{plugintools.SurfaceOffice},
}
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 1, Tools: []plugintools.Definition{kanban, office}}))
s.SetProfile(mcpprofile.New(mcpprofile.SurfaceOfficeTask, nil, nil))
tools := s.mcpServer.ListTools()
if _, ok := tools[office.ExposedName]; !ok {
t.Fatal("office tool was lost when the MCP surface changed")
}
if _, ok := tools[kanban.ExposedName]; ok {
t.Fatal("kanban-only tool remained on the office surface")
}
}
// 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)
t.Cleanup(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, "report_pr_auto_fix_outcome_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")
}
// TestPlanWriteTools_DescribeContentCeiling pins
// AC-TASKS-PLAN-CONTENT-SIZE-LIMIT-002.5: both plan write tools state the
// byte ceiling in their registered description, so a caller can size its
// document before its first write.
func TestPlanWriteTools_DescribeContentCeiling(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
t.Cleanup(backend.Close)
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask)
require.NotNil(t, s)
tools := s.mcpServer.ListTools()
for _, name := range []string{"create_task_plan_kandev", "update_task_plan_kandev"} {
tool, ok := tools[name]
require.Truef(t, ok, "%s not registered", name)
contentSchema, ok := tool.Tool.InputSchema.Properties["content"].(map[string]any)
require.Truef(t, ok, "%s content property is not a JSON schema object", name)
description, ok := contentSchema["description"].(string)
require.Truef(t, ok, "%s content property has no description", name)
assert.Containsf(t, description, "262,144", "%s content property does not state the byte ceiling", name)
}
}
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 TestServerSurfaceAutomationHasFixedCoordinatorCatalog(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
profile := mcpprofile.NewAutomation()
s := NewWithProfile(backend, "automation-session", "automation-task", 10005, log, "", false, profile)
want := []string{
"list_workspaces_kandev", "list_workflows_kandev", "list_workflow_steps_kandev",
"list_repositories_kandev", "list_tasks_kandev", "list_agents_kandev",
"list_executors_kandev", "list_executor_profiles_kandev", "list_related_tasks_kandev",
"get_task_conversation_kandev", "list_task_sessions_kandev", "create_task_kandev",
"update_task_kandev", "move_task_kandev", "archive_task_kandev",
"add_task_dependency_kandev", "remove_task_dependency_kandev", "message_task_kandev",
"stop_task_kandev", "spawn_session_kandev", "list_pending_questions_kandev",
"answer_question_kandev", "list_pending_agent_permissions_kandev", "resolve_agent_permission_kandev",
}
assert.ElementsMatch(t, want, getRegisteredToolNames(s))
}
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, "export_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.wantPR, containsTool(tools, "report_pr_auto_fix_outcome_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)
// Absolute count: a tool added to task mode must be reflected here as well
// as in TestServerModeTask_ToolCount and
// TestRegisterTools_LoggedCountMatchesRegisteredTools (list_task_sessions_test.go),
// which pin the per-mode registration rather than this SetProviders rebuild.
require.Len(t, tools, 36, "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 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 {
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)
// 20 kanban (incl. delete + archive task + stop_task + spawn_session +
// list_task_sessions + PR automation + MR automation + PR auto-fix outcome) + 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
// + 2 task-dependency (add/remove) + 1 rich-output = 39.
// 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.Contains(t, tools, "list_task_sessions_kandev", "session discovery must be registered in task mode")
assert.Contains(t, tools, "add_task_dependency_kandev", "dependency edges must be manageable in task mode")
assert.Contains(t, tools, "remove_task_dependency_kandev")
assert.Contains(t, tools, "show_rich_output_kandev", "native rich output must be registered in task mode")
assert.Equal(t, 39, len(tools))
}
func TestServerStepCompleteTool_TaskAndOfficeOnlyAndDiscoverable(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")
officeServer := New(backend, "test-session", "test-task", 10005, log, "", false, ModeOffice)
assert.Contains(t, officeServer.mcpServer.ListTools(), "step_complete_kandev", "office mode must register step_complete_kandev (ADR 0015)")
for _, mode := range []string{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)
// 13 workflow (incl. list_repositories + import_workflow + export_workflow) + 4 agent + 4 mcp + 2 saved prompts + 5 executor + 7 task (incl. list_task_sessions) + 1 interaction = 36
assert.NotContains(t, tools, "step_complete_kandev", "step_complete_kandev requires a live task session; must NOT register in config mode")
assert.Equal(t, 36, 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")
assert.Contains(t, tools, "show_rich_output_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 gets the ADR 0015 declarative completion signal too, so a
// step gated by auto_advance_requires_signal can advance without relying
// solely on the agent process exiting.
assert.Contains(t, tools, "step_complete_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
// + 1 rich-output + 1 step_complete (ADR 0015) = 11.
// (delegate_task_kandev retired in favour of `agentctl kandev task create …`).
// (list_task_comments_kandev retired in favour of `agentctl kandev comment list …`).
assert.Contains(t, tools, "step_complete_kandev", "office mode must register the ADR 0015 completion signal")
assert.Equal(t, 11, 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 + 1 rich-output
// + 1 step_complete (ADR 0015) = 10
// (no ask_user_question, no delegate, no list_task_comments)
assert.Equal(t, 10, 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, "export_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, "external client")
assert.Contains(t, createTask.Tool.Description, "native subagent mechanism")
assert.Contains(t, createTask.Tool.Description, "external_id")
agentProfile := toolInputProperties(t, s, "create_task_kandev")["agent_profile_id"].(map[string]interface{})
agentProfileDescription := agentProfile["description"].(string)
assert.Contains(t, agentProfileDescription, "current_task")
assert.Contains(t, agentProfileDescription, "workspace_default")
assert.Contains(t, agentProfileDescription, "workflow profiles")
assert.Contains(t, agentProfileDescription, "parent task profile")
assert.Contains(t, agentProfileDescription, "External mode never copies creator-session runtime values")
// 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")
// External mode includes the question-answering tools (spec S1/S2); the
// agent-facing ask_user_question_kandev stays off this surface (spec S3).
assert.Contains(t, tools, "list_pending_questions_kandev")
assert.Contains(t, tools, "answer_question_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)
// 13 workflow (incl. list_repositories + import_workflow + export_workflow) + 4 agent + 4 mcp + 2 saved prompts + 5 executor + 7 task (incl. list_task_sessions) + 1 create_task + 2 task-dependency + 2 question-answering (list_pending_questions + answer_question) + 2 agent permission (list_pending_agent_permissions + resolve_agent_permission) = 42.
// add_branch_to_task_kandev is task-mode only — external coding agents have no live session to attach a worktree to.
assert.Equal(t, 42, len(tools))
assert.NotContains(t, tools, "add_branch_to_task_kandev")
}
func TestExternalAnswerQuestionSchemaDoesNotRestrictOptionCardinality(t *testing.T) {
log := newTestLogger(t)
backend := NewChannelBackendClient(log)
defer backend.Close()
s := New(backend, "", "", 0, log, "", true, ModeExternal)
answers := toolInputProperties(t, s, "answer_question_kandev")["answers"].(map[string]interface{})
answerItem := answers["items"].(map[string]interface{})
answerProperties := answerItem["properties"].(map[string]interface{})
selectedOptions := answerProperties["selected_options"].(map[string]interface{})
description := selectedOptions["description"].(string)
assert.Contains(t, description, "Zero or more option IDs")
assert.NotContains(t, description, "single-choice")
}
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"))
}