From 3b00a22589d49273f511213ad219082e8f7cf340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 2 Aug 2026 15:53:46 +0100 Subject: [PATCH] feat: add optional agent-generated task titles (#2104) --- apps/backend/config/prompts/kandev-context.md | 2 +- .../agentctl/server/api/mcp_mode_test.go | 2 +- .../internal/agentctl/server/api/server.go | 4 +- .../internal/backendapp/boot_state_routes.go | 1 + .../boot_state_user_settings_test.go | 11 + .../backend/internal/mcp/handlers/handlers.go | 43 ++- .../mcp/handlers/set_task_title_test.go | 110 ++++++ apps/backend/internal/mcp/server/server.go | 57 ++- .../internal/mcp/server/server_test.go | 46 +++ .../orchestrator/event_handlers_workflow.go | 6 + .../orchestrator/executor/executor.go | 6 +- .../orchestrator/executor/executor_execute.go | 3 + .../executor/executor_mcp_mode_test.go | 48 +++ .../orchestrator/spawn_origin_test.go | 19 + .../internal/orchestrator/task_operations.go | 97 +++-- .../orchestrator/task_operations_test.go | 7 +- apps/backend/internal/sysprompt/sysprompt.go | 34 +- .../internal/sysprompt/sysprompt_test.go | 13 + apps/backend/internal/task/handlers/errors.go | 2 +- .../task/handlers/message_handlers.go | 1 + .../task/handlers/task_http_handlers.go | 4 +- .../task/handlers/task_http_handlers_test.go | 62 ++++ apps/backend/internal/task/models/models.go | 12 + .../repository/sqlite/postgres_schema_test.go | 61 ++++ .../internal/task/repository/sqlite/task.go | 74 +++- .../repository/sqlite/task_title_cas_test.go | 86 +++++ .../internal/task/service/service_requests.go | 1 + .../internal/task/service/service_tasks.go | 125 ++++++- .../task/service/service_tasks_test.go | 164 +++++++++ .../internal/user/controller/controller.go | 1 + apps/backend/internal/user/dto/dto.go | 3 + apps/backend/internal/user/dto/dto_test.go | 30 ++ apps/backend/internal/user/models/models.go | 1 + apps/backend/internal/user/service/service.go | 5 + .../internal/user/service/service_test.go | 24 ++ apps/backend/internal/user/store/sqlite.go | 6 + .../internal/user/store/sqlite_test.go | 25 ++ apps/backend/pkg/websocket/actions.go | 1 + ...ent-generated-task-title-settings.test.tsx | 71 ++++ .../agent-generated-task-title-settings.tsx | 74 ++++ .../components/settings/general-settings.tsx | 2 + .../task-create-dialog-footer.test.ts | 16 + .../components/task-create-dialog-footer.tsx | 8 +- .../task-create-dialog-helpers.test.ts | 49 ++- .../components/task-create-dialog-helpers.ts | 7 +- .../task-create-dialog-prop-builders.ts | 8 +- .../task-create-dialog-setup.test.ts | 144 ++++++++ .../components/task-create-dialog-setup.ts | 337 ++++++++++++++++++ .../components/task-create-dialog-submit.tsx | 19 +- .../components/task-create-dialog-types.ts | 4 + apps/web/components/task-create-dialog.tsx | 323 +---------------- .../components/task/new-subtask-dialog.tsx | 6 + .../task/new-subtask-form-parts.tsx | 38 +- .../task/use-subtask-submit.test.ts | 136 ++++++- .../web/components/task/use-subtask-submit.ts | 160 ++++++--- apps/web/e2e/helpers/api-client.ts | 2 + .../task/agent-generated-task-titles.spec.ts | 64 ++++ ...mobile-agent-generated-task-titles.spec.ts | 52 +++ .../hooks/use-ensure-user-settings.test.ts | 1 + apps/web/lib/api/domains/kanban-api.ts | 16 +- apps/web/lib/ssr/user-settings.test.ts | 18 + apps/web/lib/ssr/user-settings.ts | 2 + apps/web/lib/state/slices/settings/types.ts | 1 + apps/web/lib/types/http-user-settings.ts | 2 + apps/web/lib/ws/handlers/users.test.ts | 12 + .../2026-07-31-agent-generated-task-titles.md | 67 ++++ docs/decisions/INDEX.md | 1 + .../plans/agent-generated-task-titles/plan.md | 217 +++++++++++ .../task-01-backend-title-lifecycle.md | 73 ++++ .../task-02-mcp-title-tool.md | 79 ++++ .../task-03-frontend-title-flow.md | 85 +++++ .../task-04-e2e-and-docs.md | 76 ++++ docs/public/automation-and-mcp.md | 8 + docs/public/coordination.md | 4 +- docs/public/coverage.json | 1 + docs/public/tasks-and-workflows.md | 23 +- docs/specs/INDEX.md | 1 + docs/specs/tasks/agent-generated-titles.md | 179 ++++++++++ 78 files changed, 3119 insertions(+), 464 deletions(-) create mode 100644 apps/backend/internal/mcp/handlers/set_task_title_test.go create mode 100644 apps/backend/internal/orchestrator/executor/executor_mcp_mode_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/task_title_cas_test.go create mode 100644 apps/web/components/settings/agent-generated-task-title-settings.test.tsx create mode 100644 apps/web/components/settings/agent-generated-task-title-settings.tsx create mode 100644 apps/web/components/task-create-dialog-setup.test.ts create mode 100644 apps/web/components/task-create-dialog-setup.ts create mode 100644 apps/web/e2e/tests/task/agent-generated-task-titles.spec.ts create mode 100644 apps/web/e2e/tests/task/mobile-agent-generated-task-titles.spec.ts create mode 100644 docs/decisions/2026-07-31-agent-generated-task-titles.md create mode 100644 docs/plans/agent-generated-task-titles/plan.md create mode 100644 docs/plans/agent-generated-task-titles/task-01-backend-title-lifecycle.md create mode 100644 docs/plans/agent-generated-task-titles/task-02-mcp-title-tool.md create mode 100644 docs/plans/agent-generated-task-titles/task-03-frontend-title-flow.md create mode 100644 docs/plans/agent-generated-task-titles/task-04-e2e-and-docs.md create mode 100644 docs/specs/tasks/agent-generated-titles.md diff --git a/apps/backend/config/prompts/kandev-context.md b/apps/backend/config/prompts/kandev-context.md index e11840c561..b18361ed1f 100644 --- a/apps/backend/config/prompts/kandev-context.md +++ b/apps/backend/config/prompts/kandev-context.md @@ -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. diff --git a/apps/backend/internal/agentctl/server/api/mcp_mode_test.go b/apps/backend/internal/agentctl/server/api/mcp_mode_test.go index 59d946329c..771b13396a 100644 --- a/apps/backend/internal/agentctl/server/api/mcp_mode_test.go +++ b/apps/backend/internal/agentctl/server/api/mcp_mode_test.go @@ -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 { diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 1713917f01..341b4b6c6f 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -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) diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index b9206a4046..b2e3a79ae5 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -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, diff --git a/apps/backend/internal/backendapp/boot_state_user_settings_test.go b/apps/backend/internal/backendapp/boot_state_user_settings_test.go index 585edc047b..66a4067244 100644 --- a/apps/backend/internal/backendapp/boot_state_user_settings_test.go +++ b/apps/backend/internal/backendapp/boot_state_user_settings_test.go @@ -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}, diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index ce8d0ebf88..e066b1f2d8 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -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 diff --git a/apps/backend/internal/mcp/handlers/set_task_title_test.go b/apps/backend/internal/mcp/handlers/set_task_title_test.go new file mode 100644 index 0000000000..2bb1836590 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/set_task_title_test.go @@ -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) +} diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index d75ca5201c..6c711d0075 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -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 == "" { diff --git a/apps/backend/internal/mcp/server/server_test.go b/apps/backend/internal/mcp/server/server_test.go index e30fe04139..d819c229da 100644 --- a/apps/backend/internal/mcp/server/server_test.go +++ b/apps/backend/internal/mcp/server/server_test.go @@ -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) diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 6be3d171fe..b84253f159 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -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) } } diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 2927339e42..4e7ebd76d3 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -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 diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index bc6026b056..18f45f0c8b 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -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 } diff --git a/apps/backend/internal/orchestrator/executor/executor_mcp_mode_test.go b/apps/backend/internal/orchestrator/executor/executor_mcp_mode_test.go new file mode 100644 index 0000000000..9debeea8ef --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/executor_mcp_mode_test.go @@ -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) +} diff --git a/apps/backend/internal/orchestrator/spawn_origin_test.go b/apps/backend/internal/orchestrator/spawn_origin_test.go index 8bbecc7e96..1f1037fd5e 100644 --- a/apps/backend/internal/orchestrator/spawn_origin_test.go +++ b/apps/backend/internal/orchestrator/spawn_origin_test.go @@ -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) +} diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 4ed77a9fe6..7cb3c5611f 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -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 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 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) } diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index 04931c949f..98b64b4d07 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -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) { diff --git a/apps/backend/internal/sysprompt/sysprompt.go b/apps/backend/internal/sysprompt/sysprompt.go index 9f6df51ef3..c24fa2d28d 100644 --- a/apps/backend/internal/sysprompt/sysprompt.go +++ b/apps/backend/internal/sysprompt/sysprompt.go @@ -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, }) } diff --git a/apps/backend/internal/sysprompt/sysprompt_test.go b/apps/backend/internal/sysprompt/sysprompt_test.go index 8650976376..41a0aeadb0 100644 --- a/apps/backend/internal/sysprompt/sysprompt_test.go +++ b/apps/backend/internal/sysprompt/sysprompt_test.go @@ -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", diff --git a/apps/backend/internal/task/handlers/errors.go b/apps/backend/internal/task/handlers/errors.go index f9dc8f162f..ab99daf830 100644 --- a/apps/backend/internal/task/handlers/errors.go +++ b/apps/backend/internal/task/handlers/errors.go @@ -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) { diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index 0e4db2ca21..33cf760b00 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -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) } } diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index d9225797e7..962fb91c6d 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -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) diff --git a/apps/backend/internal/task/handlers/task_http_handlers_test.go b/apps/backend/internal/task/handlers/task_http_handlers_test.go index 53ca515b2e..01bf6ab8e1 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers_test.go +++ b/apps/backend/internal/task/handlers/task_http_handlers_test.go @@ -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) diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 5ecd59d3ea..48ede6c1f6 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -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. diff --git a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go index add1ff5fee..0ea33e44b0 100644 --- a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go +++ b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go @@ -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) diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 06e57798a0..eac157346d 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -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 } diff --git a/apps/backend/internal/task/repository/sqlite/task_title_cas_test.go b/apps/backend/internal/task/repository/sqlite/task_title_cas_test.go new file mode 100644 index 0000000000..cf6449852e --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/task_title_cas_test.go @@ -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) + } +} diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 108af1ea81..71ae5bec91 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -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"` diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index ec9ea5b187..c4d43b99fa 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -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. diff --git a/apps/backend/internal/task/service/service_tasks_test.go b/apps/backend/internal/task/service/service_tasks_test.go index a5eb8dadeb..2bcf1977b5 100644 --- a/apps/backend/internal/task/service/service_tasks_test.go +++ b/apps/backend/internal/task/service/service_tasks_test.go @@ -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. diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 2d248ffa6b..f885d9b16c 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -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, diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 841b74ebc7..481d7e88d6 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -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, diff --git a/apps/backend/internal/user/dto/dto_test.go b/apps/backend/internal/user/dto/dto_test.go index 49b01c4848..73697b6bf8 100644 --- a/apps/backend/internal/user/dto/dto_test.go +++ b/apps/backend/internal/user/dto/dto_test.go @@ -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 diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 99d7db3ee0..72e202fece 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -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"` diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index dbb28f4ee1..bc79719f44 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -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, diff --git a/apps/backend/internal/user/service/service_test.go b/apps/backend/internal/user/service/service_test.go index b743810bf4..dd30d8d6a7 100644 --- a/apps/backend/internal/user/service/service_test.go +++ b/apps/backend/internal/user/service/service_test.go @@ -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"}, diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index aa68d37dad..6b33e4c9cc 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -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 diff --git a/apps/backend/internal/user/store/sqlite_test.go b/apps/backend/internal/user/store/sqlite_test.go index a31632d4ad..6e10d9d39d 100644 --- a/apps/backend/internal/user/store/sqlite_test.go +++ b/apps/backend/internal/user/store/sqlite_test.go @@ -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 diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index bc133db3e1..b726448241 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -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" diff --git a/apps/web/components/settings/agent-generated-task-title-settings.test.tsx b/apps/web/components/settings/agent-generated-task-title-settings.test.tsx new file mode 100644 index 0000000000..5fb9b1df2b --- /dev/null +++ b/apps/web/components/settings/agent-generated-task-title-settings.test.tsx @@ -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( + + + + + , + ); +} + +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"), + ); + }); +}); diff --git a/apps/web/components/settings/agent-generated-task-title-settings.tsx b/apps/web/components/settings/agent-generated-task-title-settings.tsx new file mode 100644 index 0000000000..88a9c83a2e --- /dev/null +++ b/apps/web/components/settings/agent-generated-task-title-settings.tsx @@ -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 ( + + + Agent-generated task titles + + 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. + + + +
+
+ +

+ Disabled by default; save this setting before opening a creation dialog. +

+
+ +
+
+
+ ); +} diff --git a/apps/web/components/settings/general-settings.tsx b/apps/web/components/settings/general-settings.tsx index 1dee0a5d6e..5d8d58d20f 100644 --- a/apps/web/components/settings/general-settings.tsx +++ b/apps/web/components/settings/general-settings.tsx @@ -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() { >
+
diff --git a/apps/web/components/task-create-dialog-footer.test.ts b/apps/web/components/task-create-dialog-footer.test.ts index a511bdc51e..14837202eb 100644 --- a/apps/web/components/task-create-dialog-footer.test.ts +++ b/apps/web/components/task-create-dialog-footer.test.ts @@ -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, diff --git a/apps/web/components/task-create-dialog-footer.tsx b/apps/web/components/task-create-dialog-footer.tsx index 17d1f0b11b..482dc529be 100644 --- a/apps/web/components/task-create-dialog-footer.tsx +++ b/apps/web/components/task-create-dialog-footer.tsx @@ -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; diff --git a/apps/web/components/task-create-dialog-helpers.test.ts b/apps/web/components/task-create-dialog-helpers.test.ts index aa512e40d6..fab51fbad0 100644 --- a/apps/web/components/task-create-dialog-helpers.test.ts +++ b/apps/web/components/task-create-dialog-helpers.test.ts @@ -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"); + }); +}); diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index aa5b89bf3c..0a50349ab7 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -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 && diff --git a/apps/web/components/task-create-dialog-prop-builders.ts b/apps/web/components/task-create-dialog-prop-builders.ts index 3a5d5091bd..da5315da10 100644 --- a/apps/web/components/task-create-dialog-prop-builders.ts +++ b/apps/web/components/task-create-dialog-prop-builders.ts @@ -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, diff --git a/apps/web/components/task-create-dialog-setup.test.ts b/apps/web/components/task-create-dialog-setup.test.ts new file mode 100644 index 0000000000..5b01a4061d --- /dev/null +++ b/apps/web/components/task-create-dialog-setup.test.ts @@ -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); + }); +}); diff --git a/apps/web/components/task-create-dialog-setup.ts b/apps/web/components/task-create-dialog-setup.ts new file mode 100644 index 0000000000..42c5c982e8 --- /dev/null +++ b/apps/web/components/task-create-dialog-setup.ts @@ -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; + computed: ReturnType["computed"]; + workspaceRepositories: ReturnType["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, +) { + 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], + ); +} diff --git a/apps/web/components/task-create-dialog-submit.tsx b/apps/web/components/task-create-dialog-submit.tsx index ede42ecb17..e177c0c29b 100644 --- a/apps/web/components/task-create-dialog-submit.tsx +++ b/apps/web/components/task-create-dialog-submit.tsx @@ -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, diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index 2bb93641bd..3e06852f4a 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -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; diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 29f1d09fac..50232534d8 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -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 ( <> { - 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; - computed: ReturnType["computed"]; - workspaceRepositories: ReturnType["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["pendingDiscard"]; + pending: ReturnType["submitHandlers"]["pendingDiscard"]; }) { if (!pending) return null; return ( diff --git a/apps/web/components/task/new-subtask-dialog.tsx b/apps/web/components/task/new-subtask-dialog.tsx index dab0ca5c52..c6d6d5c7a5 100644 --- a/apps/web/components/task/new-subtask-dialog.tsx +++ b/apps/web/components/task/new-subtask-dialog.tsx @@ -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} /> diff --git a/apps/web/components/task/new-subtask-form-parts.tsx b/apps/web/components/task/new-subtask-form-parts.tsx index 1f61bbe3be..73361688ae 100644 --- a/apps/web/components/task/new-subtask-form-parts.tsx +++ b/apps/web/components/task/new-subtask-form-parts.tsx @@ -253,6 +253,7 @@ type SubtaskFormBodyProps = { handlers: ReturnType; 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 (
-
- - setTitle(clampTaskTitleInput(e.target.value))} - placeholder="Subtask title" - className="min-w-0 max-w-full text-sm" - data-testid="subtask-title-input" - disabled={isCreating} - /> -
+ {!autoTitle && ( +
+ + setTitle(clampTaskTitleInput(e.target.value))} + placeholder="Subtask title" + className="min-w-0 max-w-full text-sm" + data-testid="subtask-title-input" + disabled={isCreating} + /> +
+ )}