feat: add optional workflow-level agent prompt (#2338)
This commit is contained in:
@@ -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{}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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). |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user