feat(mcp): add get_task_conversation tool (#756)
* feat(mcp): add get_task_conversation tool * fix(mcp): correct conversation pagination cursor and limits --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -0,0 +1,135 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleGetTaskConversation_MissingTaskID(t *testing.T) {
|
||||||
|
h := &Handlers{}
|
||||||
|
msg := makeWSMessage(t, ws.ActionMCPGetTaskConversation, map[string]interface{}{})
|
||||||
|
resp, err := h.handleGetTaskConversation(context.Background(), msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertWSError(t, resp, ws.ErrorCodeValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetTaskConversation_UsesPrimarySession(t *testing.T) {
|
||||||
|
svc, repo := newTestTaskService(t)
|
||||||
|
task, sess := seedTaskWithSession(t, svc, repo, models.TaskSessionStateWaitingForInput)
|
||||||
|
|
||||||
|
_, err := svc.CreateMessage(context.Background(), &service.CreateMessageRequest{
|
||||||
|
TaskSessionID: sess.ID,
|
||||||
|
TaskID: task.ID,
|
||||||
|
AuthorType: "user",
|
||||||
|
Content: "hello from task",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()}
|
||||||
|
msg := makeWSMessage(t, ws.ActionMCPGetTaskConversation, map[string]interface{}{
|
||||||
|
"task_id": task.ID,
|
||||||
|
"limit": 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := h.handleGetTaskConversation(context.Background(), msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ws.MessageTypeResponse, resp.Type)
|
||||||
|
|
||||||
|
var payload map[string]interface{}
|
||||||
|
require.NoError(t, json.Unmarshal(resp.Payload, &payload))
|
||||||
|
assert.Equal(t, task.ID, payload["task_id"])
|
||||||
|
assert.Equal(t, sess.ID, payload["session_id"])
|
||||||
|
assert.Equal(t, float64(1), payload["total"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetTaskConversation_SessionMustBelongToTask(t *testing.T) {
|
||||||
|
svc, repo := newTestTaskService(t)
|
||||||
|
taskA, _ := seedTaskWithSession(t, svc, repo, models.TaskSessionStateWaitingForInput)
|
||||||
|
|
||||||
|
// Create another task/session in the same workflow to validate cross-task mismatch.
|
||||||
|
taskB, err := svc.CreateTask(context.Background(), &service.CreateTaskRequest{
|
||||||
|
WorkspaceID: "ws-1",
|
||||||
|
WorkflowID: "wf-1",
|
||||||
|
Title: "Other task",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
sessB := &models.TaskSession{
|
||||||
|
ID: "sess-2",
|
||||||
|
TaskID: taskB.ID,
|
||||||
|
AgentProfileID: "agent-profile-1",
|
||||||
|
IsPrimary: true,
|
||||||
|
State: models.TaskSessionStateWaitingForInput,
|
||||||
|
}
|
||||||
|
require.NoError(t, repo.CreateTaskSession(context.Background(), sessB))
|
||||||
|
|
||||||
|
h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()}
|
||||||
|
msg := makeWSMessage(t, ws.ActionMCPGetTaskConversation, map[string]interface{}{
|
||||||
|
"task_id": taskA.ID,
|
||||||
|
"session_id": "sess-2",
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := h.handleGetTaskConversation(context.Background(), msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertWSError(t, resp, ws.ErrorCodeValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetTaskConversation_NegativeLimit(t *testing.T) {
|
||||||
|
svc, repo := newTestTaskService(t)
|
||||||
|
task, _ := seedTaskWithSession(t, svc, repo, models.TaskSessionStateWaitingForInput)
|
||||||
|
|
||||||
|
h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()}
|
||||||
|
msg := makeWSMessage(t, ws.ActionMCPGetTaskConversation, map[string]interface{}{
|
||||||
|
"task_id": task.ID,
|
||||||
|
"limit": -1,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := h.handleGetTaskConversation(context.Background(), msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertWSError(t, resp, ws.ErrorCodeValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetTaskConversation_FilteredPageStillReturnsCursor(t *testing.T) {
|
||||||
|
svc, repo := newTestTaskService(t)
|
||||||
|
task, sess := seedTaskWithSession(t, svc, repo, models.TaskSessionStateWaitingForInput)
|
||||||
|
|
||||||
|
_, err := svc.CreateMessage(context.Background(), &service.CreateMessageRequest{
|
||||||
|
TaskSessionID: sess.ID,
|
||||||
|
TaskID: task.ID,
|
||||||
|
AuthorType: "agent",
|
||||||
|
Type: "tool_call",
|
||||||
|
Content: "tool call 1",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = svc.CreateMessage(context.Background(), &service.CreateMessageRequest{
|
||||||
|
TaskSessionID: sess.ID,
|
||||||
|
TaskID: task.ID,
|
||||||
|
AuthorType: "agent",
|
||||||
|
Type: "tool_call",
|
||||||
|
Content: "tool call 2",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()}
|
||||||
|
msg := makeWSMessage(t, ws.ActionMCPGetTaskConversation, map[string]interface{}{
|
||||||
|
"task_id": task.ID,
|
||||||
|
"limit": 1,
|
||||||
|
"message_types": []string{"message"},
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := h.handleGetTaskConversation(context.Background(), msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ws.MessageTypeResponse, resp.Type)
|
||||||
|
|
||||||
|
var payload map[string]interface{}
|
||||||
|
require.NoError(t, json.Unmarshal(resp.Payload, &payload))
|
||||||
|
assert.Equal(t, float64(0), payload["total"])
|
||||||
|
assert.Equal(t, true, payload["has_more"])
|
||||||
|
assert.NotEmpty(t, payload["cursor"])
|
||||||
|
}
|
||||||
@@ -148,13 +148,14 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) {
|
|||||||
d.RegisterFunc(ws.ActionMCPCreateTask, h.handleCreateTask)
|
d.RegisterFunc(ws.ActionMCPCreateTask, h.handleCreateTask)
|
||||||
d.RegisterFunc(ws.ActionMCPUpdateTask, h.handleUpdateTask)
|
d.RegisterFunc(ws.ActionMCPUpdateTask, h.handleUpdateTask)
|
||||||
d.RegisterFunc(ws.ActionMCPMessageTask, h.handleMessageTask)
|
d.RegisterFunc(ws.ActionMCPMessageTask, h.handleMessageTask)
|
||||||
|
d.RegisterFunc(ws.ActionMCPGetTaskConversation, h.handleGetTaskConversation)
|
||||||
d.RegisterFunc(ws.ActionMCPAskUserQuestion, h.handleAskUserQuestion)
|
d.RegisterFunc(ws.ActionMCPAskUserQuestion, h.handleAskUserQuestion)
|
||||||
d.RegisterFunc(ws.ActionMCPCreateTaskPlan, h.handleCreateTaskPlan)
|
d.RegisterFunc(ws.ActionMCPCreateTaskPlan, h.handleCreateTaskPlan)
|
||||||
d.RegisterFunc(ws.ActionMCPGetTaskPlan, h.handleGetTaskPlan)
|
d.RegisterFunc(ws.ActionMCPGetTaskPlan, h.handleGetTaskPlan)
|
||||||
d.RegisterFunc(ws.ActionMCPUpdateTaskPlan, h.handleUpdateTaskPlan)
|
d.RegisterFunc(ws.ActionMCPUpdateTaskPlan, h.handleUpdateTaskPlan)
|
||||||
d.RegisterFunc(ws.ActionMCPDeleteTaskPlan, h.handleDeleteTaskPlan)
|
d.RegisterFunc(ws.ActionMCPDeleteTaskPlan, h.handleDeleteTaskPlan)
|
||||||
d.RegisterFunc(ws.ActionMCPClarificationTimeout, h.handleClarificationTimeout)
|
d.RegisterFunc(ws.ActionMCPClarificationTimeout, h.handleClarificationTimeout)
|
||||||
count := 13
|
count := 14
|
||||||
|
|
||||||
// Config-mode handlers (registered when config deps are set)
|
// Config-mode handlers (registered when config deps are set)
|
||||||
if h.workflowSvc != nil {
|
if h.workflowSvc != nil {
|
||||||
@@ -646,6 +647,132 @@ func (h *Handlers) handleMessageTask(ctx context.Context, msg *ws.Message) (*ws.
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetTaskConversation returns paginated conversation history for a task.
|
||||||
|
// If session_id is omitted, it uses the task's primary session.
|
||||||
|
func (h *Handlers) handleGetTaskConversation(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||||
|
req, errResp := parseTaskConversationRequest(msg)
|
||||||
|
if errResp != nil {
|
||||||
|
return errResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
session, errResp := h.resolveConversationSession(ctx, msg, req.TaskID, req.SessionID)
|
||||||
|
if errResp != nil {
|
||||||
|
return errResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, hasMore, err := h.taskSvc.ListMessagesPaginated(ctx, service.ListMessagesRequest{
|
||||||
|
TaskSessionID: session.ID,
|
||||||
|
Limit: conversationLimit(req.Limit),
|
||||||
|
Before: req.Before,
|
||||||
|
After: req.After,
|
||||||
|
Sort: req.Sort,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to list task conversation", zap.Error(err))
|
||||||
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list task conversation", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := filterAndConvertMessages(messages, req.Types)
|
||||||
|
cursor := conversationCursor(messages)
|
||||||
|
|
||||||
|
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
|
||||||
|
"task_id": req.TaskID,
|
||||||
|
"session_id": session.ID,
|
||||||
|
"messages": result,
|
||||||
|
"total": len(result),
|
||||||
|
"has_more": hasMore,
|
||||||
|
"cursor": cursor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskConversationRequest struct {
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Before string `json:"before"`
|
||||||
|
After string `json:"after"`
|
||||||
|
Sort string `json:"sort"`
|
||||||
|
Types []string `json:"message_types"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTaskConversationRequest(msg *ws.Message) (*taskConversationRequest, *ws.Message) {
|
||||||
|
var req taskConversationRequest
|
||||||
|
if err := json.Unmarshal(msg.Payload, &req); err != nil {
|
||||||
|
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error())
|
||||||
|
}
|
||||||
|
if req.TaskID == "" {
|
||||||
|
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required")
|
||||||
|
}
|
||||||
|
if req.Before != "" && req.After != "" {
|
||||||
|
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeValidation, "only one of before or after can be set")
|
||||||
|
}
|
||||||
|
if req.Sort != "" && req.Sort != "asc" && req.Sort != "desc" {
|
||||||
|
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeValidation, "sort must be asc or desc")
|
||||||
|
}
|
||||||
|
if req.Limit < 0 {
|
||||||
|
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeValidation, "limit must be non-negative")
|
||||||
|
}
|
||||||
|
return &req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) resolveConversationSession(ctx context.Context, msg *ws.Message, taskID, sessionID string) (*models.TaskSession, *ws.Message) {
|
||||||
|
if sessionID != "" {
|
||||||
|
session, err := h.taskSvc.GetTaskSession(ctx, sessionID)
|
||||||
|
if err != nil || session == nil {
|
||||||
|
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "session not found")
|
||||||
|
}
|
||||||
|
if session.TaskID != taskID {
|
||||||
|
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id does not belong to task_id")
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
session, err := h.taskSvc.GetPrimarySession(ctx, taskID)
|
||||||
|
if err != nil || session == nil {
|
||||||
|
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "task has no session")
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func wsError(id, action, code, message string) *ws.Message {
|
||||||
|
resp, _ := ws.NewError(id, action, code, message, nil)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterAndConvertMessages(messages []*models.Message, types []string) []*v1.Message {
|
||||||
|
filterTypes := make(map[string]struct{}, len(types))
|
||||||
|
for _, mt := range types {
|
||||||
|
if mt == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filterTypes[mt] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*v1.Message, 0, len(messages))
|
||||||
|
for _, message := range messages {
|
||||||
|
if len(filterTypes) > 0 {
|
||||||
|
if _, ok := filterTypes[string(message.Type)]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, message.ToAPI())
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func conversationLimit(requested int) int {
|
||||||
|
if requested > 0 {
|
||||||
|
return requested
|
||||||
|
}
|
||||||
|
return service.DefaultMessagesPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func conversationCursor(messages []*models.Message) string {
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return messages[len(messages)-1].ID
|
||||||
|
}
|
||||||
|
|
||||||
// dispatchTaskMessage routes a message to the right delivery path based on session state.
|
// dispatchTaskMessage routes a message to the right delivery path based on session state.
|
||||||
// Returns the action taken: "queued", "sent", or "started".
|
// Returns the action taken: "queued", "sent", or "started".
|
||||||
func (h *Handlers) dispatchTaskMessage(ctx context.Context, taskID string, session *models.TaskSession, prompt string) (string, error) {
|
func (h *Handlers) dispatchTaskMessage(ctx context.Context, taskID string, session *models.TaskSession, prompt string) (string, error) {
|
||||||
|
|||||||
@@ -278,6 +278,19 @@ func (s *Server) registerConfigTaskTools() {
|
|||||||
),
|
),
|
||||||
s.wrapHandler("update_task_state_kandev", s.updateTaskStateHandler()),
|
s.wrapHandler("update_task_state_kandev", s.updateTaskStateHandler()),
|
||||||
)
|
)
|
||||||
|
s.mcpServer.AddTool(
|
||||||
|
mcp.NewTool("get_task_conversation_kandev",
|
||||||
|
mcp.WithDescription("Get conversation history for a task. If session_id is omitted, the primary session is used."),
|
||||||
|
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID")),
|
||||||
|
mcp.WithString("session_id", mcp.Description("Optional session ID (must belong to task_id)")),
|
||||||
|
mcp.WithNumber("limit", mcp.Description("Optional page size (defaults to backend setting, max backend-capped)")),
|
||||||
|
mcp.WithString("before", mcp.Description("Optional cursor message ID to fetch messages before this ID")),
|
||||||
|
mcp.WithString("after", mcp.Description("Optional cursor message ID to fetch messages after this ID")),
|
||||||
|
mcp.WithString("sort", mcp.Description("Optional sort order: asc or desc")),
|
||||||
|
mcp.WithArray("message_types", mcp.Description("Optional message type filters (e.g. message, tool_call, error)"), mcp.Items(map[string]any{"type": "string"})),
|
||||||
|
),
|
||||||
|
s.wrapHandler("get_task_conversation_kandev", s.getTaskConversationHandler()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Handler implementations ---
|
// --- Handler implementations ---
|
||||||
|
|||||||
@@ -196,6 +196,72 @@ func (s *Server) messageTaskHandler() server.ToolHandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) getTaskConversationHandler() server.ToolHandlerFunc {
|
||||||
|
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
taskID, err := req.RequireString("task_id")
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError("task_id is required"), nil
|
||||||
|
}
|
||||||
|
payload := buildTaskConversationPayload(req, taskID)
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := s.backend.RequestPayload(ctx, ws.ActionMCPGetTaskConversation, 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")
|
||||||
|
copyOptionalStringArg(payload, req, "before")
|
||||||
|
copyOptionalStringArg(payload, req, "after")
|
||||||
|
copyOptionalStringArg(payload, req, "sort")
|
||||||
|
copyOptionalLimitArg(payload, req)
|
||||||
|
copyOptionalMessageTypesArg(payload, req)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyOptionalStringArg(payload map[string]interface{}, req mcp.CallToolRequest, key string) {
|
||||||
|
if value := req.GetString(key, ""); value != "" {
|
||||||
|
payload[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyOptionalLimitArg(payload map[string]interface{}, req mcp.CallToolRequest) {
|
||||||
|
args := req.GetArguments()
|
||||||
|
if raw := args["limit"]; raw != nil {
|
||||||
|
if limit, ok := raw.(float64); ok {
|
||||||
|
payload["limit"] = int(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyOptionalMessageTypesArg(payload map[string]interface{}, req mcp.CallToolRequest) {
|
||||||
|
args := req.GetArguments()
|
||||||
|
raw := args["message_types"]
|
||||||
|
if raw == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, ok := raw.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
types := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
value, ok := item.(string)
|
||||||
|
if !ok || value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
types = append(types, value)
|
||||||
|
}
|
||||||
|
if len(types) > 0 {
|
||||||
|
payload["message_types"] = types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) askUserQuestionHandler() server.ToolHandlerFunc {
|
func (s *Server) askUserQuestionHandler() server.ToolHandlerFunc {
|
||||||
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
prompt, err := req.RequireString("prompt")
|
prompt, err := req.RequireString("prompt")
|
||||||
|
|||||||
@@ -310,3 +310,45 @@ func TestMessageTask_MissingPrompt_ReturnsError(t *testing.T) {
|
|||||||
|
|
||||||
assert.True(t, result.IsError)
|
assert.True(t, result.IsError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetTaskConversation_ForwardsToBackend(t *testing.T) {
|
||||||
|
backend := &testBackend{
|
||||||
|
response: map[string]interface{}{
|
||||||
|
"task_id": "task-target",
|
||||||
|
"session_id": "sess-1",
|
||||||
|
"messages": []interface{}{},
|
||||||
|
"total": 0,
|
||||||
|
"has_more": false,
|
||||||
|
"cursor": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s := newTaskModeServer(t, backend, "task-current")
|
||||||
|
|
||||||
|
result := callTool(t, s, "get_task_conversation_kandev", map[string]interface{}{
|
||||||
|
"task_id": "task-target",
|
||||||
|
"session_id": "sess-1",
|
||||||
|
"limit": 25,
|
||||||
|
"sort": "desc",
|
||||||
|
"message_types": []interface{}{"message", "tool_call"},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.False(t, result.IsError)
|
||||||
|
assert.Equal(t, ws.ActionMCPGetTaskConversation, backend.lastAction)
|
||||||
|
|
||||||
|
payload, ok := backend.lastPayload.(map[string]interface{})
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "task-target", payload["task_id"])
|
||||||
|
assert.Equal(t, "sess-1", payload["session_id"])
|
||||||
|
assert.Equal(t, 25, payload["limit"])
|
||||||
|
assert.Equal(t, "desc", payload["sort"])
|
||||||
|
assert.Equal(t, []string{"message", "tool_call"}, payload["message_types"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTaskConversation_MissingTaskID_ReturnsError(t *testing.T) {
|
||||||
|
backend := &testBackend{}
|
||||||
|
s := newTaskModeServer(t, backend, "task-current")
|
||||||
|
|
||||||
|
result := callTool(t, s, "get_task_conversation_kandev", map[string]interface{}{})
|
||||||
|
|
||||||
|
assert.True(t, result.IsError)
|
||||||
|
}
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ func (s *Server) registerTools() {
|
|||||||
s.registerConfigExecutorTools()
|
s.registerConfigExecutorTools()
|
||||||
count += 5
|
count += 5
|
||||||
s.registerConfigTaskTools()
|
s.registerConfigTaskTools()
|
||||||
count += 5
|
count += 6
|
||||||
if !s.disableAskQuestion {
|
if !s.disableAskQuestion {
|
||||||
s.registerInteractionTools()
|
s.registerInteractionTools()
|
||||||
count++
|
count++
|
||||||
@@ -302,12 +302,12 @@ func (s *Server) registerTools() {
|
|||||||
s.registerConfigExecutorTools()
|
s.registerConfigExecutorTools()
|
||||||
count += 5
|
count += 5
|
||||||
s.registerConfigTaskTools()
|
s.registerConfigTaskTools()
|
||||||
count += 5
|
count += 6
|
||||||
s.registerCreateTaskTool()
|
s.registerCreateTaskTool()
|
||||||
count++
|
count++
|
||||||
default: // ModeTask
|
default: // ModeTask
|
||||||
s.registerKanbanTools()
|
s.registerKanbanTools()
|
||||||
count += 10
|
count += 11
|
||||||
if !s.disableAskQuestion {
|
if !s.disableAskQuestion {
|
||||||
s.registerInteractionTools()
|
s.registerInteractionTools()
|
||||||
count++
|
count++
|
||||||
@@ -408,6 +408,19 @@ Returns the dispatch status: "queued", "sent", or "started".`),
|
|||||||
),
|
),
|
||||||
s.wrapHandler("message_task_kandev", s.messageTaskHandler()),
|
s.wrapHandler("message_task_kandev", s.messageTaskHandler()),
|
||||||
)
|
)
|
||||||
|
s.mcpServer.AddTool(
|
||||||
|
mcp.NewTool("get_task_conversation_kandev",
|
||||||
|
mcp.WithDescription("Get conversation history for a task. If session_id is omitted, the primary session is used."),
|
||||||
|
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID")),
|
||||||
|
mcp.WithString("session_id", mcp.Description("Optional session ID (must belong to task_id)")),
|
||||||
|
mcp.WithNumber("limit", mcp.Description("Optional page size (defaults to backend setting, max backend-capped)")),
|
||||||
|
mcp.WithString("before", mcp.Description("Optional cursor message ID to fetch messages before this ID")),
|
||||||
|
mcp.WithString("after", mcp.Description("Optional cursor message ID to fetch messages after this ID")),
|
||||||
|
mcp.WithString("sort", mcp.Description("Optional sort order: asc or desc")),
|
||||||
|
mcp.WithArray("message_types", mcp.Description("Optional message type filters (e.g. message, tool_call, error)"), mcp.Items(map[string]any{"type": "string"})),
|
||||||
|
),
|
||||||
|
s.wrapHandler("get_task_conversation_kandev", s.getTaskConversationHandler()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerCreateTaskTool registers the create_task_kandev tool. Shared between
|
// registerCreateTaskTool registers the create_task_kandev tool. Shared between
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func TestServerModeTask_RegistersCorrectTools(t *testing.T) {
|
|||||||
assert.Contains(t, tools, "update_task_kandev")
|
assert.Contains(t, tools, "update_task_kandev")
|
||||||
assert.Contains(t, tools, "move_task_kandev")
|
assert.Contains(t, tools, "move_task_kandev")
|
||||||
assert.Contains(t, tools, "message_task_kandev")
|
assert.Contains(t, tools, "message_task_kandev")
|
||||||
|
assert.Contains(t, tools, "get_task_conversation_kandev")
|
||||||
|
|
||||||
// Task mode should have plan tools
|
// Task mode should have plan tools
|
||||||
assert.Contains(t, tools, "create_task_plan_kandev")
|
assert.Contains(t, tools, "create_task_plan_kandev")
|
||||||
@@ -129,6 +130,7 @@ func TestServerModeConfig_RegistersCorrectTools(t *testing.T) {
|
|||||||
assert.Contains(t, tools, "delete_task_kandev")
|
assert.Contains(t, tools, "delete_task_kandev")
|
||||||
assert.Contains(t, tools, "archive_task_kandev")
|
assert.Contains(t, tools, "archive_task_kandev")
|
||||||
assert.Contains(t, tools, "update_task_state_kandev")
|
assert.Contains(t, tools, "update_task_state_kandev")
|
||||||
|
assert.Contains(t, tools, "get_task_conversation_kandev")
|
||||||
|
|
||||||
// Config mode should have interaction tools
|
// Config mode should have interaction tools
|
||||||
assert.Contains(t, tools, "ask_user_question_kandev")
|
assert.Contains(t, tools, "ask_user_question_kandev")
|
||||||
@@ -194,8 +196,8 @@ func TestServerModeTask_ToolCount(t *testing.T) {
|
|||||||
|
|
||||||
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask)
|
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask)
|
||||||
tools := getRegisteredToolNames(s)
|
tools := getRegisteredToolNames(s)
|
||||||
// 10 kanban + 1 interaction + 4 plan = 15
|
// 11 kanban + 1 interaction + 4 plan = 16
|
||||||
assert.Equal(t, 15, len(tools))
|
assert.Equal(t, 16, len(tools))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerModeConfig_ToolCount(t *testing.T) {
|
func TestServerModeConfig_ToolCount(t *testing.T) {
|
||||||
@@ -205,8 +207,8 @@ func TestServerModeConfig_ToolCount(t *testing.T) {
|
|||||||
|
|
||||||
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeConfig)
|
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeConfig)
|
||||||
tools := getRegisteredToolNames(s)
|
tools := getRegisteredToolNames(s)
|
||||||
// 10 workflow + 4 agent + 4 mcp + 5 executor + 5 task + 1 interaction = 29
|
// 10 workflow + 4 agent + 4 mcp + 5 executor + 6 task + 1 interaction = 30
|
||||||
assert.Equal(t, 29, len(tools))
|
assert.Equal(t, 30, len(tools))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerModeConfig_ToolDescriptions(t *testing.T) {
|
func TestServerModeConfig_ToolDescriptions(t *testing.T) {
|
||||||
@@ -271,8 +273,8 @@ func TestServerModeExternal_ToolCount(t *testing.T) {
|
|||||||
|
|
||||||
s := New(backend, "", "", 0, log, "", true, ModeExternal)
|
s := New(backend, "", "", 0, log, "", true, ModeExternal)
|
||||||
tools := getRegisteredToolNames(s)
|
tools := getRegisteredToolNames(s)
|
||||||
// 10 workflow + 4 agent + 4 mcp + 5 executor + 5 task + 1 create_task = 29
|
// 10 workflow + 4 agent + 4 mcp + 5 executor + 6 task + 1 create_task = 30
|
||||||
assert.Equal(t, 29, len(tools))
|
assert.Equal(t, 30, len(tools))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewExternal_Constructs(t *testing.T) {
|
func TestNewExternal_Constructs(t *testing.T) {
|
||||||
|
|||||||
@@ -347,11 +347,12 @@ const (
|
|||||||
ActionMCPUpdateExecutorProfile = "mcp.update_executor_profile"
|
ActionMCPUpdateExecutorProfile = "mcp.update_executor_profile"
|
||||||
ActionMCPDeleteExecutorProfile = "mcp.delete_executor_profile"
|
ActionMCPDeleteExecutorProfile = "mcp.delete_executor_profile"
|
||||||
|
|
||||||
ActionMCPMoveTask = "mcp.move_task"
|
ActionMCPMoveTask = "mcp.move_task"
|
||||||
ActionMCPDeleteTask = "mcp.delete_task"
|
ActionMCPDeleteTask = "mcp.delete_task"
|
||||||
ActionMCPArchiveTask = "mcp.archive_task"
|
ActionMCPArchiveTask = "mcp.archive_task"
|
||||||
ActionMCPUpdateTaskState = "mcp.update_task_state"
|
ActionMCPUpdateTaskState = "mcp.update_task_state"
|
||||||
ActionMCPMessageTask = "mcp.message_task"
|
ActionMCPMessageTask = "mcp.message_task"
|
||||||
|
ActionMCPGetTaskConversation = "mcp.get_task_conversation"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GitHub integration actions
|
// GitHub integration actions
|
||||||
|
|||||||
Reference in New Issue
Block a user