Files
kandev/apps/backend/internal/integration/mcp_integration_test.go
T
João Salavisa 3d2a04e776 feat(orchestrator): deferred task move with hand-off prompt + boot/turn-end event split (#743)
* feat(orchestrator): reuse existing same-profile session on workflow step switch

When a workflow step transition needs a different agent profile, look up
an existing session on the same task that already uses the target profile
and promote it back to primary instead of creating a fresh one. Reuses
COMPLETED and FAILED sessions (CANCELLED stays excluded — explicit user
stops shouldn't be auto-revived).

Backend (event_handlers_workflow.go):
  - findReusableSessionForProfile: most recently updated session matching
    the target profile, skipping CANCELLED.
  - reuseSessionForStep: promotes the session to primary; if terminal,
    revives via reviveReusedSession.
  - reviveReusedSession: distinguishes "previously launched" (has
    executors_running record -> flip to WAITING_FOR_INPUT, lazy-resume via
    ResumeSession with stored token) from "never launched" (no record ->
    flip to CREATED so autoStartStepPrompt routes through
    StartCreatedSession for a full fresh launch). Clears stale
    ErrorMessage/CompletedAt so the UI stops surfacing prior failures.
  - createNewSessionForStep + completeAndStopSession: shared helpers.

Backend (task_operations.go):
  - StartCreatedSession no longer calls processOnTurnStartViaEngine.
    User-initiated messages already fire on_turn_start in
    dispatchPromptAsync. Workflow-driven auto-start prompts arrived
    because the workflow just transitioned us into this step via
    on_turn_complete; firing on_turn_start again cascades the workflow
    back out and the redirect-to-active-session logic delivers the prompt
    to the wrong session's agent.

Frontend (web):
  - task.updated handler follows focus to the new primary when the user
    is viewing the task and was sitting on the previous primary.
  - pinnedSessionId tracks user-initiated session selection
    (setActiveSession sets it; setActiveSessionAuto leaves it alone).
    The terminal-handoff branch in maybeAdoptSessionOnTransition skips
    replacement when the terminating session matches the pin, so manually
    clicking a session no longer yanks focus back to the workflow primary
    each time the pinned session terminates.

Tests:
  - TestSwitchSessionForStep_ReusesExistingProfileSession (never-launched).
  - TestSwitchSessionForStep_ReusesPreviouslyLaunchedSession (launched).
  - TestSwitchSessionForStep_ReusesFailedSession (FAILED reuse + cleanup).
  - tasks.test.ts: focus-follow behavior across 4 scenarios.
  - agent-session.test.ts: pinnedSessionId guard plus migration to
    setActiveSessionAuto for WS-driven adoption.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(orchestrator): deferred task move with hand-off prompt + boot/turn-end event split

Adds move_task_kandev MCP tool that lets a workflow agent (e.g. QA at "Review")
move a task to another step with a prompt for the receiving agent. The move is
queued as a PendingMove and applied on the calling agent's turn-end so on_enter
processing doesn't race the still-running turn.

Also splits agent.ready into agent.boot_ready + agent.ready at the lifecycle
layer. Previously both signals shared one event and the orchestrator
disambiguated them via a resumeInProgressSessions flag — which had a race
when the boot signal arrived before persistResumeState wrote state=STARTING,
causing the boot to fire on_turn_complete against the just-revived step and
ping-pong the task forward through the workflow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address PR #743 review feedback (Greptile + CodeRabbit)

Backend:
- isTransientPromptError no longer treats ErrExecutionNotFound as transient,
  preventing executeQueuedMessage from infinite-requeueing on permanent
  missing-execution errors. autoStartStepPrompt's explicit errors.Is check
  before the transient branch keeps the recovery path working (Greptile P2 #1)
- applyPendingMove drains the orphaned hand-off prompt on ApplyTransition
  failure so it can't be misdelivered to the source step on a future turn
  (Greptile P2 #2)
- handleMoveTask returns errors instead of panicking when messageQueue is
  nil; queueMoveTaskPrompt returns an error so the deferred path fails fast
  if the queue can't accept the prompt (CodeRabbit CR-2)
- handleMoveTask rejects when no primary session exists and a hand-off prompt
  is required, instead of silently dropping it (CodeRabbit CR-3)
- autoStartStepPrompt preserves attachments from queued messages (was: silently
  dropped); attachment-only messages are no longer skipped; the fallback path
  also forwards attachments (CodeRabbit CR-5)
- applyPendingMove reinserts the pending move on early failure paths (config
  missing, task/step load errors) so a future turn-end retries naturally
  (CodeRabbit CR-6)
- registerKanbanTools split into 3 helpers (list/profile/task) to satisfy
  backend function-size lint limits (CodeRabbit CR-1)

Web:
- maybeAdoptSessionOnTransition's auto-adopt path also checks pinnedSessionId
  to handle the reverse-event-ordering race (old COMPLETED arrives before new
  STARTING) — pinned sessions are no longer yanked off the user. Regression
  test added (CodeRabbit CR-7)

Tests:
- mockAgentManager records (executionID, prompt) pairs; new helper
  capturedPromptsForExecution actually filters by exec ID instead of
  returning all captured prompts (CodeRabbit CR-4)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): respect pinned session in primary-follow-focus path

When the user clicks a session, setActiveSession sets pinnedSessionId. If a
workflow profile switch then updates the task's primarySessionId while the
user happens to still be on the previous primary, the focus-follow block in
tasks.ts was calling setActiveSessionAuto(taskId, newPrimary) and silently
overriding the explicit pin. Add pinnedSessionId !== previousPrimary as a
fifth condition. Regression test in tasks.test.ts covers exactly this
sequence.

Addresses Greptile P1 on PR #743.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address CodeRabbit review on PR #743 (3 new comments)

- handleMoveTask idle path now rolls back the queued hand-off prompt when
  taskSvc.MoveTask fails, so a "you were moved..." message can't be delivered
  on a future turn for a transition that didn't actually happen. Added
  TakeQueued to the MessageQueuer interface to support this.
- lookupSession now returns (*models.TaskSession, error) so callers can
  distinguish a backend lookup failure from a legitimate "no primary session"
  state. handleMoveTask maps the error case to ErrorCodeInternalError instead
  of collapsing it into ErrorCodeValidation.
- queueMoveTaskPrompt returns errors instead of log-and-continue (signature
  change already shipped); existing tests updated to reflect the new contract.
- Refactored TestPendingMove_ReviewToInProgress_OneTransitionOnly: the
  ~280-line monolithic test is now split into a pendingMoveScenario builder
  + assertion helpers, each well under the 80-line / 50-statement lint limit.

All 14 review threads resolved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: relax move_task_kandev prompt requirement; fix CI lint

CI surfaced two issues after rebasing onto main:

- nestif lint warning on autoStartStepPrompt's queued-message merge block.
  Extracted the take-and-merge logic into takeAndMergeHandoffMessage helper,
  flattening the nested conditionals.
- E2E test settings/config-management 'agent can move a task to a different
  step' failed because my CR-3 fix made the move_task_kandev prompt parameter
  required. That broke admin/config-mode moves where there's no agent to
  hand off to. Made prompt optional: when supplied AND the source session
  is mid-turn, the deferred-move flow runs (queue prompt + record
  PendingMove); otherwise just move the task. Refactored handleMoveTask
  into deferMoveTaskWithPrompt and applyMoveTaskImmediate to keep each
  function under the 80-line lint limit.

Tool descriptions in mcp/server/server.go and mcp/server/config_handlers.go
updated to reflect prompt's now-optional status. moveTaskHandler in
config_handlers.go forwards prompt only when set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(mcp): treat 'no primary session' as a not-found, not an internal error

GetPrimarySessionByTaskID returns a plain error string ("no primary session
found for task: <id>") when the repo has no rows. My CR-B refactor was
treating ALL lookup errors as ErrorCodeInternalError, which broke the E2E
test 'agent can move a task to a different step' (config-mode move on a
freshly-created task that has no agent yet).

Match the not-found message and return (nil, nil); only real backend
failures bubble up as InternalError. Distinguishing properly with
errors.Is would require an exported sentinel that doesn't exist; matching
the noRowsErr substring is the pragmatic fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 18:17:36 +01:00

286 lines
9.8 KiB
Go

package integration
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
mcphandlers "github.com/kandev/kandev/internal/mcp/handlers"
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, ts.Logger)
mcpH.RegisterHandlers(ts.Gateway.Dispatcher)
client := NewWSClient(t, ts.Server.URL)
t.Cleanup(func() { client.Close() })
workspaceID := createWorkspace(t, client)
// 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
}
func TestMCPCreateTask_SubtaskInheritsFromParent(t *testing.T) {
ts, parentTaskID, workspaceID, workflowID, _ := setupMCPTestServer(t)
defer ts.Close()
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
// Create subtask with title, description, and parent_id — workspace/workflow/step should be inherited
resp, err := client.SendRequest("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 := client.SendRequest("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 := client.SendRequest("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()
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
// Create top-level task (no parent_id, all IDs provided)
resp, err := client.SendRequest("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()
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
// No parent_id and no workspace/workflow -> should fail
resp, err := client.SendRequest("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()
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
resp, err := client.SendRequest("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()
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
// With start_agent=false, description should NOT be required for subtasks
resp, err := client.SendRequest("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()
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
// With start_agent=true (default), description IS required for subtasks
resp, err := client.SendRequest("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()
client := NewWSClient(t, ts.Server.URL)
defer client.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 := client.SendRequest("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()
client := NewWSClient(t, ts.Server.URL)
defer client.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 := client.SendRequest("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"])
}