feat: add list_task_sessions_kandev MCP tool (#2442)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -314,6 +314,7 @@ func (s *Server) registerConfigTaskTools() {
|
||||
),
|
||||
s.wrapHandler("get_task_conversation_kandev", s.getTaskConversationHandler()),
|
||||
)
|
||||
s.registerListTaskSessionsTool()
|
||||
}
|
||||
|
||||
// --- Handler implementations ---
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -435,6 +435,7 @@ const (
|
||||
ActionMCPStopTask = "mcp.stop_task"
|
||||
ActionMCPSpawnSession = "mcp.spawn_session"
|
||||
ActionMCPGetTaskConversation = "mcp.get_task_conversation"
|
||||
ActionMCPListTaskSessions = "mcp.list_task_sessions"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -258,6 +258,7 @@
|
||||
"externalMcpToolListAgents": "Ĺĩśţ ćōńƒĩĝũŕēď àĝēńţś àńď ţĥēĩŕ ƥŕōƒĩĺēś.",
|
||||
"externalMcpToolListExecutorProfiles": "Ĺĩśţ ƥŕōƒĩĺēś ƒōŕ àń ēxēćũţōŕ.",
|
||||
"externalMcpToolListExecutors": "Ĺĩśţ àĺĺ ēxēćũţōŕś.",
|
||||
"externalMcpToolListTaskSessions": "Ĺĩśţ ēvēŕŷ àĝēńţ śēśśĩōń ōń à ţàśķ, ńēŵēśţ ƒĩŕśţ, śō àńōţĥēŕ ţōōĺ ćàń àďďŕēśś ōńē ƀŷ ĨĎ.",
|
||||
"externalMcpToolListTasks": "Ĺĩśţ ţàśķś ĩń à ŵōŕķƒĺōŵ.",
|
||||
"externalMcpToolListWorkflowSteps": "Ĺĩśţ àĺĺ śţēƥś ĩń à ŵōŕķƒĺōŵ.",
|
||||
"externalMcpToolListWorkflows": "Ĺĩśţ ŵōŕķƒĺōŵś ĩń à ŵōŕķśƥàćē.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -258,6 +258,7 @@
|
||||
"externalMcpToolListAgents": "列出已配置的代理及其配置。",
|
||||
"externalMcpToolListExecutorProfiles": "列出执行器的配置。",
|
||||
"externalMcpToolListExecutors": "列出所有执行器。",
|
||||
"externalMcpToolListTaskSessions": "按最近启动的顺序列出任务的所有代理会话,以便其他工具通过 ID 定位会话。",
|
||||
"externalMcpToolListTasks": "列出工作流中的任务。",
|
||||
"externalMcpToolListWorkflowSteps": "列出工作流中的所有步骤。",
|
||||
"externalMcpToolListWorkflows": "列出工作区中的工作流。",
|
||||
|
||||
@@ -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.
|
||||
|
||||
<details>
|
||||
@@ -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 |
|
||||
|
||||
@@ -329,14 +329,14 @@ http://127.0.0.1:<backend-port>/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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user