fix: gate step_complete_kandev tool on per-step signal flag (#1300)

* fix: gate step_complete_kandev tool on per-step signal flag

Two ADR-0015 follow-up bugs:

- "Wait for agent completion signal" checkbox snapped back because
  updateWorkflowStepAction dropped auto_advance_requires_signal from the
  PUT body. Add it to the Pick<>, body builder, BackendWorkflowStep, and
  transformWorkflowStep.
- step_complete_kandev MCP tool was injected into every first-turn
  prompt, polluting context and tempting agents on non-signal-gated
  steps to call it. Replace the static description in
  kandev-context.md with a {step_complete_section} placeholder filled
  only when the current step has auto_advance_requires_signal=true.
  Add orchestrator.Service.StepRequiresCompletionSignal helper and
  thread it through all four InjectKandevContext call sites.

* fix: address PR 1300 review feedback

- Hoist ProcessOnTurnStart above the first-turn MCP wrap so an
  on_turn_start step transition is reflected in the
  auto_advance_requires_signal flag baked into the stored/forwarded
  prompt (coderabbit, cubic).
- Add Service.WorkflowStepRequiresCompletionSignal step-ID variant and
  use it from task_operations.go / autoStartStepPrompt to skip the
  redundant GetTask round-trip at hot first-turn launch sites (claude).
- Add table-driven tests covering every silent-false branch and the
  positive path of both helpers (greptile, claude, cubic).

* docs(sysprompt): document stepCompleteSection trailing newline contract

Address Claude review suggestion on PR 1300: make explicit that the
constant must end with "\n" because the kandev-context.md template inlines
{step_complete_section} immediately before the next bullet, so the
trailing newline is what separates the two list items.

* fix: restore task-id lookup at startTask wrap site

Address PR 1300 review regression: the previous swap to
WorkflowStepRequiresCompletionSignal(ctx, workflowStepID) silently hid
step_complete_kandev on manual starts where the workflowStepID parameter
is empty but the task is already bound to a signal-gated step in the DB.

The `task` variable in scope at this call site is `*v1.Task` (from the
scheduler) and does not carry WorkflowStepID, so the cheaper step-ID
variant is not applicable here — go back through StepRequiresCompletionSignal
so the repo lookup pulls the canonical step.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-06-08 08:14:52 +01:00
committed by GitHub
parent f81b5133bf
commit 2b74f4e189
11 changed files with 282 additions and 33 deletions
+5
View File
@@ -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
@@ -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.
@@ -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)
@@ -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
@@ -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")
}
})
}
@@ -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
@@ -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 {
+34 -8
View File
@@ -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 <kandev-system> tags.
func InjectKandevContext(taskID, sessionID, prompt string) string {
return Wrap(FormatKandevContext(taskID, sessionID)) + "\n\n" + prompt
// The system content is wrapped in <kandev-system> 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
@@ -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.
@@ -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
+5
View File
@@ -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<WorkflowStep> {
@@ -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<BackendWorkflowStep>(
`${apiBaseUrl}/api/v1/workflow/steps/${stepId}`,
{