feat: enforce task title length limit (#2134)
This commit is contained in:
@@ -14,7 +14,7 @@ Available tools:
|
||||
- list_workspaces_kandev: List all workspaces.
|
||||
- list_workflows_kandev: List workflows in a workspace. Required params: workspace_id.
|
||||
- list_tasks_kandev: List tasks in a workflow. Required params: workflow_id.
|
||||
- create_task_kandev: Create a new task or subtask. Required params: title. For subtasks, set parent_id to the literal string "self" (the MCP server expands it to your current task ID) and omit workspace_id/workflow_id/workflow_step_id; they inherit from the parent. Pass workspace_id/workflow_id on a subtask only when deliberately targeting another task workspace/workflow; any supplied workflow_id must belong to the effective workspace_id. MCP subtasks reuse the parent's materialized workspace by default; set workspace_mode to "new_workspace" only when the subtask should launch in its own worktree/materialized workspace. For top-level tasks, provide workspace_id/workflow_id unless each can be auto-resolved uniquely. workflow_step_id is optional.
|
||||
- create_task_kandev: Create a new task or subtask. Required params: title (keep it concise, a few words, and no more than 60 characters; put detailed context in description). For subtasks, set parent_id to the literal string "self" (the MCP server expands it to your current task ID) and omit workspace_id/workflow_id/workflow_step_id; they inherit from the parent. Pass workspace_id/workflow_id on a subtask only when deliberately targeting another task workspace/workflow; any supplied workflow_id must belong to the effective workspace_id. MCP subtasks reuse the parent's materialized workspace by default; set workspace_mode to "new_workspace" only when the subtask should launch in its own worktree/materialized workspace. For top-level tasks, provide workspace_id/workflow_id unless each can be auto-resolved uniquely. workflow_step_id is optional.
|
||||
- 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}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -567,6 +568,33 @@ func TestHandleUpdateTask_PersistsState(t *testing.T) {
|
||||
assert.Equal(t, v1.TaskStateCompleted, task.State)
|
||||
}
|
||||
|
||||
func TestHandleUpdateTask_TitleTooLongReturnsValidation(t *testing.T) {
|
||||
svc, repo := newTestTaskService(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
|
||||
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{
|
||||
ID: "ws-update-title", Name: "Update Title", CreatedAt: now, UpdatedAt: now,
|
||||
}))
|
||||
require.NoError(t, repo.CreateWorkflow(ctx, &models.Workflow{
|
||||
ID: "wf-update-title", WorkspaceID: "ws-update-title", Name: "Board", CreatedAt: now, UpdatedAt: now,
|
||||
}))
|
||||
require.NoError(t, repo.CreateTask(ctx, &models.Task{
|
||||
ID: "task-update-title", WorkspaceID: "ws-update-title", WorkflowID: "wf-update-title",
|
||||
Title: "Original", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now,
|
||||
}))
|
||||
|
||||
h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()}
|
||||
msg := makeWSMessage(t, ws.ActionMCPUpdateTask, map[string]interface{}{
|
||||
"task_id": "task-update-title",
|
||||
"title": strings.Repeat("x", service.TaskTitleMaxLength+1),
|
||||
})
|
||||
|
||||
resp, err := h.handleUpdateTask(ctx, msg)
|
||||
require.NoError(t, err)
|
||||
assertWSError(t, resp, ws.ErrorCodeValidation)
|
||||
}
|
||||
|
||||
// TestHandleMoveTask_ActiveSessionWithoutPrompt_DefersMove pins the production
|
||||
// bug where an agent on Work called move_task_kandev → Done without a prompt
|
||||
// mid-turn. The immediate path hit validateMoveSessions (RUNNING session) and
|
||||
|
||||
@@ -697,6 +697,8 @@ func classifyCreateTaskError(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrWIPLimitExceeded):
|
||||
return ws.ErrorCodeConflict
|
||||
case errors.Is(err, service.ErrTaskTitleTooLong):
|
||||
return ws.ErrorCodeValidation
|
||||
case errors.Is(err, service.ErrSubtaskDepthExceeded),
|
||||
errors.Is(err, service.ErrInvalidTaskWorkflow),
|
||||
isMCPWorkflowNotFoundError(err):
|
||||
@@ -1303,6 +1305,9 @@ func (h *Handlers) handleUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to update task", zap.Error(err))
|
||||
if errors.Is(err, service.ErrTaskTitleTooLong) {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
|
||||
}
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task", nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -203,6 +204,53 @@ func TestClassifyCreateTaskErrorMapsWIPLimitToConflict(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyCreateTaskErrorMapsOverlongTitleToValidation(t *testing.T) {
|
||||
if got := classifyCreateTaskError(service.ErrTaskTitleTooLong); got != ws.ErrorCodeValidation {
|
||||
t.Fatalf("expected ErrorCodeValidation, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCreateTask_RejectsOverlongTitle(t *testing.T) {
|
||||
svc, repo := newTestTaskService(t)
|
||||
ctx := context.Background()
|
||||
workspaces, err := svc.ListWorkspaces(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workspaces, 1)
|
||||
workflows, err := svc.ListWorkflows(ctx, workspaces[0].ID, false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workflows, 1)
|
||||
|
||||
source, err := svc.CreateTask(ctx, &service.CreateTaskRequest{
|
||||
WorkspaceID: workspaces[0].ID,
|
||||
WorkflowID: workflows[0].ID,
|
||||
Title: "Source task",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{
|
||||
ID: "source-title-session",
|
||||
TaskID: source.ID,
|
||||
AgentProfileID: "source-profile",
|
||||
ExecutorProfileID: "source-executor-profile",
|
||||
State: models.TaskSessionStateWaitingForInput,
|
||||
IsPrimary: true,
|
||||
}))
|
||||
|
||||
h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()}
|
||||
resp, err := h.handleCreateTask(ctx, makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{
|
||||
"source_task_id": source.ID,
|
||||
"workspace_id": workspaces[0].ID,
|
||||
"workflow_id": workflows[0].ID,
|
||||
"title": strings.Repeat("x", service.TaskTitleMaxLength+1),
|
||||
"start_agent": false,
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
assertWSError(t, resp, ws.ErrorCodeValidation)
|
||||
|
||||
tasks, err := svc.ListTasks(ctx, workflows[0].ID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, tasks, 1, "validation failure must not persist a task")
|
||||
}
|
||||
|
||||
// TestHandleAddBranchToTask_RejectsMultipleLocators pins the mutual-exclusion
|
||||
// check at the WS handler tier: supplying two of repository_id /
|
||||
// repository_url / local_path is an agent mistake that previously got
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/service"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -35,6 +36,13 @@ func TestCreateTask_ToolSchema_HasParentID(t *testing.T) {
|
||||
require.True(t, ok, "schema should have properties")
|
||||
assert.Contains(t, props, "parent_id", "create_task schema must expose parent_id")
|
||||
assert.Contains(t, props, "title")
|
||||
titleProp, ok := props["title"].(map[string]interface{})
|
||||
require.True(t, ok, "title should be an object")
|
||||
assert.Equal(t, float64(service.TaskTitleMaxLength), titleProp["maxLength"])
|
||||
titleDesc, ok := titleProp["description"].(string)
|
||||
require.True(t, ok, "title should have a description")
|
||||
assert.Contains(t, titleDesc, "concise")
|
||||
assert.Contains(t, titleDesc, "60")
|
||||
assert.Contains(t, props, "workspace_id")
|
||||
assert.Contains(t, props, "workflow_id")
|
||||
assert.Contains(t, props, "workflow_step_id")
|
||||
@@ -96,6 +104,23 @@ func TestCreateTask_ToolSchema_HasParentID(t *testing.T) {
|
||||
assert.False(t, requiredSet["workflow_id"], "workflow_id should not be required")
|
||||
}
|
||||
|
||||
func TestUpdateTask_ToolSchema_HasTitleMaxLength(t *testing.T) {
|
||||
s := newTaskModeServer(t, &testBackend{}, "task-current")
|
||||
|
||||
tool, ok := s.mcpServer.ListTools()["update_task_kandev"]
|
||||
require.True(t, ok, "update_task tool not registered")
|
||||
schema, err := json.Marshal(tool.Tool.InputSchema)
|
||||
require.NoError(t, err)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(schema, &parsed))
|
||||
props, ok := parsed["properties"].(map[string]interface{})
|
||||
require.True(t, ok, "schema should have properties")
|
||||
titleProp, ok := props["title"].(map[string]interface{})
|
||||
require.True(t, ok, "title should be an object")
|
||||
assert.Equal(t, float64(service.TaskTitleMaxLength), titleProp["maxLength"])
|
||||
}
|
||||
|
||||
func TestCreateTask_PromptCanonical(t *testing.T) {
|
||||
backend := &testBackend{
|
||||
response: map[string]interface{}{"id": "subtask-1", "parent_id": "task-current"},
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/task/service"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
@@ -574,7 +575,7 @@ func (s *Server) registerKanbanTools() {
|
||||
mcp.NewTool("update_task_kandev",
|
||||
mcp.WithDescription("Update an existing task."),
|
||||
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID")),
|
||||
mcp.WithString("title", mcp.Description("New title")),
|
||||
mcp.WithString("title", mcp.MaxLength(service.TaskTitleMaxLength), mcp.Description("New concise task title (maximum 60 characters)")),
|
||||
mcp.WithString("description", mcp.Description("New description")),
|
||||
mcp.WithString("state", mcp.Description("New state: not_started, in_progress, etc.")),
|
||||
),
|
||||
@@ -741,7 +742,7 @@ IMPORTANT:
|
||||
mcp.WithString("workflow_id", mcp.Description("The workflow ID. Auto-resolved if the workspace has only one workflow. Defaulted from parent for subtasks when workspace_id is also omitted; if supplied, it must belong to the effective workspace_id.")),
|
||||
mcp.WithString("workflow_step_id", mcp.Description("The workflow step ID (optional, auto-resolved if omitted; for subtasks, pass only with an explicit workflow_id)")),
|
||||
mcp.WithString("workspace_mode", mcp.Description("Subtask materialized-workspace mode: inherit_parent reuses the parent's worktree/materialized workspace (default for subtasks); new_workspace launches the subtask in its own workspace/worktree.")),
|
||||
mcp.WithString("title", mcp.Required(), mcp.Description("The task title")),
|
||||
mcp.WithString("title", mcp.Required(), mcp.MaxLength(service.TaskTitleMaxLength), mcp.Description("A concise, few-word task title (maximum 60 characters).")),
|
||||
mcp.WithString("prompt", mcp.Description("The initial prompt for the sub-agent. This is the ONLY context the agent receives when it starts — treat it as the agent's first user message. For auto-started subtasks, provide a specific and detailed prompt; omitting it starts the sub-agent without task-specific context.")),
|
||||
mcp.WithString("agent_profile_id", mcp.Description(agentProfileDesc)),
|
||||
mcp.WithString("executor_profile_id", mcp.Description("Executor profile ID to use (determines the runtime environment: local, worktree, docker, etc.). For subtasks, inherited from the parent session. For top-level tasks, ask the user which executor profile they want if not already known.")),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -66,7 +68,11 @@ func (h *Handler) completeOnboarding(c *gin.Context) {
|
||||
|
||||
result, err := h.svc.CompleteOnboarding(c.Request.Context(), CompleteRequest(req))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, taskservice.ErrTaskTitleTooLong) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, OnboardingCompleteResponse{
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/office/repository/sqlite"
|
||||
"github.com/kandev/kandev/internal/office/routing"
|
||||
"github.com/kandev/kandev/internal/office/shared"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -265,6 +266,9 @@ func (s *OnboardingService) unimportedFSWorkspaces(ctx context.Context) []FSWork
|
||||
// and marks onboarding as finished.
|
||||
func (s *OnboardingService) CompleteOnboarding(ctx context.Context, req CompleteRequest) (*CompleteResult, error) {
|
||||
result := &CompleteResult{}
|
||||
if err := taskservice.ValidateTaskTitle(req.TaskTitle); err != nil {
|
||||
return nil, fmt.Errorf("invalid onboarding task title: %w", err)
|
||||
}
|
||||
|
||||
if err := s.createOnboardingWorkspace(ctx, req.WorkspaceName, req.TaskPrefix); err != nil {
|
||||
return nil, fmt.Errorf("create workspace: %w", err)
|
||||
|
||||
@@ -2,6 +2,7 @@ package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/office/models"
|
||||
"github.com/kandev/kandev/internal/office/repository/sqlite"
|
||||
"github.com/kandev/kandev/internal/office/routing"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
)
|
||||
|
||||
// --- mock implementations ---
|
||||
@@ -267,6 +269,23 @@ func TestCompleteOnboarding_CreatesEntities(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteOnboardingRejectsOverlongTaskTitleBeforeSideEffects(t *testing.T) {
|
||||
svc, wsCreator, _ := newTestOnboardingService(t)
|
||||
longTitle := strings.Repeat("x", taskservice.TaskTitleMaxLength+1)
|
||||
|
||||
_, err := svc.CompleteOnboarding(context.Background(), CompleteRequest{
|
||||
WorkspaceName: "too-long-title",
|
||||
AgentName: "CEO",
|
||||
TaskTitle: longTitle,
|
||||
})
|
||||
if !errors.Is(err, taskservice.ErrTaskTitleTooLong) {
|
||||
t.Fatalf("CompleteOnboarding error = %v, want ErrTaskTitleTooLong", err)
|
||||
}
|
||||
if _, ok := wsCreator.workspaces["too-long-title"]; ok {
|
||||
t.Fatal("CompleteOnboarding created a workspace before rejecting the title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOnboardingAgent_KeepsRuntimeConfigurationIndirect(t *testing.T) {
|
||||
svc, _, _ := newTestOnboardingService(t)
|
||||
source := &models.AgentInstance{
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/office/models"
|
||||
"github.com/kandev/kandev/internal/office/shared"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
)
|
||||
|
||||
// Repository is the persistence interface required by RoutineService.
|
||||
@@ -499,7 +500,7 @@ func (s *RoutineService) dispatchRoutineRun(
|
||||
vars := shared.ResolveVariables(now, defaults, provided)
|
||||
|
||||
tmpl := parseTaskTemplate(routine.TaskTemplate)
|
||||
title := shared.InterpolateTemplate(tmpl.Title, vars)
|
||||
title := taskservice.TruncateTaskTitle(shared.InterpolateTemplate(tmpl.Title, vars))
|
||||
description := shared.InterpolateTemplate(tmpl.Description, vars)
|
||||
fingerprint := computeFingerprint(title, description, routine.AssigneeAgentProfileID)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/office/models"
|
||||
"github.com/kandev/kandev/internal/office/repository/sqlite"
|
||||
"github.com/kandev/kandev/internal/office/routines"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
)
|
||||
|
||||
// noopActivity implements shared.ActivityLogger for tests.
|
||||
@@ -327,6 +328,39 @@ func TestDispatch_HeavyRoutine_CreatesTaskInRoutineWorkflow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_HeavyRoutine_TruncatesRenderedTitle(t *testing.T) {
|
||||
svc := newTestRoutineService(t)
|
||||
ctx := context.Background()
|
||||
longName := strings.Repeat("x", taskservice.TaskTitleMaxLength+10)
|
||||
routine := &models.Routine{
|
||||
WorkspaceID: "ws-1",
|
||||
Name: "Long title",
|
||||
TaskTemplate: `{"title":"{{name}}","description":"Details: {{name}}"}`,
|
||||
AssigneeAgentProfileID: "agent-1",
|
||||
Status: "active",
|
||||
ConcurrencyPolicy: "always_create",
|
||||
Variables: `{"name":{"default":"short"}}`,
|
||||
}
|
||||
if err := svc.CreateRoutine(ctx, routine); err != nil {
|
||||
t.Fatalf("create routine: %v", err)
|
||||
}
|
||||
|
||||
wf := &fakeWorkflowEnsurer{}
|
||||
tc := &fakeTaskCreator{}
|
||||
svc.SetWorkflowEnsurer(wf)
|
||||
svc.SetTaskCreator(tc)
|
||||
if _, err := svc.FireManual(ctx, routine.ID, map[string]string{"name": longName}); err != nil {
|
||||
t.Fatalf("fire manual: %v", err)
|
||||
}
|
||||
|
||||
if gotLen := len([]rune(tc.captured.title)); gotLen != taskservice.TaskTitleMaxLength {
|
||||
t.Fatalf("captured title rune length = %d, want %d", gotLen, taskservice.TaskTitleMaxLength)
|
||||
}
|
||||
if !strings.Contains(tc.captured.description, longName) {
|
||||
t.Fatalf("description = %q, want full rendered value", tc.captured.description)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatch_HeavyRoutine_FallsBackWithoutDeps confirms that a heavy
|
||||
// (task_template set) routine degrades to lightweight behaviour when
|
||||
// the workflow ensurer or task creator is not wired — no task is
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/github"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
)
|
||||
|
||||
@@ -310,17 +311,17 @@ func (s *Service) resolveAutomationTaskTitle(a *automation.Automation, evt *auto
|
||||
if a.TaskTitleTemplate != "" {
|
||||
title := automation.InterpolatePrompt(a.TaskTitleTemplate, evt.TriggerType, evt.TriggerData)
|
||||
if title != "" {
|
||||
return title
|
||||
return taskservice.TruncateTaskTitle(title)
|
||||
}
|
||||
}
|
||||
// Fall back to trigger type default from registry.
|
||||
if info := automation.GetTriggerTypeInfo(evt.TriggerType); info != nil && info.DefaultTaskTitle != "" {
|
||||
title := automation.InterpolatePrompt(info.DefaultTaskTitle, evt.TriggerType, evt.TriggerData)
|
||||
if title != "" {
|
||||
return title
|
||||
return taskservice.TruncateTaskTitle(title)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("[Auto] %s", a.Name)
|
||||
return taskservice.TruncateTaskTitle(fmt.Sprintf("[Auto] %s", a.Name))
|
||||
}
|
||||
|
||||
// associateAutomationPR links a task to a GitHub PR using trigger data.
|
||||
|
||||
@@ -3,12 +3,14 @@ package orchestrator
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/automation"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
)
|
||||
|
||||
// seedAutomationWorkspaceRepos creates a workspace with the given repository
|
||||
@@ -89,6 +91,21 @@ func TestResolveAutomationRepository_EmptyListFallsBackToWorkspace(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAutomationTaskTitleTruncatesRenderedTitle(t *testing.T) {
|
||||
svc := &Service{}
|
||||
longTitle := strings.Repeat("x", taskservice.TaskTitleMaxLength+20)
|
||||
a := &automation.Automation{TaskTitleTemplate: "{{pr.title}}"}
|
||||
evt := &automation.AutomationTriggeredEvent{
|
||||
TriggerType: automation.TriggerTypeGitHubPR,
|
||||
TriggerData: json.RawMessage(`{"title":"` + longTitle + `"}`),
|
||||
}
|
||||
|
||||
got := svc.resolveAutomationTaskTitle(a, evt)
|
||||
if got != strings.Repeat("x", taskservice.TaskTitleMaxLength-1)+"…" {
|
||||
t.Fatalf("resolved title = %q, want rendered title truncated with ellipsis", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveAutomationRepository_GitHubPRIgnoresConfiguredRepositoryIDs is
|
||||
// the regression guard for a CodeRabbit review finding on PR #2077: the
|
||||
// frontend disables (but does not clear) the repository picker for
|
||||
|
||||
@@ -79,6 +79,9 @@ func isValidationError(err error) bool {
|
||||
if errors.Is(err, service.ErrInvalidParent) {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, service.ErrTaskTitleTooLong) {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "pending approval") ||
|
||||
strings.Contains(msg, "validation") ||
|
||||
|
||||
@@ -432,6 +432,34 @@ func TestHTTPCreateTask_ProjectIDReachesOfficePath(t *testing.T) {
|
||||
assert.Equal(t, "wf-office", repo.captured.WorkflowID, "office workflow should be auto-resolved")
|
||||
}
|
||||
|
||||
func TestHTTPCreateTaskRejectsOverlongTitle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
log := newTestLogger(t)
|
||||
repo := &captureCreateTaskRepo{}
|
||||
svc := service.NewService(service.Repos{
|
||||
Workspaces: repo, Tasks: repo, TaskRepos: repo,
|
||||
Workflows: repo, Messages: repo, Turns: repo,
|
||||
Sessions: repo, GitSnapshots: repo, RepoEntities: repo,
|
||||
Executors: repo, Environments: repo, TaskEnvironments: repo,
|
||||
Reviews: repo,
|
||||
}, nil, log, service.RepositoryDiscoveryConfig{})
|
||||
h := &TaskHandlers{service: svc, logger: log}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(`{
|
||||
"workspace_id": "ws-1",
|
||||
"workflow_id": "wf-1",
|
||||
"title": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.httpCreateTask(c)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Nil(t, repo.captured, "overlong title must not reach repository persistence")
|
||||
}
|
||||
|
||||
func TestHTTPCreateTaskRecordsFinalLastUsedSelections(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
log := newTestLogger(t)
|
||||
@@ -642,6 +670,36 @@ func TestWSCreateTaskRecordsFinalLastUsedSelections(t *testing.T) {
|
||||
}, recorder.got)
|
||||
}
|
||||
|
||||
func TestWSCreateTaskReturnsValidationErrorForOverlongTitle(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
repo := &captureCreateTaskRepo{}
|
||||
svc := service.NewService(service.Repos{
|
||||
Workspaces: repo, Tasks: repo, TaskRepos: repo,
|
||||
Workflows: repo, Messages: repo, Turns: repo,
|
||||
Sessions: repo, GitSnapshots: repo, RepoEntities: repo,
|
||||
Executors: repo, Environments: repo, TaskEnvironments: repo,
|
||||
Reviews: repo,
|
||||
}, nil, log, service.RepositoryDiscoveryConfig{})
|
||||
h := &TaskHandlers{service: svc, logger: log}
|
||||
|
||||
msg, err := ws.NewRequest("msg-title", ws.ActionTaskCreate, map[string]any{
|
||||
"workspace_id": "ws-1",
|
||||
"workflow_id": "wf-1",
|
||||
"title": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := h.wsCreateTask(context.Background(), msg)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ws.MessageTypeError, resp.Type)
|
||||
var payload ws.ErrorPayload
|
||||
require.NoError(t, json.Unmarshal(resp.Payload, &payload))
|
||||
assert.Equal(t, ws.ErrorCodeValidation, payload.Code)
|
||||
assert.Contains(t, payload.Message, "60")
|
||||
assert.Nil(t, repo.captured, "overlong title must not reach repository persistence")
|
||||
}
|
||||
|
||||
func TestWSCreateTaskRecordsFreshBranchRequestBase(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
|
||||
|
||||
@@ -133,6 +133,36 @@ func TestWSUpdateTaskTitleOnlyPreservesRepositories(t *testing.T) {
|
||||
assert.Equal(t, []string{"repo-1"}, decodeTaskRepositoryIDs(t, resp.Payload))
|
||||
}
|
||||
|
||||
func TestHTTPUpdateTaskRejectsOverlongTitle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h, repo := newUpdateTaskHandlers(t)
|
||||
|
||||
rec := doUpdateTaskRequest(t, h, `{"title":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}`)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.False(t, repo.deleteReposCalled, "rejected title must not touch task repositories")
|
||||
}
|
||||
|
||||
func TestWSUpdateTaskReturnsValidationErrorForOverlongTitle(t *testing.T) {
|
||||
h, repo := newUpdateTaskHandlers(t)
|
||||
|
||||
msg, err := ws.NewRequest("msg-title", ws.ActionTaskUpdate, map[string]any{
|
||||
"id": "task-1",
|
||||
"title": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := h.wsUpdateTask(context.Background(), msg)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ws.MessageTypeError, resp.Type)
|
||||
var payload ws.ErrorPayload
|
||||
require.NoError(t, json.Unmarshal(resp.Payload, &payload))
|
||||
assert.Equal(t, ws.ErrorCodeValidation, payload.Code)
|
||||
assert.Contains(t, payload.Message, "60")
|
||||
assert.False(t, repo.deleteReposCalled, "rejected title must not touch task repositories")
|
||||
}
|
||||
|
||||
func TestConvertUpdateRepositories(t *testing.T) {
|
||||
assert.Nil(t, convertUpdateRepositories(false, nil), "absent field must stay nil")
|
||||
|
||||
|
||||
@@ -331,6 +331,9 @@ func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to update task", zap.Error(err))
|
||||
if isValidationError(err) {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
|
||||
}
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task", nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, dto.FromTask(task))
|
||||
|
||||
@@ -285,6 +285,9 @@ func (s *Service) inheritParentRepositories(ctx context.Context, req *CreateTask
|
||||
|
||||
// validateCreateTaskRequest validates constraints for task creation.
|
||||
func (s *Service) validateCreateTaskRequest(req *CreateTaskRequest) error {
|
||||
if err := validateTaskTitle(req.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
isOffice := isOfficeRequest(req)
|
||||
if !req.IsEphemeral && !isOffice && req.WorkflowID == "" {
|
||||
return fmt.Errorf("workflow_id is required for non-ephemeral tasks")
|
||||
@@ -1126,6 +1129,11 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ
|
||||
if err := s.authorizeTaskID(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Title != nil {
|
||||
if err := validateTaskTitle(*req.Title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
task, err := s.tasks.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TaskTitleMaxLength is the maximum number of characters allowed in a new or
|
||||
// replacement task title.
|
||||
const TaskTitleMaxLength = 60
|
||||
|
||||
// ErrTaskTitleTooLong identifies a task title that exceeds TaskTitleMaxLength.
|
||||
var ErrTaskTitleTooLong = errors.New("task title is too long")
|
||||
|
||||
// ValidateTaskTitle checks whether title fits the task title character limit.
|
||||
func ValidateTaskTitle(title string) error {
|
||||
if utf8.RuneCountInString(title) <= TaskTitleMaxLength {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: task titles must be %d characters or fewer", ErrTaskTitleTooLong, TaskTitleMaxLength)
|
||||
}
|
||||
|
||||
// TruncateTaskTitle shortens title to the task title limit, reserving the last
|
||||
// character for an ellipsis when truncation is required.
|
||||
func TruncateTaskTitle(title string) string {
|
||||
characters := []rune(title)
|
||||
if len(characters) <= TaskTitleMaxLength {
|
||||
return title
|
||||
}
|
||||
return string(characters[:TaskTitleMaxLength-1]) + "…"
|
||||
}
|
||||
|
||||
func validateTaskTitle(title string) error {
|
||||
return ValidateTaskTitle(title)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
func TestCreateTaskRejectsTitleLongerThanLimit(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-title", Name: "Titles"}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-title", WorkspaceID: "ws-title", Name: "Workflow"}); err != nil {
|
||||
t.Fatalf("CreateWorkflow: %v", err)
|
||||
}
|
||||
|
||||
_, err := svc.CreateTask(ctx, &CreateTaskRequest{
|
||||
WorkspaceID: "ws-title",
|
||||
WorkflowID: "wf-title",
|
||||
Title: strings.Repeat("x", 61),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("CreateTask accepted a title longer than 60 characters")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "60") {
|
||||
t.Fatalf("CreateTask error = %q, want 60-character validation", err)
|
||||
}
|
||||
|
||||
tasks, listErr := repo.ListTasks(ctx, "wf-title")
|
||||
if listErr != nil {
|
||||
t.Fatalf("ListTasks: %v", listErr)
|
||||
}
|
||||
if len(tasks) != 0 {
|
||||
t.Fatalf("persisted %d tasks after title validation failure, want none", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskAcceptsTitleAtLimit(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-title", Name: "Titles"}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-title", WorkspaceID: "ws-title", Name: "Workflow"}); err != nil {
|
||||
t.Fatalf("CreateWorkflow: %v", err)
|
||||
}
|
||||
|
||||
_, err := svc.CreateTask(ctx, &CreateTaskRequest{
|
||||
WorkspaceID: "ws-title",
|
||||
WorkflowID: "wf-title",
|
||||
Title: strings.Repeat("x", TaskTitleMaxLength),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask rejected a title at the limit: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTaskRejectsTitleLongerThanLimit(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-title", Name: "Titles"}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-title", WorkspaceID: "ws-title", Name: "Workflow"}); err != nil {
|
||||
t.Fatalf("CreateWorkflow: %v", err)
|
||||
}
|
||||
if err := repo.CreateTask(ctx, &models.Task{
|
||||
ID: "task-title", WorkspaceID: "ws-title", WorkflowID: "wf-title", Title: "Original", Priority: "medium",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
|
||||
title := strings.Repeat("x", 61)
|
||||
_, err := svc.UpdateTask(ctx, "task-title", &UpdateTaskRequest{Title: &title})
|
||||
if err == nil {
|
||||
t.Fatal("UpdateTask accepted a title longer than 60 characters")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "60") {
|
||||
t.Fatalf("UpdateTask error = %q, want 60-character validation", err)
|
||||
}
|
||||
|
||||
stored, getErr := repo.GetTask(ctx, "task-title")
|
||||
if getErr != nil {
|
||||
t.Fatalf("GetTask: %v", getErr)
|
||||
}
|
||||
if stored.Title != "Original" {
|
||||
t.Fatalf("stored title = %q, want unchanged original title", stored.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTaskWithoutTitlePreservesLegacyOverlongTitle(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-title", Name: "Titles"}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-title", WorkspaceID: "ws-title", Name: "Workflow"}); err != nil {
|
||||
t.Fatalf("CreateWorkflow: %v", err)
|
||||
}
|
||||
legacyTitle := strings.Repeat("legacy ", 12)
|
||||
if err := repo.CreateTask(ctx, &models.Task{
|
||||
ID: "legacy-title", WorkspaceID: "ws-title", WorkflowID: "wf-title", Title: legacyTitle, Priority: "medium",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
|
||||
description := "updated description"
|
||||
if _, err := svc.UpdateTask(ctx, "legacy-title", &UpdateTaskRequest{Description: &description}); err != nil {
|
||||
t.Fatalf("UpdateTask: %v", err)
|
||||
}
|
||||
|
||||
stored, err := repo.GetTask(ctx, "legacy-title")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
if stored.Title != legacyTitle {
|
||||
t.Fatalf("stored title = %q, want legacy title preserved", stored.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateTaskTitlePreservesCharacterLimit(t *testing.T) {
|
||||
longTitle := strings.Repeat("😀", TaskTitleMaxLength+5)
|
||||
|
||||
got := TruncateTaskTitle(longTitle)
|
||||
if got != strings.Repeat("😀", TaskTitleMaxLength-1)+"…" {
|
||||
t.Fatalf("TruncateTaskTitle = %q, want a 60-character ellipsis title", got)
|
||||
}
|
||||
if gotLen := len([]rune(got)); gotLen != TaskTitleMaxLength {
|
||||
t.Fatalf("TruncateTaskTitle rune length = %d, want %d", gotLen, TaskTitleMaxLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateTaskTitleLeavesShortTitlesUnchanged(t *testing.T) {
|
||||
for _, title := range []string{"", "short", strings.Repeat("x", TaskTitleMaxLength)} {
|
||||
if got := TruncateTaskTitle(title); got != title {
|
||||
t.Errorf("TruncateTaskTitle(%q) = %q, want unchanged title", title, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
EMPTY_STAGES,
|
||||
type StagesDraft,
|
||||
} from "./new-task-stages";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
|
||||
function buildMetadata(draft: IssueDraft): Record<string, unknown> | undefined {
|
||||
const meta: Record<string, unknown> = {};
|
||||
@@ -148,7 +149,7 @@ function NewIssueDialogBody({
|
||||
<Textarea
|
||||
placeholder="Task title"
|
||||
value={draft.title}
|
||||
onChange={(e) => updateDraft({ title: e.target.value })}
|
||||
onChange={(e) => updateDraft({ title: clampTaskTitleInput(e.target.value) })}
|
||||
className="text-lg font-medium border-0 resize-none focus-visible:ring-0 min-h-[40px]"
|
||||
rows={1}
|
||||
autoFocus
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
|
||||
export type IssueDraft = {
|
||||
title: string;
|
||||
@@ -45,7 +46,10 @@ export function useIssueDraft(
|
||||
if (typeof window === "undefined") return { ...EMPTY_DRAFT, ...defaults };
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) return JSON.parse(stored) as IssueDraft;
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as IssueDraft;
|
||||
return { ...parsed, title: clampTaskTitleInput(parsed.title ?? "") };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
import {
|
||||
DEFAULT_ONBOARDING_TASK_DESCRIPTION,
|
||||
DEFAULT_ONBOARDING_TASK_TITLE,
|
||||
@@ -34,7 +35,7 @@ export function StepTask({ agentName, taskTitle, taskDescription, onChange }: St
|
||||
<Input
|
||||
id="task-title"
|
||||
value={taskTitle}
|
||||
onChange={(e) => onChange({ taskTitle: e.target.value })}
|
||||
onChange={(e) => onChange({ taskTitle: clampTaskTitleInput(e.target.value) })}
|
||||
placeholder={DEFAULT_ONBOARDING_TASK_TITLE}
|
||||
className="mt-1"
|
||||
autoFocus
|
||||
|
||||
@@ -17,6 +17,7 @@ import { PromptSection } from "./prompt-section";
|
||||
import { RequiredFieldLabel } from "./required-field-label";
|
||||
import { TriggersSection } from "./triggers-section";
|
||||
import { WebhookCreatedDialog } from "./webhook-created-dialog";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
|
||||
type UpdateField = <K extends keyof FormState>(key: K, value: FormState[K]) => void;
|
||||
|
||||
@@ -163,7 +164,9 @@ export function ThenSection({
|
||||
<Input
|
||||
value={form.taskTitleTemplate}
|
||||
data-settings-dirty={isAutomationFieldDirty(form, savedForm, "taskTitleTemplate")}
|
||||
onChange={(event) => updateField("taskTitleTemplate", event.target.value)}
|
||||
onChange={(event) =>
|
||||
updateField("taskTitleTemplate", clampTaskTitleInput(event.target.value))
|
||||
}
|
||||
placeholder={defaultTaskTitle || "[Auto] automation name"}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useRouter } from "@/lib/routing/client-router";
|
||||
import { associateAzureDevOpsPullRequest } from "@/lib/api/domains/azure-devops-api";
|
||||
import type { AzureDevOpsPullRequest, AzureDevOpsWorkItem } from "@/lib/types/azure-devops";
|
||||
import type { Repository, Task, Workflow, WorkflowStep } from "@/lib/types/http";
|
||||
import { truncateRemoteTaskTitle } from "@/lib/task-title";
|
||||
|
||||
export type AzureDevOpsLaunchPayload =
|
||||
| { kind: "work-item"; item: AzureDevOpsWorkItem }
|
||||
@@ -24,7 +25,7 @@ function launchText(payload: AzureDevOpsLaunchPayload) {
|
||||
if (payload.kind === "work-item") {
|
||||
const item = payload.item;
|
||||
return {
|
||||
title: `${item.type} ${item.id}: ${item.title}`,
|
||||
title: truncateRemoteTaskTitle(`${item.type} ${item.id}: ${item.title}`),
|
||||
description: [
|
||||
`Azure DevOps work item: ${item.id}`,
|
||||
`URL: ${item.webUrl ?? item.apiUrl ?? ""}`,
|
||||
@@ -37,7 +38,7 @@ function launchText(payload: AzureDevOpsLaunchPayload) {
|
||||
}
|
||||
const pullRequest = payload.pullRequest;
|
||||
return {
|
||||
title: `Review PR ${pullRequest.id}: ${pullRequest.title}`,
|
||||
title: truncateRemoteTaskTitle(`Review PR ${pullRequest.id}: ${pullRequest.title}`),
|
||||
description: [
|
||||
`Azure DevOps pull request: ${pullRequest.webUrl}`,
|
||||
"",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAppStore } from "@/components/state-provider";
|
||||
import { createTaskPR, linkTaskIssue } from "@/lib/api/domains/github-api";
|
||||
import type { Repository, Task, TaskRepository, Workflow, WorkflowStep } from "@/lib/types/http";
|
||||
import type { GitHubPR, GitHubIssue } from "@/lib/types/github";
|
||||
import { truncateRemoteTaskTitle } from "@/lib/task-title";
|
||||
|
||||
export type TaskPreset = {
|
||||
id: string;
|
||||
@@ -127,7 +128,7 @@ function extractPayload(payload: LaunchPayload) {
|
||||
function buildDialogState(payload: LaunchPayload, repositories: Repository[]): DialogState {
|
||||
const data = extractPayload(payload);
|
||||
const description = payload.preset.prompt({ url: data.url, title: data.title });
|
||||
const title = `${payload.preset.label}: ${data.title}`;
|
||||
const title = truncateRemoteTaskTitle(`${payload.preset.label}: ${data.title}`);
|
||||
// For a PR launch we want the dialog to display and check out the PR's head
|
||||
// branch — matching the GitHub-URL-paste flow, where the branch selector
|
||||
// auto-resolves to the PR head. Same branch for both: the chip shows it and
|
||||
|
||||
@@ -10,6 +10,7 @@ import { linkToTask } from "@/lib/links";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import type { Issue, MR } from "@/lib/types/gitlab";
|
||||
import type { Repository, Task, Workflow, WorkflowStep } from "@/lib/types/http";
|
||||
import { truncateRemoteTaskTitle } from "@/lib/task-title";
|
||||
|
||||
export type GitLabTaskPreset = {
|
||||
id: string;
|
||||
@@ -81,7 +82,7 @@ function buildInitialValues(
|
||||
const repository = projectRepository(item.project_path, configuredHost, repositories);
|
||||
const branch = payload.kind === "mr" && repository ? payload.mr.head_branch : undefined;
|
||||
return {
|
||||
title: `${payload.preset.label}: ${item.title}`,
|
||||
title: truncateRemoteTaskTitle(`${payload.preset.label}: ${item.title}`),
|
||||
description: payload.preset.prompt({ url: item.web_url, title: item.title }),
|
||||
repositoryId: repository?.id,
|
||||
branch,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TaskCreateDialog } from "@/components/task-create-dialog";
|
||||
import type { Task, Workflow, WorkflowStep } from "@/lib/types/http";
|
||||
import type { JiraTicket } from "@/lib/types/jira";
|
||||
import type { JiraTaskPreset } from "./presets";
|
||||
import { truncateRemoteTaskTitle } from "@/lib/task-title";
|
||||
|
||||
export type JiraLaunchPayload = {
|
||||
ticket: JiraTicket;
|
||||
@@ -22,7 +23,7 @@ type QuickTaskLauncherProps = {
|
||||
|
||||
function buildDialogState(payload: JiraLaunchPayload) {
|
||||
const { ticket, preset } = payload;
|
||||
const title = `${ticket.key}: ${ticket.summary}`;
|
||||
const title = truncateRemoteTaskTitle(`${ticket.key}: ${ticket.summary}`);
|
||||
const description = preset.prompt({
|
||||
url: ticket.url,
|
||||
key: ticket.key,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "@/lib/routing/client-router";
|
||||
import { TaskCreateDialog } from "@/components/task-create-dialog";
|
||||
import type { Task, Workflow, WorkflowStep } from "@/lib/types/http";
|
||||
import type { LinearIssue } from "@/lib/types/linear";
|
||||
import { truncateRemoteTaskTitle } from "@/lib/task-title";
|
||||
|
||||
type QuickTaskLauncherProps = {
|
||||
workspaceId: string | null;
|
||||
@@ -18,7 +19,7 @@ type QuickTaskLauncherProps = {
|
||||
// the Jira prompt shape so users coming from either integration get a
|
||||
// consistent skeleton.
|
||||
function buildDialogState(issue: LinearIssue) {
|
||||
const title = `${issue.identifier}: ${issue.title}`;
|
||||
const title = truncateRemoteTaskTitle(`${issue.identifier}: ${issue.title}`);
|
||||
const description = [
|
||||
`Linear issue: ${issue.identifier}`,
|
||||
`URL: ${issue.url}`,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Executor, Repository } from "@/lib/types/http";
|
||||
import type { DialogFormState } from "@/components/task-create-dialog-types";
|
||||
import {
|
||||
applyCreatedLocalRepository,
|
||||
findDirectLocalExecutorProfile,
|
||||
@@ -7,6 +9,7 @@ import {
|
||||
readQueuedTaskCreateLastUsedState,
|
||||
resetTaskCreateLastUsedSync,
|
||||
syncTaskCreateLastUsed,
|
||||
useDialogHandlers,
|
||||
} from "./task-create-dialog-handlers";
|
||||
|
||||
const WORKTREE_PROFILE_ID = "worktree-profile";
|
||||
@@ -124,6 +127,24 @@ describe("local repository creation selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("task title handling", () => {
|
||||
it("clamps astral Unicode input before updating the dialog state", () => {
|
||||
const setTaskName = vi.fn();
|
||||
const setHasTitle = vi.fn();
|
||||
const fs = {
|
||||
executorProfileId: "",
|
||||
setTaskName,
|
||||
setHasTitle,
|
||||
} as unknown as DialogFormState;
|
||||
const { result } = renderHook(() => useDialogHandlers(fs, []));
|
||||
|
||||
act(() => result.current.handleTaskNameChange("😀".repeat(80)));
|
||||
|
||||
expect(setTaskName).toHaveBeenCalledWith("😀".repeat(60));
|
||||
expect(setHasTitle).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncTaskCreateLastUsed", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Executor, Repository } from "@/lib/types/http";
|
||||
import type { DialogFormState, TaskRepoRow } from "@/components/task-create-dialog-types";
|
||||
import { createDebugLogger } from "@/lib/debug/log";
|
||||
import type { TaskCreateLastUsedState } from "@/lib/state/slices/settings/types";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
|
||||
type TaskCreateLastUsedPatch = {
|
||||
repository_id?: string | null;
|
||||
@@ -269,8 +270,9 @@ function useProfileAndNameHandlers(fs: DialogFormState) {
|
||||
);
|
||||
const handleTaskNameChange = useCallback(
|
||||
(value: string) => {
|
||||
fs.setTaskName(value);
|
||||
fs.setHasTitle(value.trim().length > 0);
|
||||
const boundedValue = clampTaskTitleInput(value);
|
||||
fs.setTaskName(boundedValue);
|
||||
fs.setHasTitle(boundedValue.trim().length > 0);
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ import type { JiraTicket } from "@/lib/types/jira";
|
||||
import type { LinearIssue } from "@/lib/types/linear";
|
||||
import { useTaskCreatePromptMention } from "@/hooks/use-task-create-prompt-mention";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
|
||||
const CURSOR_POINTER_CLASS = "cursor-pointer";
|
||||
|
||||
@@ -310,7 +311,7 @@ export const InlineTaskName = memo(function InlineTaskName({
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={(e) => onChange(clampTaskTitleInput(e.target.value))}
|
||||
placeholder="Task name"
|
||||
data-testid="task-title-input"
|
||||
className="w-full min-w-0 max-w-full border border-input bg-input/20 dark:bg-input/30 text-sm font-medium rounded-md px-3 py-2 placeholder:text-muted-foreground/70 outline-none focus-visible:border-ring transition-colors"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computeDialogDefaultStepId } from "./task-create-dialog-defaults";
|
||||
import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
|
||||
import { useDialogFormState } from "./task-create-dialog-state";
|
||||
import { buildRepositoriesPayload } from "./task-create-dialog-helpers";
|
||||
import { TASK_TITLE_MAX_LENGTH } from "@/lib/task-title";
|
||||
|
||||
// `useBranchesByURL` triggers a real network ensure() when given a URL — stub
|
||||
// it so the dialog state hook can mount in JSDOM without hitting fetch. The
|
||||
@@ -357,6 +358,22 @@ describe("useDialogFormState — title autofill from first row GitHub URL info",
|
||||
expect(result.current.hasTitle).toBe(true);
|
||||
});
|
||||
|
||||
it("truncates a long remote PR title to the shared limit", () => {
|
||||
const longTitle = "PR #42: " + "x".repeat(100);
|
||||
seedPRInfo(PR_URL_42, 42, longTitle);
|
||||
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null));
|
||||
act(() => {
|
||||
result.current.setUseRemote(true);
|
||||
});
|
||||
const key = result.current.remoteRepos[0]?.key;
|
||||
act(() => {
|
||||
result.current.updateRemoteRepo(key!, { url: PR_URL_42 });
|
||||
});
|
||||
|
||||
expect(result.current.taskName).toHaveLength(TASK_TITLE_MAX_LENGTH);
|
||||
expect(result.current.taskName.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT overwrite a title the user typed themselves", () => {
|
||||
seedPRInfo(PR_URL_42, 42, PR_TITLE_42);
|
||||
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null));
|
||||
@@ -370,6 +387,12 @@ describe("useDialogFormState — title autofill from first row GitHub URL info",
|
||||
});
|
||||
expect(result.current.taskName).toBe(USER_TYPED_TITLE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDialogFormState — title autofill after title ownership", () => {
|
||||
beforeEach(() => {
|
||||
prInfoMap.clear();
|
||||
});
|
||||
|
||||
it("does NOT re-apply autofill after the user clears the title (user took ownership)", () => {
|
||||
// Regression: clearing an auto-filled title used to reset the ref to ""
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/components/task-create-dialog-repositories-state";
|
||||
import { useDialogComputed } from "@/components/task-create-dialog-computed";
|
||||
import { createDebugLogger } from "@/lib/debug/log";
|
||||
import { clampTaskTitleInput, truncateRemoteTaskTitle } from "@/lib/task-title";
|
||||
|
||||
const stateDebug = createDebugLogger("task-create:state");
|
||||
|
||||
@@ -136,8 +137,14 @@ function resolveFormDefaults(
|
||||
!hasUserContent(initialValues) && workspaceId ? getTaskCreateDraft(workspaceId) : null;
|
||||
const initTitle = initialValues?.title ?? "";
|
||||
const initDesc = initialValues?.description ?? "";
|
||||
let name = initTitle;
|
||||
if (draft?.title) {
|
||||
name = clampTaskTitleInput(draft.title);
|
||||
} else if (initialValues?.githubUrl) {
|
||||
name = truncateRemoteTaskTitle(initTitle);
|
||||
}
|
||||
return {
|
||||
name: draft?.title ?? initTitle,
|
||||
name,
|
||||
description: draft?.description ?? initDesc,
|
||||
source: resolveDefaultsSource(Boolean(draft), initialValues),
|
||||
};
|
||||
@@ -493,6 +500,7 @@ function useTitleAutofillFromPrimaryGitHubInfo(args: {
|
||||
const suggested = primaryRemoteUrl
|
||||
? prInfoByUrl.info(primaryRemoteUrl)?.suggestedTitle
|
||||
: undefined;
|
||||
const boundedSuggested = suggested ? truncateRemoteTaskTitle(suggested) : undefined;
|
||||
|
||||
// Reset ownership-tracking when the URL changes — switching to a different
|
||||
// PR URL grants a fresh autofill opportunity even if the user previously
|
||||
@@ -521,18 +529,18 @@ function useTitleAutofillFromPrimaryGitHubInfo(args: {
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!suggested) return;
|
||||
if (!boundedSuggested) return;
|
||||
if (lastAutoFilledRef.current === USER_CLEARED_SENTINEL) return;
|
||||
const trimmed = taskName.trim();
|
||||
// Two writeable states: title is empty AND we haven't auto-filled yet, or
|
||||
// title equals our last auto-fill (so a fresh PR URL replaces a previous
|
||||
// PR's auto-filled title).
|
||||
if (trimmed && taskName !== lastAutoFilledRef.current) return;
|
||||
if (taskName === suggested) return;
|
||||
lastAutoFilledRef.current = suggested;
|
||||
setTaskName(suggested);
|
||||
if (taskName === boundedSuggested) return;
|
||||
lastAutoFilledRef.current = boundedSuggested;
|
||||
setTaskName(boundedSuggested);
|
||||
setHasTitle(true);
|
||||
}, [open, suggested, taskName, setTaskName, setHasTitle]);
|
||||
}, [open, boundedSuggested, taskName, setTaskName, setHasTitle]);
|
||||
}
|
||||
|
||||
export type { DialogFormState } from "@/components/task-create-dialog-types";
|
||||
|
||||
@@ -180,6 +180,29 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("useTaskSubmitHandlers — started task edits", () => {
|
||||
it("preserves a legacy overlong title when it was not edited", async () => {
|
||||
const legacyTitle = "x".repeat(80);
|
||||
const deps = makeDeps({
|
||||
isEditMode: true,
|
||||
taskName: legacyTitle,
|
||||
editingTask: {
|
||||
id: TASK_ID,
|
||||
title: legacyTitle,
|
||||
description: ORIGINAL_PROMPT,
|
||||
workflowStepId: "step-1",
|
||||
state: "IN_PROGRESS",
|
||||
},
|
||||
descriptionInputRef: makeRef(""),
|
||||
});
|
||||
const { result } = renderHook(() => useTaskSubmitHandlers(deps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateWithoutAgent();
|
||||
});
|
||||
|
||||
expect(updateTaskMock).toHaveBeenCalledWith(TASK_ID, {});
|
||||
});
|
||||
|
||||
it("updates only the title so a locked prompt cannot be cleared", async () => {
|
||||
buildRepositoriesPayloadMock.mockReturnValue([
|
||||
{ repository_id: "repo-1", base_branch: "main" },
|
||||
|
||||
@@ -252,9 +252,10 @@ export function useTaskSubmitHandlers({
|
||||
: (descriptionInputRef.current?.getValue() ?? "");
|
||||
const trimmedDescription = description.trim();
|
||||
const repositoriesPayload = isStartedEdit ? [] : getRepositoriesPayload();
|
||||
const titleChanged = trimmedTitle !== editingTask.title;
|
||||
|
||||
const updatePayload: Parameters<typeof updateTask>[1] = {
|
||||
title: trimmedTitle,
|
||||
...(titleChanged && { title: trimmedTitle }),
|
||||
...(!isStartedEdit && { description: trimmedDescription }),
|
||||
...(repositoriesPayload.length > 0 && { repositories: repositoriesPayload }),
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ import { TaskCreateDialogPopoverContainerProvider } from "@/hooks/use-task-creat
|
||||
import { shouldShowTaskTitleField } from "@/components/task-create-dialog-helpers";
|
||||
import { usePromptResultDelivery } from "@/hooks/use-prompt-result-delivery";
|
||||
import { useResolvedTaskCreateWorkflowContext } from "@/components/task-create-dialog-workflow-context";
|
||||
import { truncateRemoteTaskTitle } from "@/lib/task-title";
|
||||
|
||||
const PROMPT_INSERTED_MESSAGE = "Enhanced prompt inserted.";
|
||||
|
||||
@@ -310,35 +311,34 @@ function useEnhanceForDialog(
|
||||
};
|
||||
}
|
||||
|
||||
function useJiraImportHandler(fs: DialogFormState) {
|
||||
function useJiraImportHandler(fs: DialogFormState, handleTaskNameChange: (value: string) => void) {
|
||||
return useCallback(
|
||||
(ticket: JiraTicket) => {
|
||||
const title = `[${ticket.key}] ${ticket.summary}`;
|
||||
fs.setTaskName(title);
|
||||
fs.setHasTitle(true);
|
||||
handleTaskNameChange(truncateRemoteTaskTitle(`[${ticket.key}] ${ticket.summary}`));
|
||||
const description = ticket.description?.trim()
|
||||
? `${ticket.description}\n\n---\nJira: ${ticket.url}`
|
||||
: `Jira: ${ticket.url}`;
|
||||
fs.descriptionInputRef.current?.setValue(description);
|
||||
fs.setHasDescription(true);
|
||||
},
|
||||
[fs],
|
||||
[fs, handleTaskNameChange],
|
||||
);
|
||||
}
|
||||
|
||||
function useLinearImportHandler(fs: DialogFormState) {
|
||||
function useLinearImportHandler(
|
||||
fs: DialogFormState,
|
||||
handleTaskNameChange: (value: string) => void,
|
||||
) {
|
||||
return useCallback(
|
||||
(issue: LinearIssue) => {
|
||||
const title = `[${issue.identifier}] ${issue.title}`;
|
||||
fs.setTaskName(title);
|
||||
fs.setHasTitle(true);
|
||||
handleTaskNameChange(truncateRemoteTaskTitle(`[${issue.identifier}] ${issue.title}`));
|
||||
const description = issue.description?.trim()
|
||||
? `${issue.description}\n\n---\nLinear: ${issue.url}`
|
||||
: `Linear: ${issue.url}`;
|
||||
fs.descriptionInputRef.current?.setValue(description);
|
||||
fs.setHasDescription(true);
|
||||
},
|
||||
[fs],
|
||||
[fs, handleTaskNameChange],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -523,8 +523,8 @@ export function useTaskCreateDialogSetup(
|
||||
userSettingsLoaded,
|
||||
guardedHandleSubmit,
|
||||
enhance: useEnhanceForDialog(fs, resolvedProps.taskId, resolvedProps.open),
|
||||
handleJiraImport: useJiraImportHandler(fs),
|
||||
handleLinearImport: useLinearImportHandler(fs),
|
||||
handleJiraImport: useJiraImportHandler(fs, handlers.handleTaskNameChange),
|
||||
handleLinearImport: useLinearImportHandler(fs, handlers.handleTaskNameChange),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
useDialogAttachments,
|
||||
} from "./session-dialog-shared";
|
||||
import { ContextZone } from "./chat/context-items/context-zone";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
|
||||
export function WorktreeBadge({ show, branch }: { show: boolean; branch: string | null }) {
|
||||
if (!show || !branch) return null;
|
||||
@@ -426,7 +427,7 @@ export function SubtaskFormBody({
|
||||
<Input
|
||||
id="subtask-title-input"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onChange={(e) => setTitle(clampTaskTitleInput(e.target.value))}
|
||||
placeholder="Subtask title"
|
||||
className="min-w-0 max-w-full text-sm"
|
||||
data-testid="subtask-title-input"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useRef, useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@kandev/ui/dialog";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
|
||||
type TaskRenameDialogProps = {
|
||||
open: boolean;
|
||||
@@ -43,7 +44,7 @@ function TaskRenameForm({
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={(e) => setValue(clampTaskTitleInput(e.target.value))}
|
||||
onFocus={(e) => e.target.select()}
|
||||
placeholder="Task title"
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from "@kan
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { useTaskActions } from "@/hooks/use-task-actions";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
|
||||
type TaskTopBarTitleProps = {
|
||||
taskId?: string | null;
|
||||
@@ -81,7 +82,7 @@ export function TaskTopBarTitle({ taskId, taskTitle, isArchived }: TaskTopBarTit
|
||||
aria-label="Task title"
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onChange={(e) => setDraft(clampTaskTitleInput(e.target.value))}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onBlur={handleCancel}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const LONG_TASK_TITLE =
|
||||
'Issue #1625: [Bug]: Config-session MCP advertises list_executors but backend returns "Unknown action: mcp.list_executors"';
|
||||
// Keep the fixture within the API's task-title contract while still being
|
||||
// long enough to exercise the topbar overflow behavior.
|
||||
export const LONG_TASK_TITLE = `Issue #1625: ${"W".repeat(47)}`;
|
||||
|
||||
@@ -4,17 +4,24 @@ import path from "node:path";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { makeGitEnv } from "../../helpers/git-helper";
|
||||
|
||||
function expectedRemoteTitle(title: string): string {
|
||||
const characters = Array.from(title);
|
||||
return characters.length <= 60 ? title : `${characters.slice(0, 59).join("")}…`;
|
||||
}
|
||||
|
||||
test.describe("GitHub PR task launcher", () => {
|
||||
test("opens the create task dialog in Remote mode when the matching repo is a stale task worktree", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
prCapture,
|
||||
}) => {
|
||||
const owner = "staleorg";
|
||||
const repo = "stalerepo";
|
||||
const prNumber = 1567;
|
||||
const prTitle = "Remote fallback PR";
|
||||
const prTitle =
|
||||
"Remote fallback PR with a title long enough to verify the task title ceiling is applied";
|
||||
const prURL = `https://github.com/${owner}/${repo}/pull/${prNumber}`;
|
||||
|
||||
await apiClient.mockGitHubReset();
|
||||
@@ -61,7 +68,9 @@ test.describe("GitHub PR task launcher", () => {
|
||||
await row.getByTestId("pr-start-task-trigger").click();
|
||||
await testPage.locator('[data-testid="pr-start-task-preset"][data-preset-id="review"]').click();
|
||||
|
||||
await expect(testPage.getByTestId("task-title-input")).toHaveValue(`Review: ${prTitle}`);
|
||||
const titleInput = testPage.getByTestId("task-title-input");
|
||||
await expect(titleInput).toHaveValue(expectedRemoteTitle(`Review: ${prTitle}`));
|
||||
await expect(titleInput).not.toHaveAttribute("maxlength");
|
||||
await expect(testPage.getByTestId("source-mode-remote")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
@@ -74,5 +83,8 @@ test.describe("GitHub PR task launcher", () => {
|
||||
`pull/${prNumber}`,
|
||||
);
|
||||
await expect(testPage.getByTestId("repo-chip")).toHaveCount(0);
|
||||
await prCapture.screenshot("desktop-task-title-limit", {
|
||||
caption: "Desktop remote PR task title truncated to the 60-character limit",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ test.describe("compact desktop responsive layout", () => {
|
||||
}
|
||||
const parent = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"A deliberately long parent task title that must remain inside the compact kanban card",
|
||||
"A deliberately long parent task title in compact kanban card",
|
||||
{
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
|
||||
@@ -2,6 +2,11 @@ import { test, expect } from "../../fixtures/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
|
||||
|
||||
function expectedRemoteTitle(title: string): string {
|
||||
const characters = Array.from(title);
|
||||
return characters.length <= 60 ? title : `${characters.slice(0, 59).join("")}…`;
|
||||
}
|
||||
|
||||
async function openRemotePicker(testPage: Page): Promise<void> {
|
||||
const mobile = new MobileKanbanPage(testPage);
|
||||
await mobile.goto();
|
||||
@@ -142,12 +147,17 @@ test.describe("Create task Remote repo picker on mobile", () => {
|
||||
await expectNoDocumentHorizontalOverflow(testPage);
|
||||
});
|
||||
|
||||
test("pastes a GitHub issue URL without clipping the picker", async ({ testPage, apiClient }) => {
|
||||
test("pastes a GitHub issue URL without clipping the picker", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
prCapture,
|
||||
}) => {
|
||||
await apiClient.mockGitHubAddBranches("issue-owner", "issue-repo", [{ name: "main" }]);
|
||||
await apiClient.mockGitHubAddIssues([
|
||||
{
|
||||
number: 1456,
|
||||
title: "Fix remote repo picker clipping",
|
||||
title:
|
||||
"Fix remote repo picker clipping while preserving a concise task title for the mobile flow",
|
||||
body: "The picker overlaps the dialog footer.",
|
||||
state: "open",
|
||||
author_login: "mock-user",
|
||||
@@ -163,10 +173,25 @@ test.describe("Create task Remote repo picker on mobile", () => {
|
||||
await pasteInput.fill("https://github.com/issue-owner/issue-repo/issues/1456");
|
||||
await pasteInput.press("Enter");
|
||||
|
||||
await expect(testPage.getByTestId("task-title-input")).toHaveValue(
|
||||
"Issue #1456: Fix remote repo picker clipping",
|
||||
const titleInput = testPage.getByTestId("task-title-input");
|
||||
await expect(titleInput).toHaveValue(
|
||||
expectedRemoteTitle(
|
||||
"Issue #1456: Fix remote repo picker clipping while preserving a concise task title for the mobile flow",
|
||||
),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await expect(titleInput).not.toHaveAttribute("maxlength");
|
||||
|
||||
await prCapture.screenshot("mobile-task-title-limit", {
|
||||
caption: "Mobile remote issue task title truncated to the 60-character limit",
|
||||
});
|
||||
|
||||
await titleInput.fill("x".repeat(80));
|
||||
await expect(titleInput).toHaveValue("x".repeat(60));
|
||||
|
||||
const emojiTitle = "😀".repeat(60);
|
||||
await titleInput.fill(emojiTitle);
|
||||
await expect(titleInput).toHaveValue(emojiTitle);
|
||||
});
|
||||
|
||||
test("selects a GitLab repository from the unified provider picker", async ({
|
||||
|
||||
@@ -143,8 +143,7 @@ test.describe("sidebar scrolling", () => {
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const longTitle =
|
||||
"Long sidebar title that should scroll on hover and leave room for task actions ".repeat(4);
|
||||
const longTitle = "Long sidebar title scrolls on hover with room for actions";
|
||||
const task = await apiClient.createTask(seedData.workspaceId, longTitle, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
|
||||
@@ -9,8 +9,7 @@ test.describe("Task topbar inline rename", () => {
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const originalTitle =
|
||||
"Topbar rename original with a deliberately long title that is truncated in the task header";
|
||||
const originalTitle = "Topbar rename original with a long title truncates in header";
|
||||
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
TASK_TITLE_MAX_LENGTH,
|
||||
clampTaskTitleInput,
|
||||
truncateRemoteTaskTitle,
|
||||
} from "@/lib/task-title";
|
||||
|
||||
describe("task title limits", () => {
|
||||
it("uses a 60-character maximum", () => {
|
||||
expect(TASK_TITLE_MAX_LENGTH).toBe(60);
|
||||
});
|
||||
|
||||
it("clamps manual input to the maximum without adding decoration", () => {
|
||||
const value = clampTaskTitleInput("x".repeat(80));
|
||||
|
||||
expect(value).toHaveLength(TASK_TITLE_MAX_LENGTH);
|
||||
expect(value).toBe("x".repeat(TASK_TITLE_MAX_LENGTH));
|
||||
});
|
||||
|
||||
it("counts Unicode code points as characters", () => {
|
||||
expect(clampTaskTitleInput("🙂".repeat(80))).toBe("🙂".repeat(TASK_TITLE_MAX_LENGTH));
|
||||
});
|
||||
|
||||
it("truncates remote prefills with an ellipsis", () => {
|
||||
const value = truncateRemoteTaskTitle("x".repeat(80));
|
||||
|
||||
expect(value).toHaveLength(TASK_TITLE_MAX_LENGTH);
|
||||
expect(value).toBe(`${"x".repeat(TASK_TITLE_MAX_LENGTH - 1)}…`);
|
||||
});
|
||||
|
||||
it("leaves titles at or below the limit unchanged", () => {
|
||||
expect(truncateRemoteTaskTitle("x".repeat(TASK_TITLE_MAX_LENGTH))).toBe(
|
||||
"x".repeat(TASK_TITLE_MAX_LENGTH),
|
||||
);
|
||||
expect(truncateRemoteTaskTitle("short title")).toBe("short title");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export const TASK_TITLE_MAX_LENGTH = 60;
|
||||
|
||||
function takeTitleCharacters(value: string, length: number): string {
|
||||
return Array.from(value).slice(0, length).join("");
|
||||
}
|
||||
|
||||
/** Clamp text entered directly into a task-title field. */
|
||||
export function clampTaskTitleInput(value: string): string {
|
||||
return takeTitleCharacters(value, TASK_TITLE_MAX_LENGTH);
|
||||
}
|
||||
|
||||
/** Shorten a title supplied by a remote issue/PR while preserving the limit. */
|
||||
export function truncateRemoteTaskTitle(value: string): string {
|
||||
const characters = Array.from(value);
|
||||
if (characters.length <= TASK_TITLE_MAX_LENGTH) return value;
|
||||
return `${characters.slice(0, TASK_TITLE_MAX_LENGTH - 1).join("")}…`;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
spec: docs/specs/tasks/title-length-limit.md
|
||||
created: 2026-08-01
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# Implementation Plan: Task Title Length Limit
|
||||
|
||||
## Overview
|
||||
|
||||
Add one backend validation boundary for task creates and title-bearing updates, expose the same limit in the MCP tool schema, and share a frontend title helper across all task-title inputs and remote-prefill paths. Existing rows are not migrated. Targeted service/transport tests prove the non-UI boundary, while desktop and mobile Playwright coverage proves manual input and remote prefill.
|
||||
|
||||
## Backend
|
||||
|
||||
### Task title contract
|
||||
|
||||
- Add `apps/backend/internal/task/service/task_title.go` with the exported `TaskTitleMaxLength = 60`, a typed validation sentinel, and a small validator used by both `Service.CreateTask` and `Service.UpdateTask` in `service_tasks.go`.
|
||||
- Validate before any repository write or task lifecycle event. An update validates only when `UpdateTaskRequest.Title` is non-nil, preserving legacy overlong titles during unrelated updates.
|
||||
- Update `apps/backend/internal/task/handlers/errors.go` and the WebSocket update error mapping in `task_ws_handlers.go` so the typed title error becomes the existing HTTP/WS validation response rather than a generic internal error.
|
||||
|
||||
### MCP contract
|
||||
|
||||
- Update `apps/backend/internal/mcp/server/server.go` so `create_task_kandev.title` declares `mcp.MaxLength(service.TaskTitleMaxLength)` and asks for a concise, few-word title of at most 60 characters.
|
||||
- Update `apps/backend/internal/mcp/handlers/handlers.go` classification so backend title validation is returned as an MCP validation error even if a client skips JSON-schema validation.
|
||||
- Keep `apps/backend/config/prompts/kandev-context.md` aligned with the registered schema and recommendation.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Shared title normalization
|
||||
|
||||
- Add `apps/web/lib/task-title.ts` with `TASK_TITLE_MAX_LENGTH = 60`, a user-input clamp, and a remote-prefill truncator that reserves the final character for `…`.
|
||||
- Apply the prefill helper in `apps/web/components/task-create-dialog-state.ts` for initial values, restored drafts, and GitHub URL suggestions, in the Jira/Linear import handlers in `task-create-dialog.tsx`, and in the GitHub, GitLab, Jira, Linear, and Azure DevOps launchers. Provider launchers continue to preserve the full remote title in their generated descriptions.
|
||||
|
||||
### Editable title surfaces
|
||||
|
||||
- Apply the shared limit to `InlineTaskName` in `task-create-dialog-selectors.tsx`, including the native input length hint and change-handler clamp used by create and edit mode.
|
||||
- Reuse the same helper and constant in `apps/web/components/task/task-rename-dialog.tsx`, `apps/web/components/task/task-top-bar-title.tsx`, `apps/web/components/task/new-subtask-form-parts.tsx`, `apps/web/components/automations/automation-editor-sections.tsx`, and `apps/web/app/office/components/new-task-dialog.tsx` so backend validation never appears as a late surprise in another title editor.
|
||||
- Preserve the existing responsive composition. Desktop and phone use the current shared dialog/full-height mobile surface, existing scroll owner, actions, and focus behavior.
|
||||
|
||||
## Mobile design contract
|
||||
|
||||
- **Outcome and entry point:** creating, editing, renaming, and nesting a task keeps the same mobile entry points; users can enter a valid title without hidden desktop-only behavior.
|
||||
- **Nearest exemplar:** the existing `TaskCreateDialog` phone composition and `apps/web/e2e/tests/task/mobile-create-task-remote-repo.spec.ts`; this change reuses their full-height dialog, title placement, and internal scroll behavior.
|
||||
- **Hierarchy and action:** the title remains the first editable content field and existing footer actions remain primary. No new drawer, navigation, fixed control, or touch target is introduced.
|
||||
- **State and scrolling:** title normalization is shared business logic across viewports; the existing dialog remains the single scroll owner and retains dynamic viewport/safe-area handling.
|
||||
- **Proof:** update the existing mobile remote-repository scenario to assert overlong issue prefill is shortened and the input cannot exceed 60 characters.
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** create accepts 60 characters and rejects 61 before persistence; unrelated updates preserve legacy titles; rename accepts 60 and rejects 61. **File:** `apps/backend/internal/task/service/task_title_test.go`. **How:** service tests against the real SQLite repository and event bus harness.
|
||||
- **What:** HTTP create/update and WebSocket create/update expose the typed title failure as validation. **Files:** `apps/backend/internal/task/handlers/task_http_handlers_test.go` and `task_ws_handlers_test.go`. **How:** focused handler integration tests through the service into the repository.
|
||||
- **What:** MCP schema advertises `maxLength: 60`, and a handler call that bypasses schema validation still rejects 61 characters without creating a task. **Files:** `apps/backend/internal/mcp/server/handlers_test.go` and `apps/backend/internal/mcp/handlers/handlers_test.go`. **How:** inspect the registered tool schema and call the MCP handler with the existing test harness.
|
||||
- **What:** input clamp and remote ellipsis behavior at 59/60/61-character boundaries. **File:** `apps/web/lib/task-title.test.ts`. **How:** table-driven Vitest unit tests.
|
||||
- **What:** initial values, drafts, GitHub URL autofill, Jira/Linear import, shared create/edit input, rename, subtask, and Office title fields use the shared cap. **Files:** existing focused tests beside `task-create-dialog-state.ts`, `task-create-dialog-form-body.tsx`, `task-rename-dialog.tsx`, `new-subtask-form-parts.tsx`, and `new-task-dialog.tsx`. **How:** Vitest hook/component tests.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** an overlong GitHub PR title opens the desktop create-task dialog with a 60-character title ending in `…`, while the remote PR source remains selected. **File:** `apps/web/e2e/tests/github/pr-action-create-task-dialog.spec.ts`. **What to verify:** exact input value/length and remote URL association.
|
||||
- **Scenario:** an overlong GitHub issue title is pasted from the mobile remote picker and the user cannot extend the task title past 60 characters. **File:** `apps/web/e2e/tests/task/mobile-create-task-remote-repo.spec.ts`. **What to verify:** exact shortened prefill, native input limit, retained phone viewport containment, and no document horizontal overflow.
|
||||
|
||||
## Public documentation
|
||||
|
||||
- Update `docs/public/tasks-and-workflows.md` to state the 60-character task title rule and remote-prefill behavior.
|
||||
- Update `docs/public/automation-and-mcp.md` to document the `create_task_kandev.title` maximum and validation failure.
|
||||
- Validate both pages with the repository public-doc checks.
|
||||
|
||||
## Implementation waves and parallel candidates
|
||||
|
||||
Wave 1 (parallel candidates only with explicit user authorization; default execution is sequential):
|
||||
|
||||
- [x] [Task 01: Backend and MCP title contract](task-01-backend-mcp-contract.md)
|
||||
- [x] [Task 02: Frontend title inputs and remote prefill](task-02-frontend-title-limit.md)
|
||||
|
||||
Wave 2:
|
||||
|
||||
- [x] [Task 03: E2E coverage and public documentation](task-03-e2e-docs.md)
|
||||
|
||||
## Risks
|
||||
|
||||
- Backend enforcement can surface previously hidden overlong titles produced by automations or third-party callers; typed validation and public/MCP guidance make that failure explicit.
|
||||
- Remote prefixes such as `Review:`, `PR #123:`, or `[KEY]` consume part of the 60-character allowance because the stored task title, not only the provider title, is bounded.
|
||||
- Existing overlong rows must remain updateable when a request does not include `title`; the service tests pin this compatibility behavior.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: "01-backend-mcp-contract"
|
||||
title: "Backend and MCP title contract"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/title-length-limit.md"
|
||||
---
|
||||
|
||||
# Task 01: Backend and MCP title contract
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Task creation and title-bearing updates accept 60-character titles and reject longer titles before persistence, while unrelated updates preserve legacy overlong titles.
|
||||
- HTTP, WebSocket, and MCP callers receive their existing validation response for an overlong title.
|
||||
- `create_task_kandev.title` advertises `maxLength: 60` and recommends a concise, few-word title.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
(
|
||||
cd apps/backend
|
||||
go test ./internal/task/service -run 'Test(CreateTaskAcceptsTitleAtLimit|CreateTaskRejectsTitleLongerThanLimit|UpdateTaskRejectsTitleLongerThanLimit|UpdateTaskWithoutTitlePreservesLegacyOverlongTitle)'
|
||||
go test ./internal/task/handlers -run 'Test(HTTP|WS).*(Create|Update).*Title'
|
||||
go test ./internal/mcp/server -run 'Test(CreateTask|UpdateTask)_ToolSchema'
|
||||
go test ./internal/mcp/handlers -run 'Test(ClassifyCreateTaskErrorMapsOverlongTitleToValidation|HandleCreateTask_RejectsOverlongTitle|HandleUpdateTask_TitleTooLongReturnsValidation)'
|
||||
)
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/task/service/task_title.go`
|
||||
- `apps/backend/internal/task/service/task_title_test.go`
|
||||
- `apps/backend/internal/task/service/service_tasks.go`
|
||||
- `apps/backend/internal/task/handlers/errors.go`
|
||||
- `apps/backend/internal/task/handlers/task_ws_handlers.go`
|
||||
- `apps/backend/internal/task/handlers/task_http_handlers_test.go`
|
||||
- `apps/backend/internal/task/handlers/task_update_repositories_test.go`
|
||||
- `apps/backend/internal/mcp/server/server.go`
|
||||
- `apps/backend/internal/mcp/server/handlers_test.go`
|
||||
- `apps/backend/internal/mcp/handlers/handlers.go`
|
||||
- `apps/backend/internal/mcp/handlers/handlers_test.go`
|
||||
- `apps/backend/config/prompts/kandev-context.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Parallel-safe with Task 02 because backend/MCP files and frontend files are disjoint. Execute sequentially unless the user explicitly authorizes subagents.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections: **What**, **API surface**, **Failure modes**, and the API/MCP scenarios.
|
||||
- Plan sections: **Backend**, **MCP contract**, and **Tests**.
|
||||
- Existing patterns: `Service.validateCreateTaskRequest`, `handlers.isValidationError`, `classifyCreateTaskError`, and `mcp.MaxLength` in mcp-go.
|
||||
|
||||
## Risks
|
||||
|
||||
- Do not validate unchanged legacy titles during non-title updates.
|
||||
- Return the typed validation error before task writes, events, or auto-start metadata can cause side effects.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the changed files, exact test commands/results, blockers and remaining risks, then mark this task `done` and update its checkbox in `plan.md`.
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
id: "02-frontend-title-limit"
|
||||
title: "Frontend title inputs and remote prefill"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/title-length-limit.md"
|
||||
---
|
||||
|
||||
# Task 02: Frontend title inputs and remote prefill
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Every task-title editor clamps typed and pasted input to 60 characters on desktop and mobile.
|
||||
- Remote initial values, restored drafts, GitHub URL suggestions, and Jira/Linear imports longer than 60 characters become 59 characters plus `…` without changing the full generated description or remote association.
|
||||
- Existing create/edit/rename/subtask/Office dialog composition, focus behavior, scrolling, and actions remain unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
(
|
||||
cd apps
|
||||
pnpm --filter @kandev/web test -- --run lib/task-title.test.ts components/task-create-dialog-state.test.ts components/task-create-dialog-submit.test.tsx
|
||||
)
|
||||
(
|
||||
cd apps/web
|
||||
pnpm run typecheck
|
||||
)
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/lib/task-title.ts`
|
||||
- `apps/web/lib/task-title.test.ts`
|
||||
- `apps/web/components/task-create-dialog-selectors.tsx`
|
||||
- `apps/web/components/task-create-dialog-state.ts`
|
||||
- `apps/web/components/task-create-dialog-state.test.ts`
|
||||
- `apps/web/components/task-create-dialog.tsx`
|
||||
- `apps/web/components/task-create-dialog-form-body.test.tsx`
|
||||
- `apps/web/components/task/task-rename-dialog.tsx`
|
||||
- `apps/web/components/task/task-rename-dialog.test.tsx`
|
||||
- `apps/web/components/task/new-subtask-form-parts.tsx`
|
||||
- `apps/web/components/task/new-subtask-form-parts.test.tsx`
|
||||
- `apps/web/app/office/components/new-task-dialog.tsx`
|
||||
- `apps/web/app/office/components/new-task-dialog.test.tsx`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None. The numeric limit mirrors Task 01's backend contract; if either task changes the agreed value, update both before completion.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Parallel-safe with Task 01 because frontend files are disjoint from backend/MCP files. Execute sequentially unless the user explicitly authorizes subagents.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections: **What**, **Failure modes**, and the UI/remote-prefill scenarios.
|
||||
- Plan sections: **Frontend**, **Mobile design contract**, and **Tests**.
|
||||
- Existing patterns: `InlineTaskName`, `resolveFormDefaults`, `useTitleAutofillFromPrimaryGitHubInfo`, `TaskRenameDialog`, `SubtaskFormBody`, and Office `NewTaskDialog`.
|
||||
|
||||
## Risks
|
||||
|
||||
- Apply ellipsis only to system/remote-prefilled values; manual input is capped without silently rewriting it to an ellipsis form.
|
||||
- Keep the provider's full title in descriptions and associations; truncate only the task-title state.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the changed files, exact test commands/results, rendered mobile check or exact blocker, and remaining risks, then mark this task `done` and update its checkbox in `plan.md`.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
id: "03-e2e-docs"
|
||||
title: "E2E coverage and public documentation"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-backend-mcp-contract", "02-frontend-title-limit"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/title-length-limit.md"
|
||||
---
|
||||
|
||||
# Task 03: E2E coverage and public documentation
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Desktop Playwright proves an overlong remote PR prefill becomes a valid 60-character task title while retaining its remote source.
|
||||
- Mobile Playwright proves overlong issue prefill and manual input share the cap without breaking dialog containment or causing horizontal overflow.
|
||||
- Public task and MCP documentation state the 60-character contract and validation behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
(
|
||||
cd apps/web
|
||||
pnpm e2e:run --host --project chromium tests/github/pr-action-create-task-dialog.spec.ts
|
||||
)
|
||||
(
|
||||
cd apps/web
|
||||
pnpm e2e:run --host --project mobile-chrome tests/task/mobile-create-task-remote-repo.spec.ts
|
||||
)
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
node scripts/validate-public-docs.mjs
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/tests/github/pr-action-create-task-dialog.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-create-task-remote-repo.spec.ts`
|
||||
- `docs/public/tasks-and-workflows.md`
|
||||
- `docs/public/automation-and-mcp.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 01: backend and MCP contract is complete.
|
||||
- Task 02: frontend input and prefill behavior is complete.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. This task verifies and documents the integrated behavior from Tasks 01 and 02.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec scenarios for desktop/mobile input, remote prefill, and MCP validation.
|
||||
- Plan sections: **E2E Tests**, **Public documentation**, and **Mobile design contract**.
|
||||
- Existing Playwright fixtures and provider mocks in the two named spec files.
|
||||
|
||||
## Risks
|
||||
|
||||
- Run through `pnpm e2e:run` so the production Vite bundle and Go backend are rebuilt; `--no-build` would risk testing stale artifacts.
|
||||
- Preserve the existing mobile geometry assertions and use the configured `mobile-chrome` project rather than a per-test device override.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report changed files, exact E2E/docs commands and results, rendered mobile evidence, blockers and remaining risks, then mark this task `done` and update its checkbox in `plan.md`.
|
||||
@@ -270,6 +270,8 @@ The settings page's static **Available tools** preview currently counts 29 and o
|
||||
|
||||
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, GitHub 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.
|
||||
|
||||
`create_task_kandev` accepts task titles up to 60 characters. Use a concise, few-word title and put the implementation context in `description`; longer titles are rejected as validation errors.
|
||||
|
||||
External mode has no live Kandev session, so it does not expose `stop_task_kandev` or other task-scoped questions, plans, walkthroughs, sibling-session spawning, targeted session messages, branch operations, or step-completion signals. Some external tools can delete or materially reconfigure data; review the client's tool approvals.
|
||||
|
||||
### External MCP security boundary
|
||||
|
||||
@@ -48,7 +48,7 @@ Use **New Task** in the sidebar. In an open task, the **Task** split button also
|
||||
caption="A focused task is entered while its repository, agent profile, worktree isolation, and start mode remain visible for review."
|
||||
/>
|
||||
|
||||
1. Enter a title.
|
||||
1. Enter a concise title of up to 60 characters. Titles prefilled from a remote pull request, issue, or merge request are shortened with an ellipsis when needed; the detailed context belongs in the description.
|
||||
2. Select the workspace and workflow when Kandev cannot infer them. A regular non-ephemeral task must belong to a workflow.
|
||||
3. Select a source:
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su
|
||||
| [documents](tasks/documents.md) | shipped |
|
||||
| [execution-stages](tasks/execution-stages.md) | shipped |
|
||||
| [labels](tasks/labels.md) | shipped |
|
||||
| [title-length-limit](tasks/title-length-limit.md) | complete |
|
||||
| [model-unification](tasks/model-unification.md) | draft |
|
||||
| [without-repositories](tasks/without-repositories.md) | draft |
|
||||
| [attach-workspace-sources](tasks/attach-workspace-sources.md) | building |
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
status: complete
|
||||
created: 2026-08-01
|
||||
owner: Kandev
|
||||
---
|
||||
|
||||
# Task Title Length Limit
|
||||
|
||||
## Why
|
||||
|
||||
Long task titles crowd navigation and task chrome, especially when a remote pull request or issue supplies the title automatically. Agents and API clients can also bypass the task dialog, so the product needs one predictable title boundary across every creation path.
|
||||
|
||||
## What
|
||||
|
||||
- New and renamed task titles must contain no more than 60 characters.
|
||||
- Every editable task-title field enforces the same limit, including the shared create/edit dialog, task rename dialog, subtask form, and Office new-task dialog on desktop and mobile.
|
||||
- When a remote pull request, merge request, or issue supplies a title longer than 60 characters, the task dialog uses the first 59 characters followed by `…`. The full remote title remains available in the generated task description or linked remote item; only the task title is shortened.
|
||||
- The `create_task_kandev` MCP tool advertises and enforces the 60-character limit and asks agents to use a concise, few-word title.
|
||||
- Backend task creation and title updates reject titles longer than 60 characters regardless of whether the caller uses HTTP, WebSocket, MCP, Office, automation, or another service adapter.
|
||||
- Existing stored titles longer than 60 characters remain readable and are not rewritten. Updates that omit `title` continue to work; any submitted replacement title must satisfy the new limit.
|
||||
|
||||
## API surface
|
||||
|
||||
- Task create and update requests keep their existing request and response shapes.
|
||||
- A submitted title longer than 60 characters fails validation and does not create or rename a task.
|
||||
- HTTP callers receive `400 Bad Request`; WebSocket and MCP callers receive their existing validation-error response shape with a message stating that task titles must be 60 characters or fewer.
|
||||
- The `create_task_kandev.title` input schema declares a maximum length of 60 and describes the title as concise.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- An overlong title from a user, agent, automation, plugin, or API client is rejected without persisting a task or title change.
|
||||
- Remote prefill does not fail the dialog: it is shortened before it reaches the editable title state.
|
||||
- Existing legacy titles are not changed merely by loading or displaying them.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** the create-task dialog on desktop or mobile, **WHEN** a user types or pastes more than 60 characters into the title field, **THEN** the field contains at most 60 characters and a valid title can be submitted.
|
||||
- **GIVEN** any other task-title editor, **WHEN** a user types or pastes more than 60 characters, **THEN** the field contains at most 60 characters.
|
||||
- **GIVEN** a remote pull request, merge request, or issue whose proposed task title exceeds 60 characters, **WHEN** the create-task dialog opens, **THEN** its title field contains the first 59 characters plus `…` and the complete remote context remains in the description or remote link.
|
||||
- **GIVEN** an MCP caller supplies a title of 60 characters, **WHEN** it calls `create_task_kandev`, **THEN** task creation proceeds subject to the existing validation rules.
|
||||
- **GIVEN** an MCP caller supplies a title longer than 60 characters, **WHEN** it calls `create_task_kandev`, **THEN** the call returns a validation error and creates no task.
|
||||
- **GIVEN** an HTTP or WebSocket caller supplies an overlong title for task creation or rename, **WHEN** the request is handled, **THEN** it returns the channel's validation response and leaves stored data unchanged.
|
||||
- **GIVEN** an existing task with a legacy title longer than 60 characters, **WHEN** a caller updates another task field without submitting `title`, **THEN** the existing title remains unchanged.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Rewriting or migrating existing task titles.
|
||||
- Changing remote pull request, merge request, issue, project, workflow, session, or document title limits.
|
||||
- Making the 60-character value configurable.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
See [the implementation plan](../../plans/task-title-length-limit/plan.md).
|
||||
Reference in New Issue
Block a user