feat: add optional workflow-level agent prompt (#2338)

This commit is contained in:
Luan Martins
2026-08-07 15:22:44 +01:00
committed by GitHub
parent 4d519316c1
commit 10996c1b57
44 changed files with 1020 additions and 70 deletions
@@ -369,6 +369,11 @@ func (a *orchestratorWorkflowStepGetterAdapter) GetWorkflowAgentProfileID(ctx co
return a.svc.GetWorkflowAgentProfileID(ctx, workflowID)
}
// GetWorkflowPrompt implements orchestrator.WorkflowStepGetter.
func (a *orchestratorWorkflowStepGetterAdapter) GetWorkflowPrompt(ctx context.Context, workflowID string) (string, error) {
return a.svc.GetWorkflowPrompt(ctx, workflowID)
}
// reviewTaskCreatorAdapter adapts the task service to the orchestrator's ReviewTaskCreator interface.
type reviewTaskCreatorAdapter struct {
svc *taskservice.Service
@@ -806,9 +806,11 @@ func (a *workflowProviderAdapter) CreateWorkflow(ctx context.Context, workspaceI
// UpdateWorkflow implements workflowservice.WorkflowProvider.
func (a *workflowProviderAdapter) UpdateWorkflow(ctx context.Context, workflow *taskmodels.Workflow) error {
prompt := workflow.Prompt
_, err := a.svc.UpdateWorkflow(ctx, workflow.ID, &taskservice.UpdateWorkflowRequest{
Name: &workflow.Name,
Description: &workflow.Description,
Prompt: &prompt,
AgentProfileID: &workflow.AgentProfileID,
})
return err
@@ -0,0 +1,59 @@
package backendapp
import (
"context"
"testing"
taskservice "github.com/kandev/kandev/internal/task/service"
)
// Production workflowProviderAdapter must forward Prompt on UpdateWorkflow.
// Before this fix, import/sync assigned Prompt on the in-memory workflow then
// called UpdateWorkflow, which dropped it — so the DB never received the field.
func TestWorkflowProviderAdapter_UpdateWorkflowPersistsPrompt(t *testing.T) {
harness := newBootStateTestHarness(t)
ctx := context.Background()
workspaces, err := harness.taskSvc.ListWorkspaces(ctx)
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) == 0 {
t.Fatal("expected seeded default workspace")
}
created, err := harness.taskSvc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{
WorkspaceID: workspaces[0].ID,
Name: "Adapter Prompt WF",
})
if err != nil {
t.Fatalf("CreateWorkflow: %v", err)
}
adapter := &workflowProviderAdapter{svc: harness.taskSvc}
created.Prompt = "If the PR is merged or closed, move the Task to Done."
if err := adapter.UpdateWorkflow(ctx, created); err != nil {
t.Fatalf("UpdateWorkflow: %v", err)
}
got, err := harness.taskSvc.GetWorkflow(ctx, created.ID)
if err != nil {
t.Fatalf("GetWorkflow: %v", err)
}
if got.Prompt != created.Prompt {
t.Fatalf("Prompt = %q, want %q", got.Prompt, created.Prompt)
}
// Clear through the adapter must persist empty, not leave stale text.
created.Prompt = ""
if err := adapter.UpdateWorkflow(ctx, created); err != nil {
t.Fatalf("UpdateWorkflow clear: %v", err)
}
got, err = harness.taskSvc.GetWorkflow(ctx, created.ID)
if err != nil {
t.Fatalf("GetWorkflow after clear: %v", err)
}
if got.Prompt != "" {
t.Fatalf("Prompt after clear = %q, want empty", got.Prompt)
}
}
@@ -40,10 +40,14 @@ import (
type mockStepGetter struct {
steps map[string]*wfmodels.WorkflowStep // stepID -> step
workflowAgentProfileID string // returned by GetWorkflowAgentProfileID
workflowPrompts map[string]string // workflowID -> prompt
}
func newMockStepGetter() *mockStepGetter {
return &mockStepGetter{steps: make(map[string]*wfmodels.WorkflowStep)}
return &mockStepGetter{
steps: make(map[string]*wfmodels.WorkflowStep),
workflowPrompts: make(map[string]string),
}
}
func (m *mockStepGetter) GetStep(_ context.Context, stepID string) (*wfmodels.WorkflowStep, error) {
@@ -84,6 +88,13 @@ func (m *mockStepGetter) GetWorkflowAgentProfileID(_ context.Context, workflowID
return "", nil
}
func (m *mockStepGetter) GetWorkflowPrompt(_ context.Context, workflowID string) (string, error) {
if m.workflowPrompts == nil {
return "", nil
}
return m.workflowPrompts[workflowID], nil
}
// mockTaskRepo implements scheduler.TaskRepository for testing.
type mockTaskRepo struct {
mu sync.Mutex
@@ -151,6 +151,9 @@ type WorkflowStepGetter interface {
GetNextStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error)
GetPreviousStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error)
GetWorkflowAgentProfileID(ctx context.Context, workflowID string) (string, error)
// GetWorkflowPrompt returns the optional workflow-level agent instructions
// prepended at step entry. Empty string means the workflow has no prompt.
GetWorkflowPrompt(ctx context.Context, workflowID string) (string, error)
}
// PromptReferenceExpander resolves "@name" saved-prompt references embedded in
@@ -1442,6 +1442,16 @@ func (s *Service) buildWorkflowPrompt(ctx context.Context, basePrompt string, st
return prompt
}
// workflowInstructionsHeading/End are stable, agent-facing markers for the
// optional workflow-level prompt block. Chat collapses everything between
// them by default. Do not i18n (sent to the model, same as step prompt English).
// The end marker is required so multi-paragraph workflow prompts do not break
// the frontend split (a first-blank-line heuristic would cut mid-body).
const (
workflowInstructionsHeading = "## Workflow instructions"
workflowInstructionsEnd = "<!-- /workflow-instructions -->"
)
func (s *Service) buildWorkflowPromptWithContext(
ctx context.Context,
basePrompt string,
@@ -1453,6 +1463,10 @@ func (s *Service) buildWorkflowPromptWithContext(
_ = sessionID
var parts []string
if block := s.workflowInstructionsBlock(ctx, step, taskID); block != "" {
parts = append(parts, block)
}
// Build the prompt from step.Prompt template and base prompt
if step.Prompt != "" {
interpolatedPrompt := sysprompt.InterpolatePlaceholders(step.Prompt, taskID)
@@ -1473,6 +1487,40 @@ func (s *Service) buildWorkflowPromptWithContext(
return s.expandPromptReferencesWithContext(ctx, joined, isPassthrough)
}
// workflowInstructionsBlock returns the visible "## Workflow instructions"
// section when the step's workflow has a non-empty prompt. Empty/whitespace
// prompts and missing getters/workflows omit the section entirely.
func (s *Service) workflowInstructionsBlock(ctx context.Context, step *wfmodels.WorkflowStep, taskID string) string {
if s.workflowStepGetter == nil || step == nil || step.WorkflowID == "" {
return ""
}
prompt, err := s.workflowStepGetter.GetWorkflowPrompt(ctx, step.WorkflowID)
if err != nil {
if s.logger != nil {
s.logger.Warn("failed to get workflow prompt for prompt building",
zap.String("workflow_id", step.WorkflowID),
zap.Error(err))
}
return ""
}
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return ""
}
interpolated := strings.TrimSpace(sysprompt.InterpolatePlaceholders(prompt, taskID))
if interpolated == "" {
return ""
}
// Drop any accidental end-marker text from user content so chat split
// cannot cut the block early (frontend also prefers the final marker).
interpolated = strings.ReplaceAll(interpolated, workflowInstructionsEnd, "")
interpolated = strings.TrimSpace(interpolated)
if interpolated == "" {
return ""
}
return workflowInstructionsHeading + "\n\n" + interpolated + "\n\n" + workflowInstructionsEnd
}
// expandPromptReferences resolves "@name" saved-prompt references in prompt
// via the configured PromptReferenceExpander. When no expander is set, prompt
// is returned unchanged. Passthrough sessions skip expansion entirely: the
@@ -53,6 +53,144 @@ func TestBuildWorkflowPrompt_ReplacesTaskPromptPlaceholder(t *testing.T) {
}
}
func TestBuildWorkflowPrompt_PrependsWorkflowPromptBeforeStepPrompt(t *testing.T) {
stepGetter := newMockStepGetter()
stepGetter.workflowPrompts["wf-1"] = "If the PR is merged or closed, move the Task to Done."
svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo())
step := &wfmodels.WorkflowStep{
ID: "step-1",
WorkflowID: "wf-1",
Prompt: "Commit the changes.",
}
got := svc.buildWorkflowPrompt(context.Background(), "Migrate Atlantis datasource.", step, "task-1", "session-1", false)
want := "## Workflow instructions\n\nIf the PR is merged or closed, move the Task to Done.\n\n<!-- /workflow-instructions -->\n\nCommit the changes."
if got != want {
t.Fatalf("buildWorkflowPrompt() = %q, want %q", got, want)
}
}
func TestBuildWorkflowPrompt_PrependsWorkflowPromptWithTaskPromptPlaceholder(t *testing.T) {
stepGetter := newMockStepGetter()
stepGetter.workflowPrompts["wf-1"] = "Keep CI green."
svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo())
step := &wfmodels.WorkflowStep{
ID: "step-1",
WorkflowID: "wf-1",
Prompt: "Implement this exactly:\n\n{{task_prompt}}",
}
got := svc.buildWorkflowPrompt(context.Background(), "Migrate Atlantis datasource.", step, "task-1", "session-1", false)
want := "## Workflow instructions\n\nKeep CI green.\n\n<!-- /workflow-instructions -->\n\nImplement this exactly:\n\nMigrate Atlantis datasource."
if got != want {
t.Fatalf("buildWorkflowPrompt() = %q, want %q", got, want)
}
}
func TestBuildWorkflowPrompt_PrependsWorkflowPromptWhenStepPromptEmpty(t *testing.T) {
stepGetter := newMockStepGetter()
stepGetter.workflowPrompts["wf-1"] = "Mention security constraints."
svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo())
step := &wfmodels.WorkflowStep{
ID: "step-1",
WorkflowID: "wf-1",
}
got := svc.buildWorkflowPrompt(context.Background(), "Migrate Atlantis datasource.", step, "task-1", "session-1", false)
want := "## Workflow instructions\n\nMention security constraints.\n\n<!-- /workflow-instructions -->\n\nMigrate Atlantis datasource."
if got != want {
t.Fatalf("buildWorkflowPrompt() = %q, want %q", got, want)
}
}
func TestBuildWorkflowPrompt_KeepsMultiParagraphWorkflowPromptIntact(t *testing.T) {
stepGetter := newMockStepGetter()
stepGetter.workflowPrompts["wf-1"] = "Rule one.\n\nRule two."
svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo())
step := &wfmodels.WorkflowStep{
ID: "step-1",
WorkflowID: "wf-1",
Prompt: "Do the work.",
}
got := svc.buildWorkflowPrompt(context.Background(), "base", step, "task-1", "session-1", false)
want := "## Workflow instructions\n\nRule one.\n\nRule two.\n\n<!-- /workflow-instructions -->\n\nDo the work."
if got != want {
t.Fatalf("buildWorkflowPrompt() = %q, want %q", got, want)
}
}
func TestBuildWorkflowPrompt_ScrubsEndMarkerFromUserContent(t *testing.T) {
stepGetter := newMockStepGetter()
stepGetter.workflowPrompts["wf-1"] = "Never emit <!-- /workflow-instructions --> in docs."
svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo())
step := &wfmodels.WorkflowStep{
ID: "step-1",
WorkflowID: "wf-1",
Prompt: "Do the work.",
}
got := svc.buildWorkflowPrompt(context.Background(), "base", step, "task-1", "session-1", false)
// Exactly one end marker (the structural one), and body keeps surrounding text.
if strings.Count(got, "<!-- /workflow-instructions -->") != 1 {
t.Fatalf("expected exactly one end marker, got %q", got)
}
if !strings.Contains(got, "Never emit in docs.") && !strings.Contains(got, "Never emit in docs.") {
// Scrub leaves surrounding text; either double-space or collapsed is fine.
if !strings.Contains(got, "Never emit") || !strings.Contains(got, "in docs.") {
t.Fatalf("expected scrubbed body to keep surrounding text, got %q", got)
}
}
if !strings.HasSuffix(strings.TrimSpace(got), "Do the work.") {
t.Fatalf("expected step prompt after block, got %q", got)
}
}
func TestBuildWorkflowPrompt_OmitsWorkflowHeadingWhenPromptBlank(t *testing.T) {
stepGetter := newMockStepGetter()
stepGetter.workflowPrompts["wf-1"] = " "
svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo())
step := &wfmodels.WorkflowStep{
ID: "step-1",
WorkflowID: "wf-1",
Prompt: "Commit the changes.",
}
got := svc.buildWorkflowPrompt(context.Background(), "base", step, "task-1", "session-1", false)
if strings.Contains(got, "## Workflow instructions") {
t.Fatalf("expected blank workflow prompt to be omitted, got %q", got)
}
if got != "Commit the changes." {
t.Fatalf("buildWorkflowPrompt() = %q, want step prompt only", got)
}
}
func TestBuildWorkflowPrompt_InterpolatesWorkflowPromptPlaceholders(t *testing.T) {
stepGetter := newMockStepGetter()
stepGetter.workflowPrompts["wf-1"] = "Task id is {task_id}."
svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo())
step := &wfmodels.WorkflowStep{
ID: "step-1",
WorkflowID: "wf-1",
Prompt: "Do the work.",
}
got := svc.buildWorkflowPrompt(context.Background(), "base", step, "task-99", "session-1", false)
if !strings.Contains(got, "Task id is task-99.") {
t.Fatalf("expected interpolated workflow prompt, got %q", got)
}
if strings.Contains(got, "{task_id}") {
t.Fatalf("expected {task_id} placeholder to be replaced, got %q", got)
}
}
func TestBuildWorkflowPrompt_UsesStepPromptOnlyWithoutTaskPromptPlaceholder(t *testing.T) {
svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo())
step := &wfmodels.WorkflowStep{
@@ -102,6 +240,43 @@ func TestBuildWorkflowPrompt_ExpandsStepPromptReference(t *testing.T) {
}
}
func TestBuildWorkflowPrompt_ExpandsWorkflowPromptReference(t *testing.T) {
// @name in the workflow-level prompt must expand the same way as step prompts:
// expansion runs on the full joined string after instructions are prepended.
stepGetter := newMockStepGetter()
stepGetter.workflowPrompts["wf-1"] = "Follow @security-rules always."
svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo())
expander := &fakePromptReferenceExpander{}
svc.promptExpander = expander
step := &wfmodels.WorkflowStep{
ID: "step-1",
WorkflowID: "wf-1",
Prompt: "Commit the changes.",
}
got := svc.buildWorkflowPrompt(context.Background(), "base", step, "task-1", "session-1", false)
if len(expander.calls) != 1 {
t.Fatalf("expected expander called once on joined prompt, got %d", len(expander.calls))
}
seen := expander.calls[0]
if !strings.Contains(seen, "## Workflow instructions") {
t.Fatalf("expected expander to see workflow instructions block, got %q", seen)
}
if !strings.Contains(seen, "Follow @security-rules always.") {
t.Fatalf("expected expander to see workflow @mention, got %q", seen)
}
if !strings.Contains(seen, "Commit the changes.") {
t.Fatalf("expected expander to see step prompt after instructions, got %q", seen)
}
if !strings.Contains(got, "Follow @security-rules always.") {
t.Fatalf("expected visible @mention preserved, got %q", got)
}
if !strings.Contains(got, sysprompt.Wrap(fakeResolvedPromptReferenceContext)) {
t.Fatalf("expected hidden expansion block appended for workflow @mention, got %q", got)
}
}
func TestBuildWorkflowPrompt_PassthroughSkipsReferenceExpansion(t *testing.T) {
svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo())
expander := &fakePromptReferenceExpander{}
+6
View File
@@ -14,6 +14,7 @@ type WorkflowDTO struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Prompt *string `json:"prompt,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
SortOrder int `json:"sort_order"`
Hidden bool `json:"hidden,omitempty"`
@@ -523,12 +524,17 @@ func FromWorkflow(workflow *models.Workflow) WorkflowDTO {
if workflow.Description != "" {
description = &workflow.Description
}
var prompt *string
if workflow.Prompt != "" {
prompt = &workflow.Prompt
}
return WorkflowDTO{
ID: workflow.ID,
WorkspaceID: workflow.WorkspaceID,
Name: workflow.Name,
Description: description,
Prompt: prompt,
AgentProfileID: workflow.AgentProfileID,
SortOrder: workflow.SortOrder,
Hidden: workflow.Hidden,
@@ -45,6 +45,7 @@ type CreateWorkflowRequest struct {
WorkspaceID string
Name string
Description string
Prompt string
WorkflowTemplateID *string
}
@@ -52,6 +53,7 @@ type UpdateWorkflowRequest struct {
ID string
Name *string
Description *string
Prompt *string
}
type DeleteWorkflowRequest struct {
@@ -154,6 +154,7 @@ type httpCreateWorkflowRequest struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Prompt string `json:"prompt,omitempty"`
WorkflowTemplateID *string `json:"workflow_template_id,omitempty"`
}
@@ -171,6 +172,7 @@ func (h *WorkflowHandlers) httpCreateWorkflow(c *gin.Context) {
WorkspaceID: body.WorkspaceID,
Name: body.Name,
Description: body.Description,
Prompt: body.Prompt,
WorkflowTemplateID: body.WorkflowTemplateID,
})
if err != nil {
@@ -213,6 +215,7 @@ func (h *WorkflowHandlers) rejectReadOnlyWorkflowHTTP(c *gin.Context, id string)
type httpUpdateWorkflowRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Prompt *string `json:"prompt"`
AgentProfileID *string `json:"agent_profile_id"`
}
@@ -229,6 +232,7 @@ func (h *WorkflowHandlers) httpUpdateWorkflow(c *gin.Context) {
workflow, err := h.service.UpdateWorkflow(c.Request.Context(), id, &service.UpdateWorkflowRequest{
Name: body.Name,
Description: body.Description,
Prompt: body.Prompt,
AgentProfileID: body.AgentProfileID,
})
if err != nil {
@@ -385,6 +389,7 @@ type wsCreateWorkflowRequest struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Prompt string `json:"prompt,omitempty"`
WorkflowTemplateID *string `json:"workflow_template_id,omitempty"`
}
@@ -404,6 +409,7 @@ func (h *WorkflowHandlers) wsCreateWorkflow(ctx context.Context, msg *ws.Message
WorkspaceID: req.WorkspaceID,
Name: req.Name,
Description: req.Description,
Prompt: req.Prompt,
WorkflowTemplateID: req.WorkflowTemplateID,
})
if err != nil {
@@ -437,6 +443,7 @@ type wsUpdateWorkflowRequest struct {
ID string `json:"id"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Prompt *string `json:"prompt,omitempty"`
AgentProfileID *string `json:"agent_profile_id,omitempty"`
}
@@ -455,6 +462,7 @@ func (h *WorkflowHandlers) wsUpdateWorkflow(ctx context.Context, msg *ws.Message
workflow, err := h.service.UpdateWorkflow(ctx, req.ID, &service.UpdateWorkflowRequest{
Name: req.Name,
Description: req.Description,
Prompt: req.Prompt,
AgentProfileID: req.AgentProfileID,
})
if err != nil {
+5 -2
View File
@@ -658,8 +658,11 @@ const (
type Workflow struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
Name string `json:"name"`
Description string `json:"description"`
// Prompt is optional workflow-level agent instructions prepended at
// every step entry before the step prompt. Empty means off.
Prompt string `json:"prompt,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
WorkflowTemplateID *string `json:"workflow_template_id,omitempty"`
SortOrder int `json:"sort_order"`
@@ -191,6 +191,7 @@ func (r *Repository) runMigrations() error {
// file path it was synced from.
r.migrate.Apply("workflows.source", `ALTER TABLE workflows ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
r.migrate.Apply("workflows.source_path", `ALTER TABLE workflows ADD COLUMN source_path TEXT NOT NULL DEFAULT ''`)
r.migrate.Apply("workflows.prompt", `ALTER TABLE workflows ADD COLUMN prompt TEXT NOT NULL DEFAULT ''`)
if err := r.ensureImproveKandevWorkflowTemplateUniqueness(); err != nil {
return err
}
@@ -278,6 +278,7 @@ const infraSchemaDDL = `
workflow_template_id TEXT DEFAULT '',
name TEXT NOT NULL,
description TEXT DEFAULT '',
prompt TEXT NOT NULL DEFAULT '',
hidden INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
@@ -60,9 +60,9 @@ func (r *Repository) insertWorkflow(ctx context.Context, exec sqlx.ExtContext, w
workflow.SortOrder = maxOrder + 1
_, err = exec.ExecContext(ctx, r.db.Rebind(`
INSERT INTO workflows (id, workspace_id, name, description, agent_profile_id, workflow_template_id, sort_order, hidden, style, source, source_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.AgentProfileID, workflow.WorkflowTemplateID, workflow.SortOrder, dialect.BoolToInt(workflow.Hidden), normalizeWorkflowStyle(workflow.Style), normalizeWorkflowSource(workflow.Source), workflow.SourcePath, workflow.CreatedAt, workflow.UpdatedAt)
INSERT INTO workflows (id, workspace_id, name, description, prompt, agent_profile_id, workflow_template_id, sort_order, hidden, style, source, source_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.Prompt, workflow.AgentProfileID, workflow.WorkflowTemplateID, workflow.SortOrder, dialect.BoolToInt(workflow.Hidden), normalizeWorkflowStyle(workflow.Style), normalizeWorkflowSource(workflow.Source), workflow.SourcePath, workflow.CreatedAt, workflow.UpdatedAt)
return err
}
@@ -89,7 +89,7 @@ func normalizeWorkflowStyle(style string) string {
}
const workflowSelectColumns = `
id, workspace_id, name, description, agent_profile_id,
id, workspace_id, name, description, prompt, agent_profile_id,
workflow_template_id, sort_order, hidden, style, source, source_path, created_at, updated_at
`
@@ -106,6 +106,7 @@ func scanWorkflowRow(scanner workflowScanner) (*models.Workflow, error) {
&workflow.WorkspaceID,
&workflow.Name,
&workflow.Description,
&workflow.Prompt,
&agentProfileID,
&workflowTemplateID,
&workflow.SortOrder,
@@ -169,8 +170,8 @@ func (r *Repository) UpdateWorkflow(ctx context.Context, workflow *models.Workfl
workflow.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE workflows SET name = ?, description = ?, agent_profile_id = ?, workflow_template_id = ?, hidden = ?, style = ?, source = ?, source_path = ?, updated_at = ? WHERE id = ?
`), workflow.Name, workflow.Description, workflow.AgentProfileID, workflow.WorkflowTemplateID, dialect.BoolToInt(workflow.Hidden), normalizeWorkflowStyle(workflow.Style), normalizeWorkflowSource(workflow.Source), workflow.SourcePath, workflow.UpdatedAt, workflow.ID)
UPDATE workflows SET name = ?, description = ?, prompt = ?, agent_profile_id = ?, workflow_template_id = ?, hidden = ?, style = ?, source = ?, source_path = ?, updated_at = ? WHERE id = ?
`), workflow.Name, workflow.Description, workflow.Prompt, workflow.AgentProfileID, workflow.WorkflowTemplateID, dialect.BoolToInt(workflow.Hidden), normalizeWorkflowStyle(workflow.Style), normalizeWorkflowSource(workflow.Source), workflow.SourcePath, workflow.UpdatedAt, workflow.ID)
if err != nil {
return err
}
@@ -60,6 +60,80 @@ func TestSQLiteRepository_WorkflowCRUD(t *testing.T) {
}
}
func TestSQLiteRepository_WorkflowPromptRoundTrip(t *testing.T) {
repo, cleanup := createTestSQLiteRepo(t)
t.Cleanup(cleanup)
ctx := context.Background()
workspace := &models.Workspace{ID: "ws-prompt", Name: "Workspace"}
if err := repo.CreateWorkspace(ctx, workspace); err != nil {
t.Fatalf("failed to create workspace: %v", err)
}
workflow := &models.Workflow{
WorkspaceID: workspace.ID,
Name: "Prompted Workflow",
Description: "desc",
Prompt: "If the PR is merged or closed, move the Task to Done.",
}
if err := repo.CreateWorkflow(ctx, workflow); err != nil {
t.Fatalf("failed to create workflow: %v", err)
}
retrieved, err := repo.GetWorkflow(ctx, workflow.ID)
if err != nil {
t.Fatalf("failed to get workflow: %v", err)
}
if retrieved.Prompt != workflow.Prompt {
t.Fatalf("expected prompt %q, got %q", workflow.Prompt, retrieved.Prompt)
}
workflow.Prompt = "Updated shared workflow instructions."
if err := repo.UpdateWorkflow(ctx, workflow); err != nil {
t.Fatalf("failed to update workflow prompt: %v", err)
}
retrieved, err = repo.GetWorkflow(ctx, workflow.ID)
if err != nil {
t.Fatalf("failed to get workflow after update: %v", err)
}
if retrieved.Prompt != "Updated shared workflow instructions." {
t.Fatalf("expected updated prompt, got %q", retrieved.Prompt)
}
// Clearing the prompt must persist as empty, not leave the previous value.
workflow.Prompt = ""
if err := repo.UpdateWorkflow(ctx, workflow); err != nil {
t.Fatalf("failed to clear workflow prompt: %v", err)
}
retrieved, err = repo.GetWorkflow(ctx, workflow.ID)
if err != nil {
t.Fatalf("failed to get workflow after clear: %v", err)
}
if retrieved.Prompt != "" {
t.Fatalf("expected empty prompt after clear, got %q", retrieved.Prompt)
}
// Updating an unrelated field must not wipe a stored prompt.
workflow.Prompt = "Keep me"
if err := repo.UpdateWorkflow(ctx, workflow); err != nil {
t.Fatalf("failed to set prompt before name-only update: %v", err)
}
workflow.Name = "Renamed"
if err := repo.UpdateWorkflow(ctx, workflow); err != nil {
t.Fatalf("failed to update workflow name: %v", err)
}
retrieved, err = repo.GetWorkflow(ctx, workflow.ID)
if err != nil {
t.Fatalf("failed to get workflow after name update: %v", err)
}
if retrieved.Name != "Renamed" {
t.Fatalf("expected renamed workflow, got %q", retrieved.Name)
}
if retrieved.Prompt != "Keep me" {
t.Fatalf("expected prompt to survive name update, got %q", retrieved.Prompt)
}
}
func TestSQLiteRepository_WorkflowNotFound(t *testing.T) {
repo, cleanup := createTestSQLiteRepo(t)
defer cleanup()
@@ -89,6 +89,7 @@ type CreateWorkflowRequest struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
Prompt string `json:"prompt,omitempty"`
WorkflowTemplateID *string `json:"workflow_template_id,omitempty"`
// Hidden marks the workflow as system-only; excluded from management UI and pickers.
Hidden bool `json:"hidden,omitempty"`
@@ -98,6 +99,7 @@ type CreateWorkflowRequest struct {
type UpdateWorkflowRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Prompt *string `json:"prompt,omitempty"`
AgentProfileID *string `json:"agent_profile_id,omitempty"`
}
@@ -504,6 +504,7 @@ func (s *Service) CreateWorkflow(ctx context.Context, req *CreateWorkflowRequest
WorkspaceID: req.WorkspaceID,
Name: req.Name,
Description: req.Description,
Prompt: req.Prompt,
WorkflowTemplateID: req.WorkflowTemplateID,
Hidden: req.Hidden,
}
@@ -553,6 +554,9 @@ func (s *Service) UpdateWorkflow(ctx context.Context, id string, req *UpdateWork
if req.Description != nil {
workflow.Description = *req.Description
}
if req.Prompt != nil {
workflow.Prompt = *req.Prompt
}
if req.AgentProfileID != nil {
workflow.AgentProfileID = strings.TrimSpace(*req.AgentProfileID)
}
@@ -36,6 +36,7 @@ type AgentProfileMatcher func(agentName, model, mode string) string
type WorkflowPortable struct {
Name string `json:"name" yaml:"name"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"`
AgentProfile *AgentProfilePortable `json:"agent_profile,omitempty" yaml:"agent_profile,omitempty"`
Steps []StepPortable `json:"steps" yaml:"steps"`
}
@@ -108,6 +109,7 @@ func buildWorkflowPortable(wf *taskmodels.Workflow, steps []*WorkflowStep, resol
wp := WorkflowPortable{
Name: wf.Name,
Description: wf.Description,
Prompt: wf.Prompt,
Steps: portableSteps,
}
if resolveProfile != nil && wf.AgentProfileID != "" {
@@ -14,7 +14,10 @@ import (
func TestBuildWorkflowExport(t *testing.T) {
t.Run("converts step IDs to positions", func(t *testing.T) {
wf := &taskmodels.Workflow{ID: "wf-1", Name: "My Workflow", Description: "desc"}
wf := &taskmodels.Workflow{
ID: "wf-1", Name: "My Workflow", Description: "desc",
Prompt: "If the PR is merged or closed, move the Task to Done.",
}
steps := []*WorkflowStep{
{ID: "step-a", Name: "Todo", Position: 0, Color: "blue"},
{
@@ -37,6 +40,7 @@ func TestBuildWorkflowExport(t *testing.T) {
pw := export.Workflows[0]
assert.Equal(t, "My Workflow", pw.Name)
assert.Equal(t, "desc", pw.Description)
assert.Equal(t, "If the PR is merged or closed, move the Task to Done.", pw.Prompt)
require.Len(t, pw.Steps, 2)
// The move_to_step should reference position 0 (step-a's position), not step-a ID.
@@ -165,6 +165,16 @@ func (s *Service) GetWorkflowAgentProfileID(ctx context.Context, workflowID stri
return wf.AgentProfileID, nil
}
// GetWorkflowPrompt returns the optional workflow-level agent instructions.
// Empty string means the workflow has no prompt configured.
func (s *Service) GetWorkflowPrompt(ctx context.Context, workflowID string) (string, error) {
wf, err := s.workflowProvider.GetWorkflow(ctx, workflowID)
if err != nil {
return "", err
}
return wf.Prompt, nil
}
// GetPreviousStepByPosition returns the previous step before the given position for a workflow.
// Returns nil if there is no previous step (i.e., current step is the first one).
func (s *Service) GetPreviousStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*models.WorkflowStep, error) {
@@ -554,13 +564,21 @@ func (s *Service) importSingleWorkflow(ctx context.Context, workspaceID string,
return nil, fmt.Errorf("create workflow: %w", err)
}
needsUpdate := false
// Match workflow-level agent profile if present.
if pw.AgentProfile != nil && s.matchProfile != nil {
if profileID := s.matchProfile(pw.AgentProfile.AgentName, pw.AgentProfile.Model, pw.AgentProfile.Mode); profileID != "" {
wf.AgentProfileID = profileID
if err := s.workflowProvider.UpdateWorkflow(ctx, wf); err != nil {
return nil, fmt.Errorf("set workflow agent profile: %w", err)
}
needsUpdate = true
}
}
if pw.Prompt != "" {
wf.Prompt = pw.Prompt
needsUpdate = true
}
if needsUpdate {
if err := s.workflowProvider.UpdateWorkflow(ctx, wf); err != nil {
return nil, fmt.Errorf("set workflow fields: %w", err)
}
}
@@ -713,6 +713,33 @@ func TestImportWorkflows(t *testing.T) {
assert.Len(t, steps, 2)
})
t.Run("imports workflow-level prompt", func(t *testing.T) {
svc, _, provider := setupTestServiceWithProvider(t)
ctx := context.Background()
export := &models.WorkflowExport{
Version: models.ExportVersion,
Type: models.ExportType,
Workflows: []models.WorkflowPortable{
{
Name: "Prompted WF",
Prompt: "If the PR is merged or closed, move the Task to Done.",
Steps: []models.StepPortable{
{Name: "Todo", Position: 0, Color: "gray"},
},
},
},
}
result, err := svc.ImportWorkflows(ctx, "ws-1", export)
require.NoError(t, err)
assert.Equal(t, []string{"Prompted WF"}, result.Created)
wf, err := provider.GetWorkflow(ctx, "imported-Prompted WF")
require.NoError(t, err)
assert.Equal(t, "If the PR is merged or closed, move the Task to Done.", wf.Prompt)
})
t.Run("normalizes duplicate start steps on import", func(t *testing.T) {
svc, _, _ := setupTestServiceWithProvider(t)
ctx := context.Background()
@@ -270,10 +270,11 @@ func (s *Service) applyWorkflowFields(ctx context.Context, wf *taskmodels.Workfl
if pw.AgentProfile != nil && s.matchProfile != nil {
profileID = s.matchProfile(pw.AgentProfile.AgentName, pw.AgentProfile.Model, pw.AgentProfile.Mode)
}
if wf.Description == pw.Description && wf.AgentProfileID == profileID {
if wf.Description == pw.Description && wf.Prompt == pw.Prompt && wf.AgentProfileID == profileID {
return false, nil
}
wf.Description = pw.Description
wf.Prompt = pw.Prompt
wf.AgentProfileID = profileID
return true, s.workflowProvider.UpdateWorkflow(ctx, wf)
}
@@ -118,6 +118,40 @@ func TestApplySyncedWorkflows_CreatesNewWorkflows(t *testing.T) {
assert.True(t, steps[0].IsStartStep)
}
func TestApplySyncedWorkflows_UpdatesAndClearsWorkflowPrompt(t *testing.T) {
svc, provider, _ := setupSyncService(t)
ctx := context.Background()
wf := addSyncedWorkflow(provider, "wf-1", "ws-1", "Dev Flow", "flows/dev.yml")
wf.Prompt = "Old shared instructions."
createStep(t, svc, &models.WorkflowStep{ID: "step-todo", WorkflowID: wf.ID, Name: "Todo", Position: 0, IsStartStep: true})
// Update prompt from portable definition.
updated := portableWorkflow("Dev Flow", "Todo")
updated.Prompt = "If the PR is merged or closed, move the Task to Done."
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", []SyncFileExport{
{Path: "flows/dev.yml", Export: exportOf(updated)},
})
require.NoError(t, err)
assert.Contains(t, result.Updated, "Dev Flow")
got, err := provider.GetWorkflow(ctx, wf.ID)
require.NoError(t, err)
assert.Equal(t, "If the PR is merged or closed, move the Task to Done.", got.Prompt)
// Omitting prompt in the portable file clears it on sync.
cleared := portableWorkflow("Dev Flow", "Todo")
cleared.Prompt = ""
result, err = svc.ApplySyncedWorkflows(ctx, "ws-1", []SyncFileExport{
{Path: "flows/dev.yml", Export: exportOf(cleared)},
})
require.NoError(t, err)
assert.Contains(t, result.Updated, "Dev Flow")
got, err = provider.GetWorkflow(ctx, wf.ID)
require.NoError(t, err)
assert.Equal(t, "", got.Prompt)
}
func TestApplySyncedWorkflows_UpdatesMatchedWorkflowPreservingStepIDs(t *testing.T) {
svc, provider, _ := setupSyncService(t)
ctx := context.Background()
+3
View File
@@ -8,6 +8,7 @@ type Workflow struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Prompt *string `json:"prompt,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -17,10 +18,12 @@ type Workflow struct {
type CreateWorkflowRequest struct {
Name string `json:"name" binding:"required,max=255"`
Description *string `json:"description,omitempty"`
Prompt *string `json:"prompt,omitempty"`
}
// UpdateWorkflowRequest for updating a workflow
type UpdateWorkflowRequest struct {
Name *string `json:"name,omitempty" binding:"omitempty,max=255"`
Description *string `json:"description,omitempty"`
Prompt *string `json:"prompt,omitempty"`
}
+2 -1
View File
@@ -138,6 +138,7 @@ export async function createWorkflowAction(payload: {
workspace_id: string;
name: string;
description?: string;
prompt?: string;
workflow_template_id?: string;
}) {
return fetchJson<Workflow>(`${apiBaseUrl}/api/v1/workflows`, {
@@ -148,7 +149,7 @@ export async function createWorkflowAction(payload: {
export async function updateWorkflowAction(
id: string,
payload: { name?: string; description?: string; agent_profile_id?: string },
payload: { name?: string; description?: string; prompt?: string; agent_profile_id?: string },
) {
return fetchJson<Workflow>(`${apiBaseUrl}/api/v1/workflows/${id}`, {
method: "PATCH",
@@ -167,6 +167,7 @@ function hasNewerWorkflowMetadata(current: Workflow, savedFrom: Workflow) {
return (
current.name !== savedFrom.name ||
current.description !== savedFrom.description ||
(current.prompt ?? "") !== (savedFrom.prompt ?? "") ||
current.agent_profile_id !== savedFrom.agent_profile_id
);
}
@@ -193,7 +194,7 @@ function useWorkflowActions({
const handleUpdateWorkflow = (
workflowId: string,
updates: { name?: string; description?: string; agent_profile_id?: string },
updates: { name?: string; description?: string; prompt?: string; agent_profile_id?: string },
) => {
setWorkflowItems((prev) =>
prev.map((wf) =>
@@ -47,7 +47,7 @@ function useWorkflowDraftPersistence(args: WorkflowDraftContributorArgs) {
const saveProgressRef = useRef(createWorkflowDraftSaveProgress());
const saveFailedRef = useRef(false);
const revision = JSON.stringify({
workflow: [workflow.name, workflow.description, workflow.agent_profile_id],
workflow: [workflow.name, workflow.description, workflow.prompt, workflow.agent_profile_id],
steps: workflowSteps,
});
const latestRevisionRef = useRef(revision);
@@ -246,6 +246,7 @@ export async function persistWorkflowDraft({
const updatedWorkflow = await updateWorkflowAction(persistedWorkflow.id, {
name: workflow.name.trim(),
description: workflow.description ?? "",
prompt: workflow.prompt ?? "",
agent_profile_id: workflow.agent_profile_id ?? "",
});
progress.workflow = updatedWorkflow;
@@ -272,6 +273,7 @@ async function ensurePersistedWorkflow(
workspace_id: workflow.workspace_id,
name: workflow.name.trim(),
description: workflow.description ?? undefined,
prompt: workflow.prompt ?? undefined,
workflow_template_id: workflow.workflow_template_id ?? undefined,
})
: workflow;
@@ -29,6 +29,7 @@ import { WorkflowSyncedBadge } from "./workflow-synced-badge";
import { useWorkflowMutationGuard } from "./workflow-mutation-guard";
import { WorkflowCycleGuardDialog } from "./workflow-cycle-diagnostic";
import { useWorkflowDraftContributor } from "./use-workflow-draft-contributor";
import { WorkflowPromptSection } from "./workflow-prompt-section";
const TEMP_WORKFLOW_PREFIX = "temp-workflow-";
@@ -42,6 +43,7 @@ type WorkflowCardProps = {
onUpdateWorkflow: (updates: {
name?: string;
description?: string;
prompt?: string;
agent_profile_id?: string;
}) => void;
onDeleteWorkflow: () => Promise<unknown>;
@@ -218,6 +220,7 @@ type WorkflowCardBodyProps = {
onUpdateWorkflow: (updates: {
name?: string;
description?: string;
prompt?: string;
agent_profile_id?: string;
}) => void;
workflowLoading: boolean;
@@ -306,6 +309,12 @@ function WorkflowCardBody({
</Select>
</div>
</div>
<WorkflowPromptSection
workflow={workflow}
savedWorkflow={savedWorkflow}
readOnly={readOnly}
onUpdate={(prompt) => onUpdateWorkflow({ prompt })}
/>
<div className="space-y-2">
<Label>{t("workflows:workflowSteps")}</Label>
{workflowLoading ? (
@@ -36,6 +36,14 @@ describe("workflow dirty state", () => {
expect(isWorkflowFieldDirty(draft, workflow, "name")).toBe(true);
expect(isWorkflowFieldDirty(draft, workflow, "agent_profile_id")).toBe(false);
expect(isWorkflowFieldDirty(draft, workflow, "prompt")).toBe(false);
});
it("marks a changed workflow prompt dirty", () => {
const draft = { ...workflow, prompt: "If the PR is merged or closed, move the Task to Done." };
expect(isWorkflowFieldDirty(draft, workflow, "prompt")).toBe(true);
expect(isWorkflowFieldDirty(draft, { ...workflow, prompt: "" }, "prompt")).toBe(true);
expect(isWorkflowFieldDirty(draft, draft, "prompt")).toBe(false);
});
it("marks new and changed steps dirty", () => {
@@ -9,7 +9,7 @@ function valuesEqual(left: unknown, right: unknown): boolean {
export function isWorkflowFieldDirty(
draft: Workflow,
saved: Workflow | undefined,
field: "name" | "description" | "agent_profile_id",
field: "name" | "description" | "prompt" | "agent_profile_id",
): boolean {
if (!saved) return true;
return !valuesEqual(draft[field] ?? "", saved[field] ?? "");
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { Workflow } from "@/lib/types/http";
import { WorkflowPromptSection } from "./workflow-prompt-section";
const PROMPT_INPUT = "workflow-prompt-input";
const baseWorkflow = {
id: "workflow-1",
workspace_id: "workspace-1",
name: "Workflow",
description: "",
created_at: "",
updated_at: "",
} as Workflow;
afterEach(() => {
cleanup();
});
describe("WorkflowPromptSection", () => {
it("starts collapsed when the prompt is empty", () => {
render(<WorkflowPromptSection workflow={baseWorkflow} onUpdate={vi.fn()} />);
expect(screen.getByTestId("workflow-prompt-toggle")).toBeTruthy();
expect(screen.queryByTestId(PROMPT_INPUT)).toBeNull();
expect(screen.getByText(/Optional/i)).toBeTruthy();
});
it("starts expanded when a non-empty prompt is loaded", () => {
const workflow = {
...baseWorkflow,
prompt: "If the PR is merged or closed, move the Task to Done.",
};
render(<WorkflowPromptSection workflow={workflow} onUpdate={vi.fn()} />);
const input = screen.getByTestId(PROMPT_INPUT) as HTMLTextAreaElement;
expect(input.value).toBe(workflow.prompt);
});
it("auto-expands when an empty prompt becomes non-empty", () => {
const onUpdate = vi.fn();
const { rerender } = render(
<WorkflowPromptSection workflow={baseWorkflow} onUpdate={onUpdate} />,
);
expect(screen.queryByTestId(PROMPT_INPUT)).toBeNull();
rerender(
<WorkflowPromptSection
workflow={{ ...baseWorkflow, prompt: "Keep multi-PR work in subtasks." }}
onUpdate={onUpdate}
/>,
);
expect(screen.getByTestId(PROMPT_INPUT)).toBeTruthy();
});
it("toggles open via the header control", () => {
render(<WorkflowPromptSection workflow={baseWorkflow} onUpdate={vi.fn()} />);
fireEvent.click(screen.getByTestId("workflow-prompt-toggle"));
expect(screen.getByTestId(PROMPT_INPUT)).toBeTruthy();
});
it("marks the textarea dirty when the draft prompt differs from saved", () => {
const draft = { ...baseWorkflow, prompt: "new" };
const saved = { ...baseWorkflow, prompt: "old" };
render(<WorkflowPromptSection workflow={draft} savedWorkflow={saved} onUpdate={vi.fn()} />);
const input = screen.getByTestId(PROMPT_INPUT);
expect(input.getAttribute("data-settings-dirty")).toBe("true");
});
});
@@ -0,0 +1,84 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconChevronRight } from "@tabler/icons-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
import { Label } from "@kandev/ui/label";
import { Textarea } from "@kandev/ui/textarea";
import { cn } from "@/lib/utils";
import type { Workflow } from "@/lib/types/http";
import { HelpTip } from "./workflow-pipeline-editor-helpers";
import { isWorkflowFieldDirty } from "./workflow-dirty-state";
type WorkflowPromptSectionProps = {
workflow: Workflow;
savedWorkflow?: Workflow;
readOnly?: boolean;
onUpdate: (prompt: string) => void;
};
/**
* Optional workflow-level agent prompt editor.
* Collapsed when empty so the common "no shared instructions" case stays compact;
* opens automatically when a non-empty prompt is loaded or the user expands it.
*/
export function WorkflowPromptSection({
workflow,
savedWorkflow,
readOnly,
onUpdate,
}: WorkflowPromptSectionProps) {
const { t } = useTranslation();
const prompt = workflow.prompt ?? "";
const hasPrompt = prompt.trim().length > 0;
const [open, setOpen] = useState(hasPrompt);
// Keep expanded when content arrives (e.g. load/import/dirty restore).
useEffect(() => {
if (hasPrompt) setOpen(true);
}, [hasPrompt]);
return (
<Collapsible open={open} onOpenChange={setOpen} className="space-y-1.5">
<div className="flex items-center gap-1">
<CollapsibleTrigger
type="button"
className="flex cursor-pointer items-center gap-1 rounded-md px-1 py-0.5 text-left hover:bg-muted/50"
data-testid="workflow-prompt-toggle"
>
<IconChevronRight
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
)}
/>
<Label className="cursor-pointer">{t("workflows:workflowPrompt")}</Label>
</CollapsibleTrigger>
<HelpTip text={t("workflows:workflowPromptHelp")} />
{!open && !hasPrompt && (
<span className="text-xs text-muted-foreground">
{t("workflows:workflowPromptCollapsedHint")}
</span>
)}
{!open && hasPrompt && (
<span className="truncate text-xs text-muted-foreground" title={prompt}>
{t("workflows:workflowPromptSetHint")}
</span>
)}
</div>
<CollapsibleContent className="space-y-1.5">
<Textarea
value={prompt}
onChange={(e) => onUpdate(e.target.value)}
disabled={readOnly}
placeholder={t("workflows:workflowPromptPlaceholder")}
rows={4}
className="min-h-24 font-mono text-sm"
data-testid="workflow-prompt-input"
data-settings-dirty={isWorkflowFieldDirty(workflow, savedWorkflow, "prompt")}
/>
</CollapsibleContent>
</Collapsible>
);
}
@@ -18,7 +18,6 @@ import { MessageActions } from "@/components/task/chat/messages/message-actions"
import { useMessageFavorite } from "@/hooks/domains/session/use-message-favorite";
import { useUserMessageNavigation } from "@/hooks/use-message-navigation";
import { SenderTaskBadge, type SenderTaskInfo } from "./sender-task-badge";
import { MemoizedMarkdown } from "@/components/shared/memoized-markdown";
import { ImagePreviewDialog } from "@/components/task/chat/image-preview-dialog";
import { useAppStore } from "@/components/state-provider";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@kandev/ui/hover-card";
@@ -39,7 +38,7 @@ import { entityReferencesFromMetadata } from "@/lib/entity-references/message-re
import type { EntityReference } from "@/lib/types/entity-reference";
import { attachmentContentUrl } from "@/lib/api/domains/attachment-api";
import { formatBytes } from "@/lib/utils/format-bytes";
import { t } from "@/lib/i18n";
import { renderUserMessageBody } from "./user-message-body";
type ChatMessageProps = {
comment: Message;
@@ -95,55 +94,6 @@ function renderContentWithFileRefs(content: string): React.ReactNode[] {
return parts.length > 0 ? parts : [content];
}
// ── Markdown component overrides imported from shared/markdown-components ─────
type UserMessageBodyOptions = {
hasContent: boolean;
showRaw: boolean;
hasAttachments: boolean;
content: string;
rawContent?: string;
promptMentionComponents?: Components;
taskId: string;
worktreePath?: string;
onOpenFile?: (path: string) => void;
};
function renderUserMessageBody({
hasContent,
showRaw,
hasAttachments,
content,
rawContent,
promptMentionComponents,
taskId,
worktreePath,
onOpenFile,
}: UserMessageBodyOptions): React.ReactNode {
if (hasContent && showRaw) {
return <pre className="whitespace-pre-wrap font-mono text-xs">{rawContent || content}</pre>;
}
if (hasContent) {
return (
<div className="markdown-body markdown-body-user max-w-none">
<MemoizedMarkdown
content={content}
taskId={taskId}
components={promptMentionComponents}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
/>
</div>
);
}
if (!hasAttachments) {
return (
<p className="whitespace-pre-wrap break-words overflow-wrap-anywhere">{t("task:empty")}</p>
);
}
return null;
}
// ── User message sub-component ──────────────────────────────────────
type UserMessageProps = {
@@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import type { Components } from "react-markdown";
import { IconChevronRight } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
import { MemoizedMarkdown } from "@/components/shared/memoized-markdown";
import { cn } from "@/lib/utils";
import { t } from "@/lib/i18n";
import { splitWorkflowInstructions } from "@/lib/utils/workflow-instructions";
type UserMessageBodyOptions = {
hasContent: boolean;
showRaw: boolean;
hasAttachments: boolean;
content: string;
rawContent?: string;
promptMentionComponents?: Components;
taskId: string;
worktreePath?: string;
onOpenFile?: (path: string) => void;
};
function UserMessageMarkdown({
content,
promptMentionComponents,
taskId,
worktreePath,
onOpenFile,
}: {
content: string;
promptMentionComponents?: Components;
taskId: string;
worktreePath?: string;
onOpenFile?: (path: string) => void;
}) {
return (
<div className="markdown-body markdown-body-user max-w-none">
<MemoizedMarkdown
content={content}
taskId={taskId}
components={promptMentionComponents}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
/>
</div>
);
}
function CollapsedWorkflowInstructions({
instructions,
rest,
promptMentionComponents,
taskId,
worktreePath,
onOpenFile,
}: {
instructions: string;
rest: string;
promptMentionComponents?: Components;
taskId: string;
worktreePath?: string;
onOpenFile?: (path: string) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<div className="space-y-2">
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger
className="flex w-full cursor-pointer items-center gap-1 rounded-md bg-muted/40 px-2 py-1 text-left text-xs text-muted-foreground hover:bg-muted/60"
data-testid="workflow-instructions-toggle"
>
<IconChevronRight
className={cn("h-3.5 w-3.5 shrink-0 transition-transform", open && "rotate-90")}
/>
<span>{t("workflows:workflowInstructionsCollapsed")}</span>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<UserMessageMarkdown
content={instructions}
promptMentionComponents={promptMentionComponents}
taskId={taskId}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
/>
</CollapsibleContent>
</Collapsible>
{rest.trim() !== "" && (
<UserMessageMarkdown
content={rest}
promptMentionComponents={promptMentionComponents}
taskId={taskId}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
/>
)}
</div>
);
}
export function renderUserMessageBody({
hasContent,
showRaw,
hasAttachments,
content,
rawContent,
promptMentionComponents,
taskId,
worktreePath,
onOpenFile,
}: UserMessageBodyOptions): React.ReactNode {
if (hasContent && showRaw) {
return <pre className="whitespace-pre-wrap font-mono text-xs">{rawContent || content}</pre>;
}
if (hasContent) {
const split = splitWorkflowInstructions(content);
if (split.hasInstructions) {
return (
<CollapsedWorkflowInstructions
instructions={split.instructions}
rest={split.rest}
promptMentionComponents={promptMentionComponents}
taskId={taskId}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
/>
);
}
return (
<UserMessageMarkdown
content={content}
promptMentionComponents={promptMentionComponents}
taskId={taskId}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
/>
);
}
if (!hasAttachments) {
return (
<p className="whitespace-pre-wrap break-words overflow-wrap-anywhere">{t("task:empty")}</p>
);
}
return null;
}
+1 -1
View File
@@ -945,7 +945,7 @@ export class ApiClient {
async updateWorkflow(
workflowId: string,
updates: { name?: string; description?: string; agent_profile_id?: string },
updates: { name?: string; description?: string; prompt?: string; agent_profile_id?: string },
): Promise<Workflow> {
return this.request("PATCH", `/api/v1/workflows/${workflowId}`, updates);
}
@@ -141,6 +141,7 @@ function workflowIsDirty(workflow: Workflow, savedWorkflows: Map<string, Workflo
return (
workflow.name !== saved.name ||
workflow.description !== saved.description ||
(workflow.prompt ?? "") !== (saved.prompt ?? "") ||
(workflow.agent_profile_id ?? "") !== (saved.agent_profile_id ?? "")
);
}
+1
View File
@@ -207,6 +207,7 @@ export type WorkflowPayload = {
workspace_id: string;
name: string;
description?: string;
prompt?: string;
agent_profile_id?: string;
hidden?: boolean;
/** Phase 2 (ADR-0004) UX hint — frontend-only. */
+3
View File
@@ -200,6 +200,8 @@ export type Workflow = {
workspace_id: WorkspaceId;
name: string;
description?: string | null;
/** Optional workflow-level agent instructions prepended at every step entry. */
prompt?: string;
workflow_template_id?: string | null;
agent_profile_id?: AgentProfileId;
sort_order?: number;
@@ -760,6 +762,7 @@ export type WorkflowExportData = {
export type WorkflowPortable = {
name: string;
description?: string;
prompt?: string;
steps: StepPortable[];
};
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import {
WORKFLOW_INSTRUCTIONS_END,
WORKFLOW_INSTRUCTIONS_HEADING,
splitWorkflowInstructions,
} from "./workflow-instructions";
function block(body: string, rest = ""): string {
const head = WORKFLOW_INSTRUCTIONS_HEADING + "\n\n" + body + "\n\n" + WORKFLOW_INSTRUCTIONS_END;
return rest ? head + "\n\n" + rest : head;
}
describe("splitWorkflowInstructions", () => {
it("returns the full message when no workflow heading is present", () => {
const content = "Commit the changes.";
expect(splitWorkflowInstructions(content)).toEqual({
instructions: "",
rest: content,
hasInstructions: false,
});
});
it("splits a leading workflow instructions block from the step body", () => {
expect(
splitWorkflowInstructions(block("Always open a draft PR.", "Commit the changes.")),
).toEqual({
instructions: "Always open a draft PR.",
rest: "Commit the changes.",
hasInstructions: true,
});
});
it("keeps multi-line and multi-paragraph instruction bodies intact", () => {
expect(splitWorkflowInstructions(block("Line one\n\nLine two", "Step body"))).toEqual({
instructions: "Line one\n\nLine two",
rest: "Step body",
hasInstructions: true,
});
});
it("handles a workflow block with no following step body", () => {
expect(splitWorkflowInstructions(block("Only workflow rules."))).toEqual({
instructions: "Only workflow rules.",
rest: "",
hasInstructions: true,
});
});
it("does not treat a mid-message heading as workflow instructions", () => {
const content = "Intro\n\n" + WORKFLOW_INSTRUCTIONS_HEADING + "\n\nNope";
expect(splitWorkflowInstructions(content).hasInstructions).toBe(false);
expect(splitWorkflowInstructions(content).rest).toBe(content);
});
it("does not treat a heading-prefix line as workflow instructions", () => {
const content = WORKFLOW_INSTRUCTIONS_HEADING + " for release\n\nbody";
expect(splitWorkflowInstructions(content)).toEqual({
instructions: "",
rest: content,
hasInstructions: false,
});
});
it("ignores marker-less user content that only starts with the heading", () => {
const content = WORKFLOW_INSTRUCTIONS_HEADING + "\n\nUser wrote this.\n\nMore text.";
expect(splitWorkflowInstructions(content)).toEqual({
instructions: "",
rest: content,
hasInstructions: false,
});
});
it("uses the first standalone end marker, not a quoted copy in the rest", () => {
const rest = "See docs mentioning " + WORKFLOW_INSTRUCTIONS_END + " later.\n\nDo work.";
expect(splitWorkflowInstructions(block("Rule one.", rest))).toEqual({
instructions: "Rule one.",
rest,
hasInstructions: true,
});
});
it("keeps body text that quotes the end marker when the structural marker follows", () => {
const body = "Never emit " + WORKFLOW_INSTRUCTIONS_END + " in docs.";
expect(splitWorkflowInstructions(block(body, "Step body"))).toEqual({
instructions: body,
rest: "Step body",
hasInstructions: true,
});
});
});
@@ -0,0 +1,74 @@
/**
* Parse and reassemble the optional workflow-level instructions block that the
* orchestrator prepends at step entry.
*
* The heading and end marker are stable, agent-facing tokens (English, not i18n)
* so chat can collapse the section by default without relying on message
* metadata alone. The end marker is required: without it, ordinary user text
* that happens to start with the heading is left alone.
*/
export const WORKFLOW_INSTRUCTIONS_HEADING = "## Workflow instructions";
export const WORKFLOW_INSTRUCTIONS_END = "<!-- /workflow-instructions -->";
export type WorkflowInstructionsSplit = {
/** Body under the heading, without the heading/end markers. Empty when absent. */
instructions: string;
/** Remainder of the message after the workflow block (step/task prompt). */
rest: string;
/** True when the message begins with the workflow instructions heading. */
hasInstructions: boolean;
};
function isLineBoundary(text: string, index: number): boolean {
return index >= text.length || text[index] === "\n" || text[index] === "\r";
}
/** First standalone end marker (own line), not a substring mid-sentence. */
function findStandaloneEndMarker(text: string): number {
let from = 0;
while (from <= text.length) {
const idx = text.indexOf(WORKFLOW_INSTRUCTIONS_END, from);
if (idx === -1) return -1;
const beforeOk = idx === 0 || text[idx - 1] === "\n" || text[idx - 1] === "\r";
const afterOk = isLineBoundary(text, idx + WORKFLOW_INSTRUCTIONS_END.length);
if (beforeOk && afterOk) return idx;
from = idx + 1;
}
return -1;
}
/**
* Split a user/auto-start message into workflow instructions + remainder.
* Requires a leading exact heading line plus a standalone end marker.
*/
export function splitWorkflowInstructions(content: string): WorkflowInstructionsSplit {
const text = content ?? "";
const trimmedStart = text.replace(/^\uFEFF?/, "");
if (!trimmedStart.startsWith(WORKFLOW_INSTRUCTIONS_HEADING)) {
return { instructions: "", rest: text, hasInstructions: false };
}
// Exact heading line only — reject "## Workflow instructions for release".
if (!isLineBoundary(trimmedStart, WORKFLOW_INSTRUCTIONS_HEADING.length)) {
return { instructions: "", rest: text, hasInstructions: false };
}
// Orchestrator emits:
// "## Workflow instructions\n\n{body}\n\n<!-- /workflow-instructions -->\n\n{rest}"
const afterHeading = trimmedStart.slice(WORKFLOW_INSTRUCTIONS_HEADING.length).replace(/^\n+/, "");
const endIdx = findStandaloneEndMarker(afterHeading);
if (endIdx === -1) {
// No structural end marker: leave ordinary user content alone.
return { instructions: "", rest: text, hasInstructions: false };
}
const instructions = afterHeading.slice(0, endIdx).replace(/\n+$/, "");
const afterEnd = afterHeading
.slice(endIdx + WORKFLOW_INSTRUCTIONS_END.length)
.replace(/^\n+/, "");
return {
instructions,
rest: afterEnd,
hasInstructions: true,
};
}
+6
View File
@@ -181,6 +181,12 @@
"startStepHelp": "New tasks start in this step. Only one step per workflow can be the start step.",
"stepIsPartOfReplayCycle": "{{stepName}} is part of a replay cycle",
"stepNamePlaceholder": "Step name",
"workflowPrompt": "Workflow Prompt",
"workflowPromptHelp": "Optional instructions included at every step entry, before the step prompt. Supports {task_id} and @saved-prompt references (same expansion as step prompts). {{taskPrompt}} is step-only. Leave empty to disable. Visible to the agent and shown collapsed in chat.",
"workflowPromptPlaceholder": "If the PR is merged or closed, move the Task to Done.\nIf you need to create multiple Pull Requests, prefer orchestrating subtasks.",
"workflowPromptCollapsedHint": "Optional — expand to add shared step-entry instructions",
"workflowPromptSetHint": "Configured — expand to edit",
"workflowInstructionsCollapsed": "Workflow instructions",
"stepPrompt": "Step Prompt",
"stepPromptHelp": "Custom instructions for the agent on this step. Use {{taskPrompt}} to include the task description.",
"stepPromptPlaceholderExample": "Implement user authentication with OAuth2",
@@ -181,6 +181,12 @@
"startStepHelp": "Ńēŵ ţàśķś śţàŕţ ĩń ţĥĩś śţēƥ. Ōńĺŷ ōńē śţēƥ ƥēŕ ŵōŕķƒĺōŵ ćàń ƀē ţĥē śţàŕţ śţēƥ.",
"stepIsPartOfReplayCycle": "{{stepName}} ĩś ƥàŕţ ōƒ à ŕēƥĺàŷ ćŷćĺē",
"stepNamePlaceholder": "Śţēƥ ńàḿē",
"workflowPrompt": "Ŵōŕķƒĺōŵ Ƥŕōḿƥţ",
"workflowPromptHelp": "Ōƥţĩōńàĺ ĩńśţŕũćţĩōńś ĩńćĺũďēď àţ ēvēŕŷ śţēƥ ēńţŕŷ, ƀēƒōŕē ţĥē śţēƥ ƥŕōḿƥţ. Śũƥƥōŕţś {ţàśķ_ĩď} àńď @śàvēď-ƥŕōḿƥţ ŕēƒēŕēńćēś (śàḿē ēxƥàńśĩōń àś śţēƥ ƥŕōḿƥţś). {{taskPrompt}} ĩś śţēƥ-ōńĺŷ. Ĺēàvē ēḿƥţŷ ţō ďĩśàƀĺē. Vĩśĩƀĺē ţō ţĥē àĝēńţ àńď śĥōŵń ćōĺĺàƥśēď ĩń ćĥàţ.",
"workflowPromptPlaceholder": "Ĩƒ ţĥē ƤŔ ĩś ḿēŕĝēď ōŕ ćĺōśēď, ḿōvē ţĥē Ţàśķ ţō Ďōńē.\nĨƒ ŷōũ ńēēď ţō ćŕēàţē ḿũĺţĩƥĺē Ƥũĺĺ Ŕēqũēśţś, ƥŕēƒēŕ ōŕćĥēśţŕàţĩńĝ śũƀţàśķś.",
"workflowPromptCollapsedHint": "Ōƥţĩōńàĺ — ēxƥàńď ţō àďď śĥàŕēď śţēƥ-ēńţŕŷ ĩńśţŕũćţĩōńś",
"workflowPromptSetHint": "Ćōńƒĩĝũŕēď — ēxƥàńď ţō ēďĩţ",
"workflowInstructionsCollapsed": "Ŵōŕķƒĺōŵ ĩńśţŕũćţĩōńś",
"stepPrompt": "Śţēƥ Ƥŕōḿƥţ",
"stepPromptHelp": "Ćũśţōḿ ĩńśţŕũćţĩōńś ƒōŕ ţĥē àĝēńţ ōń ţĥĩś śţēƥ. Ũśē {{taskPrompt}} ţō ĩńćĺũďē ţĥē ţàśķ ďēśćŕĩƥţĩōń.",
"stepPromptPlaceholderExample": "Ĩḿƥĺēḿēńţ ũśēŕ àũţĥēńţĩćàţĩōń ŵĩţĥ ŌÀũţĥ2",
+3
View File
@@ -63,6 +63,8 @@ Each entry under `workflows:`:
```yaml
- name: My Workflow
description: Optional human description.
prompt: | # optional shared agent instructions at every step entry
If the PR is merged or closed, move the Task to Done.
agent_profile: # optional, workflow-level default agent
agent_name: Claude Code
model: claude-opus-4-7
@@ -75,6 +77,7 @@ Each entry under `workflows:`:
|-------|------|:--------:|-------|
| `name` | string | yes | Workflow name. Required by `Validate()`. Used for **dedup on import** (see [Import rules](#import-matching-rules)). |
| `description` | string | no | Omitted from export when empty. |
| `prompt` | string | no | Optional workflow-level agent instructions prepended at every step entry before the step prompt. Omitted from export when empty. Supports `{task_id}` interpolation. |
| `agent_profile` | object | no | Workflow-level default agent profile. See [Agent profiles](#agent-profiles). Omitted when the workflow has no profile. |
| `steps` | list | — | The workflow's steps. See [`StepPortable`](#stepportable). |