diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 713f8eeae9..91db9e3296 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -22,7 +22,7 @@ on: types: [opened, synchronize, ready_for_review, reopened, labeled] concurrency: - group: claude-review-${{ github.event.pull_request.number || github.ref }} + group: claude-review-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: @@ -107,8 +107,7 @@ jobs: claude-review-fork: # Fork PRs only, via pull_request_target so secrets are available. - # Three approval paths: - # - OWNER/MEMBER/COLLABORATOR author_association (trusted user, all events) + # Two approval paths: # - CLAUDE_REVIEW_ALLOWLIST repo variable (trusted login list, all events; # JSON array, e.g. ["alice","bob"], in Settings -> Secrets and variables # -> Actions -> Variables) @@ -122,8 +121,7 @@ jobs: if: > github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository && - (contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) || - (github.event.action != 'synchronize' && contains(github.event.pull_request.labels.*.name, 'safe-to-review')) || + ((github.event.action != 'synchronize' && contains(github.event.pull_request.labels.*.name, 'safe-to-review')) || (vars.CLAUDE_REVIEW_ALLOWLIST != '' && contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login))) runs-on: ubuntu-latest permissions: @@ -136,11 +134,12 @@ jobs: uses: actions/checkout@v4 with: # pull_request_target defaults to the base ref; explicitly check out - # the PR head so the review runs against the contributor's changes. + # the PR head through the base repository's pull ref. Keeping origin + # on the base repository lets claude-code-action fetch + # refs/pull//head during its own PR setup. # Safe because the workflow never executes scripts from the PR; # claude-code-action only reads files and posts comments. - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} + ref: refs/pull/${{ github.event.pull_request.number }}/head fetch-depth: 1 - name: Run Claude Code Review (GITHUB_TOKEN fallback) diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index d3187ad233..866b5a47a8 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -15,7 +15,7 @@ on: types: [opened, synchronize, reopened, closed, labeled] concurrency: - group: preview-env-${{ github.event.pull_request.number || github.ref }} + group: preview-env-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} # Don't cancel cleanup on close — let it finish destroying the sprite. cancel-in-progress: ${{ github.event.action != 'closed' }} @@ -68,8 +68,7 @@ jobs: # ----------------------------------------------------------------------- # Deploy / update preview (fork PRs, gated by allowlist) # Uses pull_request_target so secrets are available. - # Three approval paths: - # - OWNER/MEMBER/COLLABORATOR author_association (trusted user, all events) + # Two approval paths: # - PREVIEW_ENV_ALLOWLIST repo variable (trusted login list, all events; # JSON array of logins) # - `safe-to-test` label (maintainer opt-in, per-commit). Explicitly @@ -87,8 +86,7 @@ jobs: github.event.action != 'closed' && github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository && - (contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) || - (github.event.action != 'synchronize' && contains(github.event.pull_request.labels.*.name, 'safe-to-test')) || + ((github.event.action != 'synchronize' && contains(github.event.pull_request.labels.*.name, 'safe-to-test')) || (vars.PREVIEW_ENV_ALLOWLIST != '' && contains(fromJSON(vars.PREVIEW_ENV_ALLOWLIST), github.event.pull_request.user.login))) runs-on: ubuntu-latest permissions: @@ -100,7 +98,7 @@ jobs: with: # pull_request_target checks out base by default; explicitly use PR head. # Safe: the preview CLI only reads files to build artifacts and posts comments. - # NOTE: The allowlist gate (OWNER/MEMBER/COLLABORATOR, safe-to-test, PREVIEW_ENV_ALLOWLIST) + # NOTE: The allowlist gate (safe-to-test, PREVIEW_ENV_ALLOWLIST) # authorizes running the fork's cmd/preview code with SPRITES_API_TOKEN and GH_TOKEN. # The safe-to-test label must be re-applied after each new push (see strip-safe-to-test job). repository: ${{ github.event.pull_request.head.repo.full_name }} diff --git a/Dockerfile b/Dockerfile index b59a9d60ae..c486db94d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,8 @@ # Run: # docker run -p 38429:38429 -v kandev-data:/data ghcr.io/kdlbs/kandev:latest +ARG PNPM_VERSION=9.15.9 + FROM node:24-bookworm-slim # Install runtime dependencies. gh is included because the GitHub integration diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 39d76de48c..1eceb2d070 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -875,7 +875,7 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo // runs in a goroutine and the session is already WAITING_FOR_INPUT, so // autoStartStepPrompt sends the prompt directly via PromptTask. effectivePrompt := s.buildWorkflowPrompt(taskDescription, step, taskID, sessionID) - if err := s.autoStartStepPrompt(ctx, taskID, session, step.Name, effectivePrompt, hasPlanMode, true); err != nil { + if err := s.autoStartStepPrompt(ctx, taskID, session, step, effectivePrompt, hasPlanMode, true); err != nil { s.logger.Error("failed to auto-start agent for step", zap.String("task_id", taskID), zap.String("session_id", sessionID), @@ -892,18 +892,17 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo if sessionSwitched && step.Prompt != "" { effectivePrompt := s.buildWorkflowPrompt(taskDescription, step, taskID, sessionID) planMode := hasPlanMode - stepName := step.Name stepID := step.ID s.logger.Info("auto-launching agent after profile switch (no explicit auto_start)", zap.String("task_id", taskID), zap.String("session_id", sessionID), - zap.String("step_name", stepName)) + zap.String("step_name", step.Name)) // Launch asynchronously because processOnEnter may also be called // synchronously from finalizeStepEnter (manual task move). In that path, // autoStartStepPrompt would block the caller's goroutine. go func() { asyncCtx := context.WithoutCancel(ctx) - err := s.autoStartStepPrompt(asyncCtx, taskID, session, stepName, effectivePrompt, planMode, true) + err := s.autoStartStepPrompt(asyncCtx, taskID, session, step, effectivePrompt, planMode, true) if err != nil { s.logger.Error("failed to launch agent after profile switch", zap.String("task_id", taskID), @@ -1091,13 +1090,45 @@ func (s *Service) autoStartPassthroughPrompt( return nil } +type workflowMessageOrigin struct { + StepID string + StepName string + StepColor string +} + +func workflowOriginFromStep(step *wfmodels.WorkflowStep) workflowMessageOrigin { + if step == nil { + return workflowMessageOrigin{} + } + return workflowMessageOrigin{ + StepID: step.ID, + StepName: step.Name, + StepColor: step.Color, + } +} + +func workflowMessageMetadata(planMode bool, origin workflowMessageOrigin) map[string]interface{} { + meta := NewUserMessageMeta(). + WithPlanMode(planMode). + WithAutoStart(true). + WithWorkflowStep(origin.StepID, origin.StepName, origin.StepColor). + ToMap() + if meta == nil { + meta = make(map[string]interface{}) + } + meta["workflow_auto_start"] = true + return meta +} + func (s *Service) autoStartStepPrompt( ctx context.Context, - taskID string, session *models.TaskSession, stepName, prompt string, + taskID string, session *models.TaskSession, step *wfmodels.WorkflowStep, prompt string, planMode bool, shouldQueueIfBusy bool, ) error { sessionID := session.ID + origin := workflowOriginFromStep(step) + stepName := origin.StepName // Take any queued message (e.g. from move_task_kandev with a hand-off // prompt) and merge it with the step's auto-start prompt — auto-start @@ -1118,7 +1149,7 @@ func (s *Service) autoStartStepPrompt( } if shouldQueueIfBusy { - queued, err := s.queueAutoStartPromptIfRunning(ctx, taskID, session, prompt, planMode) + queued, err := s.queueAutoStartPromptIfRunning(ctx, taskID, session, prompt, planMode, attachments, origin) if err != nil { requeueTaken() return err @@ -1140,7 +1171,7 @@ func (s *Service) autoStartStepPrompt( if session.State == models.TaskSessionStateCreated && (prompt != "" || len(attachments) > 0) && !sysprompt.HasKandevContext(prompt) { recordedPrompt = sysprompt.InjectKandevContext(taskID, sessionID, prompt) } - s.recordAutoStartMessage(ctx, taskID, sessionID, recordedPrompt, planMode) + s.recordAutoStartMessage(ctx, taskID, sessionID, recordedPrompt, planMode, origin) // If the session is in CREATED state, the agent was never started (e.g. workspace-only // preparation from a blocked auto-start). PromptTask will reject CREATED sessions, @@ -1183,7 +1214,7 @@ func (s *Service) autoStartStepPrompt( // an active agent for this session (e.g. session state is CREATED but // the agent was launched by a concurrent path). Queue instead of retrying. if isAgentAlreadyRunningError(err) && shouldQueueIfBusy { - if queueErr := s.queueAutoStartPrompt(ctx, taskID, sessionID, prompt, planMode); queueErr != nil { + if queueErr := s.queueAutoStartPrompt(ctx, taskID, sessionID, prompt, planMode, attachments, origin); queueErr != nil { requeueTaken() return queueErr } @@ -1196,7 +1227,7 @@ func (s *Service) autoStartStepPrompt( } if shouldQueueIfBusy { - if queueErr := s.queueAutoStartPrompt(ctx, taskID, sessionID, prompt, planMode); queueErr != nil { + if queueErr := s.queueAutoStartPrompt(ctx, taskID, sessionID, prompt, planMode, attachments, origin); queueErr != nil { requeueTaken() return queueErr } @@ -1310,7 +1341,12 @@ func (s *Service) takeAndMergeHandoffMessage(ctx context.Context, sessionID, bas // recordAutoStartMessage creates a user message for a workflow auto-start prompt // so it appears in the chat history. The prompt content includes system-injected // tags which are stripped when displayed to users via ToAPI(). -func (s *Service) recordAutoStartMessage(ctx context.Context, taskID, sessionID, prompt string, planMode bool) { +func (s *Service) recordAutoStartMessage( + ctx context.Context, + taskID, sessionID, prompt string, + planMode bool, + origin workflowMessageOrigin, +) { if s.messageCreator == nil || prompt == "" { return } @@ -1326,12 +1362,7 @@ func (s *Service) recordAutoStartMessage(ctx context.Context, taskID, sessionID, // re-creating the exact pileup the cleanup_policy work fixes. // workflow_auto_start is the original tag this function set; preserved // for any consumer reading it directly. - meta := NewUserMessageMeta().WithPlanMode(planMode).WithAutoStart(true) - metaMap := meta.ToMap() - if metaMap == nil { - metaMap = make(map[string]interface{}) - } - metaMap["workflow_auto_start"] = true + metaMap := workflowMessageMetadata(planMode, origin) if err := s.messageCreator.CreateUserMessage(ctx, taskID, prompt, sessionID, turnID, metaMap); err != nil { s.logger.Error("failed to create auto-start user message", zap.String("task_id", taskID), @@ -1344,25 +1375,54 @@ func (s *Service) queueAutoStartPromptIfRunning( ctx context.Context, taskID string, session *models.TaskSession, prompt string, planMode bool, + attachments []v1.MessageAttachment, + origin workflowMessageOrigin, ) (bool, error) { if session.State != models.TaskSessionStateRunning && session.State != models.TaskSessionStateStarting { return false, nil } - if err := s.queueAutoStartPrompt(ctx, taskID, session.ID, prompt, planMode); err != nil { + if err := s.queueAutoStartPrompt(ctx, taskID, session.ID, prompt, planMode, attachments, origin); err != nil { return false, err } return true, nil } +func toQueuedAttachments(attachments []v1.MessageAttachment) []messagequeue.MessageAttachment { + if len(attachments) == 0 { + return nil + } + queued := make([]messagequeue.MessageAttachment, 0, len(attachments)) + for _, attachment := range attachments { + queued = append(queued, messagequeue.MessageAttachment{ + Type: attachment.Type, + Data: attachment.Data, + MimeType: attachment.MimeType, + }) + } + return queued +} + func (s *Service) queueAutoStartPrompt( ctx context.Context, taskID, sessionID, prompt string, planMode bool, + attachments []v1.MessageAttachment, + origin workflowMessageOrigin, ) error { if s.messageQueue == nil { return fmt.Errorf("message queue is not configured") } - _, err := s.messageQueue.QueueMessage(ctx, sessionID, taskID, prompt, "", "workflow-auto-start", planMode, []messagequeue.MessageAttachment{}) + _, err := s.messageQueue.QueueMessageWithMetadata( + ctx, + sessionID, + taskID, + prompt, + "", + messagequeue.QueuedByWorkflow, + planMode, + toQueuedAttachments(attachments), + workflowMessageMetadata(planMode, origin), + ) if err != nil { return fmt.Errorf("failed to queue workflow auto-start prompt: %w", err) } diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go index 67e563f601..890af2d9a2 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go @@ -6,6 +6,7 @@ import ( "testing/synctest" "time" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" "github.com/kandev/kandev/internal/orchestrator/watcher" "github.com/kandev/kandev/internal/task/models" wfmodels "github.com/kandev/kandev/internal/workflow/models" @@ -291,7 +292,7 @@ func TestHandleTaskMovedWithSession(t *testing.T) { Events: wfmodels.StepEvents{}, } stepGetter.steps["step2"] = &wfmodels.WorkflowStep{ - ID: "step2", WorkflowID: "wf1", Name: "In Progress", Position: 1, + ID: "step2", WorkflowID: "wf1", Name: "In Progress", Position: 1, Color: "bg-emerald-500", Events: wfmodels.StepEvents{ OnEnter: []wfmodels.OnEnterAction{ {Type: wfmodels.OnEnterAutoStartAgent}, @@ -316,6 +317,22 @@ func TestHandleTaskMovedWithSession(t *testing.T) { if status.Entries[0].TaskID != "t1" { t.Fatalf("expected queued task_id t1, got %s", status.Entries[0].TaskID) } + if status.Entries[0].QueuedBy != messagequeue.QueuedByWorkflow { + t.Fatalf("expected queued_by workflow, got %s", status.Entries[0].QueuedBy) + } + meta := status.Entries[0].Metadata + if got := meta["workflow_message"]; got != true { + t.Fatalf("workflow_message = %v, want true", got) + } + if got := meta["workflow_step_id"]; got != "step2" { + t.Fatalf("workflow_step_id = %v, want step2", got) + } + if got := meta["workflow_step_name"]; got != "In Progress" { + t.Fatalf("workflow_step_name = %v, want In Progress", got) + } + if got := meta["workflow_step_color"]; got != "bg-emerald-500" { + t.Fatalf("workflow_step_color = %v, want bg-emerald-500", got) + } break } if time.Now().After(deadline) { @@ -335,7 +352,7 @@ func TestHandleTaskMovedWithSession(t *testing.T) { _ = repo.UpdateTaskSession(ctx, session) svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - queued, err := svc.queueAutoStartPromptIfRunning(ctx, "t1", session, "prompt", false) + queued, err := svc.queueAutoStartPromptIfRunning(ctx, "t1", session, "prompt", false, nil, workflowMessageOrigin{}) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go index 87e319ba9c..15012a482b 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go @@ -3,10 +3,12 @@ package orchestrator import ( "context" "errors" + "strings" "testing" "time" "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" "github.com/kandev/kandev/internal/task/models" wfmodels "github.com/kandev/kandev/internal/workflow/models" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -413,6 +415,12 @@ func TestProcessOnEnter(t *testing.T) { taskRepo := newMockTaskRepo() svc := createTestService(repo, newMockStepGetter(), taskRepo) + attachments := []messagequeue.MessageAttachment{ + {Type: "image", Data: "base64-data", MimeType: "image/png"}, + } + if _, err := svc.messageQueue.QueueMessage(ctx, "s1", "t1", "handoff prompt", "", "user", false, attachments); err != nil { + t.Fatalf("failed to seed queued handoff: %v", err) + } step := &wfmodels.WorkflowStep{ ID: "step2", WorkflowID: "wf1", Name: "In Progress", @@ -430,12 +438,19 @@ func TestProcessOnEnter(t *testing.T) { for { status := svc.messageQueue.GetStatus(ctx, "s1") if status.Count > 0 { - if status.Entries[0].TaskID != "t1" { - t.Fatalf("expected queued task_id t1, got %s", status.Entries[0].TaskID) + entry := status.Entries[0] + if entry.TaskID != "t1" { + t.Fatalf("expected queued task_id t1, got %s", entry.TaskID) } - if status.Entries[0].Content == "" { + if entry.Content == "" { t.Fatal("expected queued content to be populated") } + if !strings.Contains(entry.Content, "handoff prompt") { + t.Fatalf("expected queued content to include handoff prompt, got %q", entry.Content) + } + if len(entry.Attachments) != 1 || entry.Attachments[0].MimeType != "image/png" { + t.Fatalf("expected queued handoff attachment to be preserved, got %+v", entry.Attachments) + } break } if time.Now().After(deadline) { diff --git a/apps/backend/internal/orchestrator/message_meta.go b/apps/backend/internal/orchestrator/message_meta.go index 416d572057..374c0513bd 100644 --- a/apps/backend/internal/orchestrator/message_meta.go +++ b/apps/backend/internal/orchestrator/message_meta.go @@ -13,6 +13,10 @@ type UserMessageMeta struct { SenderTaskID string SenderTaskTitle string SenderSessionID string + WorkflowMessage bool + WorkflowStepID string + WorkflowStepName string + WorkflowStepColor string } // NewUserMessageMeta creates a UserMessageMeta builder. @@ -63,10 +67,20 @@ func (m *UserMessageMeta) WithSenderTask(taskID, taskTitle, sessionID string) *U return m } +// WithWorkflowStep records that the message was generated by workflow step +// automation rather than a direct user or inter-task agent message. +func (m *UserMessageMeta) WithWorkflowStep(stepID, stepName, stepColor string) *UserMessageMeta { + m.WorkflowMessage = true + m.WorkflowStepID = stepID + m.WorkflowStepName = stepName + m.WorkflowStepColor = stepColor + return m +} + // ToMap returns the metadata as a map suitable for message creation. // Returns nil if no metadata fields are set. func (m *UserMessageMeta) ToMap() map[string]interface{} { - if !m.PlanMode && !m.HasReviewComments && !m.AutoStart && len(m.Attachments) == 0 && len(m.ContextFiles) == 0 && m.SenderTaskID == "" { + if !m.PlanMode && !m.HasReviewComments && !m.AutoStart && len(m.Attachments) == 0 && len(m.ContextFiles) == 0 && m.SenderTaskID == "" && !m.WorkflowMessage { return nil } meta := make(map[string]interface{}) @@ -92,6 +106,18 @@ func (m *UserMessageMeta) ToMap() map[string]interface{} { meta["sender_session_id"] = m.SenderSessionID } } + if m.WorkflowMessage { + meta["workflow_message"] = true + if m.WorkflowStepID != "" { + meta["workflow_step_id"] = m.WorkflowStepID + } + if m.WorkflowStepName != "" { + meta["workflow_step_name"] = m.WorkflowStepName + } + if m.WorkflowStepColor != "" { + meta["workflow_step_color"] = m.WorkflowStepColor + } + } return meta } diff --git a/apps/backend/internal/orchestrator/messagequeue/types.go b/apps/backend/internal/orchestrator/messagequeue/types.go index ad0dc356b3..1e809d673f 100644 --- a/apps/backend/internal/orchestrator/messagequeue/types.go +++ b/apps/backend/internal/orchestrator/messagequeue/types.go @@ -15,8 +15,9 @@ const DefaultMaxPerSession = 10 // dispatch hardcodes QueuedByAgent so user clients can never overwrite an // agent-authored entry. const ( - QueuedByUser = "user" - QueuedByAgent = "agent" + QueuedByUser = "user" + QueuedByAgent = "agent" + QueuedByWorkflow = "workflow" ) // QueueFullErrorCode is the well-known WS / MCP error code surfaced when an diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index bd1fab35b4..7f7560a626 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -918,9 +918,10 @@ func (h *TaskHandlers) httpMoveTask(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "workflow_id and workflow_step_id are required"}) return } - result, err := h.service.MoveTask( + result, err := h.service.MoveTaskWithOptions( c.Request.Context(), c.Param("id"), body.WorkflowID, body.WorkflowStepID, body.Position, + service.MoveTaskOptions{AllowActivePrimarySession: true}, ) if err != nil { handleSelectedMoveError(c, h.logger, err) diff --git a/apps/backend/internal/task/handlers/task_http_handlers_test.go b/apps/backend/internal/task/handlers/task_http_handlers_test.go index 4a4344f17f..63b293090b 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers_test.go +++ b/apps/backend/internal/task/handlers/task_http_handlers_test.go @@ -202,18 +202,42 @@ func TestHandleSelectedMoveError(t *testing.T) { type moveTaskConflictRepo struct { mockRepository - task *models.Task - sessions []*models.TaskSession + task *models.Task + sessions []*models.TaskSession + workflows map[string]*models.Workflow } func (m *moveTaskConflictRepo) GetTask(ctx context.Context, id string) (*models.Task, error) { return m.task, nil } +func (m *moveTaskConflictRepo) UpdateTask(ctx context.Context, task *models.Task) error { + m.task = task + return nil +} + +func (m *moveTaskConflictRepo) GetWorkflow(ctx context.Context, id string) (*models.Workflow, error) { + if m.workflows != nil { + if workflow, ok := m.workflows[id]; ok { + return workflow, nil + } + } + return &models.Workflow{ID: id, WorkspaceID: m.task.WorkspaceID}, nil +} + func (m *moveTaskConflictRepo) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { return m.sessions, nil } +func (m *moveTaskConflictRepo) GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { + for _, session := range m.sessions { + if session.TaskID == taskID && session.IsPrimary { + return session, nil + } + } + return nil, nil +} + func TestHTTPMoveTaskMapsMoveConflict(t *testing.T) { gin.SetMode(gin.TestMode) log := newTestLogger(t) @@ -235,7 +259,7 @@ func TestHTTPMoveTaskMapsMoveConflict(t *testing.T) { }, }, { - name: "active session", + name: "active non-primary session", task: &models.Task{ ID: "task-running", WorkspaceID: "workspace-1", @@ -278,6 +302,49 @@ func TestHTTPMoveTaskMapsMoveConflict(t *testing.T) { } } +func TestHTTPMoveTaskAllowsRunningPrimarySession(t *testing.T) { + gin.SetMode(gin.TestMode) + log := newTestLogger(t) + task := &models.Task{ + ID: "task-running-primary", + WorkspaceID: "workspace-1", + WorkflowID: "wf-source", + WorkflowStepID: "step-source", + } + repo := &moveTaskConflictRepo{ + task: task, + sessions: []*models.TaskSession{{ + ID: "session-running-primary", + TaskID: task.ID, + State: models.TaskSessionStateRunning, + IsPrimary: true, + }}, + } + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + h := &TaskHandlers{service: svc, logger: log} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Params = gin.Params{{Key: "id", Value: task.ID}} + c.Request = httptest.NewRequest(http.MethodPost, "/tasks/"+task.ID+"/move", strings.NewReader(`{ + "workflow_id": "wf-target", + "workflow_step_id": "step-target", + "position": 0 + }`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.httpMoveTask(c) + + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, "wf-target", repo.task.WorkflowID) + assert.Equal(t, "step-target", repo.task.WorkflowStepID) +} + func TestResolveFreshBranchName(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index e450c2c3a6..3ebe7063fd 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -330,7 +330,14 @@ func (h *TaskHandlers) wsMoveTask(ctx context.Context, msg *ws.Message) (*ws.Mes return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_step_id is required", nil) } - result, err := h.service.MoveTask(ctx, req.ID, req.WorkflowID, req.WorkflowStepID, req.Position) + result, err := h.service.MoveTaskWithOptions( + ctx, + req.ID, + req.WorkflowID, + req.WorkflowStepID, + req.Position, + service.MoveTaskOptions{AllowActivePrimarySession: true}, + ) if err != nil { h.logger.Error("failed to move task", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to move task", nil) diff --git a/apps/backend/internal/task/service/service_workflow.go b/apps/backend/internal/task/service/service_workflow.go index cc2ee0ff53..d9b9d9d6bb 100644 --- a/apps/backend/internal/task/service/service_workflow.go +++ b/apps/backend/internal/task/service/service_workflow.go @@ -221,14 +221,31 @@ type MoveTaskResult struct { WorkflowStep *wfmodels.WorkflowStep } +// MoveTaskOptions controls non-default move behavior for trusted callers. +type MoveTaskOptions struct { + AllowActivePrimarySession bool +} + // MoveTask moves a task to a different workflow step and position func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, workflowStepID string, position int) (*MoveTaskResult, error) { + return s.MoveTaskWithOptions(ctx, id, workflowID, workflowStepID, position, MoveTaskOptions{}) +} + +// MoveTaskWithOptions moves a task with explicit caller options. +func (s *Service) MoveTaskWithOptions( + ctx context.Context, + id string, + workflowID string, + workflowStepID string, + position int, + opts MoveTaskOptions, +) (*MoveTaskResult, error) { task, err := s.tasks.GetTask(ctx, id) if err != nil { return nil, err } - if err := s.validateTaskMove(ctx, task, workflowID, workflowStepID); err != nil { + if err := s.validateTaskMove(ctx, task, workflowID, workflowStepID, opts); err != nil { return nil, err } @@ -283,11 +300,11 @@ func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, wo return result, nil } -func (s *Service) validateTaskMove(ctx context.Context, task *models.Task, workflowID, workflowStepID string) error { +func (s *Service) validateTaskMove(ctx context.Context, task *models.Task, workflowID, workflowStepID string, opts MoveTaskOptions) error { if task.ArchivedAt != nil { return fmt.Errorf("archived tasks cannot be moved") } - if err := s.validateMoveSessions(ctx, task.ID); err != nil { + if err := s.validateMoveSessions(ctx, task.ID, opts); err != nil { return err } targetWorkflow, err := s.workflows.GetWorkflow(ctx, workflowID) @@ -310,13 +327,16 @@ func (s *Service) validateTaskMove(ctx context.Context, task *models.Task, workf return nil } -func (s *Service) validateMoveSessions(ctx context.Context, taskID string) error { +func (s *Service) validateMoveSessions(ctx context.Context, taskID string, opts MoveTaskOptions) error { sessions, err := s.sessions.ListTaskSessions(ctx, taskID) if err != nil { return fmt.Errorf("failed to list task sessions: %w", err) } for _, session := range sessions { if isSessionMoveBlocked(session.State) { + if opts.AllowActivePrimarySession && session.IsPrimary { + continue + } return fmt.Errorf("task has an active session (%s)", session.State) } } @@ -423,7 +443,7 @@ func (s *Service) validateSelectedMoveBatch(ctx context.Context, taskIDs []strin return nil, err } if task.WorkflowID != targetWorkflowID || task.WorkflowStepID != targetStepID { - if err := s.validateTaskMove(ctx, task, targetWorkflowID, targetStepID); err != nil { + if err := s.validateTaskMove(ctx, task, targetWorkflowID, targetStepID, MoveTaskOptions{}); err != nil { return nil, fmt.Errorf("task %s cannot be moved: %w", id, err) } } diff --git a/apps/backend/internal/task/service/service_workflow_test.go b/apps/backend/internal/task/service/service_workflow_test.go index 8d9867cdcf..f1099be8ab 100644 --- a/apps/backend/internal/task/service/service_workflow_test.go +++ b/apps/backend/internal/task/service/service_workflow_test.go @@ -162,6 +162,35 @@ func TestService_MoveTaskRejectsRunningSession(t *testing.T) { } } +func TestService_MoveTaskWithOptionsAllowsRunningPrimarySession(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + createMoveTask(t, ctx, repo, "task-running-primary", "wf-source", "step-source", nil) + createMoveSession(t, ctx, repo, "session-running-primary", "task-running-primary", models.TaskSessionStateRunning, models.ReviewStatusNone) + eventBus.ClearEvents() + + moved, err := svc.MoveTaskWithOptions(ctx, "task-running-primary", "wf-source", "step-review-target", 0, MoveTaskOptions{ + AllowActivePrimarySession: true, + }) + if err != nil { + t.Fatalf("running primary session should be movable with explicit option: %v", err) + } + if moved.Task.WorkflowStepID != "step-review-target" { + t.Fatalf("expected step-review-target, got %s", moved.Task.WorkflowStepID) + } + + event := findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskMoved) + data, ok := event.Data.(map[string]interface{}) + if !ok { + t.Fatalf("event data type = %T, want map[string]interface{}", event.Data) + } + if got := data["session_id"]; got != "session-running-primary" { + t.Fatalf("session_id = %v, want session-running-primary", got) + } +} + func TestService_MoveTaskRejectsArchivedTask(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() diff --git a/apps/web/components/task/chat/messages/chat-message.test.tsx b/apps/web/components/task/chat/messages/chat-message.test.tsx index 8ed50b98eb..4c65e919c6 100644 --- a/apps/web/components/task/chat/messages/chat-message.test.tsx +++ b/apps/web/components/task/chat/messages/chat-message.test.tsx @@ -138,6 +138,33 @@ describe("ChatMessage sender badge", () => { expect(container.querySelector(SENDER_BADGE_SELECTOR)).toBeNull(); }); + + it("renders the workflow step badge when workflow metadata is present", () => { + const { container } = renderWithSender([], { + workflow_message: true, + workflow_step_name: "Review", + workflow_step_color: "bg-emerald-500", + }); + + const badge = container.querySelector("[data-testid='workflow-message-badge']"); + expect(badge).not.toBeNull(); + expect(badge?.textContent).toContain("Review"); + expect(container.querySelector("[data-testid='workflow-message-dot']")?.className).toContain( + "bg-emerald-500", + ); + }); + + it("falls back when workflow metadata has an unknown color class", () => { + const { container } = renderWithSender([], { + workflow_message: true, + workflow_step_name: "Review", + workflow_step_color: "unknown-step-color", + }); + + expect(container.querySelector("[data-testid='workflow-message-dot']")?.className).toContain( + "bg-neutral-400", + ); + }); }); describe("ChatMessage image attachments", () => { diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx index ddd5270d06..3cee6cf393 100644 --- a/apps/web/components/task/chat/messages/chat-message.tsx +++ b/apps/web/components/task/chat/messages/chat-message.tsx @@ -15,6 +15,12 @@ import { remarkPlugins, } from "@/components/shared/markdown-components"; import { ImagePreviewDialog } from "@/components/task/chat/image-preview-dialog"; +import { + WorkflowStepMessageBadge, + workflowMessageInfoFromMetadata, + type WorkflowMessageMetadata, + type WorkflowStepMessageInfo, +} from "./workflow-step-message-badge"; type ChatMessageProps = { comment: Message; @@ -104,7 +110,7 @@ type UserMessageProps = { onScrollToMessage?: (messageId: string) => void; }; -type UserMessageMetadata = { +type UserMessageMetadata = WorkflowMessageMetadata & { attachments?: Array<{ type: string; data: string; mime_type: string; name?: string }>; plan_mode?: boolean; has_review_comments?: boolean; @@ -128,6 +134,7 @@ function parseUserMessageMetadata(comment: Message) { const senderTask: SenderTaskInfo | null = metadata?.sender_task_id ? { id: metadata.sender_task_id, snapshotTitle: metadata.sender_task_title || "" } : null; + const workflowMessage = workflowMessageInfoFromMetadata(metadata); return { imageAttachments, fileAttachments, @@ -138,6 +145,7 @@ function parseUserMessageMetadata(comment: Message) { hasContent, hasAttachments, senderTask, + workflowMessage, }; } @@ -146,15 +154,25 @@ function UserContextBadges({ hasReviewComments, contextFiles, senderTask, + workflowMessage, }: { hasPlanMode: boolean; hasReviewComments: boolean; contextFiles: Array<{ path: string; name: string }>; senderTask: SenderTaskInfo | null; + workflowMessage: WorkflowStepMessageInfo | null; }) { - if (!hasPlanMode && !hasReviewComments && contextFiles.length === 0 && !senderTask) return null; + if ( + !hasPlanMode && + !hasReviewComments && + contextFiles.length === 0 && + !senderTask && + !workflowMessage + ) + return null; return (
+ {workflowMessage && } {senderTask && } {hasPlanMode && ( @@ -196,6 +214,7 @@ function UserMessageContent({ hasContent, hasAttachments, senderTask, + workflowMessage, } = parseUserMessageMetadata(comment); return ( @@ -206,6 +225,7 @@ function UserMessageContent({ hasReviewComments={hasReviewComments} contextFiles={contextFiles} senderTask={senderTask} + workflowMessage={workflowMessage} />
{hasAttachments && ( diff --git a/apps/web/components/task/chat/messages/workflow-step-message-badge.tsx b/apps/web/components/task/chat/messages/workflow-step-message-badge.tsx new file mode 100644 index 0000000000..56ae591179 --- /dev/null +++ b/apps/web/components/task/chat/messages/workflow-step-message-badge.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; +import { cn } from "@/lib/utils"; + +export type WorkflowMessageMetadata = { + workflow_message?: boolean; + workflow_auto_start?: boolean; + workflow_step_id?: string; + workflow_step_name?: string; + workflow_step_color?: string; +}; + +export type WorkflowStepMessageInfo = { + stepId?: string; + stepName?: string; + stepColor?: string; +}; + +const FALLBACK_STEP_COLOR = "bg-neutral-400"; +const WORKFLOW_STEP_COLOR_CLASSES = new Set([ + "bg-slate-500", + "bg-red-500", + "bg-orange-500", + "bg-yellow-500", + "bg-green-500", + "bg-emerald-500", + "bg-cyan-500", + "bg-blue-500", + "bg-indigo-500", + "bg-purple-500", + FALLBACK_STEP_COLOR, +]); + +function safeStepColor(stepColor?: string): string { + return stepColor && WORKFLOW_STEP_COLOR_CLASSES.has(stepColor) ? stepColor : FALLBACK_STEP_COLOR; +} + +export function workflowMessageInfoFromMetadata( + metadata: WorkflowMessageMetadata | null | undefined, +): WorkflowStepMessageInfo | null { + if (!metadata?.workflow_message && !metadata?.workflow_auto_start) return null; + return { + stepId: metadata.workflow_step_id, + stepName: metadata.workflow_step_name, + stepColor: metadata.workflow_step_color, + }; +} + +type WorkflowStepMessageBadgeProps = { + workflow: WorkflowStepMessageInfo; + size?: "xs" | "sm"; +}; + +export function WorkflowStepMessageBadge({ workflow, size = "sm" }: WorkflowStepMessageBadgeProps) { + const label = workflow.stepName || "workflow step"; + const sizeClass = + size === "xs" ? "gap-1 px-1.5 py-0.5 text-[10px]" : "gap-1.5 px-2.5 py-1 text-xs font-medium"; + const dotSize = size === "xs" ? "h-1.5 w-1.5" : "h-2 w-2"; + const stepColor = safeStepColor(workflow.stepColor); + const badge = ( + + + Workflow: {label} + + ); + + return ( + + + {badge} + Workflow step message from {label} + + + ); +} diff --git a/apps/web/components/task/chat/queued-ghost-list.test.tsx b/apps/web/components/task/chat/queued-ghost-list.test.tsx index b8d4266402..c1c56b173c 100644 --- a/apps/web/components/task/chat/queued-ghost-list.test.tsx +++ b/apps/web/components/task/chat/queued-ghost-list.test.tsx @@ -165,4 +165,25 @@ describe("QueueAffordance", () => { fireEvent.click(screen.getByTestId("queue-clear-all")); expect(state.clearAll).toHaveBeenCalledTimes(1); }); + + it("workflow queued entries are read-only", () => { + useQueueMock.mockReturnValue( + queueState([ + entry({ + queued_by: "workflow", + metadata: { + workflow_message: true, + workflow_step_name: "Review", + }, + }), + ]), + ); + render({CHILD}); + + fireEvent.click(screen.getByTestId(CHIP_ID)); + + expect(screen.getByTestId("workflow-message-badge").textContent).toContain("Review"); + expect(screen.queryByTitle("Edit queued message")).toBeNull(); + expect(screen.queryByTitle("Remove queued message")).toBeNull(); + }); }); diff --git a/apps/web/components/task/chat/queued-ghost-list.tsx b/apps/web/components/task/chat/queued-ghost-list.tsx index 41e0a38e4d..eebc7545d9 100644 --- a/apps/web/components/task/chat/queued-ghost-list.tsx +++ b/apps/web/components/task/chat/queued-ghost-list.tsx @@ -10,14 +10,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@/lib/utils"; import { stripSystemTags } from "@/lib/utils/system-tags"; import { useQueue } from "@/hooks/domains/session/use-queue"; -import { QueuedGhostMessage } from "./queued-ghost-message"; +import { isWorkflowQueuedMessage, QueuedGhostMessage } from "./queued-ghost-message"; import type { QueuedMessage } from "@/lib/state/slices/session/types"; const HEAD_PREVIEW_MAX = 80; /** Inter-task entries are dispatched with queued_by="agent" and stay read-only. */ function canUserEditEntry(entry: QueuedMessage): boolean { - return entry.queued_by !== "agent"; + return entry.queued_by !== "agent" && !isWorkflowQueuedMessage(entry); } function headPreviewText(entries: QueuedMessage[]): string { diff --git a/apps/web/components/task/chat/queued-ghost-message.test.tsx b/apps/web/components/task/chat/queued-ghost-message.test.tsx index eb02239fcd..4be114f16d 100644 --- a/apps/web/components/task/chat/queued-ghost-message.test.tsx +++ b/apps/web/components/task/chat/queued-ghost-message.test.tsx @@ -35,6 +35,30 @@ function entry(overrides: Partial = {}): QueuedMessage { }; } +describe("QueuedGhostMessage workflow badge", () => { + it("renders workflow metadata as a workflow step badge", () => { + render( + {}} + onRemove={() => {}} + />, + ); + + expect(screen.getByTestId("workflow-message-badge").textContent).toContain("In Progress"); + expect(screen.getByTestId("workflow-message-dot").className).toContain("bg-green-500"); + expect(screen.queryByTestId("sender-task-badge")).toBeNull(); + }); +}); + describe("QueuedGhostMessage attachment thumbnails", () => { it("renders an image thumbnail for image attachments", () => { render( diff --git a/apps/web/components/task/chat/queued-ghost-message.tsx b/apps/web/components/task/chat/queued-ghost-message.tsx index 12f5d14136..45ef9abb81 100644 --- a/apps/web/components/task/chat/queued-ghost-message.tsx +++ b/apps/web/components/task/chat/queued-ghost-message.tsx @@ -24,6 +24,11 @@ import { SenderTaskBadge, type SenderTaskInfo, } from "@/components/task/chat/messages/sender-task-badge"; +import { + WorkflowStepMessageBadge, + workflowMessageInfoFromMetadata, + type WorkflowStepMessageInfo, +} from "@/components/task/chat/messages/workflow-step-message-badge"; import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components"; import type { QueuedMessage } from "@/lib/state/slices/session/types"; @@ -76,9 +81,18 @@ export type QueuedGhostMessageHandle = { startEdit: () => void; }; -type SenderKind = "user" | "agent" | "system"; +type SenderKind = "user" | "agent" | "workflow" | "system"; + +export function isWorkflowQueuedMessage(entry: QueuedMessage): boolean { + return ( + entry.queued_by === "workflow" || + entry.queued_by === "workflow-auto-start" || + workflowMessageInfoFromMetadata(entry.metadata) !== null + ); +} function senderKindOf(entry: QueuedMessage): SenderKind { + if (isWorkflowQueuedMessage(entry)) return "workflow"; if (!entry.queued_by) return "system"; // Inter-task messages dispatched via dispatchTaskMessage hardcode // queued_by="agent"; that's the only signal needed. @@ -92,6 +106,7 @@ function senderLabel(entry: QueuedMessage): string { const title = entry.metadata?.sender_task_title; return typeof title === "string" && title.length > 0 ? `From ${title}` : "From agent"; } + if (kind === "workflow") return "Workflow"; if (kind === "system") return "System"; return "You"; } @@ -105,6 +120,9 @@ function senderIconFor(kind: SenderKind): { Icon: typeof IconUser; tone: string if (kind === "system") { return { Icon: IconInfoCircle, tone: "text-muted-foreground" }; } + if (kind === "workflow") { + return { Icon: IconInfoCircle, tone: "text-muted-foreground" }; + } return { Icon: IconUser, tone: "text-blue-500 dark:text-blue-300" }; } @@ -128,6 +146,12 @@ function getSenderTaskInfo(entry: QueuedMessage): SenderTaskInfo | null { return { id, snapshotTitle: meta?.sender_task_title ?? "" }; } +function getWorkflowMessageInfo(entry: QueuedMessage): WorkflowStepMessageInfo | null { + const info = workflowMessageInfoFromMetadata(entry.metadata); + if (info) return info; + return entry.queued_by === "workflow" || entry.queued_by === "workflow-auto-start" ? {} : null; +} + type EditViewProps = { value: string; saving: boolean; @@ -221,6 +245,7 @@ function DisplayView({ entry, positionLabel, canEdit, onStartEdit, onRemove }: D const visible = stripSystemTags(entry.content); const attachments = (entry.attachments ?? []) as QueuedAttachment[]; const senderTask = getSenderTaskInfo(entry); + const workflowMessage = getWorkflowMessageInfo(entry); const [expanded, setExpanded] = useState(false); const canExpand = shouldOfferExpand(visible); return ( @@ -232,9 +257,10 @@ function DisplayView({ entry, positionLabel, canEdit, onStartEdit, onRemove }: D > {positionLabel} - {!senderTask && } + {!senderTask && !workflowMessage && }
+ {workflowMessage && } {senderTask && } {visible && (
{ + test("manual move queue badge stays usable on narrow screens", async ({ + testPage, + apiClient, + seedData, + }) => { + const { session } = await seedQueuedWorkflowMessageScenario( + testPage, + apiClient, + seedData, + "Mobile queued workflow", + ); + + await expectWorkflowQueueBadge(session); + await expectNoQueuePanelHorizontalOverflow(testPage); + }); +}); diff --git a/apps/web/e2e/tests/workflow/workflow-manual-move-queue.spec.ts b/apps/web/e2e/tests/workflow/workflow-manual-move-queue.spec.ts new file mode 100644 index 0000000000..02bed95a2e --- /dev/null +++ b/apps/web/e2e/tests/workflow/workflow-manual-move-queue.spec.ts @@ -0,0 +1,24 @@ +import { test } from "../../fixtures/test-base"; +import { + expectDeliveredWorkflowMessage, + expectWorkflowQueueBadge, + seedQueuedWorkflowMessageScenario, +} from "./workflow-queue-helpers"; + +test.describe("Workflow queued messages", () => { + test("manual move during an active turn queues a workflow step message", async ({ + testPage, + apiClient, + seedData, + }) => { + const { session, sessionId } = await seedQueuedWorkflowMessageScenario( + testPage, + apiClient, + seedData, + "Desktop queued workflow", + ); + + await expectWorkflowQueueBadge(session); + await expectDeliveredWorkflowMessage(apiClient, session, sessionId); + }); +}); diff --git a/apps/web/e2e/tests/workflow/workflow-queue-helpers.ts b/apps/web/e2e/tests/workflow/workflow-queue-helpers.ts new file mode 100644 index 0000000000..00320b249e --- /dev/null +++ b/apps/web/e2e/tests/workflow/workflow-queue-helpers.ts @@ -0,0 +1,97 @@ +import { type Page, expect } from "@playwright/test"; +import type { ApiClient } from "../../helpers/api-client"; +import type { SeedData } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; + +type QueuedWorkflowScenario = { + session: SessionPage; + sessionId: string; +}; + +const WORKFLOW_REVIEW_STEP = "Review"; + +export async function seedQueuedWorkflowMessageScenario( + page: Page, + apiClient: ApiClient, + seedData: SeedData, + name: string, +): Promise { + const workflow = await apiClient.createWorkflow(seedData.workspaceId, `${name} Workflow`); + const sourceStep = await apiClient.createWorkflowStep(workflow.id, "Working", 0); + const reviewStep = await apiClient.createWorkflowStep(workflow.id, WORKFLOW_REVIEW_STEP, 1); + + await apiClient.updateWorkflowStep(reviewStep.id, { + prompt: 'e2e:message("workflow queued response")', + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + `${name} Task`, + seedData.agentProfileId, + { + description: 'e2e:delay(8000)\ne2e:message("initial response")', + workflow_id: workflow.id, + workflow_step_id: sourceStep.id, + repository_ids: [seedData.repositoryId], + }, + ); + if (!task.session_id) throw new Error("createTaskWithAgent did not return session_id"); + + await page.goto(`/t/${task.id}`); + const session = new SessionPage(page); + await session.waitForLoad(); + await expect(session.agentStatus()).toBeVisible({ timeout: 20_000 }); + + await apiClient.moveTask(task.id, workflow.id, reviewStep.id); + + return { session, sessionId: task.session_id }; +} + +export async function expectWorkflowQueueBadge(session: SessionPage) { + const chat = session.activeChat(); + await expect(chat.getByTestId("queue-chip")).toBeVisible({ timeout: 10_000 }); + await chat.getByTestId("queue-chip").click(); + + const panel = chat.getByTestId("queued-ghost-list"); + await expect(panel).toBeVisible({ timeout: 5_000 }); + await expect(panel.getByTestId("workflow-message-badge")).toContainText(WORKFLOW_REVIEW_STEP); + await expect(panel.getByTestId("workflow-message-dot")).toBeVisible(); + await expect(panel.getByTestId("sender-task-badge")).toHaveCount(0); +} + +export async function expectDeliveredWorkflowMessage( + apiClient: ApiClient, + session: SessionPage, + sessionId: string, +) { + const chat = session.activeChat(); + await expect + .poll( + async () => { + const { messages } = await apiClient.listSessionMessages(sessionId); + return messages.some( + (message) => + message.author_type === "user" && + message.metadata?.workflow_message === true && + message.metadata?.workflow_step_name === WORKFLOW_REVIEW_STEP, + ); + }, + { timeout: 30_000 }, + ) + .toBe(true); + + await expect(chat.getByTestId("queued-ghost-list")).toHaveCount(0, { timeout: 10_000 }); + await expect(chat.getByText(/^workflow queued response$/)).toBeVisible({ timeout: 30_000 }); + await expect( + chat.getByTestId("workflow-message-badge").filter({ hasText: WORKFLOW_REVIEW_STEP }), + ).toBeVisible({ timeout: 10_000 }); +} + +export async function expectNoQueuePanelHorizontalOverflow(page: Page) { + const hasNoOverflow = await page.getByTestId("queued-ghost-list").evaluate((panel) => { + const rect = panel.getBoundingClientRect(); + return rect.left >= 0 && rect.right <= window.innerWidth + 1; + }); + expect(hasNoOverflow).toBe(true); +} diff --git a/apps/web/lib/state/slices/session/types.ts b/apps/web/lib/state/slices/session/types.ts index 4b763ea436..e6398753cf 100644 --- a/apps/web/lib/state/slices/session/types.ts +++ b/apps/web/lib/state/slices/session/types.ts @@ -83,6 +83,17 @@ export type TaskPlansState = { lastSeenUpdatedAtByTaskId: Record; }; +export type QueuedMessageMetadata = Record & { + workflow_message?: boolean; + workflow_auto_start?: boolean; + workflow_step_id?: string; + workflow_step_name?: string; + workflow_step_color?: string; + sender_task_id?: string; + sender_task_title?: string; + sender_session_id?: string; +}; + export type QueuedMessage = { id: string; session_id: string; @@ -92,7 +103,7 @@ export type QueuedMessage = { model?: string; plan_mode: boolean; attachments?: Array<{ type: string; data: string; mime_type: string }>; - metadata?: Record; + metadata?: QueuedMessageMetadata; queued_at: string; queued_by?: string; };