feat: add optional agent-generated task titles (#2104)
This commit is contained in:
@@ -7,7 +7,7 @@ Use these IDs when calling tools that require task_id or session_id.
|
||||
|
||||
Available tools:
|
||||
- ask_user_question_kandev: Ask the user one or more clarifying questions in a single tool call. Use this whenever you need user input before proceeding. Required params: questions (array of 1-4 question objects; each object has prompt (string) and options (array of 2-6 {label, description})). Optional: context (string).
|
||||
{step_complete_section}- create_task_plan_kandev: Save an implementation plan for the current task. Required params: task_id, content (markdown). Optional: title.
|
||||
{step_complete_section}{task_title_section}- create_task_plan_kandev: Save an implementation plan for the current task. Required params: task_id, content (markdown). Optional: title.
|
||||
- get_task_plan_kandev: Retrieve the current plan for a task (includes any user edits). Required params: task_id.
|
||||
- update_task_plan_kandev: Update an existing plan. Required params: task_id, content (markdown). Optional: title.
|
||||
- delete_task_plan_kandev: Delete a task plan. Required params: task_id.
|
||||
|
||||
@@ -42,7 +42,7 @@ func setMcpMode(t *testing.T, s *Server, mode string) *httptest.ResponseRecorder
|
||||
func TestHandleSetMcpMode_AcceptsSupportedModes(t *testing.T) {
|
||||
s := newTestServerWithMCP(t)
|
||||
|
||||
for _, mode := range []string{mcpserver.ModeTask, mcpserver.ModeConfig, mcpserver.ModeOffice} {
|
||||
for _, mode := range []string{mcpserver.ModeTask, mcpserver.ModeTaskTitlePending, mcpserver.ModeConfig, mcpserver.ModeOffice} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
rec := setMcpMode(t, s, mode)
|
||||
if rec.Code != http.StatusOK {
|
||||
|
||||
@@ -308,8 +308,8 @@ func (s *Server) handleSetMcpMode(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Mode != mcp.ModeTask && req.Mode != mcp.ModeConfig && req.Mode != mcp.ModeOffice {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mode: must be 'task', 'config', or 'office'"})
|
||||
if req.Mode != mcp.ModeTask && req.Mode != mcp.ModeTaskTitlePending && req.Mode != mcp.ModeConfig && req.Mode != mcp.ModeOffice {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mode: must be 'task', 'task-title-pending', 'config', or 'office'"})
|
||||
return
|
||||
}
|
||||
s.mcpServer.SetMode(req.Mode)
|
||||
|
||||
@@ -458,6 +458,7 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str
|
||||
"reviewAutoMarkOnScroll": settings.ReviewAutoMarkOnScroll,
|
||||
"confirmTaskArchive": settings.ConfirmTaskArchive,
|
||||
"unreadDivider": settings.UnreadDivider,
|
||||
"agentGeneratedTaskTitles": settings.AgentGeneratedTaskTitles,
|
||||
"mcpTaskAgentProfileDefault": usermodels.NormalizeMCPTaskAgentProfileDefault(settings.MCPTaskAgentProfileDefault),
|
||||
"showAnchoredPromptBar": settings.ShowAnchoredPromptBar,
|
||||
"showScrollToLastPrompt": settings.ShowScrollToLastPrompt,
|
||||
|
||||
@@ -18,6 +18,17 @@ func TestMapUserSettingsStateIncludesArchiveConfirmation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapUserSettingsStateIncludesAgentGeneratedTaskTitles(t *testing.T) {
|
||||
state := mapUserSettingsState(userdto.UserSettingsResponse{
|
||||
Settings: userdto.UserSettingsDTO{AgentGeneratedTaskTitles: true},
|
||||
}, "workspace-1")
|
||||
|
||||
got, ok := state["agentGeneratedTaskTitles"].(bool)
|
||||
if !ok || !got {
|
||||
t.Fatalf("agentGeneratedTaskTitles = %#v, want true", state["agentGeneratedTaskTitles"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapUserSettingsStateIncludesTasksListShowDetails(t *testing.T) {
|
||||
state := mapUserSettingsState(userdto.UserSettingsResponse{
|
||||
Settings: userdto.UserSettingsDTO{TasksListShowDetails: true},
|
||||
|
||||
@@ -304,6 +304,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) {
|
||||
d.RegisterFunc(ws.ActionMCPListTasks, h.handleListTasks)
|
||||
d.RegisterFunc(ws.ActionMCPCreateTask, h.handleCreateTask)
|
||||
d.RegisterFunc(ws.ActionMCPUpdateTask, h.handleUpdateTask)
|
||||
d.RegisterFunc(ws.ActionMCPSetTaskTitle, h.handleSetTaskTitle)
|
||||
d.RegisterFunc(ws.ActionMCPGetTaskPRAutomation, h.handleGetTaskPRAutomation)
|
||||
d.RegisterFunc(ws.ActionMCPUpdateTaskPRAutomation, h.handleUpdateTaskPRAutomation)
|
||||
d.RegisterFunc(ws.ActionMCPAddBranchToTask, h.handleAddBranchToTask)
|
||||
@@ -328,7 +329,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) {
|
||||
d.RegisterFunc(ws.ActionTaskWalkthroughGet, h.handleGetWalkthrough)
|
||||
d.RegisterFunc(ws.ActionTaskWalkthroughDelete, h.handleDeleteWalkthrough)
|
||||
d.RegisterFunc(ws.ActionMCPClarificationTimeout, h.handleClarificationTimeout)
|
||||
count := 25
|
||||
count := 26
|
||||
count += h.registerReviewHandlers(d)
|
||||
count += 2 // task PR automation get/update
|
||||
|
||||
@@ -1314,6 +1315,46 @@ func (h *Handlers) handleUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
return ws.NewResponse(msg.ID, msg.Action, dto.FromTask(task))
|
||||
}
|
||||
|
||||
// handleSetTaskTitle resolves the one-shot provisional title created for a
|
||||
// prompt-first task. The MCP server supplies the bound task ID; a missing
|
||||
// pending marker makes the call an idempotent no-op so a human rename wins.
|
||||
func (h *Handlers) handleSetTaskTitle(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
}
|
||||
if req.TaskID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
|
||||
}
|
||||
if strings.TrimSpace(req.Title) == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil)
|
||||
}
|
||||
|
||||
task, accepted, err := h.taskSvc.SetPendingAgentTitle(ctx, req.TaskID, req.Title)
|
||||
if err != nil {
|
||||
if errors.Is(err, taskrepo.ErrTaskNotFound) {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "task not found", nil)
|
||||
}
|
||||
if errors.Is(err, service.ErrTaskTitleTooLong) {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
|
||||
}
|
||||
h.logger.Error("failed to set pending task title", zap.String("task_id", req.TaskID), zap.Error(err))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to set task title", nil)
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"accepted": accepted,
|
||||
"task_id": req.TaskID,
|
||||
"title": task.Title,
|
||||
}
|
||||
if !accepted {
|
||||
result["reason"] = "title_not_pending"
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, result)
|
||||
}
|
||||
|
||||
// handleAddBranchToTask attaches a new (repository, checkout_branch) pair to
|
||||
// an existing task. Mirrors create-time multi-repo attachment but additive:
|
||||
// the same repository may be added on a different branch, materializing a
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/pkg/api/v1"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHandleSetTaskTitle_UpdatesPendingTitleOnce(t *testing.T) {
|
||||
svc, repo := newTestTaskService(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{
|
||||
ID: "ws-title", Name: "Titles", CreatedAt: now, UpdatedAt: now,
|
||||
}))
|
||||
require.NoError(t, repo.CreateTask(ctx, &models.Task{
|
||||
ID: "task-title",
|
||||
WorkspaceID: "ws-title",
|
||||
Title: "Build the useful feature now",
|
||||
Description: "Build the useful feature now",
|
||||
State: v1.TaskStateInProgress,
|
||||
Metadata: map[string]interface{}{models.MetaKeyAgentTitlePending: true},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}))
|
||||
h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()}
|
||||
|
||||
resp, err := h.handleSetTaskTitle(ctx, makeWSMessage(t, ws.ActionMCPSetTaskTitle, map[string]interface{}{
|
||||
"task_id": "task-title",
|
||||
"title": "Useful Feature",
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
assertTaskTitleResponse(t, resp, map[string]interface{}{
|
||||
"accepted": true,
|
||||
"task_id": "task-title",
|
||||
"title": "Useful Feature",
|
||||
})
|
||||
|
||||
updated, err := svc.GetTask(ctx, "task-title")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Useful Feature", updated.Title)
|
||||
assert.False(t, models.IsAgentTitlePending(updated.Metadata))
|
||||
|
||||
resp, err = h.handleSetTaskTitle(ctx, makeWSMessage(t, ws.ActionMCPSetTaskTitle, map[string]interface{}{
|
||||
"task_id": "task-title",
|
||||
"title": "Late Agent Title",
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
assertTaskTitleResponse(t, resp, map[string]interface{}{
|
||||
"accepted": false,
|
||||
"task_id": "task-title",
|
||||
"title": "Useful Feature",
|
||||
"reason": "title_not_pending",
|
||||
})
|
||||
}
|
||||
|
||||
func assertTaskTitleResponse(t *testing.T, resp *ws.Message, want map[string]interface{}) {
|
||||
t.Helper()
|
||||
require.Equal(t, ws.MessageTypeResponse, resp.Type)
|
||||
var got map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(resp.Payload, &got))
|
||||
for key, value := range want {
|
||||
assert.Equal(t, value, got[key], "response field %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSetTaskTitle_ValidatesInput(t *testing.T) {
|
||||
h := &Handlers{logger: testLogger(t).WithFields()}
|
||||
resp, err := h.handleSetTaskTitle(context.Background(), makeWSMessage(t, ws.ActionMCPSetTaskTitle, map[string]interface{}{
|
||||
"task_id": "task-title",
|
||||
"title": " ",
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
assertWSError(t, resp, ws.ErrorCodeValidation)
|
||||
}
|
||||
|
||||
func TestHandleSetTaskTitle_RejectsOverlongTitle(t *testing.T) {
|
||||
svc, repo := newTestTaskService(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{
|
||||
ID: "ws-title-long", Name: "Titles", CreatedAt: now, UpdatedAt: now,
|
||||
}))
|
||||
require.NoError(t, repo.CreateTask(ctx, &models.Task{
|
||||
ID: "task-title-long",
|
||||
WorkspaceID: "ws-title-long",
|
||||
Title: "Temporary title",
|
||||
Description: "Prompt",
|
||||
State: v1.TaskStateInProgress,
|
||||
Metadata: map[string]interface{}{models.MetaKeyAgentTitlePending: true},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}))
|
||||
h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()}
|
||||
|
||||
resp, err := h.handleSetTaskTitle(ctx, makeWSMessage(t, ws.ActionMCPSetTaskTitle, map[string]interface{}{
|
||||
"task_id": "task-title-long",
|
||||
"title": strings.Repeat("x", 501),
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
assertWSError(t, resp, ws.ErrorCodeValidation)
|
||||
}
|
||||
@@ -33,6 +33,9 @@ type BackendClient interface {
|
||||
const (
|
||||
// ModeTask registers kanban, plan, and interaction tools (default for task-solving agents).
|
||||
ModeTask = "task"
|
||||
// ModeTaskTitlePending registers the task-mode tools plus the one-shot
|
||||
// title tool used while a prompt-first task still has its provisional title.
|
||||
ModeTaskTitlePending = "task-title-pending"
|
||||
// ModeConfig registers configuration tools for workflows, agents, and MCP servers.
|
||||
ModeConfig = "config"
|
||||
// ModeExternal registers config tools plus create_task_kandev for external coding agents
|
||||
@@ -75,7 +78,7 @@ func locatorCount(locators ...string) int {
|
||||
// normalizeMode returns a valid MCP mode, defaulting unknown values to ModeTask.
|
||||
func normalizeMode(mode string) string {
|
||||
switch mode {
|
||||
case ModeConfig, ModeExternal, ModeOffice:
|
||||
case ModeConfig, ModeExternal, ModeOffice, ModeTaskTitlePending:
|
||||
return mode
|
||||
default:
|
||||
return ModeTask
|
||||
@@ -88,7 +91,7 @@ type Server struct {
|
||||
sessionID string
|
||||
taskID string
|
||||
disableAskQuestion bool
|
||||
mode string // "task" (default), "config", or "office"
|
||||
mode string // "task" (default), "task-title-pending", "config", or "office"
|
||||
mcpServer *server.MCPServer
|
||||
sseServer *server.SSEServer
|
||||
httpServer *server.StreamableHTTPServer
|
||||
@@ -485,7 +488,7 @@ func (s *Server) registerTools() {
|
||||
count++
|
||||
s.registerTaskDocumentTools()
|
||||
count += 3
|
||||
default: // ModeTask
|
||||
case ModeTask, ModeTaskTitlePending:
|
||||
// Kanban tasks get list_related_tasks_kandev (useful for finding
|
||||
// a sibling to message_task_kandev) but NOT the task-document
|
||||
// tools — those are office coordination plumbing.
|
||||
@@ -515,6 +518,10 @@ func (s *Server) registerTools() {
|
||||
// session.
|
||||
s.registerStepCompleteTool()
|
||||
count++
|
||||
if s.mode == ModeTaskTitlePending {
|
||||
s.registerSetTaskTitleTool()
|
||||
count++
|
||||
}
|
||||
}
|
||||
s.logger.Info("registered MCP tools",
|
||||
zap.String("mode", s.mode),
|
||||
@@ -992,6 +999,50 @@ The summary you provide is shown to the user in chat and may be forwarded to the
|
||||
)
|
||||
}
|
||||
|
||||
// registerSetTaskTitleTool registers the one-shot title handoff used by
|
||||
// prompt-first task sessions. The server is bound to the current task, so the
|
||||
// agent only supplies the short user-facing title it wants to keep.
|
||||
func (s *Server) registerSetTaskTitleTool() {
|
||||
s.mcpServer.AddTool(
|
||||
mcp.NewTool("set_task_title_kandev",
|
||||
mcp.WithDescription(`Set the user-facing title for the CURRENT task.
|
||||
|
||||
Call this as your first action in the session, before planning, inspecting files,
|
||||
or doing any other work. The task currently has a provisional title derived from
|
||||
the prompt; call this tool even when that provisional title looks usable.
|
||||
|
||||
Use a concise title targeting about 3 words (no more than 6 words when practical).
|
||||
Write a short noun phrase, not a sentence or a progress update. Your title should
|
||||
summarize the requested outcome and will replace the provisional title.`),
|
||||
mcp.WithString(titleArg, mcp.Required(), mcp.Description("Short task title targeting about 3 words; use no more than 6 words when practical.")),
|
||||
),
|
||||
s.wrapHandler("set_task_title_kandev", s.setTaskTitleHandler()),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) setTaskTitleHandler() server.ToolHandlerFunc {
|
||||
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if s.taskID == "" {
|
||||
return mcp.NewToolResultError("set_task_title_kandev requires a bound task"), nil
|
||||
}
|
||||
title := strings.TrimSpace(req.GetString(titleArg, ""))
|
||||
if title == "" {
|
||||
return mcp.NewToolResultError("title is required"), nil
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
mcpKeyTaskID: s.taskID,
|
||||
"session_id": s.sessionID,
|
||||
titleArg: title,
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := s.backend.RequestPayload(ctx, ws.ActionMCPSetTaskTitle, payload, &result); err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
data, _ := json.MarshalIndent(result, "", " ")
|
||||
return mcp.NewToolResultText(string(data)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) stepCompleteHandler() server.ToolHandlerFunc {
|
||||
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if s.taskID == "" || s.sessionID == "" {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
mcplib "github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -267,6 +269,49 @@ func TestServerModeTask_DisableAskQuestion(t *testing.T) {
|
||||
assert.Contains(t, tools, "create_task_plan_kandev")
|
||||
}
|
||||
|
||||
func TestServerModeTaskTitlePending_RegistersTitleToolOnlyForPendingTaskMode(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
backend := NewChannelBackendClient(log)
|
||||
defer backend.Close()
|
||||
|
||||
pending := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTaskTitlePending)
|
||||
titleTool, ok := pending.mcpServer.ListTools()["set_task_title_kandev"]
|
||||
require.True(t, ok, "pending task mode must register set_task_title_kandev")
|
||||
assert.Contains(t, titleTool.Tool.Description, "first action")
|
||||
assert.Contains(t, titleTool.Tool.Description, "3 words")
|
||||
|
||||
ordinary := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask)
|
||||
assert.NotContains(t, ordinary.mcpServer.ListTools(), "set_task_title_kandev")
|
||||
for _, mode := range []string{ModeConfig, ModeOffice, ModeExternal} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
restricted := New(backend, "test-session", "test-task", 10005, log, "", false, mode)
|
||||
assert.NotContains(t, restricted.mcpServer.ListTools(), "set_task_title_kandev")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerSetTaskTitle_ForwardsBoundTaskAndSession(t *testing.T) {
|
||||
backend := &testBackend{response: map[string]interface{}{
|
||||
"accepted": true,
|
||||
"task_id": "task-1",
|
||||
"title": "Short title",
|
||||
}}
|
||||
log := newTestLogger(t)
|
||||
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTaskTitlePending)
|
||||
|
||||
result := callTool(t, s, "set_task_title_kandev", map[string]interface{}{"title": "Short title"})
|
||||
require.False(t, result.IsError)
|
||||
assert.Equal(t, ws.ActionMCPSetTaskTitle, backend.lastAction)
|
||||
assert.Equal(t, map[string]interface{}{
|
||||
"task_id": "task-1",
|
||||
"session_id": "session-1",
|
||||
"title": "Short title",
|
||||
}, backend.lastPayload)
|
||||
text, ok := result.Content[0].(mcplib.TextContent)
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, text.Text, `"accepted": true`)
|
||||
}
|
||||
|
||||
func TestServerModeTask_ToolCount(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
backend := NewChannelBackendClient(log)
|
||||
@@ -416,6 +461,7 @@ func TestServerModeOffice_DisableAskQuestion(t *testing.T) {
|
||||
|
||||
func TestServerModeConstants(t *testing.T) {
|
||||
assert.Equal(t, "task", ModeTask)
|
||||
assert.Equal(t, "task-title-pending", ModeTaskTitlePending)
|
||||
assert.Equal(t, "config", ModeConfig)
|
||||
assert.Equal(t, "external", ModeExternal)
|
||||
assert.Equal(t, "office", ModeOffice)
|
||||
|
||||
@@ -1576,6 +1576,11 @@ func (s *Service) autoStartStepPrompt(
|
||||
requeueTaken()
|
||||
return fmt.Errorf("resolve MCP mode for workflow auto-start: %w", err)
|
||||
}
|
||||
dbTask, err := s.repo.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
requeueTaken()
|
||||
return fmt.Errorf("resolve task title capability for workflow auto-start: %w", err)
|
||||
}
|
||||
configMode, _ := session.Metadata["config_mode"].(bool)
|
||||
requiresSignal := step != nil && step.AutoAdvanceRequiresSignal
|
||||
referenceContext := EntityReferenceContext(references)
|
||||
@@ -1585,6 +1590,7 @@ func (s *Service) autoStartStepPrompt(
|
||||
recordedPrompt = sysprompt.InjectKandevContextWithOptions(taskID, sessionID, agentPrompt, sysprompt.KandevContextOptions{
|
||||
RequiresCompletionSignal: requiresSignal,
|
||||
IncludeCoordinatorTaskControls: !configMode,
|
||||
IncludeTaskTitleTool: !configMode && dbTask != nil && models.IsAgentTitlePending(dbTask.Metadata),
|
||||
}, referenceContext)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,6 +431,10 @@ type RepoSpec struct {
|
||||
// config, tasks). Used when plan_mode is enabled on a session.
|
||||
const McpModeConfig = "config"
|
||||
|
||||
// McpModeTaskTitlePending exposes the task-mode MCP surface plus the one-shot
|
||||
// title tool while a prompt-first task still has its provisional title.
|
||||
const McpModeTaskTitlePending = "task-title-pending"
|
||||
|
||||
// McpModeOffice restricts the MCP toolset for office (autonomous) agents to
|
||||
// interaction + plan tools. Office agents manage tasks via the kandev CLI
|
||||
// (exposed through agentctl + $KANDEV_CLI), not MCP — see
|
||||
@@ -445,7 +449,7 @@ type LaunchOptions struct {
|
||||
Prompt string
|
||||
WorkflowStepID string
|
||||
StartAgent bool
|
||||
McpMode string // MCP tool mode: empty task default, McpModeConfig, or McpModeOffice
|
||||
McpMode string // MCP tool mode: empty task default, McpModeTaskTitlePending, McpModeConfig, or McpModeOffice
|
||||
Attachments []v1.MessageAttachment
|
||||
Env map[string]string
|
||||
// RouteOverride carries a provider-routing override resolved by the
|
||||
|
||||
@@ -47,6 +47,9 @@ func (e *Executor) resolveTaskSessionMCPMode(ctx context.Context, taskID string,
|
||||
if task != nil && task.IsFromOffice {
|
||||
return McpModeOffice, nil
|
||||
}
|
||||
if task != nil && models.IsAgentTitlePending(task.Metadata) {
|
||||
return McpModeTaskTitlePending, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResolveTaskSessionMCPMode_TitlePendingIsTaskModeVariant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMockRepository()
|
||||
repo.tasks["task-pending"] = &models.Task{
|
||||
ID: "task-pending",
|
||||
Metadata: map[string]interface{}{models.MetaKeyAgentTitlePending: true},
|
||||
}
|
||||
repo.sessions["session-pending"] = &models.TaskSession{TaskID: "task-pending"}
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
|
||||
mode, err := exec.resolveTaskSessionMCPMode(ctx, "task-pending", repo.sessions["session-pending"])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, McpModeTaskTitlePending, mode)
|
||||
}
|
||||
|
||||
func TestResolveTaskSessionMCPMode_TitlePendingDoesNotOverrideRestrictedModes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMockRepository()
|
||||
repo.tasks["task-pending"] = &models.Task{
|
||||
ID: "task-pending",
|
||||
IsFromOffice: true,
|
||||
Metadata: map[string]interface{}{models.MetaKeyAgentTitlePending: true},
|
||||
}
|
||||
repo.sessions["session-config"] = &models.TaskSession{
|
||||
TaskID: "task-pending",
|
||||
Metadata: map[string]interface{}{"config_mode": true},
|
||||
}
|
||||
repo.sessions["session-office"] = &models.TaskSession{TaskID: "task-pending"}
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
|
||||
mode, err := exec.resolveTaskSessionMCPMode(ctx, "task-pending", repo.sessions["session-config"])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, McpModeConfig, mode)
|
||||
|
||||
mode, err = exec.resolveTaskSessionMCPMode(ctx, "task-pending", repo.sessions["session-office"])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, McpModeOffice, mode)
|
||||
}
|
||||
@@ -74,3 +74,22 @@ func TestApplyLaunchPromptContext_PassthroughKeepsAttributionWithoutMCPBlock(t *
|
||||
assert.Contains(t, out, "session sess-spawner of task task-spawner")
|
||||
assert.Contains(t, out, "review the diff please")
|
||||
}
|
||||
|
||||
func TestApplyLaunchPromptContext_PassthroughAddsPendingTitleInstructionOnlyWhenNeeded(t *testing.T) {
|
||||
withTitle := (&Service{}).applyLaunchPromptContext(context.Background(), launchPromptContext{
|
||||
prompt: "review the diff please",
|
||||
isPassthrough: true,
|
||||
includeTaskTitleTool: true,
|
||||
})
|
||||
assert.Contains(t, withTitle, "set_task_title_kandev")
|
||||
assert.Contains(t, withTitle, "targeting about 3 words")
|
||||
assert.Contains(t, withTitle, "review the diff please")
|
||||
assert.NotContains(t, withTitle, sysprompt.TagStart)
|
||||
|
||||
withoutTitle := (&Service{}).applyLaunchPromptContext(context.Background(), launchPromptContext{
|
||||
prompt: "review the diff please",
|
||||
isPassthrough: true,
|
||||
})
|
||||
assert.NotContains(t, withoutTitle, "set_task_title_kandev")
|
||||
assert.Equal(t, "review the diff please", withoutTitle)
|
||||
}
|
||||
|
||||
@@ -490,19 +490,11 @@ func (s *Service) StartCreatedSession(
|
||||
// Passthrough profiles skip the wrap: the prompt is typed straight into the
|
||||
// agent CLI's TTY and the user sees it verbatim — they don't want a wall of
|
||||
// MCP-tool boilerplate prepended to "hello".
|
||||
if (effectivePrompt != "" || len(attachments) > 0) && !session.IsPassthrough {
|
||||
referenceContext := EntityReferenceContext(references)
|
||||
if isOfficeTask {
|
||||
effectivePrompt = sysprompt.InjectOfficeContext(
|
||||
taskID, sessionID, effectivePrompt,
|
||||
referenceContext, promptReferenceContext,
|
||||
)
|
||||
} else {
|
||||
effectivePrompt = sysprompt.InjectKandevContextWithOptions(taskID, sessionID, effectivePrompt, sysprompt.KandevContextOptions{
|
||||
RequiresCompletionSignal: s.WorkflowStepRequiresCompletionSignal(ctx, dbTask.WorkflowStepID),
|
||||
IncludeCoordinatorTaskControls: !configMode,
|
||||
}, referenceContext, promptReferenceContext)
|
||||
}
|
||||
if effectivePrompt != "" || len(attachments) > 0 {
|
||||
effectivePrompt = s.wrapCreatedSessionPrompt(
|
||||
ctx, effectivePrompt, taskID, sessionID, session, dbTask,
|
||||
isOfficeTask, configMode, references, promptReferenceContext,
|
||||
)
|
||||
}
|
||||
|
||||
executorID := session.ExecutorID
|
||||
@@ -535,6 +527,38 @@ func (s *Service) StartCreatedSession(
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
// wrapCreatedSessionPrompt adds the first-turn context for a prepared session.
|
||||
// Passthrough sessions intentionally receive only the short pending-title
|
||||
// instruction; structured sessions receive the normal task or Office block.
|
||||
func (s *Service) wrapCreatedSessionPrompt(
|
||||
ctx context.Context,
|
||||
prompt, taskID, sessionID string,
|
||||
session *models.TaskSession,
|
||||
dbTask *models.Task,
|
||||
isOfficeTask, configMode bool,
|
||||
references []v1.EntityReference,
|
||||
promptReferenceContext string,
|
||||
) string {
|
||||
referenceContext := EntityReferenceContext(references)
|
||||
switch {
|
||||
case session.IsPassthrough:
|
||||
if !configMode && models.IsAgentTitlePending(dbTask.Metadata) {
|
||||
return sysprompt.PendingTaskTitlePassthroughInstruction() + "\n\n" + prompt
|
||||
}
|
||||
return prompt
|
||||
case isOfficeTask:
|
||||
return sysprompt.InjectOfficeContext(
|
||||
taskID, sessionID, prompt, referenceContext, promptReferenceContext,
|
||||
)
|
||||
default:
|
||||
return sysprompt.InjectKandevContextWithOptions(taskID, sessionID, prompt, sysprompt.KandevContextOptions{
|
||||
RequiresCompletionSignal: s.WorkflowStepRequiresCompletionSignal(ctx, dbTask.WorkflowStepID),
|
||||
IncludeCoordinatorTaskControls: !configMode,
|
||||
IncludeTaskTitleTool: !configMode && models.IsAgentTitlePending(dbTask.Metadata),
|
||||
}, referenceContext, promptReferenceContext)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSessionLaunchFailure covers launch and resume failures that have not
|
||||
// already won terminal bookkeeping. The state CAS makes it safe as an
|
||||
// idempotent fallback when executor bookkeeping did run.
|
||||
@@ -856,6 +880,10 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s
|
||||
// strictly less harmful than leaking hidden <kandev-system> content into
|
||||
// a real passthrough session's PTY.
|
||||
isPassthrough := s.resolveIsPassthroughForLaunch(ctx, sessionID)
|
||||
launchSession, err := s.repo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reload launch session: %w", err)
|
||||
}
|
||||
|
||||
effectivePrompt, planModeActive, promptReferenceContext := s.applyWorkflowAndPlanMode(
|
||||
ctx, effectivePrompt, task.ID, sessionID, workflowStepID,
|
||||
@@ -864,7 +892,7 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s
|
||||
|
||||
// Inject config context for config-mode sessions (dedicated settings chat)
|
||||
configMode := false
|
||||
if cm, ok := task.Metadata["config_mode"].(bool); ok && cm {
|
||||
if cm, ok := launchSession.Metadata["config_mode"].(bool); ok && cm {
|
||||
configMode = true
|
||||
effectivePrompt = sysprompt.InjectConfigContext(sessionID, effectivePrompt)
|
||||
}
|
||||
@@ -887,14 +915,15 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s
|
||||
// while the task is already bound to a signal-gated step in the DB.
|
||||
if effectivePrompt != "" || len(attachments) > 0 {
|
||||
effectivePrompt = s.applyLaunchPromptContext(ctx, launchPromptContext{
|
||||
prompt: effectivePrompt,
|
||||
taskID: task.ID,
|
||||
sessionID: sessionID,
|
||||
isOfficeTask: isOfficeTask,
|
||||
isPassthrough: skipKandevMCPWrap,
|
||||
configMode: configMode,
|
||||
referenceContext: promptReferenceContext,
|
||||
spawnOrigin: opts.SpawnOrigin,
|
||||
prompt: effectivePrompt,
|
||||
taskID: task.ID,
|
||||
sessionID: sessionID,
|
||||
isOfficeTask: isOfficeTask,
|
||||
isPassthrough: skipKandevMCPWrap,
|
||||
configMode: configMode,
|
||||
referenceContext: promptReferenceContext,
|
||||
includeTaskTitleTool: !configMode && models.IsAgentTitlePending(task.Metadata),
|
||||
spawnOrigin: opts.SpawnOrigin,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -937,14 +966,15 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s
|
||||
// launchPromptContext carries what the first turn of a launch needs in order to
|
||||
// compose its system context.
|
||||
type launchPromptContext struct {
|
||||
prompt string
|
||||
taskID string
|
||||
sessionID string
|
||||
isOfficeTask bool
|
||||
isPassthrough bool
|
||||
configMode bool
|
||||
referenceContext string
|
||||
spawnOrigin *SpawnOrigin
|
||||
prompt string
|
||||
taskID string
|
||||
sessionID string
|
||||
isOfficeTask bool
|
||||
isPassthrough bool
|
||||
configMode bool
|
||||
includeTaskTitleTool bool
|
||||
referenceContext string
|
||||
spawnOrigin *SpawnOrigin
|
||||
}
|
||||
|
||||
// applyLaunchPromptContext prepends the first-turn system context to a launch
|
||||
@@ -955,7 +985,11 @@ type launchPromptContext struct {
|
||||
// applySpawnOriginText for why they skip the MCP block entirely.
|
||||
func (s *Service) applyLaunchPromptContext(ctx context.Context, p launchPromptContext) string {
|
||||
if p.isPassthrough {
|
||||
return applySpawnOriginText(p.prompt, p.spawnOrigin)
|
||||
prompt := applySpawnOriginText(p.prompt, p.spawnOrigin)
|
||||
if p.includeTaskTitleTool {
|
||||
return sysprompt.PendingTaskTitlePassthroughInstruction() + "\n\n" + prompt
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
// Spawner attribution is built here, not by the MCP handler that filled
|
||||
// SpawnOrigin: the injectors below strip every <kandev-system> block they do
|
||||
@@ -970,6 +1004,7 @@ func (s *Service) applyLaunchPromptContext(ctx context.Context, p launchPromptCo
|
||||
return sysprompt.InjectKandevContextWithOptions(p.taskID, p.sessionID, prompt, sysprompt.KandevContextOptions{
|
||||
RequiresCompletionSignal: s.StepRequiresCompletionSignal(ctx, p.taskID),
|
||||
IncludeCoordinatorTaskControls: !p.configMode,
|
||||
IncludeTaskTitleTool: p.includeTaskTitleTool,
|
||||
}, p.referenceContext, spawnContext)
|
||||
}
|
||||
|
||||
|
||||
@@ -3098,7 +3098,10 @@ func TestStartCreatedSession_ConfigModeOmitsCoordinatorTaskControls(t *testing.T
|
||||
require.NoError(t, repo.UpdateSessionMetadata(ctx, "session1", map[string]interface{}{"config_mode": true}))
|
||||
|
||||
taskRepo := newMockTaskRepo()
|
||||
taskRepo.tasks["task1"] = &v1.Task{ID: "task1", Title: "Config chat", State: v1.TaskStateInProgress}
|
||||
taskRepo.tasks["task1"] = &v1.Task{
|
||||
ID: "task1", Title: "Config chat", State: v1.TaskStateInProgress,
|
||||
Metadata: map[string]interface{}{models.MetaKeyAgentTitlePending: true},
|
||||
}
|
||||
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
|
||||
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr)
|
||||
messages := &mockMessageCreator{}
|
||||
@@ -3110,6 +3113,8 @@ func TestStartCreatedSession_ConfigModeOmitsCoordinatorTaskControls(t *testing.T
|
||||
assert.Contains(t, messages.userMessages[0].content, "KANDEV CONFIG MCP TOOLS")
|
||||
assert.NotContains(t, messages.userMessages[0].content, "stop_task_kandev",
|
||||
"Config first-turn context must not advertise a task-mode-only tool")
|
||||
assert.NotContains(t, messages.userMessages[0].content, "set_task_title_kandev",
|
||||
"Config first-turn context must not advertise the title tool")
|
||||
}
|
||||
|
||||
func TestStartCreatedSession_AssignedKanbanTaskUsesTaskMode(t *testing.T) {
|
||||
|
||||
@@ -121,11 +121,13 @@ func PlanMode() string { return prompts.Get("plan-mode") }
|
||||
|
||||
// KandevContext returns the task-mode system prompt template that provides
|
||||
// Kandev-specific instructions and session context to agents. Contains
|
||||
// {task_id}, {session_id}, and {step_complete_section} placeholders — use
|
||||
// {task_id}, {session_id}, {step_complete_section}, and {task_title_section}
|
||||
// placeholders — use
|
||||
// [FormatKandevContext] to inject values.
|
||||
func KandevContext() string {
|
||||
return Resolve("kandev-context", map[string]string{
|
||||
"coordinator_task_control_section": coordinatorTaskControlSection,
|
||||
"task_title_section": "",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -161,20 +163,43 @@ const coordinatorTaskControlSection = " Optional: session_id, delivery_mode. " +
|
||||
"- stop_task_kandev: Halt all live sessions observed for a direct child, with no prompt and no replacement turn. " +
|
||||
"Only the target task's direct parent may call it. Required params: task_id."
|
||||
|
||||
// taskTitleSection is included only for task sessions whose task metadata says
|
||||
// the provisional title still needs an agent-generated replacement. It ends in
|
||||
// a newline because the template inlines it directly before the next tool item.
|
||||
const taskTitleSection = "- set_task_title_kandev: Set the user-facing title for the CURRENT task. " +
|
||||
"Call this as your first action in the session, before planning, inspecting files, or doing any other work. " +
|
||||
"Call it even when the provisional title looks usable. " +
|
||||
"Use a concise title targeting about 3 words (no more than 6 words when practical), as a short noun phrase rather than a sentence or progress update. " +
|
||||
"Required param: title.\n"
|
||||
|
||||
// PendingTaskTitlePassthroughInstruction is the compact equivalent of the
|
||||
// structured first-turn title section for CLI passthrough sessions. Those
|
||||
// sessions intentionally skip the full hidden MCP context because it would be
|
||||
// printed into the user's terminal.
|
||||
func PendingTaskTitlePassthroughInstruction() string {
|
||||
return "Before doing any other work, call set_task_title_kandev for the current task. " +
|
||||
"Use a concise title targeting about 3 words (no more than 6 words when practical), " +
|
||||
"even if the provisional title looks usable."
|
||||
}
|
||||
|
||||
// KandevContextOptions controls capability-dependent sections in the first-turn
|
||||
// Kandev context.
|
||||
type KandevContextOptions struct {
|
||||
RequiresCompletionSignal bool
|
||||
IncludeCoordinatorTaskControls bool
|
||||
IncludeTaskTitleTool bool
|
||||
}
|
||||
|
||||
// FormatKandevContext returns the Kandev context prompt with task and session IDs injected.
|
||||
// When requiresCompletionSignal is true, the step_complete_kandev tool description is
|
||||
// included; otherwise the placeholder is collapsed to an empty string.
|
||||
// included; otherwise the placeholder is collapsed to an empty string. The
|
||||
// title-tool capability is intentionally false in this compatibility wrapper;
|
||||
// callers with a pending title use FormatKandevContextWithOptions directly.
|
||||
func FormatKandevContext(taskID, sessionID string, requiresCompletionSignal bool) string {
|
||||
return FormatKandevContextWithOptions(taskID, sessionID, KandevContextOptions{
|
||||
RequiresCompletionSignal: requiresCompletionSignal,
|
||||
IncludeCoordinatorTaskControls: true,
|
||||
IncludeTaskTitleTool: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -188,10 +213,15 @@ func FormatKandevContextWithOptions(taskID, sessionID string, options KandevCont
|
||||
if options.IncludeCoordinatorTaskControls {
|
||||
coordinatorControls = coordinatorTaskControlSection
|
||||
}
|
||||
taskTitle := ""
|
||||
if options.IncludeTaskTitleTool {
|
||||
taskTitle = taskTitleSection
|
||||
}
|
||||
return Resolve("kandev-context", map[string]string{
|
||||
"task_id": taskID,
|
||||
"session_id": sessionID,
|
||||
"step_complete_section": section,
|
||||
"task_title_section": taskTitle,
|
||||
"coordinator_task_control_section": coordinatorControls,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -117,6 +117,19 @@ func TestFormatKandevContext_OmitsStepCompleteToolByDefault(t *testing.T) {
|
||||
assert.NotContains(t, result, "{step_complete_section}")
|
||||
}
|
||||
|
||||
func TestFormatKandevContext_TitleToolFollowsCapability(t *testing.T) {
|
||||
withoutTitle := FormatKandevContextWithOptions("task-abc", "session-xyz", KandevContextOptions{})
|
||||
assert.NotContains(t, withoutTitle, "set_task_title_kandev")
|
||||
assert.NotContains(t, withoutTitle, "first action")
|
||||
|
||||
withTitle := FormatKandevContextWithOptions("task-abc", "session-xyz", KandevContextOptions{
|
||||
IncludeTaskTitleTool: true,
|
||||
})
|
||||
assert.Contains(t, withTitle, "set_task_title_kandev")
|
||||
assert.Contains(t, withTitle, "first action")
|
||||
assert.Contains(t, withTitle, "targeting about 3 words")
|
||||
}
|
||||
|
||||
func TestFormatKandevContext_IncludesStepCompleteToolWhenRequired(t *testing.T) {
|
||||
result := FormatKandevContext("task-abc", "session-xyz", true)
|
||||
assert.Contains(t, result, "step_complete_kandev",
|
||||
|
||||
@@ -76,7 +76,7 @@ func isValidationError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, service.ErrInvalidParent) {
|
||||
if errors.Is(err, service.ErrInvalidParent) || errors.Is(err, service.ErrAutoTitleUnsupportedForOffice) {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, service.ErrTaskTitleTooLong) {
|
||||
|
||||
@@ -353,6 +353,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w
|
||||
storedContent = sysprompt.InjectKandevContextWithOptions(req.TaskID, req.TaskSessionID, storedContent, sysprompt.KandevContextOptions{
|
||||
RequiresCompletionSignal: requiresSignal,
|
||||
IncludeCoordinatorTaskControls: !configMode,
|
||||
IncludeTaskTitleTool: !configMode && models.IsAgentTitlePending(task.Metadata),
|
||||
}, referenceContext)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -692,6 +692,7 @@ type httpCreateTaskRequest struct {
|
||||
WorkflowStepID string `json:"workflow_step_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AutoTitle bool `json:"auto_title,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
State *v1.TaskState `json:"state,omitempty"`
|
||||
Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"`
|
||||
@@ -832,6 +833,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
|
||||
WorkflowStepID: body.WorkflowStepID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
AutoTitle: body.AutoTitle,
|
||||
Priority: body.Priority,
|
||||
State: body.State,
|
||||
Repositories: convertToServiceRepos(repos),
|
||||
@@ -864,7 +866,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if !h.commitFreshBranch(c, task.ID, title, body.WorkspaceID, body.Repositories, repos) {
|
||||
if !h.commitFreshBranch(c, task.ID, task.Title, body.WorkspaceID, body.Repositories, repos) {
|
||||
return
|
||||
}
|
||||
taskDTO := dto.FromTask(task)
|
||||
|
||||
@@ -432,6 +432,68 @@ func TestHTTPCreateTask_ProjectIDReachesOfficePath(t *testing.T) {
|
||||
assert.Equal(t, "wf-office", repo.captured.WorkflowID, "office workflow should be auto-resolved")
|
||||
}
|
||||
|
||||
func TestHTTPCreateTaskAutoTitleUsesPromptWordsAndPendingMarker(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{})
|
||||
svc.SetWorkflowStepGetter(repo)
|
||||
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",
|
||||
"workflow_step_id":"step-1",
|
||||
"auto_title":true,
|
||||
"description":" Add\n a better task title for this request "
|
||||
}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.httpCreateTask(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
require.NotNil(t, repo.captured)
|
||||
assert.Equal(t, "Add a better task title for", repo.captured.Title)
|
||||
assert.True(t, models.IsAgentTitlePending(repo.captured.Metadata))
|
||||
}
|
||||
|
||||
func TestHTTPCreateTaskAutoTitleRequiresPrompt(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",
|
||||
"auto_title":true,
|
||||
"description":" "
|
||||
}`))
|
||||
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)
|
||||
}
|
||||
|
||||
func TestHTTPCreateTaskRejectsOverlongTitle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
log := newTestLogger(t)
|
||||
|
||||
@@ -110,8 +110,20 @@ const (
|
||||
// because the winner will (or already did) handle it.
|
||||
// Absent on ordinary (non-watcher) auto-start tasks, which launch normally.
|
||||
MetaKeyAutoStartClaimed = "auto_start_claimed"
|
||||
// MetaKeyAgentTitlePending marks tasks created in prompt-first mode whose
|
||||
// provisional title still needs the first eligible agent session to replace it.
|
||||
MetaKeyAgentTitlePending = "agent_title_pending"
|
||||
)
|
||||
|
||||
// IsAgentTitlePending reports whether task metadata contains the durable
|
||||
// prompt-first title marker. JSON rehydration produces bool values, while a
|
||||
// few in-process callers may provide typed metadata, so only an explicit true
|
||||
// value enables the capability.
|
||||
func IsAgentTitlePending(metadata map[string]interface{}) bool {
|
||||
pending, ok := metadata[MetaKeyAgentTitlePending].(bool)
|
||||
return ok && pending
|
||||
}
|
||||
|
||||
// TaskSession.Metadata key that records how the session came into existence.
|
||||
// workflow_switch means the session profile was selected by workflow routing
|
||||
// rather than direct user selection.
|
||||
|
||||
@@ -74,6 +74,67 @@ func TestPostgresExecutorRunningLocalPIDMigration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresTaskTitleCASAndStaleUpdate(t *testing.T) {
|
||||
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
|
||||
repo, err := NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("init postgres schema: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
|
||||
if _, err := db.Exec(db.Rebind(`
|
||||
INSERT INTO tasks (id, title, metadata, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`), "task-pg-title-false", "Provisional", `{"agent_title_pending":false,"keep":"value"}`, now, now); err != nil {
|
||||
t.Fatalf("seed false-marker task: %v", err)
|
||||
}
|
||||
accepted, err := repo.SetTaskTitleIfPending(ctx, "task-pg-title-false", "Agent title")
|
||||
if err != nil {
|
||||
t.Fatalf("false-marker title CAS: %v", err)
|
||||
}
|
||||
if accepted {
|
||||
t.Fatal("accepted title update with a false pending marker")
|
||||
}
|
||||
falseTask, err := repo.GetTask(ctx, "task-pg-title-false")
|
||||
if err != nil {
|
||||
t.Fatalf("reload false-marker task: %v", err)
|
||||
}
|
||||
if falseTask.Title != "Provisional" || falseTask.Metadata["agent_title_pending"] != false || falseTask.Metadata["keep"] != "value" {
|
||||
t.Fatalf("false-marker task changed unexpectedly: title=%q metadata=%#v", falseTask.Title, falseTask.Metadata)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(db.Rebind(`
|
||||
INSERT INTO tasks (id, title, metadata, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`), "task-pg-title-race", "Provisional", `{"agent_title_pending":true}`, now, now); err != nil {
|
||||
t.Fatalf("seed stale-update task: %v", err)
|
||||
}
|
||||
stale, err := repo.GetTask(ctx, "task-pg-title-race")
|
||||
if err != nil {
|
||||
t.Fatalf("load stale postgres task: %v", err)
|
||||
}
|
||||
accepted, err = repo.SetTaskTitleIfPending(ctx, "task-pg-title-race", "Agent chosen title")
|
||||
if err != nil || !accepted {
|
||||
t.Fatalf("winning title CAS: accepted=%v err=%v", accepted, err)
|
||||
}
|
||||
stale.Description = "updated concurrently"
|
||||
stale.Metadata["stale_change"] = "retained"
|
||||
if err := repo.UpdateTask(ctx, stale); err != nil {
|
||||
t.Fatalf("stale UpdateTask: %v", err)
|
||||
}
|
||||
current, err := repo.GetTask(ctx, "task-pg-title-race")
|
||||
if err != nil {
|
||||
t.Fatalf("reload stale-update task: %v", err)
|
||||
}
|
||||
if current.Title != "Agent chosen title" || current.Description != "updated concurrently" || current.Metadata["stale_change"] != "retained" {
|
||||
t.Fatalf("stale update result = title %q description %q metadata %#v", current.Title, current.Description, current.Metadata)
|
||||
}
|
||||
if _, pending := current.Metadata[models.MetaKeyAgentTitlePending]; pending {
|
||||
t.Fatalf("pending marker restored by stale update: %#v", current.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresImproveKandevWorkflowIndexMigration(t *testing.T) {
|
||||
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
|
||||
repo, err := NewWithDB(db, db, nil)
|
||||
|
||||
@@ -311,7 +311,7 @@ func (r *Repository) insertTaskTx(ctx context.Context, tx *sql.Tx, task *models.
|
||||
_, err = tx.ExecContext(ctx, r.db.Rebind(`
|
||||
INSERT INTO tasks (id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, wip_admitted, queued_for_step_id, queued_at, metadata, is_ephemeral, parent_id, created_at, updated_at, origin, project_id, labels, identifier)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`), task.ID, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, task.WIPAdmitted, task.QueuedForStepID, task.QueuedAt, string(metadata), task.IsEphemeral, task.ParentID, task.CreatedAt, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier)
|
||||
`), task.ID, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), dialect.BoolToInt(task.IsEphemeral), task.ParentID, task.CreatedAt, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -460,10 +460,24 @@ func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error {
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
result, err := tx.ExecContext(ctx, r.db.Rebind(`
|
||||
updateQuery := `
|
||||
UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, wip_admitted = ?, queued_for_step_id = ?, queued_at = ?, metadata = ?, parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ?
|
||||
WHERE id = ?
|
||||
`), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, task.WIPAdmitted, task.QueuedForStepID, task.QueuedAt, string(metadata), task.ParentID, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, task.ID)
|
||||
`
|
||||
if models.IsAgentTitlePending(task.Metadata) {
|
||||
pending := agentTitlePendingPredicate(r.db.DriverName())
|
||||
metadataMerge := pendingTaskMetadataMergeExpression(r.db.DriverName())
|
||||
updateQuery = fmt.Sprintf(`
|
||||
UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?,
|
||||
title = CASE WHEN %s THEN ? ELSE title END,
|
||||
description = ?, state = ?, priority = ?, position = ?, wip_admitted = ?,
|
||||
queued_for_step_id = ?, queued_at = ?,
|
||||
metadata = %s,
|
||||
parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ?
|
||||
WHERE id = ?
|
||||
`, pending, metadataMerge)
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, r.db.Rebind(updateQuery), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), task.ParentID, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, task.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -507,6 +521,42 @@ func (r *Repository) RemoveTaskMetadataKey(ctx context.Context, taskID, key stri
|
||||
return rows > 0, err
|
||||
}
|
||||
|
||||
// SetTaskTitleIfPending replaces a provisional title and removes its pending
|
||||
// marker in one conditional write. The compare-and-set prevents two agent
|
||||
// sessions (or a late agent call racing a human rename) from both winning.
|
||||
func (r *Repository) SetTaskTitleIfPending(ctx context.Context, taskID, title string) (bool, error) {
|
||||
var query string
|
||||
if dialect.IsPostgres(r.db.DriverName()) {
|
||||
query = `
|
||||
UPDATE tasks
|
||||
SET title = ?,
|
||||
metadata = (CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END #- ARRAY[?]::text[])::text,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND jsonb_extract_path(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END, ?) = 'true'::jsonb
|
||||
`
|
||||
} else {
|
||||
query = `
|
||||
UPDATE tasks
|
||||
SET title = ?,
|
||||
metadata = json_remove(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?),
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND json_type(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?) = 'true'
|
||||
`
|
||||
}
|
||||
path := jsonPath(models.MetaKeyAgentTitlePending)
|
||||
if dialect.IsPostgres(r.db.DriverName()) {
|
||||
path = models.MetaKeyAgentTitlePending
|
||||
}
|
||||
result, err := r.db.ExecContext(ctx, r.db.Rebind(query), title, path, time.Now().UTC(), taskID, path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
return rows > 0, err
|
||||
}
|
||||
|
||||
// SetTaskMetadataKey updates one metadata key without replacing concurrent
|
||||
// task fields. It is used to restore a deferred launch after a failed launch.
|
||||
func (r *Repository) SetTaskMetadataKey(ctx context.Context, taskID, key string, value interface{}) error {
|
||||
@@ -530,6 +580,20 @@ func (r *Repository) SetTaskMetadataKey(ctx context.Context, taskID, key string,
|
||||
|
||||
func jsonPath(key string) string { return "$." + key }
|
||||
|
||||
func agentTitlePendingPredicate(driver string) string {
|
||||
if dialect.IsPostgres(driver) {
|
||||
return "jsonb_extract_path(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END, 'agent_title_pending') = 'true'::jsonb"
|
||||
}
|
||||
return "json_type(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, '$.agent_title_pending') = 'true'"
|
||||
}
|
||||
|
||||
func pendingTaskMetadataMergeExpression(driver string) string {
|
||||
if dialect.IsPostgres(driver) {
|
||||
return "(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END || (?::jsonb - 'agent_title_pending'))::text"
|
||||
}
|
||||
return "json_patch(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, json_remove(?, '$.agent_title_pending'))"
|
||||
}
|
||||
|
||||
// DetachTask clears only the hierarchy fields involved in detachment. Keeping
|
||||
// this as a targeted update prevents concurrent task edits from being replaced
|
||||
// by a stale full-row write.
|
||||
@@ -632,7 +696,7 @@ func (r *Repository) UpdateTaskIfWorkflowStepHasCapacity(ctx context.Context, ta
|
||||
result, err := tx.ExecContext(ctx, r.db.Rebind(`
|
||||
UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, wip_admitted = ?, queued_for_step_id = ?, queued_at = ?, metadata = ?, parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ?
|
||||
WHERE id = ?
|
||||
`), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, task.WIPAdmitted, task.QueuedForStepID, task.QueuedAt, string(metadata), task.ParentID, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, task.ID)
|
||||
`), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), task.ParentID, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, task.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -695,7 +759,7 @@ func (r *Repository) PromoteQueuedTaskIfWorkflowStepHasCapacity(
|
||||
AND (queued_for_step_id = ? OR queued_for_step_id = '' OR queued_for_step_id IS NULL)
|
||||
AND archived_at IS NULL
|
||||
AND is_ephemeral = 0
|
||||
`), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, task.WIPAdmitted, task.QueuedForStepID, task.QueuedAt, string(metadata), task.ParentID, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, task.ID, fromStepID, destinationStepID)
|
||||
`), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), task.ParentID, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, task.ID, fromStepID, destinationStepID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetTaskTitleIfPendingRequiresTrueMarker(t *testing.T) {
|
||||
repo := newRepoForHealTests(t)
|
||||
ctx := context.Background()
|
||||
insertTask(t, repo.db, "task-title-false")
|
||||
if _, err := repo.db.ExecContext(ctx, `
|
||||
UPDATE tasks SET title = ?, metadata = ? WHERE id = ?
|
||||
`, "Provisional title", `{"agent_title_pending":false,"keep":"value"}`, "task-title-false"); err != nil {
|
||||
t.Fatalf("seed false pending marker: %v", err)
|
||||
}
|
||||
|
||||
accepted, err := repo.SetTaskTitleIfPending(ctx, "task-title-false", "Agent title")
|
||||
if err != nil {
|
||||
t.Fatalf("SetTaskTitleIfPending: %v", err)
|
||||
}
|
||||
if accepted {
|
||||
t.Fatal("accepted title update with a false pending marker")
|
||||
}
|
||||
task, err := repo.GetTask(ctx, "task-title-false")
|
||||
if err != nil {
|
||||
t.Fatalf("reload task: %v", err)
|
||||
}
|
||||
if task.Title != "Provisional title" {
|
||||
t.Fatalf("title = %q, want unchanged provisional title", task.Title)
|
||||
}
|
||||
if pending, ok := task.Metadata["agent_title_pending"].(bool); !ok || pending {
|
||||
t.Fatalf("pending marker = %#v, want explicit false", task.Metadata["agent_title_pending"])
|
||||
}
|
||||
if task.Metadata["keep"] != "value" {
|
||||
t.Fatalf("metadata = %#v, want unrelated key preserved", task.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTaskPreservesWinningTitleAgainstStaleUpdate(t *testing.T) {
|
||||
repo := newRepoForHealTests(t)
|
||||
ctx := context.Background()
|
||||
insertTask(t, repo.db, "task-title-race")
|
||||
if _, err := repo.db.ExecContext(ctx, `
|
||||
UPDATE tasks SET title = ?, metadata = ? WHERE id = ?
|
||||
`, "Six word provisional title", `{"agent_title_pending":true}`, "task-title-race"); err != nil {
|
||||
t.Fatalf("seed pending title: %v", err)
|
||||
}
|
||||
|
||||
stale, err := repo.GetTask(ctx, "task-title-race")
|
||||
if err != nil {
|
||||
t.Fatalf("load stale task: %v", err)
|
||||
}
|
||||
accepted, err := repo.SetTaskTitleIfPending(ctx, "task-title-race", "Agent chosen title")
|
||||
if err != nil {
|
||||
t.Fatalf("SetTaskTitleIfPending: %v", err)
|
||||
}
|
||||
if !accepted {
|
||||
t.Fatal("title CAS did not win")
|
||||
}
|
||||
|
||||
// This models an ordinary task update that started before the CAS and
|
||||
// writes after it. Its stale title/marker must not overwrite the winner.
|
||||
stale.Description = "updated concurrently"
|
||||
stale.Metadata["stale_change"] = "retained"
|
||||
if err := repo.UpdateTask(ctx, stale); err != nil {
|
||||
t.Fatalf("stale UpdateTask: %v", err)
|
||||
}
|
||||
|
||||
current, err := repo.GetTask(ctx, "task-title-race")
|
||||
if err != nil {
|
||||
t.Fatalf("reload task: %v", err)
|
||||
}
|
||||
if current.Title != "Agent chosen title" {
|
||||
t.Fatalf("title = %q, want CAS winner", current.Title)
|
||||
}
|
||||
if _, pending := current.Metadata["agent_title_pending"]; pending {
|
||||
t.Fatalf("pending marker restored by stale update: %#v", current.Metadata)
|
||||
}
|
||||
if current.Description != "updated concurrently" {
|
||||
t.Fatalf("description = %q, want stale update to retain its unrelated change", current.Description)
|
||||
}
|
||||
if current.Metadata["stale_change"] != "retained" {
|
||||
t.Fatalf("metadata = %#v, want unrelated stale metadata change retained", current.Metadata)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ type CreateTaskRequest struct {
|
||||
WorkflowStepID string `json:"workflow_step_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
AutoTitle bool `json:"auto_title,omitempty"`
|
||||
Priority string `json:"priority"`
|
||||
State *v1.TaskState `json:"state,omitempty"`
|
||||
Repositories []TaskRepositoryInput `json:"repositories,omitempty"`
|
||||
|
||||
@@ -54,6 +54,19 @@ var ErrInvalidTaskWorkflow = errors.New("invalid task workflow")
|
||||
// aborting the whole operation.
|
||||
var ErrTaskAlreadyArchived = errors.New("task is already archived")
|
||||
|
||||
// ErrAutoTitlePromptRequired is returned when prompt-first creation has no
|
||||
// prompt from which to derive a provisional title.
|
||||
var ErrAutoTitlePromptRequired = errors.New("description is required when auto_title is enabled")
|
||||
|
||||
// ErrAutoTitleUnsupportedForOffice is returned when prompt-first title
|
||||
// generation is requested for an Office task. Office agents use a restricted
|
||||
// MCP surface that does not expose the one-shot title tool.
|
||||
var ErrAutoTitleUnsupportedForOffice = errors.New("auto_title is not supported for Office tasks")
|
||||
|
||||
type pendingTaskTitleSetter interface {
|
||||
SetTaskTitleIfPending(ctx context.Context, taskID, title string) (bool, error)
|
||||
}
|
||||
|
||||
type taskStopTarget struct {
|
||||
sessionID string
|
||||
executionID string
|
||||
@@ -107,6 +120,9 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*mode
|
||||
if err := s.authorizeWorkspaceID(ctx, req.WorkspaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := prepareAutoTitle(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateCreateTaskRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -179,6 +195,38 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*mode
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func deriveProvisionalTaskTitle(description string) (string, error) {
|
||||
words := strings.Fields(description)
|
||||
if len(words) == 0 {
|
||||
return "", ErrAutoTitlePromptRequired
|
||||
}
|
||||
if len(words) > 6 {
|
||||
words = words[:6]
|
||||
}
|
||||
title := strings.Join(words, " ")
|
||||
title = TruncateTaskTitle(title)
|
||||
return title, nil
|
||||
}
|
||||
|
||||
func prepareAutoTitle(req *CreateTaskRequest) error {
|
||||
if !req.AutoTitle {
|
||||
return nil
|
||||
}
|
||||
if isOfficeRequest(req) {
|
||||
return ErrAutoTitleUnsupportedForOffice
|
||||
}
|
||||
title, err := deriveProvisionalTaskTitle(req.Description)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Title = title
|
||||
if req.Metadata == nil {
|
||||
req.Metadata = make(map[string]interface{})
|
||||
}
|
||||
req.Metadata[models.MetaKeyAgentTitlePending] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) pullTasksFromNewFeederWork(ctx context.Context, workflowID, feederStepID string) {
|
||||
stepLister, ok := s.workflowStepGetter.(workflowStepLister)
|
||||
if !ok || workflowID == "" || feederStepID == "" {
|
||||
@@ -1141,9 +1189,6 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ
|
||||
var oldState *v1.TaskState
|
||||
stateChanged := false
|
||||
|
||||
if req.Title != nil {
|
||||
task.Title = *req.Title
|
||||
}
|
||||
if req.Description != nil {
|
||||
task.Description = *req.Description
|
||||
}
|
||||
@@ -1165,6 +1210,12 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ
|
||||
if req.Metadata != nil {
|
||||
task.Metadata = req.Metadata
|
||||
}
|
||||
if req.Title != nil {
|
||||
task.Title = *req.Title
|
||||
if task.Metadata != nil {
|
||||
delete(task.Metadata, models.MetaKeyAgentTitlePending)
|
||||
}
|
||||
}
|
||||
parentCleared := false
|
||||
if req.ParentID != nil && *req.ParentID != task.ParentID {
|
||||
if err := s.resolveParentID(ctx, task, *req.ParentID); err != nil {
|
||||
@@ -1179,6 +1230,10 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ
|
||||
s.logger.Error("failed to update task", zap.String("task_id", id), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
// UpdateTask may have applied a conditional title/metadata patch because
|
||||
// this snapshot was stale. Publish and return the row that actually won so
|
||||
// callers never receive the provisional title or pending marker again.
|
||||
task = s.reloadTaskAfterMutation(ctx, id, task, "update")
|
||||
|
||||
// Update task repositories if provided
|
||||
if req.Repositories != nil {
|
||||
@@ -1212,6 +1267,70 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *Service) reloadTaskAfterMutation(ctx context.Context, id string, fallback *models.Task, operation string) *models.Task {
|
||||
current, err := s.tasks.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to reload task after mutation",
|
||||
zap.String("task_id", id), zap.String("operation", operation), zap.Error(err))
|
||||
return fallback
|
||||
}
|
||||
if current != nil {
|
||||
return current
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// SetPendingAgentTitle replaces a prompt-first provisional title exactly once.
|
||||
// A missing pending marker is an idempotent no-op so a human rename or an
|
||||
// earlier agent call always wins a late request.
|
||||
func (s *Service) SetPendingAgentTitle(ctx context.Context, id, title string) (*models.Task, bool, error) {
|
||||
if err := s.authorizeTaskID(ctx, id); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
task, err := s.tasks.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !models.IsAgentTitlePending(task.Metadata) {
|
||||
return task, false, nil
|
||||
}
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return nil, false, errors.New("title is required")
|
||||
}
|
||||
if err := validateTaskTitle(title); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if setter, ok := s.tasks.(pendingTaskTitleSetter); ok {
|
||||
accepted, err := setter.SetTaskTitleIfPending(ctx, id, title)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to set pending agent title", zap.String("task_id", id), zap.Error(err))
|
||||
return nil, false, err
|
||||
}
|
||||
if !accepted {
|
||||
current, getErr := s.tasks.GetTask(ctx, id)
|
||||
if getErr != nil {
|
||||
return nil, false, getErr
|
||||
}
|
||||
return current, false, nil
|
||||
}
|
||||
// Reload the winning row so the response/event includes any ordinary
|
||||
// task update that raced the conditional title write.
|
||||
task = s.reloadTaskAfterMutation(ctx, id, task, "set pending agent title")
|
||||
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil)
|
||||
return task, true, nil
|
||||
}
|
||||
task.Title = title
|
||||
delete(task.Metadata, models.MetaKeyAgentTitlePending)
|
||||
task.UpdatedAt = time.Now().UTC()
|
||||
if err := s.tasks.UpdateTask(ctx, task); err != nil {
|
||||
s.logger.Error("failed to set pending agent title", zap.String("task_id", id), zap.Error(err))
|
||||
return nil, false, err
|
||||
}
|
||||
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil)
|
||||
return task, true, nil
|
||||
}
|
||||
|
||||
// parentChainWalkLimit bounds the ancestor walk in resolveParentID so a
|
||||
// corrupted parent chain can never spin forever. Real subtask trees are
|
||||
// nowhere near this depth.
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -9,6 +10,169 @@ import (
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
func TestDeriveProvisionalTaskTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
description string
|
||||
want string
|
||||
}{
|
||||
{name: "first six normalized words", description: " Ship\n this task with a concise title now ", want: "Ship this task with a concise"},
|
||||
{name: "short prompt uses every word", description: " Fix login flow ", want: "Fix login flow"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := deriveProvisionalTaskTitle(tt.description)
|
||||
if err != nil {
|
||||
t.Fatalf("derive provisional title: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("title = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := deriveProvisionalTaskTitle(" \n\t "); !errors.Is(err, ErrAutoTitlePromptRequired) {
|
||||
t.Fatalf("empty prompt error = %v, want %v", err, ErrAutoTitlePromptRequired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareAutoTitleRejectsOfficeRequests(t *testing.T) {
|
||||
err := prepareAutoTitle(&CreateTaskRequest{
|
||||
ProjectID: "office-project",
|
||||
AutoTitle: true,
|
||||
Description: "Create an Office task",
|
||||
})
|
||||
if !errors.Is(err, ErrAutoTitleUnsupportedForOffice) {
|
||||
t.Fatalf("prepare office auto title error = %v, want %v", err, ErrAutoTitleUnsupportedForOffice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskAutoTitlePersistsPendingMarker(t *testing.T) {
|
||||
svc, eventBus, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-auto-title", Name: "Workspace"}); err != nil {
|
||||
t.Fatalf("create workspace: %v", err)
|
||||
}
|
||||
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-auto-title", WorkspaceID: "ws-auto-title", Name: "Workflow"}); err != nil {
|
||||
t.Fatalf("create workflow: %v", err)
|
||||
}
|
||||
|
||||
task, err := svc.CreateTask(ctx, &CreateTaskRequest{
|
||||
WorkspaceID: "ws-auto-title",
|
||||
WorkflowID: "wf-auto-title",
|
||||
Description: " Add\n a better task title for this request ",
|
||||
AutoTitle: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create auto-titled task: %v", err)
|
||||
}
|
||||
if task.Title != "Add a better task title for" {
|
||||
t.Fatalf("title = %q, want first six words", task.Title)
|
||||
}
|
||||
if !models.IsAgentTitlePending(task.Metadata) {
|
||||
t.Fatalf("metadata = %#v, want pending agent title", task.Metadata)
|
||||
}
|
||||
if len(eventBus.GetPublishedEvents()) != 1 || eventBus.GetPublishedEvents()[0].Type != "task.created" {
|
||||
t.Fatalf("events = %#v, want one task.created event", eventBus.GetPublishedEvents())
|
||||
}
|
||||
reloaded, err := svc.GetTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload auto-titled task: %v", err)
|
||||
}
|
||||
if !models.IsAgentTitlePending(reloaded.Metadata) {
|
||||
t.Fatalf("reloaded metadata = %#v, want pending marker", reloaded.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskAutoTitleSupportsSubtasks(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-auto-subtask", Name: "Workspace"}); err != nil {
|
||||
t.Fatalf("create workspace: %v", err)
|
||||
}
|
||||
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-auto-subtask", WorkspaceID: "ws-auto-subtask", Name: "Workflow"}); err != nil {
|
||||
t.Fatalf("create workflow: %v", err)
|
||||
}
|
||||
parent, err := svc.CreateTask(ctx, &CreateTaskRequest{
|
||||
WorkspaceID: "ws-auto-subtask",
|
||||
WorkflowID: "wf-auto-subtask",
|
||||
Title: "Parent task",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create parent task: %v", err)
|
||||
}
|
||||
child, err := svc.CreateTask(ctx, &CreateTaskRequest{
|
||||
WorkspaceID: "ws-auto-subtask",
|
||||
WorkflowID: "wf-auto-subtask",
|
||||
ParentID: parent.ID,
|
||||
Description: "Investigate child behavior",
|
||||
AutoTitle: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create auto-titled subtask: %v", err)
|
||||
}
|
||||
if child.ParentID != parent.ID || child.Title != "Investigate child behavior" || !models.IsAgentTitlePending(child.Metadata) {
|
||||
t.Fatalf("child = %#v, want parent, title, and pending marker", child)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPendingAgentTitleIsOneShotAndHumanRenameWins(t *testing.T) {
|
||||
svc, eventBus, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-agent-title", Name: "Workspace"}); err != nil {
|
||||
t.Fatalf("create workspace: %v", err)
|
||||
}
|
||||
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-agent-title", WorkspaceID: "ws-agent-title", Name: "Workflow"}); err != nil {
|
||||
t.Fatalf("create workflow: %v", err)
|
||||
}
|
||||
task, err := svc.CreateTask(ctx, &CreateTaskRequest{
|
||||
WorkspaceID: "ws-agent-title",
|
||||
WorkflowID: "wf-agent-title",
|
||||
Title: "Temporary prompt title",
|
||||
Description: "Do the work",
|
||||
Metadata: map[string]interface{}{models.MetaKeyAgentTitlePending: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create pending task: %v", err)
|
||||
}
|
||||
eventBus.ClearEvents()
|
||||
|
||||
updated, accepted, err := svc.SetPendingAgentTitle(ctx, task.ID, "Ship login fix")
|
||||
if err != nil || !accepted {
|
||||
t.Fatalf("set pending title = (%v, %v), want accepted", updated, err)
|
||||
}
|
||||
if updated.Title != "Ship login fix" || models.IsAgentTitlePending(updated.Metadata) {
|
||||
t.Fatalf("updated task = %#v, want resolved title and marker removed", updated)
|
||||
}
|
||||
if len(eventBus.GetPublishedEvents()) != 1 || eventBus.GetPublishedEvents()[0].Type != "task.updated" {
|
||||
t.Fatalf("events = %#v, want one task.updated event", eventBus.GetPublishedEvents())
|
||||
}
|
||||
_, accepted, err = svc.SetPendingAgentTitle(ctx, task.ID, "Late overwrite")
|
||||
if err != nil || accepted {
|
||||
t.Fatalf("second set = (accepted=%v, err=%v), want idempotent rejection", accepted, err)
|
||||
}
|
||||
|
||||
pendingTask, err := svc.CreateTask(ctx, &CreateTaskRequest{
|
||||
WorkspaceID: "ws-agent-title",
|
||||
WorkflowID: "wf-agent-title",
|
||||
Title: "Temporary again",
|
||||
Description: "Do more work",
|
||||
Metadata: map[string]interface{}{models.MetaKeyAgentTitlePending: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create second pending task: %v", err)
|
||||
}
|
||||
humanTitle := "Human chosen title"
|
||||
if _, err := svc.UpdateTask(ctx, pendingTask.ID, &UpdateTaskRequest{Title: &humanTitle}); err != nil {
|
||||
t.Fatalf("human rename: %v", err)
|
||||
}
|
||||
updated, accepted, err = svc.SetPendingAgentTitle(ctx, pendingTask.ID, "Agent overwrite")
|
||||
if err != nil || accepted || updated.Title != humanTitle {
|
||||
t.Fatalf("late agent rename = (%v, %v, %v), want human title preserved", updated.Title, accepted, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPerformTaskCleanup_QuickChatDir verifies that performTaskCleanup removes
|
||||
// quick-chat workspace directories for both ephemeral and non-ephemeral tasks,
|
||||
// and that tasks with no directory on disk produce no error.
|
||||
|
||||
@@ -60,6 +60,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS
|
||||
ReviewAutoMarkOnScroll: req.ReviewAutoMarkOnScroll,
|
||||
ConfirmTaskArchive: req.ConfirmTaskArchive,
|
||||
UnreadDivider: req.UnreadDivider,
|
||||
AgentGeneratedTaskTitles: req.AgentGeneratedTaskTitles,
|
||||
MCPTaskAgentProfileDefault: req.MCPTaskAgentProfileDefault,
|
||||
ShowAnchoredPromptBar: req.ShowAnchoredPromptBar,
|
||||
ShowScrollToLastPrompt: req.ShowScrollToLastPrompt,
|
||||
|
||||
@@ -32,6 +32,7 @@ type UserSettingsDTO struct {
|
||||
ReviewAutoMarkOnScroll bool `json:"review_auto_mark_on_scroll"`
|
||||
ConfirmTaskArchive bool `json:"confirm_task_archive"`
|
||||
UnreadDivider bool `json:"unread_divider"`
|
||||
AgentGeneratedTaskTitles bool `json:"agent_generated_task_titles"`
|
||||
MCPTaskAgentProfileDefault string `json:"mcp_task_agent_profile_default"`
|
||||
ShowAnchoredPromptBar bool `json:"show_anchored_prompt_bar"`
|
||||
ShowScrollToLastPrompt bool `json:"show_scroll_to_last_prompt"`
|
||||
@@ -98,6 +99,7 @@ type UpdateUserSettingsRequest struct {
|
||||
ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll,omitempty"`
|
||||
ConfirmTaskArchive *bool `json:"confirm_task_archive,omitempty"`
|
||||
UnreadDivider *bool `json:"unread_divider,omitempty"`
|
||||
AgentGeneratedTaskTitles *bool `json:"agent_generated_task_titles,omitempty"`
|
||||
MCPTaskAgentProfileDefault *string `json:"mcp_task_agent_profile_default,omitempty"`
|
||||
ShowAnchoredPromptBar *bool `json:"show_anchored_prompt_bar,omitempty"`
|
||||
ShowScrollToLastPrompt *bool `json:"show_scroll_to_last_prompt,omitempty"`
|
||||
@@ -225,6 +227,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO {
|
||||
ReviewAutoMarkOnScroll: settings.ReviewAutoMarkOnScroll,
|
||||
ConfirmTaskArchive: settings.ConfirmTaskArchive,
|
||||
UnreadDivider: settings.UnreadDivider,
|
||||
AgentGeneratedTaskTitles: settings.AgentGeneratedTaskTitles,
|
||||
MCPTaskAgentProfileDefault: models.NormalizeMCPTaskAgentProfileDefault(settings.MCPTaskAgentProfileDefault),
|
||||
ShowAnchoredPromptBar: settings.ShowAnchoredPromptBar,
|
||||
ShowScrollToLastPrompt: settings.ShowScrollToLastPrompt,
|
||||
|
||||
@@ -93,6 +93,36 @@ func TestFromUserSettingsIncludesArchiveConfirmation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentGeneratedTaskTitlesDTOAndPatchSemantics(t *testing.T) {
|
||||
if !FromUserSettings(&models.UserSettings{AgentGeneratedTaskTitles: true}).AgentGeneratedTaskTitles {
|
||||
t.Fatal("AgentGeneratedTaskTitles = false, want true")
|
||||
}
|
||||
|
||||
var omitted UpdateUserSettingsRequest
|
||||
if err := json.Unmarshal([]byte(`{}`), &omitted); err != nil {
|
||||
t.Fatalf("decode omitted request: %v", err)
|
||||
}
|
||||
if omitted.AgentGeneratedTaskTitles != nil {
|
||||
t.Fatalf("AgentGeneratedTaskTitles = %#v, want nil for omitted field", omitted.AgentGeneratedTaskTitles)
|
||||
}
|
||||
|
||||
var explicit UpdateUserSettingsRequest
|
||||
if err := json.Unmarshal([]byte(`{"agent_generated_task_titles":true}`), &explicit); err != nil {
|
||||
t.Fatalf("decode explicit request: %v", err)
|
||||
}
|
||||
if explicit.AgentGeneratedTaskTitles == nil || !*explicit.AgentGeneratedTaskTitles {
|
||||
t.Fatalf("AgentGeneratedTaskTitles = %#v, want true", explicit.AgentGeneratedTaskTitles)
|
||||
}
|
||||
|
||||
var disabled UpdateUserSettingsRequest
|
||||
if err := json.Unmarshal([]byte(`{"agent_generated_task_titles":false}`), &disabled); err != nil {
|
||||
t.Fatalf("decode explicit false request: %v", err)
|
||||
}
|
||||
if disabled.AgentGeneratedTaskTitles == nil || *disabled.AgentGeneratedTaskTitles {
|
||||
t.Fatalf("AgentGeneratedTaskTitles = %#v, want false", disabled.AgentGeneratedTaskTitles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromUserSettingsIncludesNormalizedMCPTaskAgentProfileDefault(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -72,6 +72,7 @@ type UserSettings struct {
|
||||
ReviewAutoMarkOnScroll bool `json:"review_auto_mark_on_scroll"`
|
||||
ConfirmTaskArchive bool `json:"confirm_task_archive"`
|
||||
UnreadDivider bool `json:"unread_divider"`
|
||||
AgentGeneratedTaskTitles bool `json:"agent_generated_task_titles"`
|
||||
MCPTaskAgentProfileDefault string `json:"mcp_task_agent_profile_default"`
|
||||
ShowAnchoredPromptBar bool `json:"show_anchored_prompt_bar"` // desktop-only sticky last-prompt bar
|
||||
ShowScrollToLastPrompt bool `json:"show_scroll_to_last_prompt"`
|
||||
|
||||
@@ -51,6 +51,7 @@ type UpdateUserSettingsRequest struct {
|
||||
ReviewAutoMarkOnScroll *bool
|
||||
ConfirmTaskArchive *bool
|
||||
UnreadDivider *bool
|
||||
AgentGeneratedTaskTitles *bool
|
||||
MCPTaskAgentProfileDefault *string
|
||||
ShowAnchoredPromptBar *bool
|
||||
ShowScrollToLastPrompt *bool
|
||||
@@ -285,6 +286,9 @@ func applyTaskActionPreferences(settings *models.UserSettings, req *UpdateUserSe
|
||||
if req.UnreadDivider != nil {
|
||||
settings.UnreadDivider = *req.UnreadDivider
|
||||
}
|
||||
if req.AgentGeneratedTaskTitles != nil {
|
||||
settings.AgentGeneratedTaskTitles = *req.AgentGeneratedTaskTitles
|
||||
}
|
||||
if err := applyMCPTaskAgentProfileDefault(settings, req.MCPTaskAgentProfileDefault); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -680,6 +684,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models
|
||||
"review_auto_mark_on_scroll": settings.ReviewAutoMarkOnScroll,
|
||||
"confirm_task_archive": settings.ConfirmTaskArchive,
|
||||
"unread_divider": settings.UnreadDivider,
|
||||
"agent_generated_task_titles": settings.AgentGeneratedTaskTitles,
|
||||
"mcp_task_agent_profile_default": models.NormalizeMCPTaskAgentProfileDefault(settings.MCPTaskAgentProfileDefault),
|
||||
"show_anchored_prompt_bar": settings.ShowAnchoredPromptBar,
|
||||
"show_scroll_to_last_prompt": settings.ShowScrollToLastPrompt,
|
||||
|
||||
@@ -183,6 +183,30 @@ func TestApplyBasicSettings_ConfirmTaskArchive(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyBasicSettingsAgentGeneratedTaskTitles(t *testing.T) {
|
||||
settings := &models.UserSettings{AgentGeneratedTaskTitles: false}
|
||||
if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{}); err != nil {
|
||||
t.Fatalf("apply omitted setting: %v", err)
|
||||
}
|
||||
if settings.AgentGeneratedTaskTitles {
|
||||
t.Fatal("AgentGeneratedTaskTitles changed on omitted patch")
|
||||
}
|
||||
|
||||
if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{AgentGeneratedTaskTitles: ptr(true)}); err != nil {
|
||||
t.Fatalf("apply enabled setting: %v", err)
|
||||
}
|
||||
if !settings.AgentGeneratedTaskTitles {
|
||||
t.Fatal("AgentGeneratedTaskTitles = false, want true")
|
||||
}
|
||||
|
||||
if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{AgentGeneratedTaskTitles: ptr(false)}); err != nil {
|
||||
t.Fatalf("apply disabled setting: %v", err)
|
||||
}
|
||||
if settings.AgentGeneratedTaskTitles {
|
||||
t.Fatal("AgentGeneratedTaskTitles = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBasicSettingsAppStatusBarOrder(t *testing.T) {
|
||||
saved := models.AppStatusBarOrder{
|
||||
LeftItemIDs: []string{"builtin:connection"},
|
||||
|
||||
@@ -460,6 +460,7 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) {
|
||||
"review_auto_mark_on_scroll": settings.ReviewAutoMarkOnScroll,
|
||||
"confirm_task_archive": settings.ConfirmTaskArchive,
|
||||
"unread_divider": settings.UnreadDivider,
|
||||
"agent_generated_task_titles": settings.AgentGeneratedTaskTitles,
|
||||
"mcp_task_agent_profile_default": models.NormalizeMCPTaskAgentProfileDefault(settings.MCPTaskAgentProfileDefault),
|
||||
"show_anchored_prompt_bar": settings.ShowAnchoredPromptBar,
|
||||
"show_scroll_to_last_prompt": settings.ShowScrollToLastPrompt,
|
||||
@@ -579,6 +580,7 @@ func defaultUserSettings(userID string) *models.UserSettings {
|
||||
ReviewAutoMarkOnScroll: true,
|
||||
ConfirmTaskArchive: true,
|
||||
UnreadDivider: false,
|
||||
AgentGeneratedTaskTitles: false,
|
||||
MCPTaskAgentProfileDefault: models.MCPTaskAgentProfileDefaultCurrentTask,
|
||||
ShowAnchoredPromptBar: false,
|
||||
ShowScrollToLastPrompt: true,
|
||||
@@ -626,6 +628,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
|
||||
ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll"`
|
||||
ConfirmTaskArchive *bool `json:"confirm_task_archive"`
|
||||
UnreadDivider *bool `json:"unread_divider"`
|
||||
AgentGeneratedTaskTitles *bool `json:"agent_generated_task_titles"`
|
||||
MCPTaskAgentProfileDefault string `json:"mcp_task_agent_profile_default"`
|
||||
ShowAnchoredPromptBar *bool `json:"show_anchored_prompt_bar"`
|
||||
ShowScrollToLastPrompt *bool `json:"show_scroll_to_last_prompt"`
|
||||
@@ -688,6 +691,9 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
|
||||
if payload.UnreadDivider != nil {
|
||||
settings.UnreadDivider = *payload.UnreadDivider
|
||||
}
|
||||
if payload.AgentGeneratedTaskTitles != nil {
|
||||
settings.AgentGeneratedTaskTitles = *payload.AgentGeneratedTaskTitles
|
||||
}
|
||||
settings.MCPTaskAgentProfileDefault = models.NormalizeMCPTaskAgentProfileDefault(payload.MCPTaskAgentProfileDefault)
|
||||
if payload.ShowAnchoredPromptBar != nil {
|
||||
settings.ShowAnchoredPromptBar = *payload.ShowAnchoredPromptBar
|
||||
|
||||
@@ -150,6 +150,31 @@ func TestScanUserSettingsUnreadDividerDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanUserSettingsAgentGeneratedTaskTitlesDefault(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{name: "empty settings disable title generation", raw: `{}`, want: false},
|
||||
{name: "missing setting disables title generation", raw: `{"chat_submit_key":"enter"}`, want: false},
|
||||
{name: "explicit false disables title generation", raw: `{"agent_generated_task_titles":false}`, want: false},
|
||||
{name: "explicit true enables title generation", raw: `{"agent_generated_task_titles":true}`, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
settings, err := scanUserSettings(settingsScanner{raw: tt.raw}, DefaultUserID)
|
||||
if err != nil {
|
||||
t.Fatalf("scan settings: %v", err)
|
||||
}
|
||||
if settings.AgentGeneratedTaskTitles != tt.want {
|
||||
t.Fatalf("AgentGeneratedTaskTitles = %v, want %v", settings.AgentGeneratedTaskTitles, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanUserSettingsMCPTaskAgentProfileDefault(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -372,6 +372,7 @@ const (
|
||||
ActionMCPDeleteWalkthrough = "mcp.delete_walkthrough"
|
||||
ActionMCPPublishReviewFindings = "mcp.publish_review_findings"
|
||||
ActionMCPClarificationTimeout = "mcp.clarification_timeout"
|
||||
ActionMCPSetTaskTitle = "mcp.set_task_title"
|
||||
|
||||
// Office task handoffs (cross-task context).
|
||||
ActionMCPListRelatedTasks = "mcp.list_related_tasks"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { defaultState } from "@/lib/state/default-state";
|
||||
import { SettingsSaveProvider } from "./settings-save-provider";
|
||||
|
||||
const updateUserSettings = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
updateUserSettings: (...args: unknown[]) => updateUserSettings(...args),
|
||||
}));
|
||||
|
||||
import { AgentGeneratedTaskTitleSettings } from "./agent-generated-task-title-settings";
|
||||
|
||||
function renderSettings(agentGeneratedTaskTitles = false) {
|
||||
return render(
|
||||
<StateProvider
|
||||
initialState={{
|
||||
userSettings: { ...defaultState.userSettings, agentGeneratedTaskTitles },
|
||||
}}
|
||||
>
|
||||
<SettingsSaveProvider>
|
||||
<AgentGeneratedTaskTitleSettings />
|
||||
</SettingsSaveProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
updateUserSettings.mockReset().mockResolvedValue({ settings: {} });
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("AgentGeneratedTaskTitleSettings", () => {
|
||||
it("defaults to disabled and explains the prompt-first behavior", () => {
|
||||
renderSettings();
|
||||
|
||||
const toggle = screen.getByRole("switch", { name: "Use the agent for new task titles" });
|
||||
expect(toggle.getAttribute("data-state")).toBe("unchecked");
|
||||
expect(screen.getByText(/first six words/i)).toBeTruthy();
|
||||
expect(screen.getByText(/targeting about three words/i)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/existing and edited tasks keep their normal title field/i),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the draft local until Save changes is pressed", async () => {
|
||||
renderSettings();
|
||||
const toggle = screen.getByRole("switch", { name: "Use the agent for new task titles" });
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(updateUserSettings).not.toHaveBeenCalled();
|
||||
expect(toggle.getAttribute("data-state")).toBe("checked");
|
||||
expect(
|
||||
screen.getByTestId("agent-generated-task-title-card").getAttribute("data-settings-dirty"),
|
||||
).toBe("true");
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Save changes" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateUserSettings).toHaveBeenCalledWith({ agent_generated_task_titles: true }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId("agent-generated-task-title-card").getAttribute("data-settings-dirty"),
|
||||
).toBe("false"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Switch } from "@kandev/ui/switch";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import { updateUserSettings } from "@/lib/api";
|
||||
import { SettingsCard } from "./settings-card";
|
||||
import { useSettingsSaveContributor } from "./settings-save-provider";
|
||||
|
||||
export function AgentGeneratedTaskTitleSettings() {
|
||||
const preference = useAppStore((state) => state.userSettings.agentGeneratedTaskTitles);
|
||||
const setUserSettings = useAppStore((state) => state.setUserSettings);
|
||||
const storeApi = useAppStoreApi();
|
||||
const [saved, setSaved] = useState(preference);
|
||||
const [draft, setDraft] = useState(preference);
|
||||
const draftRef = useRef(draft);
|
||||
draftRef.current = draft;
|
||||
const isDirty = draft !== saved;
|
||||
|
||||
useEffect(() => {
|
||||
setSaved((previous) => {
|
||||
if (draftRef.current === previous) setDraft(preference);
|
||||
return preference;
|
||||
});
|
||||
}, [preference]);
|
||||
|
||||
useSettingsSaveContributor({
|
||||
id: "general-agent-generated-task-titles",
|
||||
order: 20,
|
||||
revision: Number(draft),
|
||||
isDirty,
|
||||
save: async (revision) => {
|
||||
const submitted = Boolean(revision);
|
||||
await updateUserSettings({ agent_generated_task_titles: submitted });
|
||||
setSaved(submitted);
|
||||
setUserSettings({ ...storeApi.getState().userSettings, agentGeneratedTaskTitles: submitted });
|
||||
},
|
||||
discard: () => setDraft(saved),
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard isDirty={isDirty} data-testid="agent-generated-task-title-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Agent-generated task titles</CardTitle>
|
||||
<CardDescription>
|
||||
When enabled, new task and subtask forms hide the manual title field and require a prompt.
|
||||
Kandev shows a provisional title using the prompt's first six words immediately, then
|
||||
asks the agent to replace it with a concise title targeting about three words. If the
|
||||
agent cannot rename the task, the provisional title remains available for you to edit.
|
||||
Existing and edited tasks keep their normal title field.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex min-h-11 items-center justify-between gap-4">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<Label htmlFor="agent-generated-task-titles">Use the agent for new task titles</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disabled by default; save this setting before opening a creation dialog.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="agent-generated-task-titles"
|
||||
checked={draft}
|
||||
data-settings-dirty={isDirty}
|
||||
onCheckedChange={setDraft}
|
||||
className="shrink-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { ArchiveConfirmationSettings } from "@/components/settings/archive-confi
|
||||
import { LanguageSettings } from "@/components/settings/language-settings";
|
||||
import { MCPTaskAgentProfileDefaultSettings } from "@/components/settings/mcp-task-agent-profile-default-settings";
|
||||
import { UnreadDividerSettings } from "@/components/settings/unread-divider-settings";
|
||||
import { AgentGeneratedTaskTitleSettings } from "@/components/settings/agent-generated-task-title-settings";
|
||||
import { AnchoredPromptBarSettings } from "@/components/settings/anchored-prompt-bar-settings";
|
||||
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
|
||||
import type { StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides";
|
||||
@@ -249,6 +250,7 @@ export function TaskActionsSettings() {
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<MCPTaskAgentProfileDefaultSettings />
|
||||
<AgentGeneratedTaskTitleSettings />
|
||||
<ArchiveConfirmationSettings />
|
||||
<UnreadDividerSettings />
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
REASON_WORKFLOW,
|
||||
REASON_AGENT,
|
||||
REASON_DESCRIPTION,
|
||||
REASON_PROMPT,
|
||||
} from "./task-create-dialog-footer";
|
||||
import type { ButtonKind, TaskCreateDialogFooterProps } from "./task-create-dialog-footer";
|
||||
|
||||
@@ -63,6 +64,21 @@ describe("computeDisabledReason (start-task)", () => {
|
||||
).toBe(REASON_TITLE);
|
||||
});
|
||||
|
||||
it("requires the prompt instead of a title in auto-title mode", () => {
|
||||
expect(
|
||||
computeDisabledReason(
|
||||
makeProps({ autoTitle: true, hasTitle: false, hasDescription: false }),
|
||||
KIND_START,
|
||||
),
|
||||
).toBe(REASON_PROMPT);
|
||||
expect(
|
||||
computeDisabledReason(
|
||||
makeProps({ autoTitle: true, hasTitle: false, hasDescription: true }),
|
||||
KIND_START,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("flags missing repository selection", () => {
|
||||
expect(computeDisabledReason(makeProps({ hasRepositorySelection: false }), KIND_START)).toBe(
|
||||
REASON_REPO,
|
||||
|
||||
@@ -205,6 +205,7 @@ export type TaskCreateDialogFooterProps = {
|
||||
isSessionMode: boolean;
|
||||
isCreateMode: boolean;
|
||||
isEditMode: boolean;
|
||||
autoTitle?: boolean;
|
||||
isTaskStarted: boolean;
|
||||
isCreatingSession: boolean;
|
||||
isCreatingTask: boolean;
|
||||
@@ -252,7 +253,8 @@ function computeBaseDisabled(props: TaskCreateDialogFooterProps) {
|
||||
);
|
||||
return (
|
||||
props.isCreatingTask ||
|
||||
!props.hasTitle ||
|
||||
(!props.autoTitle && !props.hasTitle) ||
|
||||
(props.autoTitle && !props.hasDescription) ||
|
||||
!props.hasRepositorySelection ||
|
||||
!props.hasAllBranches ||
|
||||
missingCtx ||
|
||||
@@ -263,6 +265,7 @@ function computeBaseDisabled(props: TaskCreateDialogFooterProps) {
|
||||
export type ButtonKind = "update" | "start-task" | "default";
|
||||
|
||||
export const REASON_TITLE = "Add a task title";
|
||||
export const REASON_PROMPT = "Add a task prompt";
|
||||
export const REASON_REPO = "Select a repository";
|
||||
export const REASON_BRANCH = "Select a branch";
|
||||
export const REASON_WORKSPACE = "Select a workspace";
|
||||
@@ -276,7 +279,8 @@ function noCompatibleAgentReason(executorProfileName: string | null): string {
|
||||
}
|
||||
|
||||
function baseReason(props: TaskCreateDialogFooterProps): string | null {
|
||||
if (!props.hasTitle) return REASON_TITLE;
|
||||
if (props.autoTitle && !props.hasDescription) return REASON_PROMPT;
|
||||
if (!props.autoTitle && !props.hasTitle) return REASON_TITLE;
|
||||
if (!props.hasRepositorySelection) return REASON_REPO;
|
||||
if (!props.hasAllBranches) return REASON_BRANCH;
|
||||
if (props.isCreateMode && !props.workspaceId) return REASON_WORKSPACE;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { autoSelectBranch, shouldShowTaskTitleField } from "./task-create-dialog-helpers";
|
||||
import {
|
||||
autoSelectBranch,
|
||||
buildCreateTaskPayload,
|
||||
shouldShowTaskTitleField,
|
||||
validateCreateInputs,
|
||||
} from "./task-create-dialog-helpers";
|
||||
const STORAGE_KEYS = { LAST_BRANCH: "kandev.dialog.lastBranch" } as const;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -126,3 +131,45 @@ describe("shouldShowTaskTitleField", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("auto-title creation helpers", () => {
|
||||
const base = {
|
||||
workspaceId: "ws-1",
|
||||
effectiveWorkflowId: "wf-1",
|
||||
repositories: [{ key: "repo-1", repositoryId: "repo-1", branch: "main" }],
|
||||
agentProfileId: "agent-1",
|
||||
noRepository: false,
|
||||
};
|
||||
|
||||
it("requires a prompt instead of a title when auto-title mode is enabled", () => {
|
||||
expect(
|
||||
validateCreateInputs({ ...base, trimmedTitle: "", trimmedDescription: "", autoTitle: true }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateCreateInputs({
|
||||
...base,
|
||||
trimmedTitle: "",
|
||||
trimmedDescription: "Describe the work",
|
||||
autoTitle: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("omits the manual title and opts into backend provisional naming", () => {
|
||||
const payload = buildCreateTaskPayload({
|
||||
workspaceId: "ws-1",
|
||||
effectiveWorkflowId: "wf-1",
|
||||
trimmedTitle: "ignored",
|
||||
trimmedDescription: "Fix the login flow",
|
||||
repositoriesPayload: [],
|
||||
agentProfileId: "agent-1",
|
||||
executorId: "executor-1",
|
||||
executorProfileId: "profile-1",
|
||||
withAgent: true,
|
||||
autoTitle: true,
|
||||
});
|
||||
|
||||
expect(payload).toMatchObject({ auto_title: true });
|
||||
expect(payload).not.toHaveProperty("title");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,6 +160,7 @@ export type BuildCreatePayloadArgs = {
|
||||
effectiveWorkflowId: string;
|
||||
trimmedTitle: string;
|
||||
trimmedDescription: string;
|
||||
autoTitle?: boolean;
|
||||
repositoriesPayload: CreateTaskParams["repositories"];
|
||||
agentProfileId: string;
|
||||
executorId: string;
|
||||
@@ -175,7 +176,7 @@ export function buildCreateTaskPayload(args: BuildCreatePayloadArgs): CreateTask
|
||||
return {
|
||||
workspace_id: args.workspaceId,
|
||||
workflow_id: args.effectiveWorkflowId,
|
||||
title: args.trimmedTitle,
|
||||
...(args.autoTitle ? { auto_title: true } : { title: args.trimmedTitle }),
|
||||
description: args.trimmedDescription,
|
||||
repositories: args.repositoriesPayload,
|
||||
state: args.withAgent ? "IN_PROGRESS" : "CREATED",
|
||||
@@ -193,6 +194,8 @@ export function buildCreateTaskPayload(args: BuildCreatePayloadArgs): CreateTask
|
||||
|
||||
export function validateCreateInputs(inputs: {
|
||||
trimmedTitle: string;
|
||||
trimmedDescription?: string;
|
||||
autoTitle?: boolean;
|
||||
workspaceId: string | null;
|
||||
effectiveWorkflowId: string | null;
|
||||
/** Unified repos list. The form is valid if any row has a repo set OR URL mode is filled. */
|
||||
@@ -208,7 +211,7 @@ export function validateCreateInputs(inputs: {
|
||||
inputs.repositories.some((r) => r.repositoryId || r.localPath) ||
|
||||
hasRemoteRepo;
|
||||
return Boolean(
|
||||
inputs.trimmedTitle &&
|
||||
(inputs.autoTitle ? inputs.trimmedDescription : inputs.trimmedTitle) &&
|
||||
inputs.workspaceId &&
|
||||
inputs.effectiveWorkflowId &&
|
||||
inputs.agentProfileId &&
|
||||
|
||||
@@ -8,10 +8,8 @@
|
||||
// ReturnType<>), so the `import type` form makes the otherwise-circular
|
||||
// dependency with task-create-dialog.tsx explicitly type-only — bundlers
|
||||
// and analysis tools won't treat it as a real runtime cycle.
|
||||
import type {
|
||||
TaskCreateDialogProps,
|
||||
useTaskCreateDialogSetup,
|
||||
} from "@/components/task-create-dialog";
|
||||
import type { TaskCreateDialogProps } from "@/components/task-create-dialog";
|
||||
import type { useTaskCreateDialogSetup } from "@/components/task-create-dialog-setup";
|
||||
import type { DialogFormBodyProps, DialogFormState } from "@/components/task-create-dialog-types";
|
||||
|
||||
export function computeHasAllBranches(fs: DialogFormState): boolean {
|
||||
@@ -37,6 +35,7 @@ export function buildDialogFormBodyProps(
|
||||
isSessionMode: setup.isSessionMode,
|
||||
isCreateMode: setup.isCreateMode,
|
||||
isEditMode: setup.isEditMode,
|
||||
autoTitle: setup.autoTitle,
|
||||
isTaskStarted: setup.isTaskStarted,
|
||||
onTaskNameChange: handlers.handleTaskNameChange,
|
||||
onRowRepositoryChange: handlers.handleRowRepositoryChange,
|
||||
@@ -97,6 +96,7 @@ export function buildDialogFooterProps(
|
||||
isSessionMode: setup.isSessionMode,
|
||||
isCreateMode: setup.isCreateMode,
|
||||
isEditMode: setup.isEditMode,
|
||||
autoTitle: setup.autoTitle,
|
||||
isTaskStarted: setup.isTaskStarted,
|
||||
isCreatingSession: fs.isCreatingSession,
|
||||
isCreatingTask: fs.isCreatingTask,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TaskCreateDialogProps } from "./task-create-dialog";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
agentGeneratedTaskTitles: false,
|
||||
submit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/keyboard/constants", () => ({ SHORTCUTS: { SUBMIT: "submit" } }));
|
||||
vi.mock("@/hooks/use-is-utility-configured", () => ({ useIsUtilityConfigured: () => false }));
|
||||
vi.mock("@/hooks/use-keyboard-shortcut", () => ({
|
||||
useKeyboardShortcutHandler: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("@/hooks/use-utility-agent-generator", () => ({
|
||||
useUtilityAgentGenerator: () => ({ enhancePrompt: vi.fn(), isEnhancingPrompt: false }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-prompt-result-delivery", () => ({
|
||||
usePromptResultDelivery: () => ({
|
||||
pendingResult: null,
|
||||
captureScope: vi.fn(),
|
||||
deliver: vi.fn(),
|
||||
applyPending: vi.fn(),
|
||||
copyPending: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
userSettings: { agentGeneratedTaskTitles: mocks.agentGeneratedTaskTitles },
|
||||
upsertRepository: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/components/task-create-dialog-submit", () => ({
|
||||
useTaskSubmitHandlers: () => ({ handleSubmit: mocks.submit, pendingDiscard: null }),
|
||||
}));
|
||||
vi.mock("@/components/task-create-dialog-workflow-context", () => ({
|
||||
useResolvedTaskCreateWorkflowContext: (props: TaskCreateDialogProps) => props,
|
||||
}));
|
||||
vi.mock("@/components/task-create-dialog-state", () => ({
|
||||
computeIsTaskStarted: () => false,
|
||||
useDialogFormState: () => ({
|
||||
taskName: "",
|
||||
setTaskName: vi.fn(),
|
||||
hasTitle: false,
|
||||
setHasTitle: vi.fn(),
|
||||
hasDescription: false,
|
||||
setHasDescription: vi.fn(),
|
||||
descriptionInputRef: { current: null },
|
||||
openCycle: 1,
|
||||
repositories: [],
|
||||
discoveredRepositories: [],
|
||||
remoteRepos: [],
|
||||
prInfoByUrl: {},
|
||||
useRemote: false,
|
||||
executorId: "",
|
||||
executorProfileId: "",
|
||||
freshBranchEnabled: false,
|
||||
noRepository: false,
|
||||
workspacePath: "",
|
||||
isCreatingSession: false,
|
||||
isCreatingTask: false,
|
||||
setIsCreatingSession: vi.fn(),
|
||||
setIsCreatingTask: vi.fn(),
|
||||
setRepositories: vi.fn(),
|
||||
setRemoteRepos: vi.fn(),
|
||||
setAgentProfileId: vi.fn(),
|
||||
setExecutorId: vi.fn(),
|
||||
setSelectedWorkflowId: vi.fn(),
|
||||
setFetchedSteps: vi.fn(),
|
||||
clearDraft: vi.fn(),
|
||||
currentDefaults: { description: "" },
|
||||
}),
|
||||
useTaskCreateDialogData: () => ({
|
||||
workflows: [],
|
||||
agentProfiles: [],
|
||||
executors: [],
|
||||
snapshots: [],
|
||||
repositories: [],
|
||||
repositoriesLoading: false,
|
||||
refreshRepositories: vi.fn(),
|
||||
taskCreateLastUsed: {
|
||||
repositoryId: null,
|
||||
agentProfileId: null,
|
||||
executorProfileId: null,
|
||||
branch: null,
|
||||
},
|
||||
userSettingsLoaded: true,
|
||||
computed: {
|
||||
effectiveWorkflowId: null,
|
||||
effectiveDefaultStepId: null,
|
||||
effectiveAgentProfileId: "",
|
||||
compatibleAgentProfiles: [],
|
||||
authLoaded: true,
|
||||
workspaceDefaults: {},
|
||||
isLocalExecutor: true,
|
||||
isPassthroughProfile: false,
|
||||
agentProfileOptions: [],
|
||||
executorProfileOptions: [],
|
||||
agentProfilesLoading: false,
|
||||
executorsLoading: false,
|
||||
workflowAgentLocked: false,
|
||||
noCompatibleAgent: false,
|
||||
selectedExecutorProfileName: "",
|
||||
executorHint: "",
|
||||
hasRepositorySelection: false,
|
||||
},
|
||||
}),
|
||||
useTaskCreateDialogEffects: vi.fn(),
|
||||
useLockedFieldSync: vi.fn(),
|
||||
useDialogHandlers: () => ({}),
|
||||
useSessionRepoName: () => null,
|
||||
}));
|
||||
|
||||
import { useTaskCreateDialogSetup } from "./task-create-dialog-setup";
|
||||
|
||||
const props: TaskCreateDialogProps = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
workspaceId: "workspace-1",
|
||||
workflowId: "workflow-1",
|
||||
defaultStepId: null,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
describe("useTaskCreateDialogSetup auto-title mode", () => {
|
||||
beforeEach(() => {
|
||||
mocks.agentGeneratedTaskTitles = false;
|
||||
mocks.submit.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["create", false, false],
|
||||
["create", true, true],
|
||||
["edit", true, false],
|
||||
["session", true, false],
|
||||
] as const)("derives autoTitle=%s for %s mode with setting=%s", (mode, enabled, expected) => {
|
||||
mocks.agentGeneratedTaskTitles = enabled;
|
||||
const { result } = renderHook(() => useTaskCreateDialogSetup({ ...props, mode }));
|
||||
|
||||
expect(result.current.autoTitle).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useCallback } from "react";
|
||||
import type { JiraTicket } from "@/lib/types/jira";
|
||||
import type { LinearIssue } from "@/lib/types/linear";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import { SHORTCUTS } from "@/lib/keyboard/constants";
|
||||
import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured";
|
||||
import { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut";
|
||||
import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator";
|
||||
import { usePromptResultDelivery } from "@/hooks/use-prompt-result-delivery";
|
||||
import { useTaskSubmitHandlers } from "@/components/task-create-dialog-submit";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import {
|
||||
useDialogFormState,
|
||||
useTaskCreateDialogEffects,
|
||||
useDialogHandlers,
|
||||
useLockedFieldSync,
|
||||
useSessionRepoName,
|
||||
useTaskCreateDialogData,
|
||||
computeIsTaskStarted,
|
||||
type DialogFormState,
|
||||
} from "@/components/task-create-dialog-state";
|
||||
import type { TaskCreateDialogProps } from "@/components/task-create-dialog";
|
||||
import { useResolvedTaskCreateWorkflowContext } from "@/components/task-create-dialog-workflow-context";
|
||||
import { truncateRemoteTaskTitle } from "@/lib/task-title";
|
||||
|
||||
const PROMPT_INSERTED_MESSAGE = "Enhanced prompt inserted.";
|
||||
|
||||
function useEnhanceForDialog(
|
||||
fs: DialogFormState,
|
||||
taskId: string | null | undefined,
|
||||
open: boolean,
|
||||
) {
|
||||
const isConfigured = useIsUtilityConfigured();
|
||||
const { toast } = useToast();
|
||||
const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({
|
||||
sessionId: null,
|
||||
taskTitle: fs.taskName,
|
||||
});
|
||||
const applyDescription = useCallback(
|
||||
(value: string) => {
|
||||
const input = fs.descriptionInputRef.current;
|
||||
if (!input) return false;
|
||||
input.setValue(value);
|
||||
const applied = input.getValue() === value;
|
||||
if (applied) fs.setHasDescription(value.trim().length > 0);
|
||||
return applied;
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
const promptDelivery = usePromptResultDelivery({
|
||||
scopeKey: `task-create:${open}:${fs.openCycle}:${taskId ?? ""}`,
|
||||
getCurrent: () => fs.descriptionInputRef.current?.getValue() ?? null,
|
||||
apply: applyDescription,
|
||||
});
|
||||
const onEnhance = useCallback(() => {
|
||||
const current = fs.descriptionInputRef.current?.getValue() ?? "";
|
||||
if (!current.trim()) return;
|
||||
const generation = promptDelivery.captureScope();
|
||||
void enhancePrompt(current, (result) => {
|
||||
const inserted = promptDelivery.deliver(current, result, generation);
|
||||
if (inserted) toast({ description: PROMPT_INSERTED_MESSAGE, variant: "success" });
|
||||
return inserted;
|
||||
});
|
||||
}, [enhancePrompt, fs.descriptionInputRef, promptDelivery, toast]);
|
||||
return {
|
||||
onEnhance,
|
||||
isLoading: isEnhancingPrompt,
|
||||
isConfigured,
|
||||
pendingResult: promptDelivery.pendingResult,
|
||||
onApplyPending: promptDelivery.applyPending,
|
||||
onCopyPending: promptDelivery.copyPending,
|
||||
};
|
||||
}
|
||||
|
||||
function useJiraImportHandler(fs: DialogFormState, handleTaskNameChange: (value: string) => void) {
|
||||
return useCallback(
|
||||
(ticket: JiraTicket) => {
|
||||
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, handleTaskNameChange],
|
||||
);
|
||||
}
|
||||
|
||||
function useLinearImportHandler(
|
||||
fs: DialogFormState,
|
||||
handleTaskNameChange: (value: string) => void,
|
||||
) {
|
||||
return useCallback(
|
||||
(issue: LinearIssue) => {
|
||||
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, handleTaskNameChange],
|
||||
);
|
||||
}
|
||||
|
||||
type SubmitWiringArgs = {
|
||||
props: TaskCreateDialogProps;
|
||||
fs: ReturnType<typeof useDialogFormState>;
|
||||
computed: ReturnType<typeof useTaskCreateDialogData>["computed"];
|
||||
workspaceRepositories: ReturnType<typeof useTaskCreateDialogData>["repositories"];
|
||||
repositoryLocalPath: string;
|
||||
isSessionMode: boolean;
|
||||
isEditMode: boolean;
|
||||
autoTitle: boolean;
|
||||
preserveQueuedLastUsedOnClose: () => void;
|
||||
};
|
||||
|
||||
function useSubmitHandlersWiring({
|
||||
props,
|
||||
fs,
|
||||
computed,
|
||||
workspaceRepositories,
|
||||
repositoryLocalPath,
|
||||
isSessionMode,
|
||||
isEditMode,
|
||||
autoTitle,
|
||||
preserveQueuedLastUsedOnClose,
|
||||
}: SubmitWiringArgs) {
|
||||
const { workspaceId, workflowId, editingTask, onSuccess, onCreateSession, onOpenChange } = props;
|
||||
const { parentTaskId } = props;
|
||||
const taskId = props.taskId ?? null;
|
||||
return useTaskSubmitHandlers({
|
||||
isSessionMode,
|
||||
isEditMode,
|
||||
autoTitle,
|
||||
isPassthroughProfile: computed.isPassthroughProfile,
|
||||
taskName: fs.taskName,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
effectiveWorkflowId: computed.effectiveWorkflowId,
|
||||
effectiveDefaultStepId: computed.effectiveDefaultStepId,
|
||||
repositories: fs.repositories,
|
||||
discoveredRepositories: fs.discoveredRepositories,
|
||||
workspaceRepositories,
|
||||
useRemote: fs.useRemote,
|
||||
remoteRepos: fs.remoteRepos,
|
||||
prInfoByUrl: fs.prInfoByUrl,
|
||||
agentProfileId: computed.effectiveAgentProfileId,
|
||||
executorId: fs.executorId,
|
||||
executorProfileId: fs.executorProfileId,
|
||||
editingTask,
|
||||
onSuccess,
|
||||
onCreateSession,
|
||||
onOpenChange,
|
||||
preserveTaskCreateLastUsedOnClose: preserveQueuedLastUsedOnClose,
|
||||
taskId,
|
||||
parentTaskId,
|
||||
descriptionInputRef: fs.descriptionInputRef,
|
||||
setIsCreatingSession: fs.setIsCreatingSession,
|
||||
setIsCreatingTask: fs.setIsCreatingTask,
|
||||
setHasTitle: fs.setHasTitle,
|
||||
setHasDescription: fs.setHasDescription,
|
||||
setTaskName: fs.setTaskName,
|
||||
setRepositories: fs.setRepositories,
|
||||
setRemoteRepos: fs.setRemoteRepos,
|
||||
setAgentProfileId: fs.setAgentProfileId,
|
||||
setExecutorId: fs.setExecutorId,
|
||||
setSelectedWorkflowId: fs.setSelectedWorkflowId,
|
||||
setFetchedSteps: fs.setFetchedSteps,
|
||||
clearDraft: fs.clearDraft,
|
||||
freshBranchEnabled: fs.freshBranchEnabled,
|
||||
isLocalExecutor: computed.isLocalExecutor,
|
||||
repositoryLocalPath,
|
||||
noRepository: fs.noRepository,
|
||||
workspacePath: fs.workspacePath,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSingleRowLocalPath(fs: DialogFormState, repositories: Repository[]): string {
|
||||
if (fs.repositories.length !== 1) return "";
|
||||
const row = fs.repositories[0];
|
||||
if (row.localPath) return row.localPath;
|
||||
if (row.repositoryId)
|
||||
return repositories.find((r) => r.id === row.repositoryId)?.local_path ?? "";
|
||||
return "";
|
||||
}
|
||||
|
||||
function useDialogSetupData(
|
||||
props: TaskCreateDialogProps,
|
||||
fs: ReturnType<typeof useDialogFormState>,
|
||||
) {
|
||||
const { open, workspaceId, workflowId, defaultStepId, initialValues } = props;
|
||||
const { toast } = useToast();
|
||||
const upsertWorkspaceRepository = useAppStore((state) => state.upsertRepository);
|
||||
const data = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs);
|
||||
const {
|
||||
workflows,
|
||||
agentProfiles,
|
||||
executors,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
taskCreateLastUsed,
|
||||
userSettingsLoaded,
|
||||
computed,
|
||||
} = data;
|
||||
useTaskCreateDialogEffects(fs, {
|
||||
open,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
effectiveWorkflowId: computed.effectiveWorkflowId,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
agentProfiles,
|
||||
compatibleAgentProfiles: computed.compatibleAgentProfiles,
|
||||
authLoaded: computed.authLoaded,
|
||||
executors,
|
||||
workspaceDefaults: computed.workspaceDefaults,
|
||||
toast,
|
||||
workflows,
|
||||
isLocalExecutor: computed.isLocalExecutor,
|
||||
lastUsedRepositoryId: taskCreateLastUsed.repositoryId,
|
||||
userSettingsLoaded,
|
||||
lastUsedAgentProfileId: taskCreateLastUsed.agentProfileId,
|
||||
lastUsedExecutorProfileId: taskCreateLastUsed.executorProfileId,
|
||||
lastUsedBranch: taskCreateLastUsed.branch,
|
||||
preserveBranch: initialValues?.checkoutBranch || initialValues?.branch,
|
||||
});
|
||||
useLockedFieldSync(open, workflowId, initialValues, fs);
|
||||
const handlers = useDialogHandlers(fs, repositories, {
|
||||
workspaceId,
|
||||
executors,
|
||||
upsertWorkspaceRepository,
|
||||
});
|
||||
return {
|
||||
...data,
|
||||
handlers,
|
||||
repositoryLocalPath: resolveSingleRowLocalPath(fs, repositories),
|
||||
};
|
||||
}
|
||||
|
||||
export function useTaskCreateDialogSetup(
|
||||
props: TaskCreateDialogProps,
|
||||
options: { preserveQueuedLastUsedOnClose?: () => void } = {},
|
||||
) {
|
||||
const resolvedProps = useResolvedTaskCreateWorkflowContext(props);
|
||||
const { open, mode = "create", workspaceId, workflowId } = resolvedProps;
|
||||
const { editingTask, initialValues } = resolvedProps;
|
||||
const isSessionMode = mode === "session";
|
||||
const isEditMode = mode === "edit";
|
||||
const isTaskStarted = computeIsTaskStarted(isEditMode, editingTask);
|
||||
const agentGeneratedTaskTitles = useAppStore(
|
||||
(state) => state.userSettings.agentGeneratedTaskTitles,
|
||||
);
|
||||
const autoTitle = mode === "create" && agentGeneratedTaskTitles;
|
||||
const fs = useDialogFormState(open, workspaceId, workflowId, initialValues);
|
||||
const sessionRepoName = useSessionRepoName(isSessionMode);
|
||||
const data = useDialogSetupData(resolvedProps, fs);
|
||||
const {
|
||||
workflows,
|
||||
agentProfiles,
|
||||
snapshots,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
refreshRepositories,
|
||||
taskCreateLastUsed,
|
||||
userSettingsLoaded,
|
||||
computed,
|
||||
handlers,
|
||||
repositoryLocalPath,
|
||||
} = data;
|
||||
const submitHandlers = useSubmitHandlersWiring({
|
||||
props: resolvedProps,
|
||||
fs,
|
||||
computed,
|
||||
workspaceRepositories: repositories,
|
||||
repositoryLocalPath,
|
||||
isSessionMode,
|
||||
isEditMode,
|
||||
autoTitle,
|
||||
preserveQueuedLastUsedOnClose: options.preserveQueuedLastUsedOnClose ?? (() => undefined),
|
||||
});
|
||||
const guardedHandleSubmit = useGuardedSubmit(
|
||||
submitHandlers.handleSubmit,
|
||||
resolvedProps.submitBlockedReason,
|
||||
);
|
||||
const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => {
|
||||
guardedHandleSubmit(event as unknown as FormEvent);
|
||||
});
|
||||
const enhance = useEnhanceForDialog(fs, resolvedProps.taskId, resolvedProps.open);
|
||||
const handleJiraImport = useJiraImportHandler(fs, data.handlers.handleTaskNameChange);
|
||||
const handleLinearImport = useLinearImportHandler(fs, data.handlers.handleTaskNameChange);
|
||||
const freshBranchAvailable =
|
||||
!fs.useRemote && computed.isLocalExecutor && fs.repositories.length === 1;
|
||||
return {
|
||||
fs,
|
||||
isSessionMode,
|
||||
isEditMode,
|
||||
isCreateMode: mode === "create",
|
||||
autoTitle,
|
||||
isTaskStarted,
|
||||
sessionRepoName,
|
||||
workflows,
|
||||
agentProfiles,
|
||||
snapshots,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
refreshRepositories,
|
||||
computed,
|
||||
handlers,
|
||||
submitHandlers,
|
||||
handleKeyDown,
|
||||
freshBranchAvailable,
|
||||
taskCreateLastUsed,
|
||||
userSettingsLoaded,
|
||||
guardedHandleSubmit,
|
||||
enhance,
|
||||
handleJiraImport,
|
||||
handleLinearImport,
|
||||
};
|
||||
}
|
||||
|
||||
function useGuardedSubmit(
|
||||
handleSubmit: (e: FormEvent) => void,
|
||||
blockedReason: string | null | undefined,
|
||||
) {
|
||||
const blocked = Boolean(blockedReason);
|
||||
return useCallback(
|
||||
(e: FormEvent) => {
|
||||
if (blocked) e.preventDefault();
|
||||
else handleSubmit(e);
|
||||
},
|
||||
[blocked, handleSubmit],
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ function notifyQueuedTask(
|
||||
export function useTaskSubmitHandlers({
|
||||
isSessionMode,
|
||||
isEditMode,
|
||||
autoTitle = false,
|
||||
isPassthroughProfile,
|
||||
taskName,
|
||||
workspaceId,
|
||||
@@ -104,9 +105,11 @@ export function useTaskSubmitHandlers({
|
||||
isFreshBranchActive ? { confirmDiscard: true, consentedDirtyFiles } : undefined;
|
||||
|
||||
const validateForCreate = useCallback(
|
||||
(trimmedTitle: string) =>
|
||||
(trimmedTitle: string, trimmedDescription = "") =>
|
||||
validateCreateInputs({
|
||||
trimmedTitle,
|
||||
trimmedDescription,
|
||||
autoTitle,
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
repositories,
|
||||
@@ -122,6 +125,7 @@ export function useTaskSubmitHandlers({
|
||||
remoteRepos,
|
||||
agentProfileId,
|
||||
noRepository,
|
||||
autoTitle,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -343,6 +347,7 @@ export function useTaskSubmitHandlers({
|
||||
effectiveWorkflowId,
|
||||
trimmedTitle: opts.trimmedTitle,
|
||||
trimmedDescription: opts.trimmedDescription,
|
||||
autoTitle,
|
||||
repositoriesPayload: getRepositoriesPayload(c),
|
||||
agentProfileId,
|
||||
executorId,
|
||||
@@ -386,6 +391,7 @@ export function useTaskSubmitHandlers({
|
||||
[
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
autoTitle,
|
||||
agentProfileId,
|
||||
executorId,
|
||||
executorProfileId,
|
||||
@@ -472,7 +478,7 @@ export function useTaskSubmitHandlers({
|
||||
const description = descriptionInputRef.current?.getValue() ?? "";
|
||||
const trimmedDescription = description.trim();
|
||||
const attachments = toMessageAttachments(descriptionInputRef.current?.getAttachments() ?? []);
|
||||
if (!validateForCreate(trimmedTitle)) return;
|
||||
if (!validateForCreate(trimmedTitle, trimmedDescription)) return;
|
||||
if (checkRemoteDuplicates()) return;
|
||||
const consent = await ensureFreshBranchConsent();
|
||||
if (consent === null) return;
|
||||
@@ -513,7 +519,7 @@ export function useTaskSubmitHandlers({
|
||||
const description = descriptionInputRef.current?.getValue() ?? "";
|
||||
const trimmedDescription = description.trim();
|
||||
const attachments = toMessageAttachments(descriptionInputRef.current?.getAttachments() ?? []);
|
||||
if (!validateForCreate(trimmedTitle)) return;
|
||||
if (!validateForCreate(trimmedTitle, trimmedDescription)) return;
|
||||
if (checkRemoteDuplicates()) return;
|
||||
const consent = await ensureFreshBranchConsent();
|
||||
if (consent === null) return;
|
||||
@@ -530,7 +536,7 @@ export function useTaskSubmitHandlers({
|
||||
withAgent: true,
|
||||
attachments,
|
||||
});
|
||||
} else {
|
||||
} else if (!autoTitle) {
|
||||
await handleCreatePlanMode(trimmedTitle, consent);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -544,6 +550,7 @@ export function useTaskSubmitHandlers({
|
||||
}
|
||||
}, [
|
||||
taskName,
|
||||
autoTitle,
|
||||
validateForCreate,
|
||||
checkRemoteDuplicates,
|
||||
ensureFreshBranchConsent,
|
||||
@@ -558,7 +565,7 @@ export function useTaskSubmitHandlers({
|
||||
const handleCreateWithoutAgent = useCallback(async () => {
|
||||
const trimmedTitle = taskName.trim();
|
||||
const trimmedDescription = (descriptionInputRef.current?.getValue() ?? "").trim();
|
||||
if (!validateForCreate(trimmedTitle)) return;
|
||||
if (!validateForCreate(trimmedTitle, trimmedDescription)) return;
|
||||
if (!trimmedDescription || !effectiveDefaultStepId || !workspaceId || !effectiveWorkflowId)
|
||||
return;
|
||||
if (checkRemoteDuplicates()) return;
|
||||
@@ -574,6 +581,7 @@ export function useTaskSubmitHandlers({
|
||||
effectiveWorkflowId,
|
||||
trimmedTitle,
|
||||
trimmedDescription,
|
||||
autoTitle,
|
||||
repositoriesPayload: getRepositoriesPayload(c),
|
||||
agentProfileId,
|
||||
executorId,
|
||||
@@ -604,6 +612,7 @@ export function useTaskSubmitHandlers({
|
||||
}
|
||||
}, [
|
||||
taskName,
|
||||
autoTitle,
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
agentProfileId,
|
||||
|
||||
@@ -321,6 +321,8 @@ export type DialogFormState = {
|
||||
export type SubmitHandlersDeps = {
|
||||
isSessionMode: boolean;
|
||||
isEditMode: boolean;
|
||||
/** Create-mode opt-in: derive a provisional title from the prompt. */
|
||||
autoTitle?: boolean;
|
||||
isPassthroughProfile: boolean;
|
||||
taskName: string;
|
||||
workspaceId: string | null;
|
||||
@@ -408,6 +410,8 @@ export type DialogFormBodyProps = {
|
||||
isSessionMode: boolean;
|
||||
isCreateMode: boolean;
|
||||
isEditMode: boolean;
|
||||
/** Create-mode opt-in: hide the manual title field and use the prompt. */
|
||||
autoTitle?: boolean;
|
||||
isTaskStarted: boolean;
|
||||
initialDescription: string;
|
||||
workspaceId: string | null;
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { JiraTicket } from "@/lib/types/jira";
|
||||
import type { LinearIssue } from "@/lib/types/linear";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@kandev/ui/dialog";
|
||||
import type { Task, Repository } from "@/lib/types/http";
|
||||
import type { Task } from "@/lib/types/http";
|
||||
import type { TaskCreateLastUsedState } from "@/lib/state/slices/settings/types";
|
||||
import { SHORTCUTS } from "@/lib/keyboard/constants";
|
||||
import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured";
|
||||
import { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut";
|
||||
import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator";
|
||||
import { TaskCreateDialogFooter } from "@/components/task-create-dialog-footer";
|
||||
import { DiscardLocalChangesDialog } from "@/components/discard-local-changes-dialog";
|
||||
import { DialogHeaderContent } from "@/components/task-create-dialog-header";
|
||||
@@ -23,21 +17,9 @@ import {
|
||||
ExecutorProfileSelector,
|
||||
InlineTaskName,
|
||||
} from "@/components/task-create-dialog-selectors";
|
||||
import { useTaskSubmitHandlers } from "@/components/task-create-dialog-submit";
|
||||
import { CreateModeSelectors } from "@/components/task-create-dialog-create-mode-selectors";
|
||||
import { RepoChipsRow } from "@/components/task-create-dialog-repo-chips";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import {
|
||||
useDialogFormState,
|
||||
useTaskCreateDialogEffects,
|
||||
useDialogHandlers,
|
||||
useLockedFieldSync,
|
||||
useSessionRepoName,
|
||||
useTaskCreateDialogData,
|
||||
computeIsTaskStarted,
|
||||
type DialogFormState,
|
||||
type TaskCreateDialogInitialValues,
|
||||
} from "@/components/task-create-dialog-state";
|
||||
import type { TaskCreateDialogInitialValues } from "@/components/task-create-dialog-state";
|
||||
import type { DialogFormBodyProps } from "@/components/task-create-dialog-types";
|
||||
import {
|
||||
buildDialogFooterProps,
|
||||
@@ -47,11 +29,7 @@ import { resetTaskCreateLastUsedSync } from "@/components/task-create-dialog-han
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { TaskCreateDialogPopoverContainerProvider } from "@/hooks/use-task-create-dialog-popover-container";
|
||||
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.";
|
||||
import { useTaskCreateDialogSetup } from "@/components/task-create-dialog-setup";
|
||||
|
||||
export interface TaskCreateDialogProps {
|
||||
open: boolean;
|
||||
@@ -114,6 +92,7 @@ export interface TaskCreateDialogProps {
|
||||
function CreateModeBody(props: DialogFormBodyProps) {
|
||||
const {
|
||||
isCreateMode,
|
||||
autoTitle,
|
||||
isEditMode,
|
||||
isTaskStarted,
|
||||
workspaceId,
|
||||
@@ -141,8 +120,9 @@ function CreateModeBody(props: DialogFormBodyProps) {
|
||||
isLocalExecutor,
|
||||
localRepositoryCreation,
|
||||
} = props;
|
||||
const showTaskName = shouldShowTaskTitleField(isCreateMode, isEditMode, isTaskStarted);
|
||||
const taskNameAutoFocus = !isEditMode && !fs.useRemote;
|
||||
const showTaskName =
|
||||
shouldShowTaskTitleField(isCreateMode, isEditMode, isTaskStarted) && !autoTitle;
|
||||
const taskNameAutoFocus = !autoTitle && !isEditMode && !fs.useRemote;
|
||||
return (
|
||||
<>
|
||||
<RepoChipsRow
|
||||
@@ -258,293 +238,6 @@ function DialogFormBody(props: DialogFormBodyProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function useEnhanceForDialog(
|
||||
fs: DialogFormState,
|
||||
taskId: string | null | undefined,
|
||||
open: boolean,
|
||||
) {
|
||||
const isConfigured = useIsUtilityConfigured();
|
||||
const { toast } = useToast();
|
||||
const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({
|
||||
sessionId: null,
|
||||
taskTitle: fs.taskName,
|
||||
});
|
||||
const applyDescription = useCallback(
|
||||
(value: string) => {
|
||||
const input = fs.descriptionInputRef.current;
|
||||
if (!input) {
|
||||
return false;
|
||||
}
|
||||
input.setValue(value);
|
||||
const applied = input.getValue() === value;
|
||||
if (applied) {
|
||||
fs.setHasDescription(value.trim().length > 0);
|
||||
}
|
||||
return applied;
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
const promptDelivery = usePromptResultDelivery({
|
||||
scopeKey: `task-create:${open}:${fs.openCycle}:${taskId ?? ""}`,
|
||||
getCurrent: () => fs.descriptionInputRef.current?.getValue() ?? null,
|
||||
apply: applyDescription,
|
||||
});
|
||||
const onEnhance = useCallback(() => {
|
||||
const current = fs.descriptionInputRef.current?.getValue() ?? "";
|
||||
if (!current.trim()) return;
|
||||
const generation = promptDelivery.captureScope();
|
||||
void enhancePrompt(current, (result) => {
|
||||
const inserted = promptDelivery.deliver(current, result, generation);
|
||||
if (inserted) {
|
||||
toast({ description: PROMPT_INSERTED_MESSAGE, variant: "success" });
|
||||
}
|
||||
return inserted;
|
||||
});
|
||||
}, [enhancePrompt, fs.descriptionInputRef, promptDelivery, toast]);
|
||||
return {
|
||||
onEnhance,
|
||||
isLoading: isEnhancingPrompt,
|
||||
isConfigured,
|
||||
pendingResult: promptDelivery.pendingResult,
|
||||
onApplyPending: promptDelivery.applyPending,
|
||||
onCopyPending: promptDelivery.copyPending,
|
||||
};
|
||||
}
|
||||
|
||||
function useJiraImportHandler(fs: DialogFormState, handleTaskNameChange: (value: string) => void) {
|
||||
return useCallback(
|
||||
(ticket: JiraTicket) => {
|
||||
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, handleTaskNameChange],
|
||||
);
|
||||
}
|
||||
|
||||
function useLinearImportHandler(
|
||||
fs: DialogFormState,
|
||||
handleTaskNameChange: (value: string) => void,
|
||||
) {
|
||||
return useCallback(
|
||||
(issue: LinearIssue) => {
|
||||
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, handleTaskNameChange],
|
||||
);
|
||||
}
|
||||
|
||||
type SubmitWiringArgs = {
|
||||
props: TaskCreateDialogProps;
|
||||
fs: ReturnType<typeof useDialogFormState>;
|
||||
computed: ReturnType<typeof useTaskCreateDialogData>["computed"];
|
||||
workspaceRepositories: ReturnType<typeof useTaskCreateDialogData>["repositories"];
|
||||
repositoryLocalPath: string;
|
||||
isSessionMode: boolean;
|
||||
isEditMode: boolean;
|
||||
preserveQueuedLastUsedOnClose: () => void;
|
||||
};
|
||||
|
||||
function useSubmitHandlersWiring({
|
||||
props,
|
||||
fs,
|
||||
computed,
|
||||
workspaceRepositories,
|
||||
repositoryLocalPath,
|
||||
isSessionMode,
|
||||
isEditMode,
|
||||
preserveQueuedLastUsedOnClose,
|
||||
}: SubmitWiringArgs) {
|
||||
const { workspaceId, workflowId, editingTask, onSuccess, onCreateSession, onOpenChange } = props;
|
||||
const { parentTaskId } = props;
|
||||
const taskId = props.taskId ?? null;
|
||||
return useTaskSubmitHandlers({
|
||||
isSessionMode,
|
||||
isEditMode,
|
||||
isPassthroughProfile: computed.isPassthroughProfile,
|
||||
taskName: fs.taskName,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
effectiveWorkflowId: computed.effectiveWorkflowId,
|
||||
effectiveDefaultStepId: computed.effectiveDefaultStepId,
|
||||
repositories: fs.repositories,
|
||||
discoveredRepositories: fs.discoveredRepositories,
|
||||
workspaceRepositories,
|
||||
useRemote: fs.useRemote,
|
||||
remoteRepos: fs.remoteRepos,
|
||||
prInfoByUrl: fs.prInfoByUrl,
|
||||
agentProfileId: computed.effectiveAgentProfileId,
|
||||
executorId: fs.executorId,
|
||||
executorProfileId: fs.executorProfileId,
|
||||
editingTask,
|
||||
onSuccess,
|
||||
onCreateSession,
|
||||
onOpenChange,
|
||||
preserveTaskCreateLastUsedOnClose: preserveQueuedLastUsedOnClose,
|
||||
taskId,
|
||||
parentTaskId,
|
||||
descriptionInputRef: fs.descriptionInputRef,
|
||||
setIsCreatingSession: fs.setIsCreatingSession,
|
||||
setIsCreatingTask: fs.setIsCreatingTask,
|
||||
setHasTitle: fs.setHasTitle,
|
||||
setHasDescription: fs.setHasDescription,
|
||||
setTaskName: fs.setTaskName,
|
||||
setRepositories: fs.setRepositories,
|
||||
setRemoteRepos: fs.setRemoteRepos,
|
||||
setAgentProfileId: fs.setAgentProfileId,
|
||||
setExecutorId: fs.setExecutorId,
|
||||
setSelectedWorkflowId: fs.setSelectedWorkflowId,
|
||||
setFetchedSteps: fs.setFetchedSteps,
|
||||
clearDraft: fs.clearDraft,
|
||||
freshBranchEnabled: fs.freshBranchEnabled,
|
||||
isLocalExecutor: computed.isLocalExecutor,
|
||||
repositoryLocalPath,
|
||||
noRepository: fs.noRepository,
|
||||
workspacePath: fs.workspacePath,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the on-disk path for the (single) selected row, used by the
|
||||
* fresh-branch consent flow. Multi-row tasks hide fresh-branch in the UI,
|
||||
* so we only need to resolve a path when there is exactly one row.
|
||||
*/
|
||||
function resolveSingleRowLocalPath(fs: DialogFormState, repositories: Repository[]): string {
|
||||
if (fs.repositories.length !== 1) return "";
|
||||
const row = fs.repositories[0];
|
||||
if (row.localPath) return row.localPath;
|
||||
if (row.repositoryId) {
|
||||
return repositories.find((r) => r.id === row.repositoryId)?.local_path ?? "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function useTaskCreateDialogSetup(
|
||||
props: TaskCreateDialogProps,
|
||||
options: { preserveQueuedLastUsedOnClose?: () => void } = {},
|
||||
) {
|
||||
const resolvedProps = useResolvedTaskCreateWorkflowContext(props);
|
||||
const { open, mode = "create", workspaceId, workflowId, defaultStepId } = resolvedProps;
|
||||
const { editingTask, initialValues } = resolvedProps;
|
||||
const isSessionMode = mode === "session";
|
||||
const isEditMode = mode === "edit";
|
||||
const isTaskStarted = computeIsTaskStarted(isEditMode, editingTask);
|
||||
const fs = useDialogFormState(open, workspaceId, workflowId, initialValues);
|
||||
const upsertWorkspaceRepository = useAppStore((state) => state.upsertRepository);
|
||||
const { toast } = useToast();
|
||||
const sessionRepoName = useSessionRepoName(isSessionMode);
|
||||
const {
|
||||
workflows,
|
||||
agentProfiles,
|
||||
executors,
|
||||
snapshots,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
refreshRepositories,
|
||||
taskCreateLastUsed,
|
||||
userSettingsLoaded,
|
||||
computed,
|
||||
} = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs);
|
||||
const repositoryLocalPath = resolveSingleRowLocalPath(fs, repositories);
|
||||
useTaskCreateDialogEffects(fs, {
|
||||
open,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
effectiveWorkflowId: computed.effectiveWorkflowId,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
agentProfiles,
|
||||
compatibleAgentProfiles: computed.compatibleAgentProfiles,
|
||||
authLoaded: computed.authLoaded,
|
||||
executors,
|
||||
workspaceDefaults: computed.workspaceDefaults,
|
||||
toast,
|
||||
workflows,
|
||||
isLocalExecutor: computed.isLocalExecutor,
|
||||
lastUsedRepositoryId: taskCreateLastUsed.repositoryId,
|
||||
userSettingsLoaded,
|
||||
lastUsedAgentProfileId: taskCreateLastUsed.agentProfileId,
|
||||
lastUsedExecutorProfileId: taskCreateLastUsed.executorProfileId,
|
||||
lastUsedBranch: taskCreateLastUsed.branch,
|
||||
preserveBranch: initialValues?.checkoutBranch || initialValues?.branch,
|
||||
});
|
||||
useLockedFieldSync(open, workflowId, initialValues, fs);
|
||||
const handlers = useDialogHandlers(fs, repositories, {
|
||||
workspaceId,
|
||||
executors,
|
||||
upsertWorkspaceRepository,
|
||||
});
|
||||
const submitHandlers = useSubmitHandlersWiring({
|
||||
props: resolvedProps,
|
||||
fs,
|
||||
computed,
|
||||
workspaceRepositories: repositories,
|
||||
repositoryLocalPath,
|
||||
isSessionMode,
|
||||
isEditMode,
|
||||
preserveQueuedLastUsedOnClose: options.preserveQueuedLastUsedOnClose ?? (() => undefined),
|
||||
});
|
||||
const guardedHandleSubmit = useGuardedSubmit(
|
||||
submitHandlers.handleSubmit,
|
||||
resolvedProps.submitBlockedReason,
|
||||
);
|
||||
const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => {
|
||||
guardedHandleSubmit(event as unknown as FormEvent);
|
||||
});
|
||||
const freshBranchAvailable =
|
||||
!fs.useRemote && computed.isLocalExecutor && fs.repositories.length === 1;
|
||||
return {
|
||||
fs,
|
||||
isSessionMode,
|
||||
isEditMode,
|
||||
isCreateMode: mode === "create",
|
||||
isTaskStarted,
|
||||
sessionRepoName,
|
||||
workflows,
|
||||
agentProfiles,
|
||||
snapshots,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
refreshRepositories,
|
||||
computed,
|
||||
handlers,
|
||||
submitHandlers,
|
||||
handleKeyDown,
|
||||
freshBranchAvailable,
|
||||
taskCreateLastUsed,
|
||||
userSettingsLoaded,
|
||||
guardedHandleSubmit,
|
||||
enhance: useEnhanceForDialog(fs, resolvedProps.taskId, resolvedProps.open),
|
||||
handleJiraImport: useJiraImportHandler(fs, handlers.handleTaskNameChange),
|
||||
handleLinearImport: useLinearImportHandler(fs, handlers.handleTaskNameChange),
|
||||
};
|
||||
}
|
||||
|
||||
// Buttons are disabled when submitBlockedReason is set, but the form can still
|
||||
// be submitted via Enter; gate the submit path here so a wrapper's async
|
||||
// bootstrap step always finishes before any task is created.
|
||||
function useGuardedSubmit(
|
||||
handleSubmit: (e: FormEvent) => void,
|
||||
blockedReason: string | null | undefined,
|
||||
) {
|
||||
const blocked = Boolean(blockedReason);
|
||||
return useCallback(
|
||||
(e: FormEvent) => {
|
||||
if (blocked) e.preventDefault();
|
||||
else handleSubmit(e);
|
||||
},
|
||||
[blocked, handleSubmit],
|
||||
);
|
||||
}
|
||||
|
||||
// Synthetic submit event used by the voice auto-send path. Calling the form
|
||||
// handler directly (instead of `form.requestSubmit()`) matches the chat
|
||||
// composer's pattern and avoids the Safari < 16 gap where `requestSubmit` is
|
||||
@@ -634,7 +327,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
|
||||
function PendingDiscardModal({
|
||||
pending,
|
||||
}: {
|
||||
pending: ReturnType<typeof useTaskSubmitHandlers>["pendingDiscard"];
|
||||
pending: ReturnType<typeof useTaskCreateDialogSetup>["submitHandlers"]["pendingDiscard"];
|
||||
}) {
|
||||
if (!pending) return null;
|
||||
return (
|
||||
|
||||
@@ -345,6 +345,7 @@ type SubtaskFormProps = {
|
||||
/** Whether the parent dialog is open — required for the GitHub URL effect. */
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
autoTitle: boolean;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
@@ -363,6 +364,7 @@ function NewSubtaskForm({
|
||||
availableRepositories,
|
||||
isOpen,
|
||||
onClose,
|
||||
autoTitle,
|
||||
}: SubtaskFormProps) {
|
||||
const { toast } = useToast();
|
||||
const isUtilityConfigured = useIsUtilityConfigured();
|
||||
@@ -417,6 +419,7 @@ function NewSubtaskForm({
|
||||
attachments: promptZone.attachments.attachments,
|
||||
resolvePrompt: promptZone.resolvePrompt,
|
||||
title,
|
||||
autoTitle,
|
||||
setIsCreating,
|
||||
onClose,
|
||||
workspaceMode,
|
||||
@@ -426,6 +429,7 @@ function NewSubtaskForm({
|
||||
handlers,
|
||||
title,
|
||||
setTitle,
|
||||
autoTitle,
|
||||
workspaceId,
|
||||
availableRepositories,
|
||||
parentRepositoryId,
|
||||
@@ -497,6 +501,7 @@ export function NewSubtaskDialog({
|
||||
const siblingCount = useAppStore(
|
||||
(s) => s.kanban.tasks.filter((t) => t.parentTaskId === parentTaskId).length,
|
||||
);
|
||||
const autoTitle = useAppStore((s) => s.userSettings.agentGeneratedTaskTitles);
|
||||
|
||||
const defaultTitle = useMemo(
|
||||
() => `${parentTaskTitle} / Subtask ${siblingCount + 1}`,
|
||||
@@ -530,6 +535,7 @@ export function NewSubtaskDialog({
|
||||
availableRepositories={availableRepositories}
|
||||
isOpen={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
autoTitle={autoTitle}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -253,6 +253,7 @@ type SubtaskFormBodyProps = {
|
||||
handlers: ReturnType<typeof useDialogHandlers>;
|
||||
title: string;
|
||||
setTitle: (v: string) => void;
|
||||
autoTitle?: boolean;
|
||||
workspaceId: string | null;
|
||||
availableRepositories: Repository[];
|
||||
parentRepositoryId: string | null;
|
||||
@@ -391,11 +392,13 @@ function shouldShowWorktreeBadge(
|
||||
* context picker, prompt zone, footer). Extracted from `NewSubtaskForm` so
|
||||
* the parent stays under the per-function complexity cap.
|
||||
*/
|
||||
// eslint-disable-next-line max-lines-per-function -- shared form keeps workspace, prompt, and submit controls together.
|
||||
export function SubtaskFormBody({
|
||||
fs,
|
||||
handlers,
|
||||
title,
|
||||
setTitle,
|
||||
autoTitle = false,
|
||||
workspaceId,
|
||||
availableRepositories,
|
||||
parentRepositoryId,
|
||||
@@ -420,20 +423,25 @@ export function SubtaskFormBody({
|
||||
const inheritParent = workspaceMode === "inherit_parent";
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="min-w-0 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="subtask-title-input" className="text-xs font-medium text-muted-foreground">
|
||||
Title
|
||||
</label>
|
||||
<Input
|
||||
id="subtask-title-input"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(clampTaskTitleInput(e.target.value))}
|
||||
placeholder="Subtask title"
|
||||
className="min-w-0 max-w-full text-sm"
|
||||
data-testid="subtask-title-input"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
{!autoTitle && (
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="subtask-title-input"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<Input
|
||||
id="subtask-title-input"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(clampTaskTitleInput(e.target.value))}
|
||||
placeholder="Subtask title"
|
||||
className="min-w-0 max-w-full text-sm"
|
||||
data-testid="subtask-title-input"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<WorkspaceModeToggle
|
||||
value={workspaceMode}
|
||||
onChange={onWorkspaceModeChange}
|
||||
@@ -479,7 +487,7 @@ export function SubtaskFormBody({
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isCreating || isSummarizing || !hasPrompt}
|
||||
disabled={isCreating || isSummarizing || !hasPrompt || (!autoTitle && !title.trim())}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{isCreating ? "Creating..." : "Create Subtask"}
|
||||
|
||||
@@ -4,6 +4,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { UtilityGenerationResult } from "@/hooks/use-utility-agent-generator";
|
||||
|
||||
const { mockCreateTask, mockReplaceTaskUrl, mockSetActiveTask, mockSetActiveSession } = vi.hoisted(
|
||||
() => ({
|
||||
mockCreateTask: vi.fn(),
|
||||
mockReplaceTaskUrl: vi.fn(),
|
||||
mockSetActiveTask: vi.fn(),
|
||||
mockSetActiveSession: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
const mockToast = vi.fn();
|
||||
const mockEnhancePrompt = vi.fn();
|
||||
|
||||
@@ -18,7 +27,25 @@ vi.mock("@/hooks/use-utility-agent-generator", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useSubtaskPromptZone } from "./use-subtask-submit";
|
||||
vi.mock("@/lib/api/domains/kanban-api", () => ({
|
||||
createTask: mockCreateTask,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/links", () => ({
|
||||
replaceTaskUrl: mockReplaceTaskUrl,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) =>
|
||||
selector({ setActiveTask: mockSetActiveTask, setActiveSession: mockSetActiveSession }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/task-create-dialog-helpers", () => ({
|
||||
buildRepositoriesPayload: vi.fn(() => []),
|
||||
toMessageAttachments: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
import { useSubtaskPromptZone, useSubtaskSubmit } from "./use-subtask-submit";
|
||||
|
||||
const GENERATED_RESULT = {
|
||||
content: "improved prompt",
|
||||
@@ -26,6 +53,8 @@ const GENERATED_RESULT = {
|
||||
durationMs: 1_200,
|
||||
} satisfies UtilityGenerationResult;
|
||||
const ORIGINAL_PROMPT = "original prompt";
|
||||
const CREATED_TASK_ID = "created-task";
|
||||
const CREATED_SESSION_ID = "created-session";
|
||||
|
||||
function useSubtaskPromptHarness(initialPrompt = ORIGINAL_PROMPT) {
|
||||
const [promptValue, setPromptValue] = useState(initialPrompt);
|
||||
@@ -179,3 +208,108 @@ describe("useSubtaskPromptZone", () => {
|
||||
expect(result.current.resolvePrompt()).toBe("next prompt");
|
||||
});
|
||||
});
|
||||
|
||||
function makeSubmitOptions(
|
||||
overrides: Partial<Parameters<typeof useSubtaskSubmit>[0]> = {},
|
||||
): Parameters<typeof useSubtaskSubmit>[0] {
|
||||
return {
|
||||
fs: {
|
||||
useRemote: false,
|
||||
remoteRepos: [],
|
||||
prInfoByUrl: {},
|
||||
repositories: [],
|
||||
discoveredRepositories: [],
|
||||
agentProfileId: "",
|
||||
executorProfileId: "",
|
||||
} as unknown as Parameters<typeof useSubtaskSubmit>[0]["fs"],
|
||||
parentTaskId: "parent-task",
|
||||
defaultProfileId: "default-profile",
|
||||
workspaceId: "workspace-1",
|
||||
workflowId: "workflow-1",
|
||||
availableRepositories: [],
|
||||
attachments: [],
|
||||
resolvePrompt: () => "do the work",
|
||||
title: "Manual title",
|
||||
setIsCreating: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
workspaceMode: "new_workspace",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useSubtaskSubmit", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateTask.mockResolvedValue({ id: CREATED_TASK_ID, session_id: CREATED_SESSION_ID });
|
||||
});
|
||||
|
||||
it("sends the auto-title contract without a title", async () => {
|
||||
const opts = makeSubmitOptions({ autoTitle: true });
|
||||
const { result } = renderHook(() => useSubtaskSubmit(opts));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSubmit({ preventDefault: vi.fn() } as never);
|
||||
});
|
||||
|
||||
expect(mockCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ auto_title: true, description: "do the work" }),
|
||||
);
|
||||
expect(mockCreateTask.mock.calls[0][0]).not.toHaveProperty("title");
|
||||
expect(opts.onClose).toHaveBeenCalledOnce();
|
||||
expect(mockSetActiveTask).toHaveBeenCalledWith(CREATED_TASK_ID);
|
||||
expect(mockSetActiveSession).toHaveBeenCalledWith(CREATED_TASK_ID, CREATED_SESSION_ID);
|
||||
expect(mockReplaceTaskUrl).toHaveBeenCalledWith(CREATED_TASK_ID);
|
||||
});
|
||||
|
||||
it("requires a title when auto-title mode is omitted", async () => {
|
||||
const opts = makeSubmitOptions({ title: "" });
|
||||
const { result } = renderHook(() => useSubtaskSubmit(opts));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSubmit({ preventDefault: vi.fn() } as never);
|
||||
});
|
||||
|
||||
expect(mockCreateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the legacy title payload", async () => {
|
||||
const opts = makeSubmitOptions({ title: "Manual title" });
|
||||
const { result } = renderHook(() => useSubtaskSubmit(opts));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSubmit({ preventDefault: vi.fn() } as never);
|
||||
});
|
||||
|
||||
expect(mockCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Manual title", description: "do the work" }),
|
||||
);
|
||||
expect(mockCreateTask.mock.calls[0][0]).not.toHaveProperty("auto_title");
|
||||
});
|
||||
|
||||
it("rejects an empty prompt before creating", async () => {
|
||||
const opts = makeSubmitOptions({ resolvePrompt: () => " " });
|
||||
const { result } = renderHook(() => useSubtaskSubmit(opts));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSubmit({ preventDefault: vi.fn() } as never);
|
||||
});
|
||||
|
||||
expect(mockCreateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans up the creating state after a request failure", async () => {
|
||||
const opts = makeSubmitOptions();
|
||||
const error = new Error("request failed");
|
||||
mockCreateTask.mockRejectedValueOnce(error);
|
||||
const { result } = renderHook(() => useSubtaskSubmit(opts));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSubmit({ preventDefault: vi.fn() } as never);
|
||||
});
|
||||
|
||||
expect(opts.setIsCreating).toHaveBeenNthCalledWith(1, true);
|
||||
expect(opts.setIsCreating).toHaveBeenLastCalledWith(false);
|
||||
expect(opts.onClose).not.toHaveBeenCalled();
|
||||
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,12 +25,77 @@ type UseSubtaskSubmitOpts = {
|
||||
attachments: ReturnType<typeof useDialogAttachments>["attachments"];
|
||||
resolvePrompt: () => string;
|
||||
title: string;
|
||||
autoTitle?: boolean;
|
||||
setIsCreating: (v: boolean) => void;
|
||||
onClose: () => void;
|
||||
/** Workspace mode for the new subtask (handoffs phase 5). */
|
||||
workspaceMode: SubtaskWorkspaceMode;
|
||||
};
|
||||
|
||||
type CreateSubtaskArgs = {
|
||||
fs: UseSubtaskSubmitOpts["fs"];
|
||||
parentTaskId: string;
|
||||
defaultProfileId: string;
|
||||
workspaceId: string;
|
||||
workflowId: string;
|
||||
availableRepositories: Repository[];
|
||||
attachments: UseSubtaskSubmitOpts["attachments"];
|
||||
trimmedTitle: string;
|
||||
prompt: string;
|
||||
autoTitle: boolean;
|
||||
workspaceMode: SubtaskWorkspaceMode;
|
||||
setActiveTask: (taskId: string) => void;
|
||||
setActiveSession: (taskId: string, sessionId: string) => void;
|
||||
};
|
||||
|
||||
async function createSubtask({
|
||||
fs,
|
||||
parentTaskId,
|
||||
defaultProfileId,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
availableRepositories,
|
||||
attachments,
|
||||
trimmedTitle,
|
||||
prompt,
|
||||
autoTitle,
|
||||
workspaceMode,
|
||||
setActiveTask,
|
||||
setActiveSession,
|
||||
}: CreateSubtaskArgs) {
|
||||
const repositories =
|
||||
workspaceMode === "inherit_parent"
|
||||
? undefined
|
||||
: buildRepositoriesPayload({
|
||||
useRemote: fs.useRemote,
|
||||
remoteRepos: fs.remoteRepos,
|
||||
prInfoByUrl: fs.prInfoByUrl,
|
||||
repositories: fs.repositories,
|
||||
discoveredRepositories: fs.discoveredRepositories,
|
||||
workspaceRepositories: availableRepositories,
|
||||
});
|
||||
const response = await createTask({
|
||||
workspace_id: workspaceId,
|
||||
workflow_id: workflowId,
|
||||
...(autoTitle ? { auto_title: true } : { title: trimmedTitle }),
|
||||
description: prompt,
|
||||
repositories,
|
||||
start_agent: true,
|
||||
agent_profile_id: fs.agentProfileId || defaultProfileId || undefined,
|
||||
executor_profile_id:
|
||||
workspaceMode === "inherit_parent" ? undefined : fs.executorProfileId || undefined,
|
||||
parent_id: parentTaskId,
|
||||
attachments: toMessageAttachments(attachments),
|
||||
workspace_mode: workspaceMode,
|
||||
});
|
||||
const newSessionId = response.session_id ?? response.primary_session_id ?? null;
|
||||
if (newSessionId) {
|
||||
setActiveTask(response.id);
|
||||
setActiveSession(response.id, newSessionId);
|
||||
replaceTaskUrl(response.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates the subtask creation flow: builds the repositories payload,
|
||||
* calls createTask, and activates the new session. Returns `handleSubmit`
|
||||
@@ -47,6 +112,7 @@ export function useSubtaskSubmit(opts: UseSubtaskSubmitOpts) {
|
||||
attachments,
|
||||
resolvePrompt,
|
||||
title,
|
||||
autoTitle = false,
|
||||
setIsCreating,
|
||||
onClose,
|
||||
workspaceMode,
|
||||
@@ -59,73 +125,32 @@ export function useSubtaskSubmit(opts: UseSubtaskSubmitOpts) {
|
||||
// double-click) can re-enter handleSubmit and call createTask twice.
|
||||
const isSubmittingRef = useRef(false);
|
||||
|
||||
const performCreate = useCallback(
|
||||
async (trimmedTitle: string, prompt: string) => {
|
||||
// inherit_parent: omit repositories — backend inherits parent repos
|
||||
// and records workspace-group membership for launch reuse.
|
||||
const repositories =
|
||||
workspaceMode === "inherit_parent"
|
||||
? undefined
|
||||
: buildRepositoriesPayload({
|
||||
useRemote: fs.useRemote,
|
||||
remoteRepos: fs.remoteRepos,
|
||||
prInfoByUrl: fs.prInfoByUrl,
|
||||
repositories: fs.repositories,
|
||||
discoveredRepositories: fs.discoveredRepositories,
|
||||
workspaceRepositories: availableRepositories,
|
||||
});
|
||||
const response = await createTask({
|
||||
workspace_id: workspaceId!,
|
||||
workflow_id: workflowId!,
|
||||
title: trimmedTitle,
|
||||
description: prompt,
|
||||
repositories,
|
||||
start_agent: true,
|
||||
agent_profile_id: fs.agentProfileId || defaultProfileId || undefined,
|
||||
// inherit_parent reuses the parent's materialized environment, so the
|
||||
// executor is inherited too — never send a chosen executor profile.
|
||||
executor_profile_id:
|
||||
workspaceMode === "inherit_parent" ? undefined : fs.executorProfileId || undefined,
|
||||
parent_id: parentTaskId,
|
||||
attachments: toMessageAttachments(attachments),
|
||||
workspace_mode: workspaceMode,
|
||||
});
|
||||
const newSessionId = response.session_id ?? response.primary_session_id ?? null;
|
||||
if (newSessionId) {
|
||||
setActiveTask(response.id);
|
||||
setActiveSession(response.id, newSessionId);
|
||||
replaceTaskUrl(response.id);
|
||||
}
|
||||
},
|
||||
// The fs object is referenced as a whole so React's deep dependency
|
||||
// tracking re-runs performCreate when any of its fields change.
|
||||
[
|
||||
fs,
|
||||
availableRepositories,
|
||||
defaultProfileId,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
parentTaskId,
|
||||
attachments,
|
||||
setActiveTask,
|
||||
setActiveSession,
|
||||
workspaceMode,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (isSubmittingRef.current) return;
|
||||
const trimmedTitle = title.trim();
|
||||
if (!trimmedTitle || !workspaceId || !workflowId) return;
|
||||
const prompt = resolvePrompt();
|
||||
if (!prompt) return;
|
||||
const prompt = resolvePrompt().trim();
|
||||
if ((!autoTitle && !trimmedTitle) || !prompt || !workspaceId || !workflowId) return;
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await performCreate(trimmedTitle, prompt);
|
||||
await createSubtask({
|
||||
fs,
|
||||
parentTaskId,
|
||||
defaultProfileId,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
availableRepositories,
|
||||
attachments,
|
||||
trimmedTitle,
|
||||
prompt,
|
||||
autoTitle,
|
||||
workspaceMode,
|
||||
setActiveTask,
|
||||
setActiveSession,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -138,7 +163,24 @@ export function useSubtaskSubmit(opts: UseSubtaskSubmitOpts) {
|
||||
setIsCreating(false);
|
||||
}
|
||||
},
|
||||
[title, workspaceId, workflowId, resolvePrompt, performCreate, setIsCreating, onClose, toast],
|
||||
[
|
||||
title,
|
||||
autoTitle,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
resolvePrompt,
|
||||
fs,
|
||||
parentTaskId,
|
||||
defaultProfileId,
|
||||
availableRepositories,
|
||||
attachments,
|
||||
setActiveTask,
|
||||
setActiveSession,
|
||||
workspaceMode,
|
||||
setIsCreating,
|
||||
onClose,
|
||||
toast,
|
||||
],
|
||||
);
|
||||
|
||||
return { handleSubmit };
|
||||
|
||||
@@ -835,6 +835,7 @@ export class ApiClient {
|
||||
mcp_task_agent_profile_default?: MCPTaskAgentProfileDefault;
|
||||
tasks_list_show_details?: boolean;
|
||||
show_transcript_auto_scroll_control?: boolean;
|
||||
agent_generated_task_titles?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}> {
|
||||
@@ -845,6 +846,7 @@ export class ApiClient {
|
||||
enable_preview_on_click?: boolean;
|
||||
confirm_task_archive?: boolean;
|
||||
unread_divider?: boolean;
|
||||
agent_generated_task_titles?: boolean;
|
||||
mcp_task_agent_profile_default?: MCPTaskAgentProfileDefault;
|
||||
show_anchored_prompt_bar?: boolean;
|
||||
show_scroll_to_last_prompt?: boolean;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
useRegularMode();
|
||||
|
||||
test.describe("Agent-generated task titles", () => {
|
||||
test("hides the new-task title and persists a six-word provisional title", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
const initial = await apiClient.getUserSettings();
|
||||
const initialEnabled = Boolean(initial.settings.agent_generated_task_titles);
|
||||
|
||||
try {
|
||||
await testPage.goto("/settings/general/task-actions");
|
||||
const toggle = testPage.getByRole("switch", { name: "Use the agent for new task titles" });
|
||||
if (!(await toggle.isChecked())) await toggle.click();
|
||||
await testPage
|
||||
.getByTestId("settings-floating-save")
|
||||
.getByRole("button", { name: "Save changes" })
|
||||
.click();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
Boolean((await apiClient.getUserSettings()).settings.agent_generated_task_titles),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByTestId("task-title-input")).toHaveCount(0);
|
||||
await dialog
|
||||
.getByTestId("task-description-input")
|
||||
.fill("Design a small login recovery flow with tests and docs");
|
||||
await expect(dialog.getByTestId("submit-start-agent")).toBeEnabled({ timeout: 30_000 });
|
||||
await dialog.getByTestId("submit-start-agent").click();
|
||||
|
||||
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
|
||||
const taskID = testPage.url().match(/\/t\/([^/?]+)/)?.[1];
|
||||
expect(taskID).toBeTruthy();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const task = await apiClient.getTask(taskID!);
|
||||
return (
|
||||
task.title === "Design a small login recovery flow" ||
|
||||
task.metadata?.agent_title_pending !== true
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
const task = await apiClient.getTask(taskID!);
|
||||
expect(task.title).toBeTruthy();
|
||||
expect(
|
||||
task.title === "Design a small login recovery flow" ||
|
||||
task.metadata?.agent_title_pending !== true,
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await apiClient.saveUserSettings({ agent_generated_task_titles: initialEnabled });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
useRegularMode();
|
||||
|
||||
test.describe("Agent-generated task titles on mobile", () => {
|
||||
test("keeps the prompt-first task and subtask dialogs touch-safe", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await testPage.setViewportSize({ width: 390, height: 844 });
|
||||
const initial = await apiClient.getUserSettings();
|
||||
const initialEnabled = Boolean(initial.settings.agent_generated_task_titles);
|
||||
|
||||
try {
|
||||
await apiClient.saveUserSettings({ agent_generated_task_titles: true });
|
||||
await testPage.goto("/settings/general/task-actions");
|
||||
await expect(
|
||||
testPage.getByRole("switch", { name: "Use the agent for new task titles" }),
|
||||
).toBeChecked();
|
||||
|
||||
const parent = await apiClient.seedTask(seedData.workspaceId, "Mobile title parent", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
await testPage.goto(`/t/${parent.task_id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
expect(
|
||||
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
).toBe(true);
|
||||
await testPage.getByTestId("mobile-session-menu").click();
|
||||
const taskSheet = testPage.getByRole("dialog", { name: "Tasks" });
|
||||
const taskRow = taskSheet
|
||||
.getByTestId("sidebar-task-item")
|
||||
.filter({ hasText: "Mobile title parent" });
|
||||
await taskRow.getByRole("button", { name: "Task actions" }).click();
|
||||
await testPage.getByRole("menuitem", { name: "Create Subtask", exact: true }).click();
|
||||
|
||||
const subtaskDialog = testPage.getByTestId("new-subtask-dialog");
|
||||
await expect(subtaskDialog).toBeVisible();
|
||||
await expect(subtaskDialog.getByTestId("subtask-title-input")).toHaveCount(0);
|
||||
await expect(subtaskDialog.getByTestId("subtask-prompt-input")).toBeVisible();
|
||||
await subtaskDialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
} finally {
|
||||
await apiClient.saveUserSettings({ agent_generated_task_titles: initialEnabled });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,7 @@ function makeUnloadedSettings(): UserSettingsState {
|
||||
reviewAutoMarkOnScroll: true,
|
||||
confirmTaskArchive: true,
|
||||
unreadDivider: true,
|
||||
agentGeneratedTaskTitles: false,
|
||||
mcpTaskAgentProfileDefault: "current_task",
|
||||
showAnchoredPromptBar: false,
|
||||
showScrollToLastPrompt: true,
|
||||
|
||||
@@ -46,13 +46,23 @@ export async function reorderWorkflows(
|
||||
}
|
||||
|
||||
// Task operations
|
||||
type CreateTaskTitlePayload =
|
||||
| {
|
||||
title: string;
|
||||
auto_title?: false;
|
||||
description?: string;
|
||||
}
|
||||
| {
|
||||
title?: never;
|
||||
auto_title: true;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export async function createTask(
|
||||
payload: {
|
||||
payload: CreateTaskTitlePayload & {
|
||||
workspace_id: string;
|
||||
workflow_id: string;
|
||||
workflow_step_id?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
position?: number;
|
||||
repositories?: Array<{
|
||||
repository_id: string;
|
||||
|
||||
@@ -291,6 +291,24 @@ describe("mapUserSettingsResponse unread divider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapUserSettingsResponse agent-generated task titles", () => {
|
||||
it("defaults the preference off and preserves explicit enablement", () => {
|
||||
const fallback = mapUserSettingsResponse(null) as { agentGeneratedTaskTitles?: boolean };
|
||||
const enabled = mapUserSettingsResponse({
|
||||
settings: {
|
||||
user_id: DEFAULT_USER_ID,
|
||||
workspace_id: toWorkspaceId(""),
|
||||
repository_ids: [],
|
||||
agent_generated_task_titles: true,
|
||||
updated_at: UPDATED_AT,
|
||||
} as unknown as NonNullable<Parameters<typeof mapUserSettingsResponse>[0]>["settings"],
|
||||
}) as { agentGeneratedTaskTitles?: boolean };
|
||||
|
||||
expect(fallback.agentGeneratedTaskTitles).toBe(false);
|
||||
expect(enabled.agentGeneratedTaskTitles).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transcript navigation settings", () => {
|
||||
it("uses the requested defaults when settings are unavailable", () => {
|
||||
const settings = mapUserSettingsResponse(null);
|
||||
|
||||
@@ -37,6 +37,7 @@ export function createDefaultUserSettings(): UserSettingsState {
|
||||
reviewAutoMarkOnScroll: true,
|
||||
confirmTaskArchive: true,
|
||||
unreadDivider: false,
|
||||
agentGeneratedTaskTitles: false,
|
||||
mcpTaskAgentProfileDefault: "current_task",
|
||||
showAnchoredPromptBar: false,
|
||||
showScrollToLastPrompt: true,
|
||||
@@ -228,6 +229,7 @@ function buildBehaviorFields(s: UserSettingsData, current: UserSettingsState) {
|
||||
reviewAutoMarkOnScroll: s.review_auto_mark_on_scroll ?? current.reviewAutoMarkOnScroll,
|
||||
confirmTaskArchive: s.confirm_task_archive ?? current.confirmTaskArchive,
|
||||
unreadDivider: s.unread_divider ?? current.unreadDivider,
|
||||
agentGeneratedTaskTitles: s.agent_generated_task_titles ?? false,
|
||||
mcpTaskAgentProfileDefault: mapDefined(
|
||||
s.mcp_task_agent_profile_default,
|
||||
current.mcpTaskAgentProfileDefault,
|
||||
|
||||
@@ -183,6 +183,7 @@ export type UserSettingsState = {
|
||||
reviewAutoMarkOnScroll: boolean;
|
||||
confirmTaskArchive: boolean;
|
||||
unreadDivider: boolean;
|
||||
agentGeneratedTaskTitles: boolean;
|
||||
mcpTaskAgentProfileDefault: MCPTaskAgentProfileDefault;
|
||||
showAnchoredPromptBar: boolean;
|
||||
showScrollToLastPrompt: boolean;
|
||||
|
||||
@@ -68,6 +68,7 @@ export type UserSettings = {
|
||||
review_auto_mark_on_scroll?: boolean;
|
||||
confirm_task_archive?: boolean;
|
||||
unread_divider?: boolean;
|
||||
agent_generated_task_titles?: boolean;
|
||||
mcp_task_agent_profile_default?: MCPTaskAgentProfileDefault;
|
||||
show_release_notification?: boolean;
|
||||
release_notes_last_seen_version?: string;
|
||||
@@ -123,6 +124,7 @@ export type UserSettingsUpdatePayload = {
|
||||
review_auto_mark_on_scroll?: boolean;
|
||||
confirm_task_archive?: boolean;
|
||||
unread_divider?: boolean;
|
||||
agent_generated_task_titles?: boolean;
|
||||
mcp_task_agent_profile_default?: MCPTaskAgentProfileDefault;
|
||||
show_release_notification?: boolean;
|
||||
release_notes_last_seen_version?: string;
|
||||
|
||||
@@ -125,6 +125,18 @@ describe("user settings websocket handler", () => {
|
||||
expect(store.getState().userSettings.confirmTaskArchive).toBe(false);
|
||||
});
|
||||
|
||||
it("syncs the agent-generated title preference", () => {
|
||||
const store = makeStore();
|
||||
|
||||
registerUsersHandlers(store)["user.settings.updated"]?.(
|
||||
userSettingsMessage({ agent_generated_task_titles: true }),
|
||||
);
|
||||
expect(store.getState().userSettings.agentGeneratedTaskTitles).toBe(true);
|
||||
|
||||
registerUsersHandlers(store)["user.settings.updated"]?.(userSettingsMessage({}));
|
||||
expect(store.getState().userSettings.agentGeneratedTaskTitles).toBe(false);
|
||||
});
|
||||
|
||||
it("syncs transcript navigation preferences and uses the documented defaults", () => {
|
||||
const store = makeStore();
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# ADR-2026-07-31-agent-generated-task-titles: Bind Agent Title Generation to Pending Tasks
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-07-31
|
||||
**Area:** backend, frontend, protocol
|
||||
|
||||
## Context
|
||||
|
||||
Users can currently create Kanban tasks and subtasks only after choosing a title. Kandev needs an
|
||||
optional flow where the initial prompt is enough to create the task, a readable provisional title is
|
||||
available immediately, and the first agent session replaces it with a concise title. The feature
|
||||
crosses portable user settings, task persistence, first-turn prompt composition, and the task-mode MCP
|
||||
surface, and it must not cause later sessions to rename an already-titled task.
|
||||
|
||||
## Decision
|
||||
|
||||
The regular task-creation HTTP contract accepts an explicit `auto_title` opt-in. The task service
|
||||
derives a provisional title from the first six whitespace-normalized words of the initial prompt and
|
||||
persists `tasks.metadata.agent_title_pending=true` with the task. The setting that causes the UI to
|
||||
send this opt-in remains a backend-owned portable user preference; Kandev does not read the current
|
||||
preference at session launch time.
|
||||
|
||||
The executor resolves a title-pending task-mode MCP variant only when the task has
|
||||
`agent_title_pending=true`; regular task mode does not register `set_task_title_kandev`. The tool is
|
||||
bound to the MCP server's current task and accepts only the replacement `title`; callers cannot select
|
||||
another task. Its tool and argument descriptions teach the agent to target three words and use no more
|
||||
than six when practical. This is generation guidance rather than a new persistence constraint.
|
||||
First-turn prompt composition includes both the tool inventory entry and the instruction to call it
|
||||
before other work only while the marker is true, even if the provisional title appears usable. A
|
||||
successful call replaces the title, removes the marker, and publishes the normal `task.updated` event.
|
||||
Any ordinary title update also removes the marker so a user rename wins a race with the agent. Calls
|
||||
made after the marker is gone return a non-mutating `accepted: false` result.
|
||||
|
||||
The title-pending task mode is resolved from persisted task intent when the session launches; Config
|
||||
and Office restrictions still take precedence, and the tool is never registered in Office, Config, or
|
||||
External mode. Structured sessions receive the conditional instruction in the normal hidden Kandev
|
||||
context; passthrough sessions receive the equivalent short launch instruction because they have no
|
||||
hidden system-prompt channel.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every created task has a usable persisted title before an agent process starts, even when launch is
|
||||
delayed or fails.
|
||||
- The pending marker, rather than the user's current setting, determines whether a task still needs an
|
||||
agent title, so preference changes do not retroactively rename tasks.
|
||||
- Manual renames and successful agent renames converge on the same normal task-update event path.
|
||||
- Tasks created without agent naming incur no prompt or tool-schema token cost for this feature.
|
||||
- Tool discovery remains stable for the lifetime of one MCP server. A successful call removes the
|
||||
persisted marker, so later sessions use regular task mode and omit the tool; the originating session
|
||||
may retain an idempotent tool until it ends.
|
||||
- Passthrough users may see the short title instruction in the native TUI because passthrough has no
|
||||
separate hidden system channel.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **Reuse `update_task_kandev`.** Rejected because it requires an arbitrary task ID, exposes unrelated
|
||||
mutations, and cannot express the one-time pending/no-overwrite contract.
|
||||
2. **Read the live user setting whenever a session starts.** Rejected because enabling the setting
|
||||
would instruct sessions on older manually titled tasks, while disabling it could strand a newly
|
||||
created provisional title.
|
||||
3. **Register the MCP tool for every task-mode session.** Rejected because users who leave the feature
|
||||
disabled would pay prompt/tool-schema token cost for a capability their tasks cannot use.
|
||||
4. **Derive and track titles only in the frontend.** Rejected because creation callers could diverge on
|
||||
whitespace, word selection, and pending-state semantics, and the backend could not enforce the
|
||||
manual-rename race.
|
||||
5. **Generate the title with a separate utility-agent request before task creation.** Rejected because
|
||||
it adds latency, cost, another failure dependency, and still needs a provisional title.
|
||||
@@ -93,6 +93,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 2026-07-30-session-owned-mcp-observability | [Keep MCP Attachment Evidence Session Owned](2026-07-30-session-owned-mcp-observability.md) | accepted | backend, frontend, protocol, security | 2026-07-30 |
|
||||
| 2026-07-30-runtime-task-state-before-running-event | [Publish Task State Before Running Session State](2026-07-30-runtime-task-state-before-running-event.md) | accepted | backend, frontend, protocol, workflow | 2026-07-30 |
|
||||
| 2026-07-31-isolate-manual-pr-review-content | [Isolate Manual PR Review Content](2026-07-31-isolate-manual-pr-review-content.md) | accepted | infra, workflow, security | 2026-07-31 |
|
||||
| 2026-07-31-agent-generated-task-titles | [Bind Agent Title Generation to Pending Tasks](2026-07-31-agent-generated-task-titles.md) | accepted | backend, frontend, protocol | 2026-07-31 |
|
||||
| 2026-07-31-system-service-user-continuity | [Preserve System Service Identity Across Reinstallation](2026-07-31-system-service-user-continuity.md) | accepted | backend, cli, security, operations | 2026-07-31 |
|
||||
| 2026-08-01-repository-task-executor-defaults | [Resolve Task Executor Policy Before Last-Used Profile](2026-08-01-repository-task-executor-defaults.md) | accepted | frontend | 2026-08-01 |
|
||||
| 2026-08-01-validate-mcp-tool-arguments | [Validate MCP Tool Arguments at the Shared Server Boundary](2026-08-01-validate-mcp-tool-arguments.md) | accepted | backend, protocol | 2026-08-01 |
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
---
|
||||
spec: docs/specs/tasks/agent-generated-titles.md
|
||||
created: 2026-07-31
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# Implementation Plan: Agent-Generated Task Titles
|
||||
|
||||
## Overview
|
||||
|
||||
Add the portable preference and backend-owned provisional-title lifecycle first, then expose the
|
||||
task-bound MCP mutation and conditional first-turn instruction. After those contracts exist, wire the
|
||||
shared desktop/mobile creation dialogs to opt in and finish with end-to-end coverage and public docs.
|
||||
The persisted pending marker is the integration seam: frontend creation sets it through `auto_title`,
|
||||
prompt composition reads it, and either an agent or human title update clears it.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### Portable user setting
|
||||
|
||||
- Extend `apps/backend/internal/user/models/models.go`, `dto/dto.go`, and
|
||||
`service/service.go` with `AgentGeneratedTaskTitles bool` /
|
||||
`agent_generated_task_titles`, using pointer PATCH semantics and a missing-field default of `false`.
|
||||
- Extend `apps/backend/internal/user/store/sqlite.go` JSON encode/decode and defaults. This remains in
|
||||
the existing `users.settings` JSON blob, so no schema migration is needed.
|
||||
- Project the field through `apps/backend/internal/backendapp/boot_state_routes.go` and the existing
|
||||
`user.settings.updated` payload.
|
||||
- Add focused store, DTO, service, event-payload, and boot-state tests beside the existing archive and
|
||||
MCP-profile preference tests.
|
||||
|
||||
### Provisional-title creation and pending state
|
||||
|
||||
- Add `AutoTitle bool` to `service.CreateTaskRequest` in
|
||||
`apps/backend/internal/task/service/service_requests.go` and define
|
||||
`models.MetaKeyAgentTitlePending = "agent_title_pending"` in
|
||||
`apps/backend/internal/task/models/models.go`.
|
||||
- In `apps/backend/internal/task/service/service_tasks.go`, add a rune-safe helper that trims the
|
||||
prompt, selects the first six entries from `strings.Fields`, and joins them with one space. Shorter
|
||||
prompts use every word; there is no normal character truncation or ellipsis. Preserve the existing
|
||||
absolute 500-character task-title safety boundary. When `AutoTitle` is true, reject an empty
|
||||
normalized prompt, replace the request title with the derived value, and add the pending metadata
|
||||
before the task row is inserted.
|
||||
- Add `auto_title` to `httpCreateTaskRequest` in
|
||||
`apps/backend/internal/task/handlers/task_http_handlers.go`. Preserve the existing required-title
|
||||
path when it is false; pass the opt-in to the service when true.
|
||||
- Make an ordinary `Service.UpdateTask` call with a non-nil title remove
|
||||
`agent_title_pending` before its single task-row update. Add a task-service method for the MCP path
|
||||
that validates the 500-character title limit, accepts only a pending task, updates title and metadata,
|
||||
and publishes the standard `task.updated` event.
|
||||
- Cover whitespace normalization, six-word selection, shorter prompts, the existing absolute title
|
||||
limit, empty prompt rejection, top-level and subtask creation, restart-durable metadata, ordinary
|
||||
rename precedence, repeated agent calls, and update-event publication in `service_tasks_test.go` and
|
||||
focused HTTP handler tests.
|
||||
|
||||
### Task-mode MCP tool
|
||||
|
||||
- Add internal `mcp.set_task_title` action plumbing in `apps/backend/pkg/websocket/actions.go`,
|
||||
`apps/backend/internal/mcp/server/handlers.go`, and
|
||||
`apps/backend/internal/mcp/handlers/handlers.go`.
|
||||
- Add a title-pending task-mode variant in `apps/backend/internal/mcp/server/server.go`. It registers all
|
||||
regular task tools plus `set_task_title_kandev`; regular `ModeTask` and restricted modes omit the new
|
||||
tool entirely. Its public schema has one required `title`; the server injects `s.taskID` into the
|
||||
internal payload and never accepts a caller-selected task ID. Write both the tool description and
|
||||
`title` argument description to target three words, use no more than six when practical, and clarify
|
||||
that the existing title is provisional and must still be replaced.
|
||||
- Extend `resolveTaskSessionMCPMode` in
|
||||
`apps/backend/internal/orchestrator/executor/executor_execute.go` to select the title-pending task mode
|
||||
from `agent_title_pending`. Config and Office modes win. Thread the new supported mode through the
|
||||
existing agentctl mode validation/configuration path without adding a second feature flag.
|
||||
- Return accepted task/title data on success and an idempotent
|
||||
`{accepted:false, reason:"title_not_pending"}` when the marker is absent. Preserve validation and
|
||||
authorization errors from the task service.
|
||||
- Update regular/pending/restricted-mode catalog tests, MCP mode API tests, executor mode-resolution
|
||||
tests, handler forwarding tests, MCP integration tests, and the raw-WebSocket MCP-prefix regression
|
||||
inventory. Assert that ordinary task mode does not include the new tool schema.
|
||||
|
||||
### Conditional first-turn instruction
|
||||
|
||||
- Add a conditional placeholder to `apps/backend/config/prompts/kandev-context.md` that emits both the
|
||||
`set_task_title_kandev` inventory entry and first-call instruction only for title-pending task mode.
|
||||
- Extend `sysprompt.KandevContextOptions` in `apps/backend/internal/sysprompt/sysprompt.go` with the
|
||||
pending-title capability. The instruction says to call the tool before any other work or tool call,
|
||||
even though a provisional title already exists, and requests a title targeting three words and no
|
||||
more than six when practical. It remains absent when the marker is not true.
|
||||
- Pass the marker through every first-turn composition path in
|
||||
`apps/backend/internal/orchestrator/task_operations.go`,
|
||||
`event_handlers_workflow.go`, and
|
||||
`apps/backend/internal/task/handlers/message_handlers.go`. Keep canonicalization and trusted-context
|
||||
handling intact.
|
||||
- For passthrough sessions, prepend the equivalent short instruction only while pending; do not add the
|
||||
full task MCP boilerplate that passthrough intentionally skips.
|
||||
- Extend sysprompt placeholder/canonicalization tests, orchestration launch/auto-start tests, and
|
||||
`apps/backend/internal/mcp/server/sysprompt_sync_test.go` so prompt references and registered mode
|
||||
catalogs cannot drift. Explicitly prove that ordinary tasks get neither prompt text nor tool schema.
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### User setting and hydration
|
||||
|
||||
- Add `agentGeneratedTaskTitles` to `UserSettingsState` and its default (`false`) in
|
||||
`apps/web/lib/state/slices/settings/types.ts` and `settings-slice.ts`.
|
||||
- Add `agent_generated_task_titles` to `apps/web/lib/types/http-user-settings.ts`, boot/SSR mapping in
|
||||
`apps/web/lib/ssr/user-settings.ts`, WS mapping in `apps/web/lib/ws/handlers/users.ts`, and any shared
|
||||
settings edit-state mapper that enumerates portable fields.
|
||||
- Add `apps/web/components/settings/agent-generated-task-title-settings.tsx` under
|
||||
`TaskActionsSettings`. Register it with `useSettingsSaveContributor`, and explain visibly that the
|
||||
title input disappears for new tasks/subtasks, a prompt is required, a provisional title from the
|
||||
prompt's first six words appears immediately, and the fallback remains if the agent cannot rename it.
|
||||
- Add focused component, SSR, store/hydration, and WS mapping tests.
|
||||
|
||||
### New Task dialog
|
||||
|
||||
- Read the preference once from the hydrated app store and thread an `autoTitle`/`requiresManualTitle`
|
||||
contract through `task-create-dialog.tsx`, form-body props, footer props, submit handlers, and
|
||||
`buildCreateTaskPayload`.
|
||||
- In create mode with the preference enabled, suppress `InlineTaskName`, focus the description input,
|
||||
disable all creation variants until the prompt has content, omit the manual title, and send
|
||||
`auto_title: true`. Edit mode and session-only mode retain their existing title behavior.
|
||||
- Ensure GitHub/Jira/Linear title autofill does not become a hidden source of truth in auto-title mode.
|
||||
Voice auto-send, create-only, plan-mode, repository selection, and task-create-last-used persistence
|
||||
continue through the shared submit path.
|
||||
- Update helper, footer, payload-builder, form-body, state, and dialog tests for enabled/disabled/edit
|
||||
cases and empty-prompt gating.
|
||||
|
||||
### New Subtask dialog and mobile contract
|
||||
|
||||
- Read the same preference in `new-subtask-dialog.tsx`. When enabled, omit the title state/input from
|
||||
`new-subtask-form-parts.tsx`, require the existing prompt, and have `use-subtask-submit.ts` send
|
||||
`auto_title: true` without a manual title. When disabled, preserve the proposed
|
||||
`Parent / Subtask N` title and editable input.
|
||||
- Keep the existing full-height phone dialogs, fixed headers/footers, internal scroll owner, safe-area
|
||||
behavior, and touch-sized actions. No new drawer or route is needed: the prompt simply becomes the
|
||||
first editable field in the existing task and subtask surfaces.
|
||||
- Use the shipped `TaskCreateDialog` and `NewSubtaskDialog` phone compositions as the mobile exemplars;
|
||||
share all preference, payload, validation, and submit logic across viewports.
|
||||
- Add component/hook tests for both setting states and payloads.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** portable preference defaults false, PATCH preserves omitted values, and boot/WS payloads
|
||||
carry it. **Files:** user store/DTO/service tests, backend boot-state tests, frontend SSR/WS/settings
|
||||
tests. **How:** table-driven Go JSON tests plus Vitest mapping/component tests.
|
||||
- **What:** provisional title whitespace normalization, first-six-word selection, shorter prompts,
|
||||
empty-prompt rejection, and pending metadata persistence. **Files:**
|
||||
`apps/backend/internal/task/service/service_tasks_test.go` and task HTTP handler tests. **How:**
|
||||
table-driven service tests and handler-to-service integration.
|
||||
- **What:** ordinary title edits win, the first pending MCP title succeeds, repeats are idempotent, and
|
||||
`task.updated` is published. **Files:** task-service and MCP handler tests. **How:** real repository +
|
||||
event bus tests and WS action handler tests.
|
||||
- **What:** ordinary task sessions receive no title prompt/tool schema, while pending-task catalog and
|
||||
conditional first-turn instruction stay aligned across direct launch, workflow auto-start,
|
||||
message-start, and passthrough. **Files:** MCP server/sysprompt/executor/orchestrator/task handler
|
||||
tests. **How:** regular-versus-pending catalog assertions include the three-word target and six-word
|
||||
practical cap, and captured launch-prompt tests verify the same gating, guidance, and call ordering.
|
||||
- **What:** enabled/disabled UI states produce the correct title visibility, validation, and HTTP
|
||||
payload for tasks and subtasks. **Files:** focused `*.test.ts(x)` files beside settings and dialogs.
|
||||
**How:** Vitest component/hook tests with mocked API calls.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** GIVEN the setting is enabled, WHEN a user creates a task from the desktop dialog, THEN
|
||||
the title input is absent, empty prompt is blocked, and the six-word provisional title appears.
|
||||
Backend service/MCP tests cover the mock-agent `set_task_title_kandev` replacement and its
|
||||
idempotent late-call behavior. **File:**
|
||||
`apps/web/e2e/tests/task/agent-generated-task-titles.spec.ts`.
|
||||
- **Scenario:** GIVEN the setting is enabled on a phone viewport, WHEN the user opens the subtask
|
||||
creation surface, THEN the prompt is reachable and usable without a title control, actions remain
|
||||
within the viewport, and the document has no horizontal overflow. The shared task dialog contract is
|
||||
covered by focused frontend tests. **File:**
|
||||
`apps/web/e2e/tests/task/mobile-agent-generated-task-titles.spec.ts`.
|
||||
- **Scenario:** GIVEN the user saves the setting and reloads, WHEN they reopen task creation, THEN the
|
||||
enabled behavior persists. **File:** desktop spec; restore the prior user setting in `afterEach`.
|
||||
|
||||
## Public documentation
|
||||
|
||||
- Update `docs/public/tasks-and-workflows.md` with the setting, fallback lifecycle, empty-prompt rule,
|
||||
and manual-rename fallback.
|
||||
- Update `docs/public/coordination.md` so subtask title requirements describe both setting states.
|
||||
- Update `docs/public/automation-and-mcp.md`, `docs/public/agent-communication.md`, and
|
||||
`docs/public/coverage.json` with the task-bound `set_task_title_kandev` contract, three-word target,
|
||||
six-word practical cap, and mode boundary.
|
||||
- Validate public docs with `node --test scripts/validate-public-docs.test.mjs` and
|
||||
`node scripts/validate-public-docs.mjs`.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
The default execution order is sequential in the primary conversation. These waves do not authorize
|
||||
subagents.
|
||||
|
||||
Wave 1:
|
||||
- [x] [Task 01: Backend preference and provisional-title lifecycle](task-01-backend-title-lifecycle.md) — done
|
||||
|
||||
Wave 2:
|
||||
- [x] [Task 02: Task MCP tool and first-turn instruction](task-02-mcp-title-tool.md) — done
|
||||
|
||||
Wave 3:
|
||||
- [x] [Task 03: Settings and task/subtask dialogs](task-03-frontend-title-flow.md) — done
|
||||
|
||||
Wave 4:
|
||||
- [x] [Task 04: End-to-end coverage and public docs](task-04-e2e-and-docs.md) — done
|
||||
|
||||
## Final verification
|
||||
|
||||
- Backend focused tests: 3,665 passing across 15 affected packages; MCP server tests: 149 passing.
|
||||
- Backend lint: `make -C apps/backend lint` — 0 issues.
|
||||
- Frontend focused Vitest suite: 98 passing across 9 files.
|
||||
- Frontend lint and TypeScript typecheck — passing.
|
||||
- Desktop and mobile Playwright scenarios — 1 passing each.
|
||||
- Public documentation tests and validator — 58 tests passing; 41 published pages validated.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
id: "01-backend-title-lifecycle"
|
||||
title: "Backend preference and provisional-title lifecycle"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/agent-generated-titles.md"
|
||||
---
|
||||
|
||||
# Task 01: Backend preference and provisional-title lifecycle
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The backend-owned `agent_generated_task_titles` preference defaults to false, round-trips through
|
||||
GET/PATCH, boot state, and `user.settings.updated`, and preserves omitted PATCH fields.
|
||||
- `POST /api/v1/tasks` with `auto_title:true` derives the provisional title from the first six
|
||||
whitespace-normalized prompt words, uses every word for shorter prompts, rejects an empty prompt,
|
||||
and persists `agent_title_pending`; ordinary creation stays unchanged.
|
||||
- Ordinary title updates clear pending state, and the task service exposes a validated one-time pending
|
||||
title mutation that publishes `task.updated` without overwriting a prior human rename.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/user/... ./internal/backendapp ./internal/task/service ./internal/task/handlers -run 'Test.*(AgentGeneratedTaskTitles|AutoTitle|AgentTitlePending)'
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/user/models/models.go`
|
||||
- `apps/backend/internal/user/dto/dto.go`
|
||||
- `apps/backend/internal/user/dto/dto_test.go`
|
||||
- `apps/backend/internal/user/service/service.go`
|
||||
- `apps/backend/internal/user/service/service_test.go`
|
||||
- `apps/backend/internal/user/store/sqlite.go`
|
||||
- `apps/backend/internal/user/store/sqlite_test.go`
|
||||
- `apps/backend/internal/backendapp/boot_state_routes.go`
|
||||
- `apps/backend/internal/backendapp/boot_state_user_settings_test.go`
|
||||
- `apps/backend/internal/task/models/models.go`
|
||||
- `apps/backend/internal/task/service/service_requests.go`
|
||||
- `apps/backend/internal/task/service/service_tasks.go`
|
||||
- `apps/backend/internal/task/service/service_tasks_test.go`
|
||||
- `apps/backend/internal/task/handlers/task_http_handlers.go`
|
||||
- focused task HTTP handler test file(s)
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. This task establishes shared persisted and HTTP/service contracts consumed by every later
|
||||
task.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections: **What**, **Data model**, **Task creation**, **Failure modes**.
|
||||
- Plan sections: **Portable user setting**, **Provisional-title creation and pending state**.
|
||||
- Patterns: archive-confirmation preference plumbing and existing `Service.UpdateTask` event publication.
|
||||
|
||||
## Risks
|
||||
|
||||
- Preserve user-settings omitted-field semantics and do not introduce a schema migration for the JSON
|
||||
blob.
|
||||
- Do not add a normal provisional character limit or ellipsis; preserve the existing absolute
|
||||
500-character task-title safety boundary.
|
||||
- Do not make the live preference a launch-time source of truth; only the task marker is durable intent.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report behavior implemented, files changed, the exact test command/result, blockers or risks, and update
|
||||
this task plus `plan.md` status in the same conversation.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
id: "02-mcp-title-tool"
|
||||
title: "Task MCP tool and first-turn instruction"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-backend-title-lifecycle"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/agent-generated-titles.md"
|
||||
---
|
||||
|
||||
# Task 02: Task MCP tool and first-turn instruction
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `set_task_title_kandev(title)` is discoverable only in the title-pending task-mode variant and is
|
||||
absent from ordinary task, Config, Office, and External modes. It targets only the server-bound
|
||||
current task and returns the specified success/idempotent responses. Its tool and argument
|
||||
descriptions target three words, request no more than six when practical, and make clear that the
|
||||
agent replaces the provisional title before beginning work.
|
||||
- Every structured first-turn path includes the before-other-work instruction only for pending tasks;
|
||||
the instruction repeats the same word-count guidance and says to call despite the provisional title.
|
||||
Later sessions and non-pending tasks omit both the instruction and tool schema, and
|
||||
sysprompt/tool-catalog sync tests pass.
|
||||
- Passthrough launch receives the equivalent short instruction only while pending, and a failed MCP
|
||||
persistence call leaves the provisional title and marker intact.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/mcp/handlers ./internal/mcp/server ./internal/sysprompt ./internal/orchestrator ./internal/task/handlers -run 'Test.*(SetTaskTitle|AgentTitleInstruction|AgentTitlePending|Sysprompt)'
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/pkg/websocket/actions.go`
|
||||
- `apps/backend/internal/mcp/server/server.go`
|
||||
- `apps/backend/internal/mcp/server/handlers.go`
|
||||
- `apps/backend/internal/mcp/server/server_test.go`
|
||||
- `apps/backend/internal/mcp/server/handlers_test.go`
|
||||
- `apps/backend/internal/mcp/server/sysprompt_sync_test.go`
|
||||
- `apps/backend/internal/mcp/handlers/handlers.go`
|
||||
- focused MCP handler/integration test files
|
||||
- `apps/backend/config/prompts/kandev-context.md`
|
||||
- `apps/backend/internal/sysprompt/sysprompt.go`
|
||||
- `apps/backend/internal/sysprompt/sysprompt_test.go`
|
||||
- `apps/backend/internal/orchestrator/task_operations.go`
|
||||
- `apps/backend/internal/orchestrator/executor/executor_execute.go`
|
||||
- focused executor MCP-mode tests
|
||||
- `apps/backend/internal/orchestrator/event_handlers_workflow.go`
|
||||
- focused orchestrator prompt tests
|
||||
- `apps/backend/internal/task/handlers/message_handlers.go`
|
||||
- `apps/backend/internal/task/handlers/message_handlers_blocked_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01 service mutation and pending metadata contract.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. It consumes Task 01's service contract and changes shared MCP/prompt registries.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections: **Task MCP**, **Permissions**, **Failure modes**, pending-title scenarios.
|
||||
- ADR decision on pending-only registration and task-bound identity.
|
||||
- Existing patterns: `step_complete_kandev` mode registration/prompt gating and raw `mcp.*` WebSocket
|
||||
rejection.
|
||||
|
||||
## Risks
|
||||
|
||||
- Audit every first-turn composition path and MCP mode-resolution path so prompt text and tool catalogs
|
||||
cannot disagree.
|
||||
- Preserve system-context canonicalization against forged user `<kandev-system>` blocks.
|
||||
- Do not accept a task ID from tool arguments or register the tool in External/Office/Config mode.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report behavior implemented, files changed, the exact test command/result, blockers or risks, and update
|
||||
this task plus `plan.md` status in the same conversation.
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
id: "03-frontend-title-flow"
|
||||
title: "Settings and task/subtask dialogs"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["01-backend-title-lifecycle", "02-mcp-title-tool"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/agent-generated-titles.md"
|
||||
---
|
||||
|
||||
# Task 03: Settings and task/subtask dialogs
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Task Actions exposes a self-documenting, manually saved setting that defaults false and stays synced
|
||||
through boot, HTTP, and WebSocket settings paths.
|
||||
- With the setting enabled, create-mode task and subtask dialogs omit title controls, require/focus the
|
||||
prompt, and send `auto_title:true`; disabled and edit modes retain current title behavior.
|
||||
- Desktop and phone reuse the same state, validation, and submit logic while preserving their existing
|
||||
dialog geometry, scroll ownership, touch targets, and non-title actions.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- components/settings/agent-generated-task-title-settings.test.tsx lib/ssr/user-settings.test.ts lib/ws/handlers/users.test.ts components/task-create-dialog.test.tsx components/task-create-dialog-footer.test.ts components/task-create-dialog-form-body.test.tsx components/task-create-dialog-helpers.test.ts components/task-create-dialog-setup.test.ts components/task/new-subtask-form-parts.test.tsx components/task/use-subtask-submit.test.ts
|
||||
cd apps && pnpm --filter @kandev/web lint
|
||||
cd apps/web && pnpm run typecheck
|
||||
```
|
||||
|
||||
The focused Vitest suite passed 108 tests across 10 files; frontend lint (`cd apps && pnpm --filter
|
||||
@kandev/web lint`) and TypeScript typecheck also passed.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/settings/general-settings.tsx`
|
||||
- `apps/web/components/settings/agent-generated-task-title-settings.tsx`
|
||||
- its focused component test
|
||||
- `apps/web/lib/state/slices/settings/types.ts`
|
||||
- `apps/web/lib/state/slices/settings/settings-slice.ts`
|
||||
- `apps/web/lib/types/http-user-settings.ts`
|
||||
- `apps/web/lib/types/backend.ts`
|
||||
- `apps/web/lib/ssr/user-settings.ts`
|
||||
- `apps/web/lib/ssr/user-settings.test.ts`
|
||||
- `apps/web/lib/ws/handlers/users.ts`
|
||||
- `apps/web/lib/ws/handlers/users.test.ts`
|
||||
- `apps/web/components/task-create-dialog.tsx`
|
||||
- `apps/web/components/task-create-dialog-types.ts`
|
||||
- `apps/web/components/task-create-dialog-form-body.tsx`
|
||||
- `apps/web/components/task-create-dialog-footer.tsx`
|
||||
- `apps/web/components/task-create-dialog-submit.tsx`
|
||||
- `apps/web/components/task-create-dialog-helpers.ts`
|
||||
- `apps/web/components/task-create-dialog-prop-builders.ts`
|
||||
- `apps/web/lib/api/domains/kanban-api.ts`
|
||||
- focused dialog/helper tests
|
||||
- `apps/web/components/task/new-subtask-dialog.tsx`
|
||||
- `apps/web/components/task/new-subtask-form-parts.tsx`
|
||||
- `apps/web/components/task/use-subtask-submit.ts`
|
||||
- focused subtask tests
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tasks 01 and 02 define the HTTP and agent-completion contracts the UI opts into.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. The task spans shared user-settings and task-create types and should remain one coherent
|
||||
frontend change.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections: **What**, **User settings**, desktop/mobile scenarios.
|
||||
- Plan sections: **User setting and hydration**, **New Task dialog**, **New Subtask dialog and mobile
|
||||
contract**.
|
||||
- Mobile exemplars: current `TaskCreateDialog` and `NewSubtaskDialog` phone compositions.
|
||||
|
||||
## Risks
|
||||
|
||||
- Avoid a title-field flash by relying on boot-hydrated settings before the dialog opens.
|
||||
- Do not let hidden Jira/Linear/GitHub autofill values accidentally become submitted manual titles.
|
||||
- Empty-prompt gating must cover voice submit, split actions, plan mode, and create-only paths.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report behavior implemented, files changed, the exact test/typecheck commands and results, blockers or
|
||||
risks, and update this task plus `plan.md` status in the same conversation.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
id: "04-e2e-and-docs"
|
||||
title: "End-to-end coverage and public docs"
|
||||
status: done
|
||||
wave: 4
|
||||
depends_on: ["01-backend-title-lifecycle", "02-mcp-title-tool", "03-frontend-title-flow"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/agent-generated-titles.md"
|
||||
---
|
||||
|
||||
# Task 04: End-to-end coverage and public docs
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Desktop E2E proves saved preference persistence, absent title inputs, empty-prompt gating, and the
|
||||
immediate six-word provisional title. Backend service/MCP tests cover the agent replacement and
|
||||
idempotent late-call behavior.
|
||||
- Mobile E2E proves the subtask creation surface remains usable, contained, and free of document-level
|
||||
horizontal overflow with the prompt as the first editable field; the shared task dialog uses the
|
||||
same preference/payload contract covered by focused frontend tests.
|
||||
- Public task, subtask, and MCP documentation describes the setting, fallback/pending lifecycle, mode
|
||||
boundary, pending-only tool availability/token behavior, three-word target, and six-word practical
|
||||
cap; public-doc validators pass.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm e2e --project=chromium tests/task/agent-generated-task-titles.spec.ts
|
||||
cd apps/web && pnpm e2e --project=mobile-chrome tests/task/mobile-agent-generated-task-titles.spec.ts
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
node scripts/validate-public-docs.mjs
|
||||
```
|
||||
|
||||
The desktop and mobile scenarios each passed. The two `node` commands run from the repository root;
|
||||
the public-doc suite passed all 58 tests and validated 41 published pages.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/tests/task/agent-generated-task-titles.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-agent-generated-task-titles.spec.ts`
|
||||
- `apps/web/e2e/helpers/api-client.ts`
|
||||
- relevant task/subtask page objects if a stable helper is missing
|
||||
- `docs/public/tasks-and-workflows.md`
|
||||
- `docs/public/coordination.md`
|
||||
- `docs/public/automation-and-mcp.md`
|
||||
- `docs/public/agent-communication.md`
|
||||
- `docs/public/coverage.json`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tasks 01–03 completed with their targeted checks passing.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. E2E depends on the assembled backend/frontend flow; docs must describe the final verified
|
||||
contract.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Every conformance scenario in the spec.
|
||||
- Plan sections: **E2E Tests** and **Public documentation**.
|
||||
- E2E rules: managed headless runner, production rebuild, API seeding with UI assertions, and restored
|
||||
user settings after each test.
|
||||
|
||||
## Risks
|
||||
|
||||
- The mock agent does not infer natural-language instructions; use an explicit mock MCP script while
|
||||
backend prompt-capture tests prove the instruction wording and ordering.
|
||||
- Capture the provisional state deterministically (for example create-only before launch) rather than
|
||||
racing an auto-started mock agent.
|
||||
- `mobile-*.spec.ts` must use the configured `mobile-chrome` project and assert real user outcomes.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report scenarios covered, docs changed, the exact E2E/docs commands and results, blockers or risks, and
|
||||
update this task plus `plan.md` status in the same conversation.
|
||||
@@ -170,6 +170,14 @@ Task mode currently registers these tool groups:
|
||||
| Relationships and workspace sources | List related tasks, add a mixed repository/folder source batch to an idle task, use the legacy one-branch tool, and change a repository's diff base. |
|
||||
| Workflow signal | Signal step completion when an auto-advance step explicitly requires that signal. |
|
||||
|
||||
When **Settings → General → Task Actions → Agent-generated task titles** is enabled, a task-mode
|
||||
session for a newly created task or subtask also exposes `set_task_title_kandev`. The first session is
|
||||
prompted to call it before any other work, even though the task already has a provisional title from
|
||||
the prompt. Use a short noun phrase targeting about three words (no more than six when practical).
|
||||
The tool is omitted for ordinary tasks, tasks created while the setting was disabled, config sessions,
|
||||
Office sessions, and later sessions after the pending title has been resolved. A human rename wins if
|
||||
it happens first; a late agent call returns `title_not_pending` without changing the title.
|
||||
|
||||
Task identity is injected for operations that require it. Workspace, parent/subtask, executor, and task-state rules still apply.
|
||||
|
||||
`spawn_session_kandev` creates a named sibling session on the current task by default and can target another task in the same workspace. `message_task_kandev` can address a task's primary session or an explicit session ID: a running agent receives queued input, an idle/created session can be started, and a failed or cancelled session rejects the message.
|
||||
|
||||
@@ -72,7 +72,7 @@ Open it from either:
|
||||
- the current task action beside **New Task** in the sidebar (**New subtask of current task**); or
|
||||
- the task workbench **Task** split menu → **Subtask**.
|
||||
|
||||
The dialog proposes `_Parent title_ / Subtask _N_`. Title and prompt are required, and **Create Subtask** starts the agent immediately. The subtask inherits the parent's workflow. Its profile starts from the active parent session's profile and can be changed, but the dialog does not filter profiles for executor compatibility.
|
||||
The dialog proposes `_Parent title_ / Subtask _N_`. Title and prompt are required, and **Create Subtask** starts the agent immediately. When **Agent-generated task titles** is enabled, the title field is hidden and the prompt is required instead; Kandev derives the same six-word provisional title and lets the first session replace it. The subtask inherits the parent's workflow. Its profile starts from the active parent session's profile and can be changed, but the dialog does not filter profiles for executor compatibility.
|
||||
|
||||
Choose a workspace mode:
|
||||
|
||||
@@ -268,7 +268,7 @@ When Office is enabled, it prototypes **Blocked by** and **Blocking** properties
|
||||
- **New Agent has no usable profile:** configure credentials under **Settings → Agents** and a compatible executor under **Settings → Executors**.
|
||||
- **Summarize session is missing:** configure a utility agent; otherwise use **Blank** or **Copy initial prompt**.
|
||||
- **Parallel agents overwrite work:** stop one writer, assign disjoint files, or create an isolated subtask workspace.
|
||||
- **Subtask cannot be created:** confirm title, prompt, compatible profile, workflow, and the one-level Kanban depth limit.
|
||||
- **Subtask cannot be created:** confirm the prompt (and title unless **Agent-generated task titles** is enabled), compatible profile, workflow, and the one-level Kanban depth limit.
|
||||
- **Subtask creates but its agent or repositories fail to start:** the dialog does not enforce agent/executor compatibility. Confirm the agent is configured on that executor; multi-repository creation supports Worktree, Local Docker, SSH, and Sprites.
|
||||
- **Inherited subtask sees unexpected changes:** it intentionally shares the parent's materialized files and branch.
|
||||
- **Message remains queued:** the target is busy and only one queued message drains per turn. Check for `queue_full` before retrying.
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"delete_task_kandev",
|
||||
"list_tasks_kandev",
|
||||
"move_task_kandev",
|
||||
"set_task_title_kandev",
|
||||
"step_complete_kandev",
|
||||
"stop_task_kandev",
|
||||
"update_repository_base_branch_kandev",
|
||||
|
||||
@@ -54,7 +54,12 @@ 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 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.
|
||||
1. When the title field is shown, 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. If **Settings → General → Task Actions → Agent-generated
|
||||
task titles** is enabled, the New Task dialog hides this field, requires a nonempty prompt, and uses
|
||||
the prompt's first six words as a provisional title while the first eligible agent session chooses
|
||||
the final title. The empty-description Plan Mode exception applies only when this setting is disabled.
|
||||
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:
|
||||
|
||||
@@ -65,7 +70,7 @@ Use **New Task** in the sidebar. In an open task, the **Task** split button also
|
||||
| **None** | Planning, research, or work outside Git | Use a scratch workspace or an optional folder on the Kandev host. Git worktree execution and repository-aware Changes, branch, and pull-request features are unavailable. |
|
||||
|
||||
4. Select a compatible executor profile and agent profile. A workflow default agent profile locks the task-level agent selector. Executor and agent compatibility is validated before launch.
|
||||
5. Enter the initial description. In the **New Task** dialog, an empty description changes the primary action to **Start Plan Mode**; the other dialog actions require a description. Agent-facing task MCP has different empty-description rules. A nonempty description exposes the standard split actions.
|
||||
5. Enter the initial description. In the **New Task** dialog, an empty description changes the primary action to **Start Plan Mode**; the other dialog actions require a description. Agent-facing task MCP has different empty-description rules. When agent-generated task titles are enabled, every task and subtask action requires a nonempty prompt; the empty-description Plan Mode exception is disabled. A nonempty description exposes the standard split actions.
|
||||
6. Choose the applicable action:
|
||||
- **Start Plan Mode** is the primary empty-description action and creates the task through the plan-mode path.
|
||||
- **Start task** requires a nonempty description, creates the task, and starts its agent.
|
||||
@@ -83,6 +88,20 @@ Creating a repository is available only in an unlocked, single-repository **New
|
||||
<details>
|
||||
<summary>Advanced task creation: agent-created tasks, long transcripts, multiple sources, and attachments</summary>
|
||||
|
||||
### Let the agent name new tasks
|
||||
|
||||
Open **Settings → General → Task Actions → Agent-generated task titles** and choose **Save changes**.
|
||||
The setting is disabled by default. When enabled, new task and subtask dialogs use the prompt as the
|
||||
source of the title: the prompt must contain text, and Kandev immediately displays its first six
|
||||
normalized words as a provisional title. The first eligible task-mode session receives the
|
||||
`set_task_title_kandev` MCP tool and is instructed to call it before doing any other work. Ask for a
|
||||
short noun phrase targeting about three words (no more than six when practical). If the agent never
|
||||
renames the task, the provisional title remains usable and can still be edited by a person.
|
||||
|
||||
The setting affects only new task/subtask creation. Existing task edits keep the title field, and
|
||||
sessions for tasks created while the setting was disabled receive neither this instruction nor the
|
||||
tool. Config and Office sessions never receive the title tool.
|
||||
|
||||
### Choose the profile for tasks created by agents
|
||||
|
||||
Open **Settings → General → Task Actions → Profile for Tasks Created by Agents** to choose which agent profile Kandev assigns when an agent calls a Kandev MCP tool that creates a task without choosing `agent_profile_id`. The profile determines the agent, model, and setup used when the new task starts:
|
||||
|
||||
@@ -74,6 +74,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su
|
||||
| [parent-child-task-stop](tasks/parent-child-task-stop.md) | shipped |
|
||||
| [mcp-task-agent-profile-default](tasks/mcp-task-agent-profile-default/spec.md) | shipped |
|
||||
| [runtime-state-publication-order](tasks/runtime-state-publication-order.md) | shipped |
|
||||
| [agent-generated-titles](tasks/agent-generated-titles.md) | shipped |
|
||||
| [task-create-executor-default](tasks/task-create-executor-default.md) | approved |
|
||||
| [explicit-completion-signal](workflow/explicit-completion-signal/spec.md) | shipped |
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
---
|
||||
status: shipped
|
||||
created: 2026-07-31
|
||||
owner: kandev
|
||||
---
|
||||
|
||||
# Agent-Generated Task Titles
|
||||
|
||||
## Why
|
||||
|
||||
Writing a separate title adds friction when the task prompt already describes the work. Users who
|
||||
prefer prompt-first creation need tasks and subtasks to appear immediately with a readable label while
|
||||
letting the assigned agent replace that label with a concise title.
|
||||
|
||||
Decision: [ADR-2026-07-31-agent-generated-task-titles](../../decisions/2026-07-31-agent-generated-task-titles.md).
|
||||
|
||||
## What
|
||||
|
||||
- **Let agents name new tasks** is a per-user setting under **Settings → General → Task Actions**. It is
|
||||
disabled by default, including when the saved settings record predates the field.
|
||||
- With the setting disabled, the existing task and subtask creation flows remain unchanged: their title
|
||||
inputs are visible and the user-supplied title is required. Sessions for those tasks receive neither
|
||||
the agent-title prompt instruction nor the `set_task_title_kandev` tool schema.
|
||||
- With the setting enabled, the **New Task** and **New Subtask** dialogs hide their title inputs. Editing
|
||||
or manually renaming an existing task still shows the normal title control.
|
||||
- Auto-titled creation requires a non-empty prompt. An empty prompt cannot create a task in this mode,
|
||||
because Kandev has no source for either a provisional title or the agent's first turn.
|
||||
- Kandev creates the task with a provisional title before starting or preparing a session. It trims the
|
||||
user-visible prompt, splits it on whitespace, and joins its first six words with one ASCII space. A
|
||||
shorter prompt uses all available words. No ellipsis is appended and there is no normal
|
||||
character-count truncation; the existing absolute 500-character task-title limit remains a safety
|
||||
boundary for pathological words.
|
||||
- The provisional title remains usable if session preparation, agent launch, MCP discovery, or the
|
||||
agent's title call fails.
|
||||
- The first session launched while the title is pending instructs the agent to call
|
||||
`set_task_title_kandev` before any other work or tool call, even though the task already has a
|
||||
provisional title. The replacement summarizes the user's request with a target of three words and
|
||||
no more than six words when practical.
|
||||
- A successful agent title call updates every live task surface through the normal `task.updated`
|
||||
event and ends the pending state. Later sessions are not instructed to rename the task again.
|
||||
- A user or other ordinary task-title update made while the agent title is pending wins: it ends the
|
||||
pending state, and a later `set_task_title_kandev` call does not overwrite it.
|
||||
- Task and subtask dialogs provide the same capability on desktop and phone. Their existing responsive
|
||||
surfaces, scroll ownership, repository/profile controls, and primary actions remain unchanged; the
|
||||
prompt becomes the first editable field when the title control is absent.
|
||||
|
||||
## Data model
|
||||
|
||||
`users.settings` stores `agent_generated_task_titles` as a boolean in the existing per-user JSON
|
||||
settings blob. A missing field is interpreted as `false`.
|
||||
|
||||
An opted-in task stores `agent_title_pending: true` in its existing `tasks.metadata` JSON object. The
|
||||
key is removed, rather than set to `false`, when an agent or ordinary title update resolves the title.
|
||||
Tasks without the key are never treated as pending.
|
||||
|
||||
No database column or schema migration is required.
|
||||
|
||||
## API surface
|
||||
|
||||
### User settings
|
||||
|
||||
- `GET /api/v1/user/settings`: `settings.agent_generated_task_titles: boolean`
|
||||
- `PATCH /api/v1/user/settings`: optional `agent_generated_task_titles: boolean`
|
||||
- `user.settings.updated`: `agent_generated_task_titles: boolean`
|
||||
|
||||
### Task creation
|
||||
|
||||
`POST /api/v1/tasks` accepts `auto_title?: boolean`.
|
||||
|
||||
- When absent or `false`, `title` remains required and existing behavior is unchanged.
|
||||
- When `true`, `description` must contain non-whitespace text, `title` may be omitted, and Kandev
|
||||
derives the persisted provisional title and pending marker from `description`.
|
||||
- `auto_title` applies only to this creation request. It is not stored as a permanent task preference.
|
||||
|
||||
### Task MCP
|
||||
|
||||
A title-pending task-mode session exposes:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "set_task_title_kandev",
|
||||
"arguments": {
|
||||
"title": "string"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The tool targets the current task bound to the MCP server. A successful response contains
|
||||
`{"accepted": true, "task_id": "...", "title": "..."}`. A task whose pending marker is already gone
|
||||
returns `{"accepted": false, "reason": "title_not_pending", "task_id": "..."}` without mutation.
|
||||
Blank titles and titles over the existing 500-character limit are validation errors.
|
||||
|
||||
The tool description tells the agent to set a short title for the current task before beginning work,
|
||||
to target three words (and use no more than six when practical), and to make the call even when the
|
||||
existing provisional title appears usable. The `title` argument repeats that guidance. The word counts
|
||||
are generation guidance, not server-side rejection thresholds; the existing 500-character limit
|
||||
remains the hard validation boundary.
|
||||
|
||||
`set_task_title_kandev` is registered only for a task-mode MCP server launched while the current task
|
||||
has `agent_title_pending: true`. Ordinary task-mode sessions, including tasks created while the setting
|
||||
is disabled, do not expose its schema. It is also absent from `ModeOffice`, `ModeConfig`, and
|
||||
`ModeExternal`. Registration is stable for the lifetime of that MCP server; after a successful title
|
||||
call, the current session may retain the now-idempotent tool, but later sessions omit it because the
|
||||
pending marker is gone.
|
||||
|
||||
## Permissions
|
||||
|
||||
- Only the task agent connected through its task-bound MCP server can call `set_task_title_kandev`.
|
||||
- The server injects the current task ID; the agent cannot supply or override it.
|
||||
- Existing task-service authorization and task-owner scoping apply before the title is read or changed.
|
||||
- Human title edits keep their existing permissions and take precedence by clearing the pending marker.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- Saving the setting fails through the existing settings save coordinator; the persisted value and
|
||||
creation behavior remain unchanged.
|
||||
- Auto-titled creation with an empty prompt returns a validation error and creates no task or session.
|
||||
- If the agent never starts, cannot discover MCP, ignores the instruction, or receives an MCP error,
|
||||
the provisional title remains and the task stays pending for the next eligible first session.
|
||||
- If two sessions try to set the title, only the first accepted pending update mutates it; later calls
|
||||
return `title_not_pending`.
|
||||
- If a human changes the title before the agent call arrives, the human title remains and the tool call
|
||||
returns `title_not_pending`.
|
||||
- If the title update cannot be persisted, the tool returns an error, the pending marker remains, and no
|
||||
success event is published.
|
||||
|
||||
## Persistence guarantees
|
||||
|
||||
The setting survives browser and backend restarts as part of backend-owned portable user settings and
|
||||
applies across the current user's workspaces. The provisional title and pending marker survive backend,
|
||||
session, and executor restarts as part of the task row. MCP catalog state is reconstructed from task
|
||||
mode and the pending marker; the marker is re-read when Kandev launches a session and composes its
|
||||
eligible first-turn instruction.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** the setting is missing or disabled, **WHEN** a user opens either creation dialog, **THEN**
|
||||
the title input is visible and required exactly as before.
|
||||
- **GIVEN** a task was created while the setting was disabled, **WHEN** its task-mode session starts,
|
||||
**THEN** its system context contains no agent-title instruction and its MCP catalog does not contain
|
||||
`set_task_title_kandev`.
|
||||
- **GIVEN** the setting is enabled, **WHEN** a user opens **New Task** or **New Subtask** on desktop or
|
||||
phone, **THEN** no title input is rendered and the prompt is the first editable field.
|
||||
- **GIVEN** the setting is enabled and the prompt contains leading whitespace, line breaks, repeated
|
||||
spaces, and more than six words, **WHEN** the task is created, **THEN** its immediate title is the
|
||||
first six words joined by single spaces and it is marked title-pending.
|
||||
- **GIVEN** the setting is enabled and the prompt contains fewer than six words, **WHEN** the task is
|
||||
created, **THEN** its immediate title contains every normalized prompt word without an ellipsis.
|
||||
- **GIVEN** the setting is enabled and the prompt is empty, **WHEN** the user tries to create the task,
|
||||
**THEN** creation is blocked with prompt-required guidance and no task is created.
|
||||
- **GIVEN** a title-pending task starts a structured task-mode session, **WHEN** Kandev composes its
|
||||
first-turn context, **THEN** the agent is told to call `set_task_title_kandev` before any other work,
|
||||
even though a provisional title exists, with a title targeting three words and no more than six when
|
||||
practical, and the session's MCP catalog exposes that tool.
|
||||
- **GIVEN** a title-pending task starts a passthrough session, **WHEN** Kandev sends its initial launch
|
||||
prompt, **THEN** the equivalent short instruction precedes the user prompt in the native TUI.
|
||||
- **GIVEN** the agent follows the tool guidance and calls `set_task_title_kandev` with a valid few-word
|
||||
title, **WHEN** persistence succeeds, **THEN** the task title changes, pending state ends, and
|
||||
connected task surfaces update.
|
||||
- **GIVEN** a user manually renames a title-pending task, **WHEN** the agent later calls
|
||||
`set_task_title_kandev`, **THEN** the user title is preserved and the tool returns
|
||||
`title_not_pending`.
|
||||
- **GIVEN** an agent title call fails or never occurs, **WHEN** the user returns after a restart,
|
||||
**THEN** the provisional title remains visible and the task still has a usable name.
|
||||
- **GIVEN** an agent already resolved the pending title, **WHEN** a later session starts, **THEN** its
|
||||
first-turn context contains no instruction to rename the task and its MCP catalog omits
|
||||
`set_task_title_kandev`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing titles supplied to `create_task_kandev`, Office tasks, automation runs, integration imports,
|
||||
plugin-created tasks, or external task-creation clients that do not opt into `auto_title`.
|
||||
- Generating a title through a separate utility-agent request before task creation.
|
||||
- Retrying or enforcing the agent tool call after the first-turn instruction.
|
||||
- Removing manual task rename/edit controls.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
- [Agent-Generated Task Titles](../../plans/agent-generated-task-titles/plan.md)
|
||||
Reference in New Issue
Block a user