From deb39de837620daf31b298409a481f7ebd3e1725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Sat, 8 Aug 2026 22:02:48 +0200 Subject: [PATCH] feat: add list_task_sessions_kandev MCP tool (#2442) Co-authored-by: Kandev Agent --- apps/backend/config/prompts/kandev-context.md | 1 + .../backend/internal/mcp/handlers/handlers.go | 2 + .../mcp/handlers/list_task_sessions.go | 100 +++++++++ .../mcp/handlers/list_task_sessions_test.go | 199 ++++++++++++++++++ .../mcp/handlers/mcp_identity_scope_test.go | 27 +++ .../internal/mcp/server/config_handlers.go | 1 + apps/backend/internal/mcp/server/handlers.go | 22 ++ .../mcp/server/list_task_sessions_test.go | 141 +++++++++++++ apps/backend/internal/mcp/server/server.go | 27 ++- .../internal/mcp/server/server_test.go | 23 +- apps/backend/pkg/websocket/actions.go | 1 + .../lib/settings/external-mcp-tools.test.ts | 11 +- apps/web/lib/settings/external-mcp-tools.ts | 12 +- apps/web/src/locales/en/settings.json | 1 + apps/web/src/locales/pseudo/settings.json | 1 + apps/web/src/locales/pt-pt/settings.json | 1 + apps/web/src/locales/zh-cn/settings.json | 1 + docs/public/agent-communication.md | 5 +- docs/public/automation-and-mcp.md | 6 +- docs/public/coordination.md | 2 +- docs/public/coverage.json | 1 + 21 files changed, 559 insertions(+), 26 deletions(-) create mode 100644 apps/backend/internal/mcp/handlers/list_task_sessions.go create mode 100644 apps/backend/internal/mcp/handlers/list_task_sessions_test.go create mode 100644 apps/backend/internal/mcp/server/list_task_sessions_test.go diff --git a/apps/backend/config/prompts/kandev-context.md b/apps/backend/config/prompts/kandev-context.md index 1084c9fb18..e4267c4991 100644 --- a/apps/backend/config/prompts/kandev-context.md +++ b/apps/backend/config/prompts/kandev-context.md @@ -21,5 +21,6 @@ Available tools: - update_task_kandev: Update a task. Required params: task_id. - spawn_session_kandev: Spawn an ADDITIONAL agent session on your current task (no new task is created — it runs alongside your session in the same workspace). Required params: prompt (the new session's ONLY initial context). Optional: agent_profile_id (defaults to your profile; specify a different one to spawn a different agent), name (session tab label, e.g. "reviewer"), task_id (defaults to your task). Returns the new session_id. - message_task_kandev: Message another task's agent, or a specific session via optional session_id — including a sibling session on your OWN task. Required params: task_id, prompt.{coordinator_task_control_section} +- list_task_sessions_kandev: List every agent session on a task, most recently started first. Use it to find the session_id for message_task_kandev or get_task_conversation_kandev when a task has more than one session; both of those default to the primary session, so siblings are only reachable by ID. Required params: task_id. Each entry reports session_id, name, state, is_primary, is_current (your own session), agent_profile_id, and timestamps. IMPORTANT: You MUST use these MCP tools when instructed to create plans, ask questions, or interact with the Kandev platform. Do not skip them. diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 40026963bd..46451939a3 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -352,6 +352,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPStopTask, h.handleStopTask) d.RegisterFunc(ws.ActionMCPSpawnSession, h.handleSpawnSession) d.RegisterFunc(ws.ActionMCPGetTaskConversation, h.handleGetTaskConversation) + d.RegisterFunc(ws.ActionMCPListTaskSessions, h.handleListTaskSessions) d.RegisterFunc(ws.ActionMCPAskUserQuestion, h.handleAskUserQuestion) d.RegisterFunc(ws.ActionMCPCreateTaskPlan, h.handleCreateTaskPlan) d.RegisterFunc(ws.ActionMCPGetTaskPlan, h.handleGetTaskPlan) @@ -2368,6 +2369,7 @@ const errorField = "error" // a wire-protocol key updates every handler in one place. const ( keyTaskID = "task_id" + keyTotal = "total" keyRepositoryID = "repository_id" keyTaskRepositoryID = "task_repository_id" keyBaseBranch = "base_branch" diff --git a/apps/backend/internal/mcp/handlers/list_task_sessions.go b/apps/backend/internal/mcp/handlers/list_task_sessions.go new file mode 100644 index 0000000000..05194731f2 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/list_task_sessions.go @@ -0,0 +1,100 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +// taskSessionListEntry is the per-session shape returned by +// list_task_sessions_kandev. It is deliberately narrower than +// dto.TaskSessionSummaryDTO: an agent choosing a session to read or message +// needs identity, role and state — not container IDs, worktree paths, or +// profile snapshots. +type taskSessionListEntry struct { + SessionID string `json:"session_id"` + Name string `json:"name,omitempty"` + State models.TaskSessionState `json:"state"` + IsPrimary bool `json:"is_primary"` + IsCurrent bool `json:"is_current"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type listTaskSessionsRequest struct { + TaskID string `json:"task_id"` + // CurrentSessionID is filled in by the MCP server from the session the tool + // call arrived on; it is not part of the tool's advertised schema, so an + // agent going through MCP cannot set it. A direct WS caller can, which only + // changes which entry its own response marks as is_current — the flag is + // informational and grants no access, and the returned set is scoped by + // authorizeTaskID either way. + CurrentSessionID string `json:"current_session_id"` +} + +// handleListTaskSessions returns every session attached to a task, newest +// started first, so an agent can pick the session_id to pass to +// get_task_conversation_kandev or message_task_kandev. Both of those default +// to the primary session when session_id is omitted, which makes sibling +// sessions (spawn_session_kandev) otherwise undiscoverable. +func (h *Handlers) handleListTaskSessions(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req listTaskSessionsRequest + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.TaskID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + + // Resolve the task first. ListTaskSessions authorizes but does not assert + // existence for unscoped callers, so without this an unknown (or invisible) + // task ID would return an empty list — indistinguishable from a real task + // that has not been started yet. + // + // A missing task and an unauthorized one both surface as ErrTaskNotFound + // (authorizeTaskID returns that same sentinel), so reporting it keeps + // existence hidden. Anything else is infrastructure failing, and must not + // be flattened into "not found". + if _, err := h.taskSvc.GetTask(ctx, req.TaskID); err != nil { + if errors.Is(err, repoerrors.ErrTaskNotFound) || errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "task not found", nil) + } + h.logger.Error("failed to look up task for session list", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list task sessions", nil) + } + + sessions, err := h.taskSvc.ListTaskSessions(ctx, req.TaskID) + if err != nil { + h.logger.Error("failed to list task sessions", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list task sessions", nil) + } + + entries := make([]taskSessionListEntry, 0, len(sessions)) + for _, session := range sessions { + entries = append(entries, taskSessionListEntry{ + SessionID: session.ID, + Name: session.Name, + State: session.State, + IsPrimary: session.IsPrimary, + IsCurrent: req.CurrentSessionID != "" && session.ID == req.CurrentSessionID, + AgentProfileID: session.AgentProfileID, + StartedAt: session.StartedAt, + CompletedAt: session.CompletedAt, + UpdatedAt: session.UpdatedAt, + }) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + keyTaskID: req.TaskID, + "sessions": entries, + keyTotal: len(entries), + }) +} diff --git a/apps/backend/internal/mcp/handlers/list_task_sessions_test.go b/apps/backend/internal/mcp/handlers/list_task_sessions_test.go new file mode 100644 index 0000000000..9a81158509 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/list_task_sessions_test.go @@ -0,0 +1,199 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" + ws "github.com/kandev/kandev/pkg/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedTaskWithSessions creates a task carrying one primary session and the +// given number of extra spawned sessions, each started later than the last so +// the newest-first ordering is unambiguous. +func seedTaskWithSessions(t *testing.T, svc *service.Service, repo seedRepo, extra int) (*models.Task, *models.TaskSession, []*models.TaskSession) { + t.Helper() + ctx := context.Background() + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Test"})) + require.NoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Board"})) + task, err := svc.CreateTask(ctx, &service.CreateTaskRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf-1", + Title: "Multi-session task", + }) + require.NoError(t, err) + + base := time.Now().Add(-time.Hour) + primary := &models.TaskSession{ + ID: "sess-primary", + TaskID: task.ID, + AgentProfileID: "agent-profile-1", + IsPrimary: true, + State: models.TaskSessionStateWaitingForInput, + StartedAt: base, + } + require.NoError(t, repo.CreateTaskSession(ctx, primary)) + + spawned := make([]*models.TaskSession, 0, extra) + for i := 0; i < extra; i++ { + session := &models.TaskSession{ + ID: fmt.Sprintf("sess-spawned-%d", i), + TaskID: task.ID, + Name: "reviewer", + AgentProfileID: "agent-profile-2", + IsPrimary: false, + State: models.TaskSessionStateRunning, + StartedAt: base.Add(time.Duration(i+1) * time.Minute), + } + require.NoError(t, repo.CreateTaskSession(ctx, session)) + spawned = append(spawned, session) + } + return task, primary, spawned +} + +func listSessionsPayload(t *testing.T, resp *ws.Message) map[string]interface{} { + t.Helper() + require.Equal(t, ws.MessageTypeResponse, resp.Type) + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Payload, &payload)) + return payload +} + +func sessionEntries(t *testing.T, payload map[string]interface{}) []map[string]interface{} { + t.Helper() + raw, ok := payload["sessions"].([]interface{}) + require.True(t, ok, "sessions must be an array") + entries := make([]map[string]interface{}, 0, len(raw)) + for _, item := range raw { + entry, ok := item.(map[string]interface{}) + require.True(t, ok, "each session must be an object") + entries = append(entries, entry) + } + return entries +} + +func TestHandleListTaskSessions_MissingTaskID(t *testing.T) { + h := &Handlers{} + msg := makeWSMessage(t, ws.ActionMCPListTaskSessions, map[string]interface{}{}) + resp, err := h.handleListTaskSessions(context.Background(), msg) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeValidation) +} + +func TestHandleListTaskSessions_ReturnsEverySessionNewestFirst(t *testing.T) { + svc, repo := newTestTaskService(t) + task, primary, spawned := seedTaskWithSessions(t, svc, repo, 2) + + h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()} + msg := makeWSMessage(t, ws.ActionMCPListTaskSessions, map[string]interface{}{ + "task_id": task.ID, + }) + + resp, err := h.handleListTaskSessions(context.Background(), msg) + require.NoError(t, err) + payload := listSessionsPayload(t, resp) + + assert.Equal(t, task.ID, payload["task_id"]) + assert.Equal(t, float64(3), payload["total"]) + + entries := sessionEntries(t, payload) + require.Len(t, entries, 3) + assert.Equal(t, []interface{}{spawned[1].ID, spawned[0].ID, primary.ID}, + []interface{}{entries[0]["session_id"], entries[1]["session_id"], entries[2]["session_id"]}, + "sessions must be ordered newest-started first") + + assert.Equal(t, true, entries[2]["is_primary"], "the primary session must be flagged") + assert.Equal(t, false, entries[0]["is_primary"]) + assert.Equal(t, "reviewer", entries[0]["name"]) + assert.Equal(t, string(models.TaskSessionStateWaitingForInput), entries[2]["state"]) + assert.Equal(t, "agent-profile-2", entries[0]["agent_profile_id"]) + assert.Contains(t, entries[0], "started_at") +} + +// The caller's own session is marked so an agent listing siblings can tell +// which entry is itself before messaging one of them. +func TestHandleListTaskSessions_MarksCurrentSession(t *testing.T) { + svc, repo := newTestTaskService(t) + task, primary, spawned := seedTaskWithSessions(t, svc, repo, 1) + + h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()} + msg := makeWSMessage(t, ws.ActionMCPListTaskSessions, map[string]interface{}{ + "task_id": task.ID, + "current_session_id": primary.ID, + }) + + resp, err := h.handleListTaskSessions(context.Background(), msg) + require.NoError(t, err) + entries := sessionEntries(t, listSessionsPayload(t, resp)) + require.Len(t, entries, 2) + + assert.Equal(t, spawned[0].ID, entries[0]["session_id"]) + assert.Equal(t, false, entries[0]["is_current"]) + assert.Equal(t, primary.ID, entries[1]["session_id"]) + assert.Equal(t, true, entries[1]["is_current"]) +} + +// An empty list must mean "this task has no sessions yet", never "no such +// task" — otherwise a typo'd task ID reads as a task that was never started. +func TestHandleListTaskSessions_UnknownTaskIsNotFound(t *testing.T) { + svc, repo := newTestTaskService(t) + seedTaskWithSessions(t, svc, repo, 0) + + h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()} + msg := makeWSMessage(t, ws.ActionMCPListTaskSessions, map[string]interface{}{ + "task_id": "task-that-does-not-exist", + }) + + resp, err := h.handleListTaskSessions(context.Background(), msg) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeNotFound) +} + +// Infrastructure failing during the existence check must not be flattened into +// "task not found" — that would report a database outage as a bad task ID. +func TestHandleListTaskSessions_LookupFailureIsInternalError(t *testing.T) { + svc, repo := newTestTaskService(t) + task, _, _ := seedTaskWithSessions(t, svc, repo, 0) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()} + msg := makeWSMessage(t, ws.ActionMCPListTaskSessions, map[string]interface{}{ + "task_id": task.ID, + }) + + resp, err := h.handleListTaskSessions(ctx, msg) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeInternalError) +} + +func TestHandleListTaskSessions_TaskWithoutSessionsReturnsEmptyList(t *testing.T) { + svc, repo := newTestTaskService(t) + ctx := context.Background() + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Test"})) + require.NoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Board"})) + task, err := svc.CreateTask(ctx, &service.CreateTaskRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf-1", + Title: "Never started", + }) + require.NoError(t, err) + + h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()} + msg := makeWSMessage(t, ws.ActionMCPListTaskSessions, map[string]interface{}{ + "task_id": task.ID, + }) + + resp, err := h.handleListTaskSessions(ctx, msg) + require.NoError(t, err) + payload := listSessionsPayload(t, resp) + assert.Equal(t, float64(0), payload["total"]) + assert.Equal(t, []interface{}{}, payload["sessions"], "sessions must serialize as [] rather than null") +} diff --git a/apps/backend/internal/mcp/handlers/mcp_identity_scope_test.go b/apps/backend/internal/mcp/handlers/mcp_identity_scope_test.go index bb16a31833..32bc534442 100644 --- a/apps/backend/internal/mcp/handlers/mcp_identity_scope_test.go +++ b/apps/backend/internal/mcp/handlers/mcp_identity_scope_test.go @@ -137,6 +137,7 @@ func newScopeFixtureWith(t *testing.T, authEnabled bool, identities stubIdentity dispatcher := ws.NewDispatcher() dispatcher.RegisterFunc(ws.ActionMCPListTasks, h.handleListTasks) dispatcher.RegisterFunc(ws.ActionMCPGetTaskConversation, h.handleGetTaskConversation) + dispatcher.RegisterFunc(ws.ActionMCPListTaskSessions, h.handleListTaskSessions) resolver := mcpscope.NewResolver(repo, identities, func() bool { return authEnabled }, testLogger(t)) return &scopeFixture{ @@ -214,6 +215,32 @@ func TestInSessionMCPConversationAllowsOwnTask(t *testing.T) { assert.Equal(t, float64(1), payload["total"]) } +// TestInSessionMCPListSessionsDeniesForeignTask covers session discovery: the +// session IDs it returns are the keys to another user's conversations and +// message delivery, so it must be scoped like the reads it feeds. +func TestInSessionMCPListSessionsDeniesForeignTask(t *testing.T) { + f := newScopeFixture(t, true) + + resp := f.dispatchAs(t, f.userA.taskID, ws.ActionMCPListTaskSessions, map[string]interface{}{ + "task_id": f.userB.taskID, + }) + + assert.Equal(t, ws.MessageTypeError, resp.Type, "user A's agent must not enumerate user B's sessions") +} + +func TestInSessionMCPListSessionsAllowsOwnTask(t *testing.T) { + f := newScopeFixture(t, true) + + resp := f.dispatchAs(t, f.userA.taskID, ws.ActionMCPListTaskSessions, map[string]interface{}{ + "task_id": f.userA.taskID, + }) + + require.Equal(t, ws.MessageTypeResponse, resp.Type) + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Payload, &payload)) + assert.Equal(t, float64(1), payload["total"]) +} + // TestInSessionMCPUnscopedWhenAuthDisabled pins the opt-out path: with the // auth feature off, dispatch stays unscoped exactly as it did before per-user // scoping existed, so single-user instances keep cross-workspace access. diff --git a/apps/backend/internal/mcp/server/config_handlers.go b/apps/backend/internal/mcp/server/config_handlers.go index caf9a45f45..de283990df 100644 --- a/apps/backend/internal/mcp/server/config_handlers.go +++ b/apps/backend/internal/mcp/server/config_handlers.go @@ -314,6 +314,7 @@ func (s *Server) registerConfigTaskTools() { ), s.wrapHandler("get_task_conversation_kandev", s.getTaskConversationHandler()), ) + s.registerListTaskSessionsTool() } // --- Handler implementations --- diff --git a/apps/backend/internal/mcp/server/handlers.go b/apps/backend/internal/mcp/server/handlers.go index 7ee9ba6779..a92a0fc202 100644 --- a/apps/backend/internal/mcp/server/handlers.go +++ b/apps/backend/internal/mcp/server/handlers.go @@ -459,6 +459,28 @@ func (s *Server) getTaskConversationHandler() server.ToolHandlerFunc { } } +func (s *Server) listTaskSessionsHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + taskID, err := req.RequireString(mcpKeyTaskID) + if err != nil { + return mcp.NewToolResultError("task_id is required"), nil + } + // current_session_id comes from the session this MCP server is bound + // to, never from the caller, so "is_current" cannot be spoofed. + payload := map[string]interface{}{ + mcpKeyTaskID: taskID, + "current_session_id": s.sessionID, + } + + var result map[string]interface{} + if err := s.backend.RequestPayload(ctx, ws.ActionMCPListTaskSessions, payload, &result); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + data, _ := json.MarshalIndent(result, "", " ") + return mcp.NewToolResultText(string(data)), nil + } +} + func buildTaskConversationPayload(req mcp.CallToolRequest, taskID string) map[string]interface{} { payload := map[string]interface{}{"task_id": taskID} copyOptionalStringArg(payload, req, "session_id") diff --git a/apps/backend/internal/mcp/server/list_task_sessions_test.go b/apps/backend/internal/mcp/server/list_task_sessions_test.go new file mode 100644 index 0000000000..40e1cc5f94 --- /dev/null +++ b/apps/backend/internal/mcp/server/list_task_sessions_test.go @@ -0,0 +1,141 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "strconv" + "testing" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" +) + +func TestListTaskSessions_RegisteredWhereverConversationIs(t *testing.T) { + for _, tc := range []struct { + name string + build func(t *testing.T) *Server + }{ + {"task", func(t *testing.T) *Server { return newTaskModeServer(t, &testBackend{}, "task-current") }}, + {"config", func(t *testing.T) *Server { return newTestServer(t, &testBackend{}) }}, + {"external", func(t *testing.T) *Server { + return New(&testBackend{}, "test-session", "", 10005, newTestLogger(t), "", false, ModeExternal) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + tools := getRegisteredToolNames(tc.build(t)) + require.Contains(t, tools, "get_task_conversation_kandev") + assert.Contains(t, tools, "list_task_sessions_kandev", + "session discovery must ship with the tools that consume a session_id") + }) + } +} + +// Office mode deliberately exposes neither the conversation reader nor session +// discovery — office agents coordinate through `agentctl kandev …`. +func TestListTaskSessions_NotRegisteredInOfficeMode(t *testing.T) { + s := New(&testBackend{}, "test-session", "task-current", 10005, newTestLogger(t), "", false, ModeOffice) + + tools := getRegisteredToolNames(s) + require.NotContains(t, tools, "get_task_conversation_kandev") + assert.NotContains(t, tools, "list_task_sessions_kandev") +} + +func TestListTaskSessions_ToolSchemaIsTaskIDOnly(t *testing.T) { + s := newTaskModeServer(t, &testBackend{}, "task-current") + + tool, ok := s.mcpServer.ListTools()["list_task_sessions_kandev"] + require.True(t, ok) + + schema, err := json.Marshal(tool.Tool.InputSchema) + require.NoError(t, err) + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal(schema, &parsed)) + + properties, ok := parsed["properties"].(map[string]interface{}) + require.True(t, ok, "schema must declare properties") + require.Len(t, properties, 1, "callers must not be able to set current_session_id") + assert.Contains(t, properties, "task_id") + assert.Equal(t, []interface{}{"task_id"}, parsed["required"]) + + assert.Contains(t, tool.Tool.Description, "get_task_conversation_kandev") + assert.Contains(t, tool.Tool.Description, "message_task_kandev") + assert.Contains(t, tool.Tool.Description, "is_primary") +} + +func TestListTaskSessions_ForwardsTaskIDAndBoundSession(t *testing.T) { + backend := &testBackend{response: map[string]interface{}{"total": 2}} + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "list_task_sessions_kandev", map[string]interface{}{ + "task_id": "task-target", + }) + require.False(t, result.IsError) + + assert.Equal(t, ws.ActionMCPListTaskSessions, backend.lastAction) + payload, ok := backend.lastPayload.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "task-target", payload["task_id"]) + assert.Equal(t, "test-session", payload["current_session_id"], + "is_current must be derived from the bound session, not the arguments") +} + +// current_session_id is not part of the advertised schema, so a caller cannot +// smuggle one in to make another session look like its own. +func TestListTaskSessions_RejectsCallerSuppliedCurrentSession(t *testing.T) { + backend := &testBackend{response: map[string]interface{}{"total": 2}} + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "list_task_sessions_kandev", map[string]interface{}{ + "task_id": "task-target", + "current_session_id": "spoofed-session", + }) + + assert.True(t, result.IsError) + assert.Empty(t, backend.lastAction, "a rejected call must not reach the backend") +} + +func TestListTaskSessions_MissingTaskID_ReturnsError(t *testing.T) { + s := newTaskModeServer(t, &testBackend{}, "task-current") + + result := callTool(t, s, "list_task_sessions_kandev", map[string]interface{}{}) + + assert.True(t, result.IsError) +} + +func TestListTaskSessions_BackendError_ReturnsError(t *testing.T) { + backend := &testBackend{err: assert.AnError} + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "list_task_sessions_kandev", map[string]interface{}{ + "task_id": "task-target", + }) + + assert.True(t, result.IsError) +} + +// The per-mode tool counts in registerTools are hand-maintained; this pins them +// to what was actually registered so adding a tool cannot silently skew the +// number operators read out of the startup log. +func TestRegisterTools_LoggedCountMatchesRegisteredTools(t *testing.T) { + for _, mode := range []string{ModeTask, ModeTaskTitlePending, ModeConfig, ModeExternal, ModeOffice} { + t.Run(mode, func(t *testing.T) { + core, logs := observer.New(zap.InfoLevel) + log, err := logger.NewFromZap(zap.New(core)) + require.NoError(t, err) + + s := New(&testBackend{}, "test-session", "task-current", 10005, log, "", false, mode, []string{"github", "gitlab"}) + + entries := logs.FilterMessage("registered MCP tools").All() + require.Len(t, entries, 1) + count, err := strconv.Atoi(fmt.Sprint(entries[0].ContextMap()["count"])) + require.NoError(t, err) + + assert.Equal(t, len(s.mcpServer.ListTools()), count, + "logged tool count must match the tools actually registered") + }) + } +} diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 0366676425..678882c3ff 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -509,7 +509,7 @@ func (s *Server) registerTools() { s.registerConfigExecutorTools() count += 5 s.registerConfigTaskTools() - count += 6 + count += 7 if !s.disableAskQuestion { s.registerInteractionTools() count++ @@ -527,7 +527,7 @@ func (s *Server) registerTools() { s.registerConfigExecutorTools() count += 5 s.registerConfigTaskTools() - count += 6 + count += 7 s.registerCreateTaskTool() count++ case ModeOffice: @@ -555,7 +555,7 @@ func (s *Server) registerTools() { // a sibling to message_task_kandev) but NOT the task-document // tools — those are office coordination plumbing. s.registerKanbanTools() - count += 15 + count += 16 if mcpproviders.Contains(s.mcpProviders, mcpproviders.GitHub) { s.registerPRAutomationTools() count += 2 @@ -755,6 +755,7 @@ If the child has no live execution, the call succeeds idempotently with status=" ), s.wrapHandler("get_task_conversation_kandev", s.getTaskConversationHandler()), ) + s.registerListTaskSessionsTool() } func (s *Server) registerPRAutomationTools() { @@ -894,6 +895,26 @@ Returns {task_id, session_id, state}.`), ) } +// registerListTaskSessionsTool registers list_task_sessions_kandev. It is the +// discovery half of the session-addressing tools: get_task_conversation_kandev +// and message_task_kandev both take an optional session_id and fall back to the +// primary session, so without this a sibling session (one spawned with +// spawn_session_kandev, or spawned by someone else) is unreachable unless the +// caller happened to create it. Registered wherever those two tools are. +func (s *Server) registerListTaskSessionsTool() { + s.mcpServer.AddTool( + mcp.NewTool("list_task_sessions_kandev", + mcp.WithDescription(`List every agent session attached to a task, most recently started first. + +Use it to find the session_id to pass to get_task_conversation_kandev or message_task_kandev when a task has more than one session — for example after spawn_session_kandev, or to read a sibling session on your own task. Both of those tools default to the task's primary session when session_id is omitted, so other sessions are only reachable by ID. + +Each entry reports session_id, name (the session tab label, if set), state, is_primary (the session the other tools default to), is_current (true for your own session), agent_profile_id, and started/updated/completed timestamps.`), + mcp.WithString(mcpKeyTaskID, mcp.Required(), mcp.Description("The task ID whose sessions to list")), + ), + s.wrapHandler("list_task_sessions_kandev", s.listTaskSessionsHandler()), + ) +} + // registerAddBranchToTaskTool registers add_branch_to_task_kandev. Scoped to // task mode only — external coding agents have no live session context to // attach the new worktree to, and shipping this tool through the shared diff --git a/apps/backend/internal/mcp/server/server_test.go b/apps/backend/internal/mcp/server/server_test.go index 44ea87945a..affcd003df 100644 --- a/apps/backend/internal/mcp/server/server_test.go +++ b/apps/backend/internal/mcp/server/server_test.go @@ -351,7 +351,11 @@ drained: require.Len(t, notifications, 1, "a live provider rebuild must emit one tools/list_changed notification") require.Equal(t, mcplib.MethodNotificationToolsListChanged, notifications[0].Method) tools := getRegisteredToolNames(s) - require.Len(t, tools, 32, "final registry should contain the complete GitLab-only task tool set") + // 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, 33, "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") } @@ -475,18 +479,19 @@ func TestServerModeTask_ToolCount(t *testing.T) { s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask, []string{"github", "gitlab"}) tools := getRegisteredToolNames(s) - // 19 kanban (incl. delete + archive task + stop_task + spawn_session + PR - // automation + MR automation) + 1 add_branch_to_task + + // 20 kanban (incl. delete + archive task + stop_task + spawn_session + + // list_task_sessions + PR automation + MR automation) + 1 add_branch_to_task + // 1 add_workspace_sources + 1 update_repository_base_branch + // 1 step_complete (ADR 0015) + 1 interaction + 4 plan + 3 walkthrough + - // 1 publish_review_findings + 1 related-tasks + 1 diagnostic bundle = 34. + // 1 publish_review_findings + 1 related-tasks + 1 diagnostic bundle = 35. // Task-document tools (list/get/write) are office-only. assert.Contains(t, tools, "step_complete_kandev", "ADR 0015 explicit-completion signal must be registered in task mode") assert.Contains(t, tools, "show_walkthrough_kandev", "walkthrough tool must be registered in task mode") assert.Contains(t, tools, "publish_review_findings_kandev", "native code-review publishing must be registered in task mode") assert.Contains(t, tools, "spawn_session_kandev", "spawn_session must be registered in task mode") assert.Contains(t, tools, "add_workspace_sources_kandev") - assert.Equal(t, 34, len(tools)) + assert.Contains(t, tools, "list_task_sessions_kandev", "session discovery must be registered in task mode") + assert.Equal(t, 35, len(tools)) } func TestServerStepCompleteTool_TaskOnlyAndDiscoverable(t *testing.T) { @@ -519,9 +524,9 @@ func TestServerModeConfig_ToolCount(t *testing.T) { s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeConfig) tools := getRegisteredToolNames(s) - // 12 workflow (incl. list_repositories + import_workflow) + 4 agent + 4 mcp + 5 executor + 6 task + 1 interaction = 32 + // 12 workflow (incl. list_repositories + import_workflow) + 4 agent + 4 mcp + 5 executor + 7 task (incl. list_task_sessions) + 1 interaction = 33 assert.NotContains(t, tools, "step_complete_kandev", "step_complete_kandev requires a live task session; must NOT register in config mode") - assert.Equal(t, 32, len(tools)) + assert.Equal(t, 33, len(tools)) } func TestServerModeConfig_ToolDescriptions(t *testing.T) { @@ -673,9 +678,9 @@ func TestServerModeExternal_ToolCount(t *testing.T) { s := New(backend, "", "", 0, log, "", true, ModeExternal) tools := getRegisteredToolNames(s) - // 12 workflow (incl. list_repositories + import_workflow) + 4 agent + 4 mcp + 5 executor + 6 task + 1 create_task = 32. + // 12 workflow (incl. list_repositories + import_workflow) + 4 agent + 4 mcp + 5 executor + 7 task (incl. list_task_sessions) + 1 create_task = 33. // add_branch_to_task_kandev is task-mode only — external coding agents have no live session to attach a worktree to. - assert.Equal(t, 32, len(tools)) + assert.Equal(t, 33, len(tools)) assert.NotContains(t, tools, "add_branch_to_task_kandev") } diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 8837c1634e..a737240917 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -435,6 +435,7 @@ const ( ActionMCPStopTask = "mcp.stop_task" ActionMCPSpawnSession = "mcp.spawn_session" ActionMCPGetTaskConversation = "mcp.get_task_conversation" + ActionMCPListTaskSessions = "mcp.list_task_sessions" ) const ( diff --git a/apps/web/lib/settings/external-mcp-tools.test.ts b/apps/web/lib/settings/external-mcp-tools.test.ts index 450048a14b..f83312a294 100644 --- a/apps/web/lib/settings/external-mcp-tools.test.ts +++ b/apps/web/lib/settings/external-mcp-tools.test.ts @@ -4,17 +4,18 @@ import enSettings from "@/src/locales/en/settings.json"; describe("external MCP tool catalog", () => { // NOTE: this pins the CATALOG, which currently lags the backend. `ModeExternal` - // registers 32 tools (12 workflow + 4 agent + 4 mcp + 5 executor + 6 task + + // registers 33 tools (12 workflow + 4 agent + 4 mcp + 5 executor + 7 task + // 1 create_task — see `TestServerModeExternal_ToolCount`), and this catalog - // lists 29: `list_repositories_kandev`, `import_workflow_kandev` and one task - // tool are missing, so the page under-advertises the endpoint by three. + // lists 30: `list_repositories_kandev`, `import_workflow_kandev` and + // `get_task_conversation_kandev` are missing, so the page under-advertises + // the endpoint by three. // // That drift predates this file's localization and is left alone here on // purpose: adding the three means writing three new user-facing descriptions // that have to be checked against the backend's own wording, which is a // content change and not a copy migration. Update this number with them. - it("lists 29 tools, the catalog's current contents", () => { - expect(countExternalMcpTools()).toBe(29); + it("lists 30 tools, the catalog's current contents", () => { + expect(countExternalMcpTools()).toBe(30); }); it("every tool name is unique and ends with the kandev suffix", () => { diff --git a/apps/web/lib/settings/external-mcp-tools.ts b/apps/web/lib/settings/external-mcp-tools.ts index c7458bb67b..732a7e5f99 100644 --- a/apps/web/lib/settings/external-mcp-tools.ts +++ b/apps/web/lib/settings/external-mcp-tools.ts @@ -2,10 +2,10 @@ // Mirrors apps/backend/internal/mcp/server (ModeExternal). Keep in sync when // tools are added, removed, or renamed. // -// KNOWN DRIFT: `ModeExternal` registers 32 tools, this lists 29 — -// `list_repositories_kandev`, `import_workflow_kandev` and one task tool are -// missing, so the settings page under-advertises the endpoint. See the note in -// `external-mcp-tools.test.ts`. +// KNOWN DRIFT: `ModeExternal` registers 33 tools, this lists 30 — +// `list_repositories_kandev`, `import_workflow_kandev` and +// `get_task_conversation_kandev` are missing, so the settings page +// under-advertises the endpoint. See the note in `external-mcp-tools.test.ts`. // // The `name`s are the protocol identifiers an external agent calls, so they stay // literal in every locale. Only the group titles and the human descriptions are @@ -132,6 +132,10 @@ export const EXTERNAL_MCP_TOOL_GROUPS: ExternalMcpToolGroup[] = [ descriptionKey: "settings:externalMcpGroupTasksDescription", tools: [ { name: "list_tasks_kandev", descriptionKey: "settings:externalMcpToolListTasks" }, + { + name: "list_task_sessions_kandev", + descriptionKey: "settings:externalMcpToolListTaskSessions", + }, { name: "move_task_kandev", descriptionKey: "settings:externalMcpToolMoveTask" }, { name: "delete_task_kandev", descriptionKey: "settings:externalMcpToolDeleteTask" }, { name: "archive_task_kandev", descriptionKey: "settings:externalMcpToolArchiveTask" }, diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index a8047ad988..192449f22d 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -258,6 +258,7 @@ "externalMcpToolListAgents": "List configured agents and their profiles.", "externalMcpToolListExecutorProfiles": "List profiles for an executor.", "externalMcpToolListExecutors": "List all executors.", + "externalMcpToolListTaskSessions": "List every agent session on a task, newest first, so another tool can address one by ID.", "externalMcpToolListTasks": "List tasks in a workflow.", "externalMcpToolListWorkflowSteps": "List all steps in a workflow.", "externalMcpToolListWorkflows": "List workflows in a workspace.", diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index ab3fc6d48e..6faf135067 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -258,6 +258,7 @@ "externalMcpToolListAgents": "Ĺĩśţ ćōńƒĩĝũŕēď àĝēńţś àńď ţĥēĩŕ ƥŕōƒĩĺēś.", "externalMcpToolListExecutorProfiles": "Ĺĩśţ ƥŕōƒĩĺēś ƒōŕ àń ēxēćũţōŕ.", "externalMcpToolListExecutors": "Ĺĩśţ àĺĺ ēxēćũţōŕś.", + "externalMcpToolListTaskSessions": "Ĺĩśţ ēvēŕŷ àĝēńţ śēśśĩōń ōń à ţàśķ, ńēŵēśţ ƒĩŕśţ, śō àńōţĥēŕ ţōōĺ ćàń àďďŕēśś ōńē ƀŷ ĨĎ.", "externalMcpToolListTasks": "Ĺĩśţ ţàśķś ĩń à ŵōŕķƒĺōŵ.", "externalMcpToolListWorkflowSteps": "Ĺĩśţ àĺĺ śţēƥś ĩń à ŵōŕķƒĺōŵ.", "externalMcpToolListWorkflows": "Ĺĩśţ ŵōŕķƒĺōŵś ĩń à ŵōŕķśƥàćē.", diff --git a/apps/web/src/locales/pt-pt/settings.json b/apps/web/src/locales/pt-pt/settings.json index ac003ae401..f976f352dc 100644 --- a/apps/web/src/locales/pt-pt/settings.json +++ b/apps/web/src/locales/pt-pt/settings.json @@ -258,6 +258,7 @@ "externalMcpToolListAgents": "Listar os agentes configurados e os respetivos perfis.", "externalMcpToolListExecutorProfiles": "Listar os perfis de um executor.", "externalMcpToolListExecutors": "Listar todos os executores.", + "externalMcpToolListTaskSessions": "Listar todas as sessões de agente de uma tarefa, da mais recente para a mais antiga, para que outra ferramenta as possa identificar pelo ID.", "externalMcpToolListTasks": "Listar as tarefas de um fluxo de trabalho.", "externalMcpToolListWorkflowSteps": "Listar todos os passos de um fluxo de trabalho.", "externalMcpToolListWorkflows": "Listar os fluxos de trabalho de uma área de trabalho.", diff --git a/apps/web/src/locales/zh-cn/settings.json b/apps/web/src/locales/zh-cn/settings.json index 869ca9cd6d..10f0d4f607 100644 --- a/apps/web/src/locales/zh-cn/settings.json +++ b/apps/web/src/locales/zh-cn/settings.json @@ -258,6 +258,7 @@ "externalMcpToolListAgents": "列出已配置的代理及其配置。", "externalMcpToolListExecutorProfiles": "列出执行器的配置。", "externalMcpToolListExecutors": "列出所有执行器。", + "externalMcpToolListTaskSessions": "按最近启动的顺序列出任务的所有代理会话,以便其他工具通过 ID 定位会话。", "externalMcpToolListTasks": "列出工作流中的任务。", "externalMcpToolListWorkflowSteps": "列出工作流中的所有步骤。", "externalMcpToolListWorkflows": "列出工作区中的工作流。", diff --git a/docs/public/agent-communication.md b/docs/public/agent-communication.md index 2e8eec1453..422dea1d4e 100644 --- a/docs/public/agent-communication.md +++ b/docs/public/agent-communication.md @@ -87,6 +87,8 @@ There is no persistent channel or shared socket between tasks. Each `message_tas - resume context after a long pause; - audit what was actually communicated. +By default this reads the task's **primary** session. A task can have several sessions — `spawn_session_kandev` adds one, and each runs its own conversation — and one call never merges them. Pass `session_id` to read a specific one, and use `list_task_sessions_kandev(task_id)` to discover the IDs: it returns every session newest-first with `session_id`, `name`, `state`, `is_primary` (the one both this tool and `message_task_kandev` default to) and `is_current` (your own session). The same `session_id` addresses a sibling session with `message_task_kandev`. + Pagination and filtering are available via `limit`, `before`, `after`, `sort`, and `message_types` parameters. `message_types=["message"]` returns every regular chat/prompt row — the same `message` type covers ordinary user turns and cross-task deliveries alike, so this filter alone does not isolate coordination traffic. To identify a message that arrived via `message_task_kandev`, check the returned message's `metadata.sender_task_id` (and `sender_task_title`): messages sent within the same task have no `sender_task_id`. Include `"tool_call"` in `message_types` to see tool outputs too.
@@ -195,7 +197,8 @@ These tools complement cross-task communication for common coordination patterns | Tool | Use for | |---|---| | `message_task_kandev` | Send a prompt to any task by UUID | -| `get_task_conversation_kandev` | Read a task's message history (pagination, type filters) | +| `get_task_conversation_kandev` | Read a task's message history (pagination, type filters); defaults to the primary session | +| `list_task_sessions_kandev` | List a task's sessions to find the `session_id` for the two tools above | | `list_related_tasks_kandev` | Discover parent / child / sibling / blocker task IDs | | `create_task_kandev` | Delegate work to a new subtask; returns the new task's ID | | `spawn_session_kandev` | Start another session on an existing task; returns `{task_id, session_id, state}` — use the `session_id` field to message the new session directly | diff --git a/docs/public/automation-and-mcp.md b/docs/public/automation-and-mcp.md index 535313cd48..41bebd5406 100644 --- a/docs/public/automation-and-mcp.md +++ b/docs/public/automation-and-mcp.md @@ -329,14 +329,14 @@ http://127.0.0.1:/mcp SSE compatibility uses `/mcp/sse` with messages sent to `/mcp/message`. A reverse proxy must support long-lived streaming connections. -External MCP exposes 32 tools in these groups: +External MCP exposes 33 tools in these groups: - workspace/workflow configuration: list workspaces, workflows, repositories, and workflow steps; create, update, delete, or import workflows; create, update, delete, or reorder steps; - agents and profiles: list/update agents; create/delete profiles; list/update profiles; get/update profile MCP configuration; - executors: list executors and profiles; create, update, or delete executor profiles; -- tasks: list, create, move, delete, archive, or update task state, and read task conversation. +- tasks: list, create, move, delete, archive, or update task state; list a task's sessions; and read task conversation. -The settings page's static **Available tools** preview currently counts 29 and omits `list_repositories_kandev`, `import_workflow_kandev`, and `get_task_conversation_kandev`. Treat the client's live `tools/list` response from the endpoint—not that preview—as authoritative. +The settings page's static **Available tools** preview currently counts 30 and omits `list_repositories_kandev`, `import_workflow_kandev`, and `get_task_conversation_kandev`. Treat the client's live `tools/list` response from the endpoint—not that preview—as authoritative. In external mode, `create_task_kandev` has no current task and does not accept the `parent_id: "self"` shorthand. Its registered top-level contract asks for a repository ID, repository URL (including a supported GitHub pull request or GitLab merge request URL), or local path; workspace and workflow resolve automatically only when unambiguous. The current handler can nevertheless accept an omitted repository and create repo-less work, which is a contract/implementation mismatch rather than a supported equivalent of the regular UI's **None** option. Supply an explicit repository locator for portable clients. A resolvable agent profile is required even with `start_agent: false`; otherwise `start_agent` defaults to true. To create a subtask, pass the full ID of an existing parent. diff --git a/docs/public/coordination.md b/docs/public/coordination.md index 7453af4999..11812d7448 100644 --- a/docs/public/coordination.md +++ b/docs/public/coordination.md @@ -158,7 +158,7 @@ Additional messaging boundaries: - Sender metadata and content become part of the target conversation. Do not send secrets. - Use bounded requests with the repository, branch, expected result, and reply target instead of treating messages as shared memory. -Use `get_task_conversation_kandev` to read a primary or explicit session conversation. It supports limits, before/after cursors, ascending or descending order, and message-type filters. Use `list_related_tasks_kandev` for the current or another same-workspace task to list its parent, direct children, siblings, stored blocker relationships, and associated GitHub pull requests. +Use `get_task_conversation_kandev` to read a primary or explicit session conversation. It supports limits, before/after cursors, ascending or descending order, and message-type filters. When a task has more than one session, use `list_task_sessions_kandev` to list them (newest first, flagging the primary and your own) and pass the `session_id` you want. Use `list_related_tasks_kandev` for the current or another same-workspace task to list its parent, direct children, siblings, stored blocker relationships, and associated GitHub pull requests. Replies close the loop: the receiving agent calls `message_task_kandev` back with the originating task's ID, turning a one-way notification into a genuine bidirectional conversation. This enables multi-turn negotiations between agents — for example, agreeing on an API contract before both sides implement. See [Agent Communication](agent-communication.md) for delivery semantics, discovery patterns, and a worked negotiation example. diff --git a/docs/public/coverage.json b/docs/public/coverage.json index b715aa022a..aeecca0826 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -101,6 +101,7 @@ "get_task_document_kandev", "list_related_tasks_kandev", "list_task_documents_kandev", + "list_task_sessions_kandev", "message_task_kandev", "spawn_session_kandev", "write_task_document_kandev"