3d2a04e776
* 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>
286 lines
9.8 KiB
Go
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"])
|
|
}
|