diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index a108ecf80e..3580e8fd7e 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -535,6 +535,11 @@ func (w *orchestratorWrapper) ProcessOnTurnStart(ctx context.Context, taskID, se return w.svc.ProcessOnTurnStart(ctx, taskID, sessionID) } +// StepRequiresCompletionSignal forwards to the orchestrator service. +func (w *orchestratorWrapper) StepRequiresCompletionSignal(ctx context.Context, taskID string) bool { + return w.svc.StepRequiresCompletionSignal(ctx, taskID) +} + // messageCreatorAdapter adapts the task service to the orchestrator.MessageCreator interface type messageCreatorAdapter struct { svc *taskservice.Service diff --git a/apps/backend/config/prompts/kandev-context.md b/apps/backend/config/prompts/kandev-context.md index 025fbde6fb..7d0f8880fa 100644 --- a/apps/backend/config/prompts/kandev-context.md +++ b/apps/backend/config/prompts/kandev-context.md @@ -7,8 +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_kandev: Signal that every user-stated requirement for the CURRENT workflow step is satisfied. Call this as the LAST action of the step (after the final tool call / commit / answer). Idempotent — a second call within the same step is a no-op. Do NOT call when asking a question, mid-conversation, or on partial progress. Required params: summary (one-paragraph plain text). Optional: handoff, blockers. -- create_task_plan_kandev: Save an implementation plan for the current task. Required params: task_id, content (markdown). Optional: title. +{step_complete_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/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 29f24da333..c80fbbc2a4 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -1250,7 +1250,8 @@ func (s *Service) autoStartStepPrompt( // the agent CLI's TTY and the user sees it verbatim. recordedPrompt := prompt if session.State == models.TaskSessionStateCreated && !session.IsPassthrough && (prompt != "" || len(attachments) > 0) && !sysprompt.HasKandevContext(prompt) { - recordedPrompt = sysprompt.InjectKandevContext(taskID, sessionID, prompt) + requiresSignal := step != nil && step.AutoAdvanceRequiresSignal + recordedPrompt = sysprompt.InjectKandevContext(taskID, sessionID, prompt, requiresSignal) } userMsgRecorded := s.recordAutoStartMessage(ctx, taskID, sessionID, recordedPrompt, planMode, origin) diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 9cb0e3befa..3174cce272 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -558,6 +558,43 @@ func (s *Service) publishTaskUpdated(ctx context.Context, task *models.Task) { s.taskEvents.PublishTaskUpdated(ctx, task) } +// StepRequiresCompletionSignal reports whether the workflow step bound to taskID +// has `auto_advance_requires_signal = true` (ADR 0015). Used by sysprompt +// injection sites to decide whether to expose the `step_complete_kandev` MCP +// tool to the agent. Returns false (without error) when the getter is unset, +// the task has no workflow step, or the step lookup fails — the caller treats +// "unknown" the same as "no signal required" so a flaky workflow lookup never +// silently enables the tool. +// +// Call sites that already have the task or step ID in scope should prefer +// [WorkflowStepRequiresCompletionSignal] to skip the extra GetTask round-trip. +func (s *Service) StepRequiresCompletionSignal(ctx context.Context, taskID string) bool { + if s.workflowStepGetter == nil { + return false + } + task, err := s.repo.GetTask(ctx, taskID) + if err != nil || task == nil { + return false + } + return s.WorkflowStepRequiresCompletionSignal(ctx, task.WorkflowStepID) +} + +// WorkflowStepRequiresCompletionSignal reports whether the given workflow step +// has `auto_advance_requires_signal = true` (ADR 0015). Cheaper alternative to +// [StepRequiresCompletionSignal] for callers that already loaded the task and +// can pass `task.WorkflowStepID` directly — avoids a redundant GetTask round-trip +// at hot first-turn launch sites. Same "unknown ⇒ false" contract. +func (s *Service) WorkflowStepRequiresCompletionSignal(ctx context.Context, stepID string) bool { + if s.workflowStepGetter == nil || stepID == "" { + return false + } + step, err := s.workflowStepGetter.GetStep(ctx, stepID) + if err != nil || step == nil { + return false + } + return step.AutoAdvanceRequiresSignal +} + // SetWorkflowStepGetter sets the workflow step getter for prompt building. // // When workflow_step_id is provided to StartTask, the orchestrator uses this getter diff --git a/apps/backend/internal/orchestrator/service_step_signal_test.go b/apps/backend/internal/orchestrator/service_step_signal_test.go new file mode 100644 index 0000000000..81a0632c8a --- /dev/null +++ b/apps/backend/internal/orchestrator/service_step_signal_test.go @@ -0,0 +1,147 @@ +package orchestrator + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// TestStepRequiresCompletionSignal covers every silent-false branch and the +// positive path of Service.StepRequiresCompletionSignal (ADR 0015 gate). +// All four "unknown ⇒ false" cases must hold: a flaky workflow lookup must +// never silently expose the step_complete_kandev tool. +func TestStepRequiresCompletionSignal(t *testing.T) { + ctx := context.Background() + + seedTaskWithStep := func(t *testing.T) (*Service, *mockStepGetter) { + t.Helper() + repo := setupTestRepo(t) + now := time.Now().UTC() + ws := &models.Workspace{ID: "ws1", Name: "WS", CreatedAt: now, UpdatedAt: now} + if err := repo.CreateWorkspace(ctx, ws); err != nil { + t.Fatalf("seed workspace: %v", err) + } + wf := &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "WF", CreatedAt: now, UpdatedAt: now} + if err := repo.CreateWorkflow(ctx, wf); err != nil { + t.Fatalf("seed workflow: %v", err) + } + task := &models.Task{ID: "task1", WorkflowID: "wf1", WorkflowStepID: "step1", Title: "T", State: v1.TaskStateInProgress, CreatedAt: now, UpdatedAt: now} + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("seed task: %v", err) + } + stepGetter := newMockStepGetter() + svc := createTestService(repo, stepGetter, newMockTaskRepo()) + return svc, stepGetter + } + + t.Run("nil getter returns false", func(t *testing.T) { + svc := &Service{} // workflowStepGetter unset + if got := svc.StepRequiresCompletionSignal(ctx, "any-task"); got { + t.Fatalf("expected false when workflowStepGetter is nil, got true") + } + }) + + t.Run("missing task returns false", func(t *testing.T) { + repo := setupTestRepo(t) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + if got := svc.StepRequiresCompletionSignal(ctx, "does-not-exist"); got { + t.Fatalf("expected false for missing task, got true") + } + }) + + t.Run("task without workflow step returns false", func(t *testing.T) { + repo := setupTestRepo(t) + now := time.Now().UTC() + ws := &models.Workspace{ID: "ws1", Name: "WS", CreatedAt: now, UpdatedAt: now} + _ = repo.CreateWorkspace(ctx, ws) + wf := &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "WF", CreatedAt: now, UpdatedAt: now} + _ = repo.CreateWorkflow(ctx, wf) + task := &models.Task{ID: "task1", WorkflowID: "wf1", Title: "T", State: v1.TaskStateInProgress, CreatedAt: now, UpdatedAt: now} + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("seed task: %v", err) + } + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + if got := svc.StepRequiresCompletionSignal(ctx, "task1"); got { + t.Fatalf("expected false when task has empty WorkflowStepID, got true") + } + }) + + t.Run("step lookup miss returns false", func(t *testing.T) { + svc, stepGetter := seedTaskWithStep(t) + // stepGetter has no step registered for "step1" — GetStep returns (nil, nil) + _ = stepGetter + if got := svc.StepRequiresCompletionSignal(ctx, "task1"); got { + t.Fatalf("expected false when step lookup returns nil, got true") + } + }) + + t.Run("step without signal requirement returns false", func(t *testing.T) { + svc, stepGetter := seedTaskWithStep(t) + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", + WorkflowID: "wf1", + AutoAdvanceRequiresSignal: false, + } + if got := svc.StepRequiresCompletionSignal(ctx, "task1"); got { + t.Fatalf("expected false when AutoAdvanceRequiresSignal is false, got true") + } + }) + + t.Run("step with signal requirement returns true", func(t *testing.T) { + svc, stepGetter := seedTaskWithStep(t) + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", + WorkflowID: "wf1", + AutoAdvanceRequiresSignal: true, + } + if got := svc.StepRequiresCompletionSignal(ctx, "task1"); !got { + t.Fatalf("expected true when step.AutoAdvanceRequiresSignal is true, got false") + } + }) +} + +// TestWorkflowStepRequiresCompletionSignal covers the step-ID variant used by +// callers that already loaded the task (task_operations.go) — avoids the extra +// GetTask round-trip but must enforce the same "unknown ⇒ false" contract. +func TestWorkflowStepRequiresCompletionSignal(t *testing.T) { + ctx := context.Background() + + t.Run("nil getter returns false", func(t *testing.T) { + svc := &Service{} + if got := svc.WorkflowStepRequiresCompletionSignal(ctx, "step1"); got { + t.Fatalf("expected false when workflowStepGetter is nil, got true") + } + }) + + t.Run("empty step ID returns false", func(t *testing.T) { + svc := &Service{workflowStepGetter: newMockStepGetter()} + if got := svc.WorkflowStepRequiresCompletionSignal(ctx, ""); got { + t.Fatalf("expected false for empty step ID, got true") + } + }) + + t.Run("missing step returns false", func(t *testing.T) { + svc := &Service{workflowStepGetter: newMockStepGetter()} + if got := svc.WorkflowStepRequiresCompletionSignal(ctx, "does-not-exist"); got { + t.Fatalf("expected false when step is not registered, got true") + } + }) + + t.Run("step flag drives return value", func(t *testing.T) { + stepGetter := newMockStepGetter() + stepGetter.steps["off"] = &wfmodels.WorkflowStep{ID: "off", AutoAdvanceRequiresSignal: false} + stepGetter.steps["on"] = &wfmodels.WorkflowStep{ID: "on", AutoAdvanceRequiresSignal: true} + svc := &Service{workflowStepGetter: stepGetter} + + if got := svc.WorkflowStepRequiresCompletionSignal(ctx, "off"); got { + t.Fatalf("expected false for step with AutoAdvanceRequiresSignal=false, got true") + } + if got := svc.WorkflowStepRequiresCompletionSignal(ctx, "on"); !got { + t.Fatalf("expected true for step with AutoAdvanceRequiresSignal=true, got false") + } + }) +} diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 28fa0c19b8..e8fe57bd9b 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -376,7 +376,7 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag // 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) && !sysprompt.HasKandevContext(effectivePrompt) && !session.IsPassthrough { - effectivePrompt = sysprompt.InjectKandevContext(taskID, sessionID, effectivePrompt) + effectivePrompt = sysprompt.InjectKandevContext(taskID, sessionID, effectivePrompt, s.WorkflowStepRequiresCompletionSignal(ctx, dbTask.WorkflowStepID)) } executorID := session.ExecutorID @@ -599,8 +599,13 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s if launchSession, sessErr := s.repo.GetTaskSession(ctx, sessionID); sessErr == nil { skipKandevMCPWrap = launchSession.IsPassthrough } + // `task` here is *v1.Task from the scheduler, which does NOT carry the + // orchestrator's WorkflowStepID — go through the task-ID variant so the + // repo lookup pulls the canonical step. Using the workflowStepID parameter + // directly is wrong because it can be empty on manual user-initiated starts + // while the task is already bound to a signal-gated step in the DB. if (effectivePrompt != "" || len(attachments) > 0) && !sysprompt.HasKandevContext(effectivePrompt) && !skipKandevMCPWrap { - effectivePrompt = sysprompt.InjectKandevContext(task.ID, sessionID, effectivePrompt) + effectivePrompt = sysprompt.InjectKandevContext(task.ID, sessionID, effectivePrompt, s.StepRequiresCompletionSignal(ctx, task.ID)) } // Office tasks restrict the MCP toolset: kanban tools (move/update/list diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index db5343b04a..e7a7fd3bc4 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -905,7 +905,7 @@ func TestStartCreatedSession_DoesNotDoubleWrapPreWrappedPrompt(t *testing.T) { svc.messageCreator = mc // Simulate an upstream caller (e.g. wsAddMessage) that has already wrapped. - preWrapped := sysprompt.InjectKandevContext("task1", "session1", "Build me a feature") + preWrapped := sysprompt.InjectKandevContext("task1", "session1", "Build me a feature", false) _, err := svc.StartCreatedSession(ctx, "task1", "session1", "profile1", preWrapped, false, false, false, nil) if err != nil { diff --git a/apps/backend/internal/sysprompt/sysprompt.go b/apps/backend/internal/sysprompt/sysprompt.go index ca0e489f40..6ac92d044a 100644 --- a/apps/backend/internal/sysprompt/sysprompt.go +++ b/apps/backend/internal/sysprompt/sysprompt.go @@ -76,15 +76,39 @@ func HasKandevContext(text string) bool { func PlanMode() string { return prompts.Get("plan-mode") } // KandevContext returns the system prompt template that provides Kandev-specific -// instructions and session context to agents. Contains {task_id} and {session_id} -// placeholders — use [FormatKandevContext] to inject values. +// instructions and session context to agents. Contains {task_id}, {session_id}, +// and {step_complete_section} placeholders — use [FormatKandevContext] to inject values. func KandevContext() string { return prompts.Get("kandev-context") } +// stepCompleteSection is the description + instruction block for the +// step_complete_kandev MCP tool. Only injected when the current workflow step +// has `auto_advance_requires_signal = true` (ADR 0015). Agents on legacy +// auto-advance steps never see the tool so they cannot fire false transitions. +// +// MUST end with "\n": the template inlines {step_complete_section} +// immediately before the next bullet (`- create_task_plan_kandev:`), so the +// trailing newline is what separates the two list items in the enabled case +// without forcing the template to add its own. Dropping the "\n" silently +// merges the two bullets onto one line; the omit path (empty string) is +// unaffected since the next line in the template already starts the bullet. +const stepCompleteSection = "- step_complete_kandev: Signal that every user-stated requirement for the CURRENT workflow step is satisfied. " + + "Call this as the LAST action of the step (after the final tool call / commit / answer). " + + "Idempotent — a second call within the same step is a no-op. " + + "Do NOT call when asking a question, mid-conversation, or on partial progress. " + + "Required params: summary (one-paragraph plain text). Optional: handoff, blockers.\n" + // FormatKandevContext returns the Kandev context prompt with task and session IDs injected. -func FormatKandevContext(taskID, sessionID string) string { +// When requiresCompletionSignal is true, the step_complete_kandev tool description is +// included; otherwise the placeholder is collapsed to an empty string. +func FormatKandevContext(taskID, sessionID string, requiresCompletionSignal bool) string { + section := "" + if requiresCompletionSignal { + section = stepCompleteSection + } return Resolve("kandev-context", map[string]string{ - "task_id": taskID, - "session_id": sessionID, + "task_id": taskID, + "session_id": sessionID, + "step_complete_section": section, }) } @@ -106,9 +130,11 @@ func InjectConfigContext(sessionID, prompt string) string { } // InjectKandevContext prepends the Kandev system prompt and session context to a user's prompt. -// The system content is wrapped in tags. -func InjectKandevContext(taskID, sessionID, prompt string) string { - return Wrap(FormatKandevContext(taskID, sessionID)) + "\n\n" + prompt +// The system content is wrapped in tags. Pass requiresCompletionSignal=true +// when the current workflow step has `auto_advance_requires_signal` enabled (ADR 0015) so the +// step_complete_kandev tool description is exposed; otherwise the tool is hidden from the agent. +func InjectKandevContext(taskID, sessionID, prompt string, requiresCompletionSignal bool) string { + return Wrap(FormatKandevContext(taskID, sessionID, requiresCompletionSignal)) + "\n\n" + prompt } // DefaultPlanPrefix returns the planning instruction prompt used when plan mode diff --git a/apps/backend/internal/sysprompt/sysprompt_test.go b/apps/backend/internal/sysprompt/sysprompt_test.go index 54ed1d4664..bbade2cd22 100644 --- a/apps/backend/internal/sysprompt/sysprompt_test.go +++ b/apps/backend/internal/sysprompt/sysprompt_test.go @@ -93,16 +93,31 @@ func TestInjectConfigContext_SystemContentStrippable(t *testing.T) { // --- KandevContext tests (existing, verify not broken) --- -func TestKandevContext_HasExactlyTwoPlaceholders(t *testing.T) { +func TestKandevContext_HasExpectedPlaceholders(t *testing.T) { ctx := KandevContext() taskCount := strings.Count(ctx, "{task_id}") sessionCount := strings.Count(ctx, "{session_id}") + stepCount := strings.Count(ctx, "{step_complete_section}") assert.Equal(t, 1, taskCount, "KandevContext should have exactly 1 {task_id} placeholder") assert.Equal(t, 1, sessionCount, "KandevContext should have exactly 1 {session_id} placeholder") + assert.Equal(t, 1, stepCount, "KandevContext should have exactly 1 {step_complete_section} placeholder") +} + +func TestFormatKandevContext_OmitsStepCompleteToolByDefault(t *testing.T) { + result := FormatKandevContext("task-abc", "session-xyz", false) + assert.NotContains(t, result, "step_complete_kandev", + "step_complete_kandev must be hidden when the step does not require an explicit signal") + assert.NotContains(t, result, "{step_complete_section}") +} + +func TestFormatKandevContext_IncludesStepCompleteToolWhenRequired(t *testing.T) { + result := FormatKandevContext("task-abc", "session-xyz", true) + assert.Contains(t, result, "step_complete_kandev", + "step_complete_kandev must be exposed when the step requires an explicit signal") } func TestFormatKandevContext_InjectsIDs(t *testing.T) { - result := FormatKandevContext("task-abc", "session-xyz") + result := FormatKandevContext("task-abc", "session-xyz", false) assert.Contains(t, result, "Kandev Task ID: task-abc") assert.Contains(t, result, "Session ID: session-xyz") assert.NotContains(t, result, "{task_id}") @@ -110,13 +125,13 @@ func TestFormatKandevContext_InjectsIDs(t *testing.T) { } func TestInjectKandevContext_WrapsInSystemTags(t *testing.T) { - result := InjectKandevContext("task-abc", "session-xyz", "Do something") + result := InjectKandevContext("task-abc", "session-xyz", "Do something", false) assert.True(t, strings.HasPrefix(result, TagStart)) assert.Contains(t, result, "Do something") } func TestInjectKandevContext_SystemContentStrippable(t *testing.T) { - result := InjectKandevContext("task-abc", "session-xyz", "Do something") + result := InjectKandevContext("task-abc", "session-xyz", "Do something", false) stripped := StripSystemContent(result) assert.Equal(t, "Do something", stripped) } @@ -124,7 +139,7 @@ func TestInjectKandevContext_SystemContentStrippable(t *testing.T) { func TestHasKandevContext_DetectsInjectedWrap(t *testing.T) { // Any prompt produced by InjectKandevContext must be detectable so call // sites can make the wrap step idempotent. - wrapped := InjectKandevContext("task-abc", "session-xyz", "Do something") + wrapped := InjectKandevContext("task-abc", "session-xyz", "Do something", false) assert.True(t, HasKandevContext(wrapped)) // A bare user message has no marker. diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index d7641ff8c5..d25d626e1c 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -29,6 +29,7 @@ type OrchestratorService interface { ResumeTaskSession(ctx context.Context, taskID, taskSessionID string) error StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode, autoStart bool, attachments []v1.MessageAttachment) error ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error + StepRequiresCompletionSignal(ctx context.Context, taskID string) bool } // MessageHandlers handles WebSocket requests for messages @@ -211,6 +212,22 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get task", nil) } + // Run on_turn_start synchronously BEFORE wrapping the prompt with the + // Kandev MCP system block. A workflow step transition fired by + // on_turn_start changes which step's `auto_advance_requires_signal` + // applies — running the wrap first would bake in the previous step's + // flag and either hide or expose `step_complete_kandev` on the wrong + // first turn. dispatchPromptAsync no longer calls ProcessOnTurnStart; + // it forwards the (now correctly-wrapped) prompt to the agent. + if h.orchestrator != nil { + if err := h.orchestrator.ProcessOnTurnStart(ctx, req.TaskID, req.TaskSessionID); err != nil { + h.logger.Warn("failed to process on_turn_start", + zap.String("task_id", req.TaskID), + zap.String("session_id", req.TaskSessionID), + zap.Error(err)) + } + } + // Build metadata with attachments, plan mode, review comments, and context files meta := orchestrator.NewUserMessageMeta(). WithPlanMode(req.PlanMode). @@ -235,7 +252,8 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w // wall of MCP-tool boilerplate prepended to "hello". storedContent := req.Content if isCreatedSession && !sessionResp.Session.IsPassthrough && (req.Content != "" || len(req.Attachments) > 0) { - storedContent = sysprompt.InjectKandevContext(req.TaskID, req.TaskSessionID, req.Content) + requiresSignal := h.orchestrator != nil && h.orchestrator.StepRequiresCompletionSignal(ctx, req.TaskID) + storedContent = sysprompt.InjectKandevContext(req.TaskID, req.TaskSessionID, req.Content, requiresSignal) req.Content = storedContent } @@ -329,20 +347,11 @@ func (h *MessageHandlers) ensureTaskInProgress(ctx context.Context, taskID strin return nil } -// dispatchPromptAsync runs on_turn_start handling and then forwards the message to the -// agent as a prompt in a background goroutine. +// dispatchPromptAsync forwards the message to the agent as a prompt in a +// background goroutine. The caller (wsAddMessage) is responsible for running +// on_turn_start synchronously BEFORE wrapping the prompt, so this function +// only handles the agent-facing dispatch. func (h *MessageHandlers) dispatchPromptAsync(ctx context.Context, req wsAddMessageRequest, agentProfileID string, isCreatedSession bool) { - // Process on_turn_start events synchronously BEFORE sending the prompt. - // This transitions the task to the right step (e.g., Todo → In Progress - // or Review → In Progress) before the agent receives the message. - // This applies to all sessions, including CREATED sessions from plan mode - // where the user sends the first message to start the agent. - if err := h.orchestrator.ProcessOnTurnStart(ctx, req.TaskID, req.TaskSessionID); err != nil { - h.logger.Warn("failed to process on_turn_start", - zap.String("task_id", req.TaskID), - zap.String("session_id", req.TaskSessionID), - zap.Error(err)) - } taskID := req.TaskID sessionID := req.TaskSessionID content := req.Content diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index c0c9e5c36c..46790af4c1 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -358,6 +358,7 @@ type BackendWorkflowStep = { show_in_command_panel?: boolean; auto_archive_after_hours?: number; agent_profile_id?: string; + auto_advance_requires_signal?: boolean; created_at: string; updated_at: string; }; @@ -375,6 +376,7 @@ const transformWorkflowStep = (step: BackendWorkflowStep): WorkflowStep => ({ show_in_command_panel: step.show_in_command_panel, auto_archive_after_hours: step.auto_archive_after_hours, agent_profile_id: step.agent_profile_id, + auto_advance_requires_signal: step.auto_advance_requires_signal, created_at: step.created_at, updated_at: step.updated_at, }); @@ -447,6 +449,7 @@ export async function updateWorkflowStepAction( | "show_in_command_panel" | "auto_archive_after_hours" | "agent_profile_id" + | "auto_advance_requires_signal" > >, ): Promise { @@ -463,6 +466,8 @@ export async function updateWorkflowStepAction( if (payload.auto_archive_after_hours !== undefined) body.auto_archive_after_hours = payload.auto_archive_after_hours; if (payload.agent_profile_id !== undefined) body.agent_profile_id = payload.agent_profile_id; + if (payload.auto_advance_requires_signal !== undefined) + body.auto_advance_requires_signal = payload.auto_advance_requires_signal; const response = await fetchJson( `${apiBaseUrl}/api/v1/workflow/steps/${stepId}`, {