Files
kandev/apps/backend/internal/integration/mcp_integration_test.go
T
José Almeida f7f1d10996 feat: add parent-controlled task stopping via MCP (#1803)
* feat: add parent-controlled task stopping via MCP

* fix: harden parent task stop lifecycle

* fix: preserve rejected task rollback state
2026-07-19 20:23:46 +01:00

286 lines
10 KiB
Go

package integration
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
mcphandlers "github.com/kandev/kandev/internal/mcp/handlers"
taskservice "github.com/kandev/kandev/internal/task/service"
ws "github.com/kandev/kandev/pkg/websocket"
)
// setupMCPTestServer creates a test server with MCP handlers registered
// and returns the server along with a parent task's IDs for subtask tests.
func setupMCPTestServer(t *testing.T) (*TestServer, string, string, string, string) {
t.Helper()
ts := NewTestServer(t)
// Register MCP handlers on the gateway dispatcher
mcpH := mcphandlers.NewHandlers(ts.TaskSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, ts.Logger)
mcpH.RegisterHandlers(ts.Gateway.Dispatcher)
client := NewWSClient(t, ts.Server.URL)
t.Cleanup(func() { client.Close() })
workspaceID := createWorkspace(t, client)
defaultProfileID := "default-agent-profile"
_, err := ts.TaskSvc.UpdateWorkspace(context.Background(), workspaceID, &taskservice.UpdateWorkspaceRequest{
DefaultAgentProfileID: &defaultProfileID,
})
require.NoError(t, err)
// Create workflow
workflowResp, err := client.SendRequest("wf-1", ws.ActionWorkflowCreate, map[string]interface{}{
"workspace_id": workspaceID,
"name": "MCP Test Workflow",
"workflow_template_id": "simple",
})
require.NoError(t, err)
var wfPayload map[string]interface{}
require.NoError(t, workflowResp.ParsePayload(&wfPayload))
workflowID := wfPayload["id"].(string)
// Get first workflow step
stepResp, err := client.SendRequest("steps-1", ws.ActionWorkflowStepList, map[string]interface{}{
"workflow_id": workflowID,
})
require.NoError(t, err)
var stepPayload map[string]interface{}
require.NoError(t, stepResp.ParsePayload(&stepPayload))
steps := stepPayload["steps"].([]interface{})
workflowStepID := steps[0].(map[string]interface{})["id"].(string)
// Create parent task
taskResp, err := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"workspace_id": workspaceID,
"workflow_id": workflowID,
"workflow_step_id": workflowStepID,
"title": "Parent Task",
})
require.NoError(t, err)
require.Equal(t, ws.MessageTypeResponse, taskResp.Type)
var taskPayload map[string]interface{}
require.NoError(t, taskResp.ParsePayload(&taskPayload))
parentTaskID := taskPayload["id"].(string)
return ts, parentTaskID, workspaceID, workflowID, workflowStepID
}
// dispatchTrustedMCP exercises the in-process dispatcher path used by the
// authenticated agent stream. Raw /ws intentionally rejects the internal
// mcp.* namespace (ADR-2026-07-19-reject-mcp-actions-on-raw-websocket).
func dispatchTrustedMCP(
t *testing.T,
ts *TestServer,
requestID, action string,
payload map[string]interface{},
) (*ws.Message, error) {
t.Helper()
request, err := ws.NewRequest(requestID, action, payload)
if err != nil {
return nil, err
}
return ts.Gateway.Dispatcher.Dispatch(context.Background(), request)
}
func TestMCPCreateTask_SubtaskInheritsFromParent(t *testing.T) {
ts, parentTaskID, workspaceID, workflowID, _ := setupMCPTestServer(t)
defer ts.Close()
// Create subtask with title, description, and parent_id — workspace/workflow/step should be inherited
resp, err := dispatchTrustedMCP(t, ts, "subtask-1", ws.ActionMCPCreateTask, map[string]interface{}{
"parent_id": parentTaskID,
"title": "Subtask via MCP",
"description": "Implement the subtask feature",
})
require.NoError(t, err)
if resp.Type == ws.MessageTypeError {
var errPayload ws.ErrorPayload
require.NoError(t, resp.ParsePayload(&errPayload))
t.Fatalf("expected response but got error: %s", errPayload.Message)
}
var payload map[string]interface{}
require.NoError(t, resp.ParsePayload(&payload))
assert.NotEmpty(t, payload["id"])
assert.Equal(t, "Subtask via MCP", payload["title"])
assert.Equal(t, parentTaskID, payload["parent_id"])
assert.Equal(t, workspaceID, payload["workspace_id"])
assert.Equal(t, workflowID, payload["workflow_id"])
}
func TestMCPCreateTask_SubtaskVisibleInListTasks(t *testing.T) {
ts, parentTaskID, _, _, _ := setupMCPTestServer(t)
defer ts.Close()
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
// Get parent's workflow_id for listing
getResp, err := client.SendRequest("get-parent", ws.ActionTaskGet, map[string]interface{}{
"id": parentTaskID,
})
require.NoError(t, err)
var parentPayload map[string]interface{}
require.NoError(t, getResp.ParsePayload(&parentPayload))
workflowID := parentPayload["workflow_id"].(string)
// Create subtask
resp, err := dispatchTrustedMCP(t, ts, "subtask-1", ws.ActionMCPCreateTask, map[string]interface{}{
"parent_id": parentTaskID,
"title": "Visible Subtask",
"description": "Implement the visible subtask feature",
})
require.NoError(t, err)
require.Equal(t, ws.MessageTypeResponse, resp.Type)
// List tasks — should contain both parent and subtask
listResp, err := dispatchTrustedMCP(t, ts, "list-1", ws.ActionMCPListTasks, map[string]interface{}{
"workflow_id": workflowID,
})
require.NoError(t, err)
var listPayload map[string]interface{}
require.NoError(t, listResp.ParsePayload(&listPayload))
tasks := listPayload["tasks"].([]interface{})
assert.Len(t, tasks, 2, "should have parent + subtask")
// Find the subtask and verify parent_id
var found bool
for _, raw := range tasks {
task := raw.(map[string]interface{})
if task["title"] == "Visible Subtask" {
assert.Equal(t, parentTaskID, task["parent_id"])
found = true
}
}
assert.True(t, found, "subtask should be visible in list_tasks")
}
func TestMCPCreateTask_TopLevelWithoutParent(t *testing.T) {
ts, _, workspaceID, workflowID, workflowStepID := setupMCPTestServer(t)
defer ts.Close()
// Create top-level task (no parent_id, all IDs provided)
resp, err := dispatchTrustedMCP(t, ts, "top-1", ws.ActionMCPCreateTask, map[string]interface{}{
"workspace_id": workspaceID,
"workflow_id": workflowID,
"workflow_step_id": workflowStepID,
"title": "Top Level Task",
})
require.NoError(t, err)
require.Equal(t, ws.MessageTypeResponse, resp.Type)
var payload map[string]interface{}
require.NoError(t, resp.ParsePayload(&payload))
assert.NotEmpty(t, payload["id"])
assert.Equal(t, "Top Level Task", payload["title"])
// parent_id should be empty (omitempty means absent from JSON)
_, hasParent := payload["parent_id"]
assert.False(t, hasParent, "top-level task should not have parent_id")
}
func TestMCPCreateTask_NoParentNoIDs_ReturnsError(t *testing.T) {
ts, _, _, _, _ := setupMCPTestServer(t)
defer ts.Close()
// No parent_id and no workspace/workflow -> should fail
resp, err := dispatchTrustedMCP(t, ts, "fail-1", ws.ActionMCPCreateTask, map[string]interface{}{
"title": "Orphan Task",
})
require.NoError(t, err)
assert.Equal(t, ws.MessageTypeError, resp.Type)
}
func TestMCPCreateTask_InvalidParentID_ReturnsError(t *testing.T) {
ts, _, _, _, _ := setupMCPTestServer(t)
defer ts.Close()
resp, err := dispatchTrustedMCP(t, ts, "bad-parent", ws.ActionMCPCreateTask, map[string]interface{}{
"parent_id": "nonexistent-task-id",
"title": "Bad Parent",
})
require.NoError(t, err)
assert.Equal(t, ws.MessageTypeError, resp.Type)
}
func TestMCPCreateTask_StartAgentFalse_DoesNotRequireDescription(t *testing.T) {
ts, parentTaskID, _, _, _ := setupMCPTestServer(t)
defer ts.Close()
// With start_agent=false, description should NOT be required for subtasks
resp, err := dispatchTrustedMCP(t, ts, "subtask-1", ws.ActionMCPCreateTask, map[string]interface{}{
"parent_id": parentTaskID,
"title": "Subtask without description",
"start_agent": false, // Don't auto-start, so no description needed
})
require.NoError(t, err)
// Should succeed because start_agent=false means no agent needs the description
assert.Equal(t, ws.MessageTypeResponse, resp.Type, "start_agent=false should allow subtask without description")
}
func TestMCPCreateTask_StartAgentTrue_RequiresDescription(t *testing.T) {
ts, parentTaskID, _, _, _ := setupMCPTestServer(t)
defer ts.Close()
// With start_agent=true (default), description IS required for subtasks
resp, err := dispatchTrustedMCP(t, ts, "subtask-1", ws.ActionMCPCreateTask, map[string]interface{}{
"parent_id": parentTaskID,
"title": "Subtask without description",
// start_agent defaults to true, description is required
})
require.NoError(t, err)
// Should fail because the sub-agent needs the description as initial prompt
assert.Equal(t, ws.MessageTypeError, resp.Type, "start_agent=true should require description for subtask")
}
func TestMCPCreateTask_SourceTaskID_TopLevel_Succeeds(t *testing.T) {
ts, parentTaskID, workspaceID, workflowID, _ := setupMCPTestServer(t)
defer ts.Close()
// source_task_id is set by agentctl to the current task; verify the path succeeds
// and the task is created (even though parentTaskID has no repositories).
resp, err := dispatchTrustedMCP(t, ts, "top-source", ws.ActionMCPCreateTask, map[string]interface{}{
"workspace_id": workspaceID,
"workflow_id": workflowID,
"title": "Top Level with Source Task",
"source_task_id": parentTaskID,
})
require.NoError(t, err)
assert.Equal(t, ws.MessageTypeResponse, resp.Type, "valid source_task_id should not cause failure")
var payload map[string]interface{}
require.NoError(t, resp.ParsePayload(&payload))
assert.NotEmpty(t, payload["id"])
}
func TestMCPCreateTask_SourceTaskID_NotFound_StillCreatesTask(t *testing.T) {
ts, _, workspaceID, workflowID, _ := setupMCPTestServer(t)
defer ts.Close()
// Non-existent source_task_id must silently fall through (Warn log only),
// not cause a validation error. This covers the error-swallow branch at
// resolveTaskRepositories:422.
resp, err := dispatchTrustedMCP(t, ts, "top-notfound", ws.ActionMCPCreateTask, map[string]interface{}{
"workspace_id": workspaceID,
"workflow_id": workflowID,
"title": "Top Level with Missing Source Task",
"source_task_id": "nonexistent-task-id-xyz",
})
require.NoError(t, err)
assert.Equal(t, ws.MessageTypeResponse, resp.Type, "missing source_task_id should silently succeed, not fail")
var payload map[string]interface{}
require.NoError(t, resp.ParsePayload(&payload))
assert.NotEmpty(t, payload["id"])
}