From 44811da70248bcb54d10d91b650ed47784a92ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 2 Mar 2026 08:54:58 +0000 Subject: [PATCH] feat: web e2e tests (#304) --- .github/workflows/backend-tests.yml | 4 + .github/workflows/e2e-tests.yml | 81 +++ .github/workflows/frontend-tests.yml | 4 + .github/workflows/pr-title.yml | 4 + Makefile | 25 + apps/backend/cmd/kandev/adapters.go | 4 +- apps/backend/cmd/kandev/e2e_reset.go | 55 ++ apps/backend/cmd/kandev/helpers.go | 28 + apps/backend/cmd/kandev/services.go | 9 + apps/backend/cmd/kandev/storage.go | 5 +- apps/backend/cmd/mock-agent/handler.go | 54 +- apps/backend/cmd/mock-agent/main.go | 51 +- apps/backend/cmd/mock-agent/mcp_client.go | 98 +++ apps/backend/cmd/mock-agent/script.go | 221 ++++++ apps/backend/cmd/mock-agent/script_test.go | 629 ++++++++++++++++++ apps/backend/cmd/mock-agent/sequences.go | 49 +- apps/backend/internal/agent/agents/mock.go | 18 +- .../agent/lifecycle/manager_events.go | 9 +- .../internal/agent/lifecycle/session.go | 6 - .../internal/agent/registry/provider.go | 15 +- apps/backend/internal/github/factory.go | 9 +- apps/backend/internal/github/factory_test.go | 51 ++ apps/backend/internal/github/handlers.go | 12 + apps/backend/internal/github/mock_client.go | 303 +++++++++ .../internal/github/mock_client_test.go | 220 ++++++ .../internal/github/mock_controller.go | 165 +++++ apps/backend/internal/github/noop_client.go | 74 +++ .../internal/github/noop_client_test.go | 46 ++ .../orchestrator_interaction_test.go | 31 +- .../internal/orchestrator/event_handlers.go | 17 + .../orchestrator/event_handlers_agent.go | 20 +- .../orchestrator/event_handlers_git.go | 40 +- .../orchestrator/event_handlers_streaming.go | 5 + .../orchestrator/event_handlers_test.go | 135 +++- .../orchestrator/event_handlers_workflow.go | 59 +- .../event_handlers_workflow_triggers_test.go | 14 + .../internal/orchestrator/session_launch.go | 2 +- .../internal/orchestrator/task_operations.go | 120 +++- .../orchestrator/task_operations_test.go | 169 ++++- .../internal/orchestrator/workflow_store.go | 12 +- .../task/handlers/message_handlers.go | 22 +- .../task/handlers/task_http_handlers.go | 3 +- .../task/handlers/task_ws_handlers.go | 1 + .../internal/task/repository/sqlite/task.go | 11 + .../task/repository/sqlite/workflow.go | 25 + apps/backend/internal/task/service/service.go | 1 + .../internal/task/service/service_requests.go | 1 + .../internal/task/service/service_tasks.go | 8 +- .../internal/workflow/service/service.go | 20 +- .../internal/workflow/service/service_test.go | 36 +- apps/pnpm-lock.yaml | 98 ++- apps/web/.gitignore | 2 + apps/web/components/kanban-board.tsx | 2 +- apps/web/components/kanban-card.tsx | 1 + apps/web/components/kanban-column.tsx | 1 + apps/web/components/kanban/kanban-header.tsx | 9 +- .../settings/workflow-card-actions.ts | 29 +- .../components/task-create-dialog-footer.tsx | 6 +- .../components/task-create-dialog-helpers.ts | 1 - .../task-create-dialog-selectors.tsx | 2 + .../components/task-create-dialog-submit.tsx | 1 + apps/web/components/task-create-dialog.tsx | 5 +- .../task/chat/messages/status-message.tsx | 2 +- .../task/chat/use-chat-panel-state.ts | 88 ++- .../components/task/chat/use-tiptap-editor.ts | 77 ++- apps/web/components/task/files-panel.tsx | 2 +- apps/web/components/task/panel-primitives.tsx | 10 +- apps/web/components/task/task-chat-panel.tsx | 2 +- .../web/components/task/task-page-content.tsx | 21 +- apps/web/components/task/task-plan-panel.tsx | 2 +- .../components/task/task-session-sidebar.tsx | 2 +- apps/web/components/task/task-switcher.tsx | 5 +- apps/web/components/task/terminal-panel.tsx | 4 +- apps/web/components/task/workflow-stepper.tsx | 7 +- apps/web/e2e/fixtures/backend.ts | 192 ++++++ apps/web/e2e/fixtures/test-base.ts | 104 +++ apps/web/e2e/global-setup.ts | 37 ++ apps/web/e2e/helpers/api-client.ts | 282 ++++++++ apps/web/e2e/pages/kanban-page.ts | 36 + apps/web/e2e/pages/session-page.ts | 86 +++ apps/web/e2e/pages/task-detail-page.ts | 22 + apps/web/e2e/playwright.config.ts | 32 + apps/web/e2e/tests/create-task.spec.ts | 224 +++++++ apps/web/e2e/tests/kanban-board.spec.ts | 45 ++ apps/web/e2e/tests/task-list.spec.ts | 16 + .../web/e2e/tests/workflow-automation.spec.ts | 558 ++++++++++++++++ .../web/e2e/tests/workflow-start-step.spec.ts | 303 +++++++++ apps/web/e2e/tests/workflow-steps.spec.ts | 63 ++ apps/web/eslint.config.mjs | 12 + apps/web/lib/state/hydration/hydrator.ts | 33 +- apps/web/lib/ws/handlers/agent-session.ts | 1 + apps/web/package.json | 6 +- apps/web/vitest.config.ts | 1 + docs/{test_e2e.md => test_e2e_agents.md} | 0 docs/test_e2e_web.md | 303 +++++++++ 95 files changed, 5496 insertions(+), 240 deletions(-) create mode 100644 .github/workflows/e2e-tests.yml create mode 100644 apps/backend/cmd/kandev/e2e_reset.go create mode 100644 apps/backend/cmd/mock-agent/mcp_client.go create mode 100644 apps/backend/cmd/mock-agent/script.go create mode 100644 apps/backend/cmd/mock-agent/script_test.go create mode 100644 apps/backend/internal/github/factory_test.go create mode 100644 apps/backend/internal/github/mock_client.go create mode 100644 apps/backend/internal/github/mock_client_test.go create mode 100644 apps/backend/internal/github/mock_controller.go create mode 100644 apps/backend/internal/github/noop_client.go create mode 100644 apps/backend/internal/github/noop_client_test.go create mode 100644 apps/web/e2e/fixtures/backend.ts create mode 100644 apps/web/e2e/fixtures/test-base.ts create mode 100644 apps/web/e2e/global-setup.ts create mode 100644 apps/web/e2e/helpers/api-client.ts create mode 100644 apps/web/e2e/pages/kanban-page.ts create mode 100644 apps/web/e2e/pages/session-page.ts create mode 100644 apps/web/e2e/pages/task-detail-page.ts create mode 100644 apps/web/e2e/playwright.config.ts create mode 100644 apps/web/e2e/tests/create-task.spec.ts create mode 100644 apps/web/e2e/tests/kanban-board.spec.ts create mode 100644 apps/web/e2e/tests/task-list.spec.ts create mode 100644 apps/web/e2e/tests/workflow-automation.spec.ts create mode 100644 apps/web/e2e/tests/workflow-start-step.spec.ts create mode 100644 apps/web/e2e/tests/workflow-steps.spec.ts rename docs/{test_e2e.md => test_e2e_agents.md} (100%) create mode 100644 docs/test_e2e_web.md diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index a89a27dd52..86822403b9 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -18,6 +18,10 @@ on: - "!**/*.mdx" - "!**/*.markdown" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: name: Run Backend Tests diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000000..3544fae310 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,81 @@ +name: E2E Tests + +on: + push: + branches: [main] + paths: + - "apps/backend/**" + - "apps/web/**" + - "apps/packages/**" + - "apps/pnpm-lock.yaml" + - ".github/workflows/e2e-tests.yml" + - "!**/*.md" + pull_request: + branches: [main] + paths: + - "apps/backend/**" + - "apps/web/**" + - "apps/packages/**" + - "apps/pnpm-lock.yaml" + - ".github/workflows/e2e-tests.yml" + - "!**/*.md" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + e2e: + name: Run E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: apps/backend/go.mod + cache-dependency-path: apps/backend/go.sum + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: "9" + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Install dependencies + working-directory: apps + run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + working-directory: apps/web + run: npx playwright install chromium --with-deps + + - name: Run E2E tests + run: make test-e2e + + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: apps/web/e2e/playwright-report/ + retention-days: 14 + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-results + path: apps/web/e2e/test-results/ + retention-days: 7 diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 054db84769..0033344f08 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -30,6 +30,10 @@ on: - "!**/*.mdx" - "!**/*.markdown" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: frontend: name: Run Frontend Lint, Tests, and Build diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 5133687b5e..93a3cf0cd2 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -5,6 +5,10 @@ on: types: [opened, edited, synchronize, reopened] branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: pull-requests: read diff --git a/Makefile b/Makefile index 197ea79f13..76a6ee66a6 100644 --- a/Makefile +++ b/Makefile @@ -78,6 +78,10 @@ help: @echo " test-backend Run backend tests" @echo " test-web Run web app tests" @echo " test-cli Run CLI tests" + @echo " test-e2e Run E2E tests (headless, parallel)" + @echo " test-e2e-headed Run E2E tests with visible browser" + @echo " test-e2e-ui Run E2E tests in Playwright UI mode" + @echo " test-e2e-report Open Playwright HTML report" @echo "" @echo "Code Quality:" @echo " lint Run linters for both components" @@ -187,6 +191,8 @@ install-backend: install-web: @printf "$(CYAN)Installing web dependencies...$(RESET)\n" @cd $(APPS_DIR) && $(PNPM) install --silent 2>/dev/null || cd $(APPS_DIR) && $(PNPM) install + @printf "$(CYAN)Installing Playwright browsers...$(RESET)\n" + @cd $(APPS_DIR) && $(PNPM) --filter @kandev/web exec playwright install chromium # # Testing @@ -215,6 +221,25 @@ test-cli: @printf "$(CYAN)Running CLI tests...$(RESET)\n" @cd $(APPS_DIR) && $(PNPM) --filter kandev test +.PHONY: test-e2e +test-e2e: build-backend build-web + @printf "$(CYAN)Running E2E tests (headless, parallel)...$(RESET)\n" + @cd $(APPS_DIR) && $(PNPM) --filter @kandev/web e2e + +.PHONY: test-e2e-headed +test-e2e-headed: build-backend build-web + @printf "$(CYAN)Running E2E tests (headed)...$(RESET)\n" + @cd $(APPS_DIR) && $(PNPM) --filter @kandev/web e2e:headed + +.PHONY: test-e2e-ui +test-e2e-ui: build-backend build-web + @printf "$(CYAN)Opening Playwright UI mode...$(RESET)\n" + @cd $(APPS_DIR) && $(PNPM) --filter @kandev/web e2e:ui + +.PHONY: test-e2e-report +test-e2e-report: + @cd $(WEB_DIR) && npx playwright show-report e2e/playwright-report + # # Code Quality # diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index 030be5d989..dbb8d49b43 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -297,8 +297,8 @@ func (w *orchestratorWrapper) ResumeTaskSession(ctx context.Context, taskID, tas } // StartCreatedSession forwards to the orchestrator service, discarding the TaskExecution result. -func (w *orchestratorWrapper) StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord bool) error { - _, err := w.svc.StartCreatedSession(ctx, taskID, sessionID, agentProfileID, prompt, skipMessageRecord) +func (w *orchestratorWrapper) StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode bool) error { + _, err := w.svc.StartCreatedSession(ctx, taskID, sessionID, agentProfileID, prompt, skipMessageRecord, planMode) return err } diff --git a/apps/backend/cmd/kandev/e2e_reset.go b/apps/backend/cmd/kandev/e2e_reset.go new file mode 100644 index 0000000000..a6e2e1e366 --- /dev/null +++ b/apps/backend/cmd/kandev/e2e_reset.go @@ -0,0 +1,55 @@ +package main + +import ( + "net/http" + "os" + "strings" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +// registerE2EResetRoutes registers the E2E data-reset endpoint. +// The endpoint is only available when KANDEV_MOCK_AGENT=true (i.e., during E2E tests). +func registerE2EResetRoutes(router *gin.Engine, repo *sqliterepo.Repository, log *logger.Logger) { + if os.Getenv("KANDEV_MOCK_AGENT") != "true" { + return + } + + api := router.Group("/api/v1/e2e") + api.DELETE("/reset/:workspaceId", func(c *gin.Context) { + workspaceID := c.Param("workspaceId") + + // Optional: comma-separated workflow IDs to keep (e.g., the seeded workflow). + var keepWorkflowIDs []string + if raw := c.Query("keep_workflows"); raw != "" { + keepWorkflowIDs = strings.Split(raw, ",") + } + + ctx := c.Request.Context() + + deletedTasks, err := repo.DeleteTasksByWorkspace(ctx, workspaceID) + if err != nil { + log.Error("e2e reset: failed to delete tasks", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + deletedWorkflows, err := repo.DeleteWorkflowsByWorkspace(ctx, workspaceID, keepWorkflowIDs) + if err != nil { + log.Error("e2e reset: failed to delete workflows", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "deleted_tasks": deletedTasks, + "deleted_workflows": deletedWorkflows, + }) + }) + + log.Info("registered E2E reset endpoint (test-only)") +} diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 5d150cde49..d3b53e3265 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -55,6 +55,7 @@ func buildSessionDataProvider(taskRepo *sqliterepo.Repository, lifecycleMgr *lif } var result []*ws.Message + result = appendSessionStateMessage(sessionID, session, result) result = appendGitStatusMessage(ctx, taskRepo, sessionID, session, result) result = appendContextWindowMessage(sessionID, session, result) result = appendAvailableCommandsMessage(sessionID, session, lifecycleMgr, result) @@ -62,6 +63,30 @@ func buildSessionDataProvider(taskRepo *sqliterepo.Repository, lifecycleMgr *lif } } +// appendSessionStateMessage always sends the current session state so clients +// that subscribe after a state change still receive the authoritative state. +func appendSessionStateMessage(sessionID string, session *models.TaskSession, result []*ws.Message) []*ws.Message { + payload := map[string]interface{}{ + "session_id": sessionID, + "task_id": session.TaskID, + "new_state": string(session.State), + } + if session.ReviewStatus != nil && *session.ReviewStatus != "" { + payload["review_status"] = *session.ReviewStatus + } + if session.WorkflowStepID != nil && *session.WorkflowStepID != "" { + payload["workflow_step_id"] = *session.WorkflowStepID + } + if session.Metadata != nil { + payload["session_metadata"] = session.Metadata + } + notification, err := ws.NewNotification(ws.ActionSessionStateChanged, payload) + if err == nil { + result = append(result, notification) + } + return result +} + // appendGitStatusMessage adds a git status notification from the latest snapshot to result. func appendGitStatusMessage(ctx context.Context, taskRepo *sqliterepo.Repository, sessionID string, session *models.TaskSession, result []*ws.Message) []*ws.Message { latestSnapshot, err := taskRepo.GetLatestGitSnapshot(ctx, sessionID) @@ -347,6 +372,7 @@ func registerSecondaryRoutes( if p.services.GitHub != nil { github.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.GitHub, p.log) + github.RegisterMockRoutes(p.router, p.services.GitHub, p.log) p.log.Debug("Registered GitHub handlers (HTTP + WebSocket)") } @@ -354,6 +380,8 @@ func registerSecondaryRoutes( p.log.Debug("Registered Docker management handlers (HTTP)") registerMCPAndDebugRoutes(p, workflowCtrl, clarificationStore, planService) + + registerE2EResetRoutes(p.router, p.taskRepo, p.log) } // registerMCPAndDebugRoutes registers MCP and debug routes and wires the MCP handler. diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 764cdbc7bd..5a0f4ed657 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -146,6 +146,15 @@ func (a *startStepResolverAdapter) ResolveStartStep(ctx context.Context, workflo return step.ID, nil } +// ResolveFirstStep implements taskservice.StartStepResolver. +func (a *startStepResolverAdapter) ResolveFirstStep(ctx context.Context, workflowID string) (string, error) { + step, err := a.svc.ResolveFirstStep(ctx, workflowID) + if err != nil { + return "", err + } + return step.ID, nil +} + // githubSecretAdapter adapts secrets.SecretStore to github.SecretProvider. type githubSecretAdapter struct { store secrets.SecretStore diff --git a/apps/backend/cmd/kandev/storage.go b/apps/backend/cmd/kandev/storage.go index c5b3a15add..4c55d4af41 100644 --- a/apps/backend/cmd/kandev/storage.go +++ b/apps/backend/cmd/kandev/storage.go @@ -89,7 +89,10 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*db.Pool, *Rep cleanups = append(cleanups, cleanup) // Initialize master key and secrets store - kandevDir := filepath.Join(os.Getenv("HOME"), ".kandev") + kandevDir := os.Getenv("KANDEV_DATA_DIR") + if kandevDir == "" { + kandevDir = filepath.Join(os.Getenv("HOME"), ".kandev") + } masterKeyProvider, err := secrets.NewMasterKeyProvider(kandevDir) if err != nil { return nil, nil, nil, fmt.Errorf("master key: %w", err) diff --git a/apps/backend/cmd/mock-agent/handler.go b/apps/backend/cmd/mock-agent/handler.go index 87d15fa38f..7793e3dc20 100644 --- a/apps/backend/cmd/mock-agent/handler.go +++ b/apps/backend/cmd/mock-agent/handler.go @@ -33,44 +33,70 @@ func fixedDelay(ms int) { time.Sleep(time.Duration(ms) * time.Millisecond) } +// stripKandevSystem removes ... blocks injected by +// injectKandevContext so that /e2e: routing works regardless of system prompt wrapping. +func stripKandevSystem(prompt string) string { + const endTag = "" + idx := strings.LastIndex(prompt, endTag) + if idx < 0 { + return prompt + } + return strings.TrimSpace(prompt[idx+len(endTag):]) +} + // handleUserPrompt routes a user prompt to the appropriate sequence generator. func handleUserPrompt(enc *json.Encoder, scanner *bufio.Scanner, prompt, model string) { prompt = strings.TrimSpace(prompt) + // Extract the user-facing content for command routing. + // The backend wraps prompts with ... context; + // routing should match the user's actual command regardless of that wrapper. + // The full prompt (including any kandev-system content) is kept available via + // the outer variable so future scenarios can inspect it if needed. + cmd := stripKandevSystem(prompt) + // Emit system message at the start of every turn emitSystemMessage(enc) + // Script mode: each line is a command (e2e:message, e2e:mcp:*, etc.) + if isScriptMode(cmd) { + executeScript(enc, prompt, cmd) + emitResult(enc, false, "") + return + } + // Some commands emit their own result; track whether we need the default one. customResult := false switch { - case strings.EqualFold(prompt, "all") || strings.EqualFold(prompt, "/all"): + case strings.EqualFold(cmd, "all") || strings.EqualFold(cmd, "/all"): emitAllTypes(enc, scanner, model) - case strings.EqualFold(prompt, "/error"): + case strings.EqualFold(cmd, "/error"): emitError(enc, model) customResult = true - case strings.EqualFold(prompt, "/slow") || strings.HasPrefix(strings.ToLower(prompt), "/slow "): - emitSlowResponse(enc, scanner, prompt, model) - case strings.EqualFold(prompt, "/thinking"): + case strings.EqualFold(cmd, "/slow") || strings.HasPrefix(strings.ToLower(cmd), "/slow "): + emitSlowResponse(enc, scanner, cmd, model) + case strings.EqualFold(cmd, "/thinking"): emitThinkingSequence(enc, model) - case strings.HasPrefix(prompt, "/tool:"): - toolName := strings.TrimPrefix(prompt, "/tool:") + case strings.HasPrefix(cmd, "/tool:"): + toolName := strings.TrimPrefix(cmd, "/tool:") emitSpecificTool(enc, scanner, strings.TrimSpace(toolName), model) - case strings.HasPrefix(prompt, "/subagent"): + case strings.HasPrefix(cmd, "/subagent"): emitSubagentSequence(enc, scanner, model) - case strings.HasPrefix(prompt, "/e2e:"): - scenarioName := strings.TrimPrefix(prompt, "/e2e:") - emitPredefinedScenario(enc, scanner, strings.TrimSpace(scenarioName)) + case strings.HasPrefix(cmd, "/e2e:"): + rest := strings.TrimPrefix(cmd, "/e2e:") + scenarioName, _, _ := strings.Cut(strings.TrimSpace(rest), " ") + emitPredefinedScenario(enc, scanner, scenarioName) // e2e:error emits its own result if strings.TrimSpace(scenarioName) == "error" { customResult = true } - case strings.HasPrefix(prompt, "/todo"): + case strings.HasPrefix(cmd, "/todo"): emitTodoSequence(enc, model) - case strings.EqualFold(prompt, "/mermaid"): + case strings.EqualFold(cmd, "/mermaid"): emitMermaidSequence(enc, model) default: - emitRandomResponse(enc, scanner, prompt, model) + emitRandomResponse(enc, scanner, cmd, model) } if !customResult { diff --git a/apps/backend/cmd/mock-agent/main.go b/apps/backend/cmd/mock-agent/main.go index 4df939eeba..9b8c5170b5 100644 --- a/apps/backend/cmd/mock-agent/main.go +++ b/apps/backend/cmd/mock-agent/main.go @@ -16,8 +16,19 @@ import ( // across parallel sessions. var sessionID = fmt.Sprintf("mock-session-%d", os.Getpid()) +// mcpServerDef describes an MCP server endpoint parsed from --mcp-config. +type mcpServerDef struct { + URL string `json:"url"` + Type string `json:"type"` +} + +// mcpServers holds MCP server definitions from --mcp-config. +var mcpServers map[string]mcpServerDef + func main() { model := parseModelFlag() + mcpServers = parseMCPConfigFlag() + defer closeMCPClients() scanner := bufio.NewScanner(os.Stdin) buf := make([]byte, 0, 64*1024) @@ -33,11 +44,13 @@ func main() { var msg IncomingMessage if err := json.Unmarshal(line, &msg); err != nil { + fmt.Fprintf(os.Stderr, "mock-agent[%d]: unmarshal error: %v (line=%s)\n", os.Getpid(), err, string(line[:min(200, len(line))])) continue } switch msg.Type { case TypeControlRequest: + fmt.Fprintf(os.Stderr, "mock-agent[%d]: control_request (request_id=%s)\n", os.Getpid(), msg.RequestID) handleControlRequest(enc, msg) case TypeUser: if msg.Message != nil { @@ -63,13 +76,46 @@ func parseModelFromArgs(args []string) string { if arg == "--model" && i+1 < len(args)-1 { return args[i+2] } - if strings.HasPrefix(arg, "--model=") { - return strings.TrimPrefix(arg, "--model=") + if v, ok := strings.CutPrefix(arg, "--model="); ok { + return v } } return "mock-default" } +// mcpConfigPayload is the JSON structure for --mcp-config. +type mcpConfigPayload struct { + MCPServers map[string]mcpServerDef `json:"mcpServers"` +} + +// parseMCPConfigFlag extracts --mcp-config value from command line args +// and returns the parsed MCP server definitions. +func parseMCPConfigFlag() map[string]mcpServerDef { + raw := parseMCPConfigFromArgs(os.Args) + if raw == "" { + return nil + } + var payload mcpConfigPayload + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + fmt.Fprintf(os.Stderr, "mock-agent: failed to parse --mcp-config: %v\n", err) + return nil + } + return payload.MCPServers +} + +// parseMCPConfigFromArgs extracts --mcp-config value from the given args slice. +func parseMCPConfigFromArgs(args []string) string { + for i, arg := range args[1:] { + if arg == "--mcp-config" && i+1 < len(args)-1 { + return args[i+2] + } + if v, ok := strings.CutPrefix(arg, "--mcp-config="); ok { + return v + } + } + return "" +} + // handleControlRequest responds to control requests from the backend. func handleControlRequest(enc *json.Encoder, msg IncomingMessage) { // We only handle the initialize request from the backend @@ -100,6 +146,7 @@ func handleControlRequest(enc *json.Encoder, msg IncomingMessage) { {Name: "e2e:subagent", Description: "E2E: subagent with child messages"}, {Name: "e2e:all-tools", Description: "E2E: one of each tool type"}, {Name: "e2e:multi-turn", Description: "E2E: minimal multi-turn response"}, + {Name: "e2e:*", Description: "E2E: scripted commands (e2e:message, e2e:mcp:*, etc.)"}, }, Agents: []string{"Bash", "Read", "Edit", "Grep", "Glob", "Task"}, }, diff --git a/apps/backend/cmd/mock-agent/mcp_client.go b/apps/backend/cmd/mock-agent/mcp_client.go new file mode 100644 index 0000000000..92c0cccee3 --- /dev/null +++ b/apps/backend/cmd/mock-agent/mcp_client.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "fmt" + "strings" + "sync" + + mcpclient "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" +) + +var ( + mcpClients = map[string]*mcpclient.Client{} + mcpClientsMu sync.Mutex +) + +// getMCPClient returns (or creates) an initialized MCP client for the named server. +func getMCPClient(serverName string) (*mcpclient.Client, error) { + mcpClientsMu.Lock() + defer mcpClientsMu.Unlock() + + if c, ok := mcpClients[serverName]; ok { + return c, nil + } + + srv, ok := mcpServers[serverName] + if !ok { + return nil, fmt.Errorf("unknown MCP server: %s", serverName) + } + + c, err := mcpclient.NewSSEMCPClient(srv.URL) + if err != nil { + return nil, fmt.Errorf("create SSE client for %s: %w", serverName, err) + } + + ctx := context.Background() + if err := c.Start(ctx); err != nil { + return nil, fmt.Errorf("start MCP client %s: %w", serverName, err) + } + + initReq := mcp.InitializeRequest{} + initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initReq.Params.ClientInfo = mcp.Implementation{ + Name: "mock-agent", + Version: "1.0", + } + + if _, err := c.Initialize(ctx, initReq); err != nil { + _ = c.Close() + return nil, fmt.Errorf("initialize MCP client %s: %w", serverName, err) + } + + mcpClients[serverName] = c + return c, nil +} + +// callMCPTool calls a tool on the named MCP server and returns the result text. +func callMCPTool(serverName, toolName string, args map[string]any) (string, error) { + c, err := getMCPClient(serverName) + if err != nil { + return "", err + } + + req := mcp.CallToolRequest{} + req.Params.Name = toolName + req.Params.Arguments = args + + result, err := c.CallTool(context.Background(), req) + if err != nil { + return "", fmt.Errorf("call tool %s/%s: %w", serverName, toolName, err) + } + + return extractMCPResultText(result), nil +} + +// extractMCPResultText extracts text from an MCP CallToolResult. +func extractMCPResultText(result *mcp.CallToolResult) string { + var parts []string + for _, c := range result.Content { + if tc, ok := c.(mcp.TextContent); ok { + parts = append(parts, tc.Text) + } + } + if len(parts) == 0 { + return "" + } + return strings.Join(parts, "\n") +} + +// closeMCPClients closes all open MCP clients (called on shutdown). +func closeMCPClients() { + mcpClientsMu.Lock() + defer mcpClientsMu.Unlock() + for _, c := range mcpClients { + _ = c.Close() + } +} diff --git a/apps/backend/cmd/mock-agent/script.go b/apps/backend/cmd/mock-agent/script.go new file mode 100644 index 0000000000..a08b6d2ec1 --- /dev/null +++ b/apps/backend/cmd/mock-agent/script.go @@ -0,0 +1,221 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "strconv" + "strings" +) + +// isScriptMode returns true if the command starts with "e2e:" prefix +// (excluding the predefined "/e2e:" slash-command format). +func isScriptMode(cmd string) bool { + return strings.HasPrefix(cmd, "e2e:") +} + +// executeScript processes a multi-line script command. +func executeScript(enc *json.Encoder, fullPrompt, cmd string) { + lines := strings.Split(cmd, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + executeCommand(enc, fullPrompt, line) + } +} + +// executeCommand dispatches a single script command line. +func executeCommand(enc *json.Encoder, fullPrompt, line string) { + switch { + case strings.HasPrefix(line, "e2e:message("): + text := extractStringArg(line, "e2e:message(") + emitTextBlock(enc, text, "") + + case strings.HasPrefix(line, "e2e:thinking("): + text := extractStringArg(line, "e2e:thinking(") + emitThinkingBlock(enc, text, "") + + case strings.HasPrefix(line, "e2e:delay("): + ms := extractIntArg(line, "e2e:delay(") + fixedDelay(ms) + + case strings.HasPrefix(line, "e2e:mcp:"): + executeMCPCommand(enc, fullPrompt, line) + + case strings.HasPrefix(line, "e2e:tool_use("): + executeSimulatedToolUse(enc, line) + + case strings.HasPrefix(line, "e2e:tool_result("): + executeSimulatedToolResult(enc, line) + } +} + +// executeMCPCommand parses and executes: e2e:mcp::() +func executeMCPCommand(enc *json.Encoder, fullPrompt, line string) { + rest := strings.TrimPrefix(line, "e2e:mcp:") + + colonIdx := strings.Index(rest, ":") + if colonIdx < 0 { + emitTextBlock(enc, "Script error: invalid MCP command: "+line, "") + return + } + server := rest[:colonIdx] + remainder := rest[colonIdx+1:] + + parenIdx := strings.Index(remainder, "(") + if parenIdx < 0 { + emitTextBlock(enc, "Script error: missing args in MCP command: "+line, "") + return + } + toolName := remainder[:parenIdx] + closeIdx := strings.LastIndex(remainder, ")") + if closeIdx <= parenIdx { + emitTextBlock(enc, "Script error: missing closing paren in MCP command: "+line, "") + return + } + argsStr := remainder[parenIdx+1 : closeIdx] + + var args map[string]any + if err := json.Unmarshal([]byte(argsStr), &args); err != nil { + emitTextBlock(enc, fmt.Sprintf("Script error: bad MCP args JSON: %v", err), "") + return + } + + substituteContextPlaceholders(args, fullPrompt) + + toolID := nextToolID() + state.lastToolID = toolID + emitToolUseBlock(enc, toolID, toolName, args) + + result, err := callMCPTool(server, toolName, args) + if err != nil { + fmt.Fprintf(os.Stderr, "mock-agent: MCP call %s/%s failed: %v\n", server, toolName, err) + emitToolResultBlock(enc, toolID, "MCP error: "+err.Error(), true) + } else { + emitToolResultBlock(enc, toolID, result, false) + } +} + +// executeSimulatedToolUse emits a simulated tool_use block (no real execution). +// Format: e2e:tool_use("name", {"key":"value"}) +func executeSimulatedToolUse(enc *json.Encoder, line string) { + inner := extractParenContent(line, "e2e:tool_use(") + name, rest := extractFirstStringArg(inner) + + var input map[string]any + rest = strings.TrimSpace(rest) + if rest != "" { + rest = strings.TrimPrefix(rest, ",") + rest = strings.TrimSpace(rest) + if err := json.Unmarshal([]byte(rest), &input); err != nil { + emitTextBlock(enc, fmt.Sprintf("Script error: bad tool_use input JSON: %v", err), "") + return + } + } + + toolID := nextToolID() + state.lastToolID = toolID + emitToolUseBlock(enc, toolID, name, input) +} + +// executeSimulatedToolResult emits a tool_result for the last tool_use. +// Format: e2e:tool_result("content") +func executeSimulatedToolResult(enc *json.Encoder, line string) { + content := extractStringArg(line, "e2e:tool_result(") + emitToolResultBlock(enc, state.lastToolID, content, false) +} + +// --- Argument parsing helpers --- + +// extractStringArg extracts a quoted string argument from a command like prefix"text") +func extractStringArg(line, prefix string) string { + rest := strings.TrimPrefix(line, prefix) + rest = strings.TrimSuffix(rest, ")") + rest = strings.TrimSpace(rest) + if len(rest) >= 2 && rest[0] == '"' && rest[len(rest)-1] == '"' { + rest = rest[1 : len(rest)-1] + } + return unescapeString(rest) +} + +// extractIntArg extracts an integer argument from a command like prefix123) +func extractIntArg(line, prefix string) int { + rest := strings.TrimPrefix(line, prefix) + rest = strings.TrimSuffix(rest, ")") + rest = strings.TrimSpace(rest) + n, _ := strconv.Atoi(rest) + return n +} + +// extractParenContent extracts everything inside the outermost parentheses. +func extractParenContent(line, prefix string) string { + rest := strings.TrimPrefix(line, prefix) + if len(rest) > 0 && rest[len(rest)-1] == ')' { + rest = rest[:len(rest)-1] + } + return rest +} + +// extractFirstStringArg extracts the first quoted string and returns +// (value, remaining). +func extractFirstStringArg(s string) (string, string) { + s = strings.TrimSpace(s) + if len(s) == 0 || s[0] != '"' { + return s, "" + } + // Find closing quote (handling escaped quotes) + for i := 1; i < len(s); i++ { + if s[i] == '\\' { + i++ // skip escaped char + continue + } + if s[i] == '"' { + return unescapeString(s[1:i]), s[i+1:] + } + } + return s[1:], "" +} + +// unescapeString handles basic escape sequences in script string args. +func unescapeString(s string) string { + s = strings.ReplaceAll(s, `\\`, "\x00") // temp placeholder + s = strings.ReplaceAll(s, `\"`, `"`) + s = strings.ReplaceAll(s, `\n`, "\n") + s = strings.ReplaceAll(s, `\t`, "\t") + s = strings.ReplaceAll(s, "\x00", `\`) + return s +} + +// --- Context placeholder substitution --- + +var ( + taskIDRegex = regexp.MustCompile(`Kandev Task ID:\s*(\S+)`) + sessionIDRegex = regexp.MustCompile(`Kandev Session ID:\s*(\S+)`) +) + +// substituteContextPlaceholders replaces {task_id} and {session_id} placeholders +// in MCP tool args with values from the kandev-system context block. +func substituteContextPlaceholders(args map[string]any, fullPrompt string) { + taskID := extractRegexMatch(taskIDRegex, fullPrompt) + sessID := extractRegexMatch(sessionIDRegex, fullPrompt) + + for k, v := range args { + if s, ok := v.(string); ok { + s = strings.ReplaceAll(s, "{task_id}", taskID) + s = strings.ReplaceAll(s, "{session_id}", sessID) + args[k] = s + } + } +} + +// extractRegexMatch returns the first capture group match, or empty string. +func extractRegexMatch(re *regexp.Regexp, s string) string { + m := re.FindStringSubmatch(s) + if len(m) >= 2 { + return m[1] + } + return "" +} diff --git a/apps/backend/cmd/mock-agent/script_test.go b/apps/backend/cmd/mock-agent/script_test.go new file mode 100644 index 0000000000..74ef2b0d56 --- /dev/null +++ b/apps/backend/cmd/mock-agent/script_test.go @@ -0,0 +1,629 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" +) + +func TestIsScriptMode(t *testing.T) { + tests := []struct { + cmd string + want bool + }{ + {"e2e:message(\"hello\")", true}, + {"e2e:thinking(\"thought\")", true}, + {"e2e:delay(100)", true}, + {"e2e:mcp:kandev:create_task_plan({})", true}, + {"/e2e:simple-message", false}, + {"hello world", false}, + {"", false}, + {"/all", false}, + } + for _, tt := range tests { + t.Run(tt.cmd, func(t *testing.T) { + if got := isScriptMode(tt.cmd); got != tt.want { + t.Errorf("isScriptMode(%q) = %v, want %v", tt.cmd, got, tt.want) + } + }) + } +} + +func TestExtractStringArg(t *testing.T) { + tests := []struct { + name string + line string + prefix string + want string + }{ + { + name: "simple string", + line: `e2e:message("hello world")`, + prefix: "e2e:message(", + want: "hello world", + }, + { + name: "string with escaped quotes", + line: `e2e:message("say \"hi\"")`, + prefix: "e2e:message(", + want: `say "hi"`, + }, + { + name: "string with newline escape", + line: `e2e:message("line1\nline2")`, + prefix: "e2e:message(", + want: "line1\nline2", + }, + { + name: "string with tab escape", + line: `e2e:message("col1\tcol2")`, + prefix: "e2e:message(", + want: "col1\tcol2", + }, + { + name: "string with escaped backslash", + line: `e2e:message("path\\file")`, + prefix: "e2e:message(", + want: `path\file`, + }, + { + name: "thinking command", + line: `e2e:thinking("deep thought")`, + prefix: "e2e:thinking(", + want: "deep thought", + }, + { + name: "tool_result command", + line: `e2e:tool_result("success")`, + prefix: "e2e:tool_result(", + want: "success", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractStringArg(tt.line, tt.prefix) + if got != tt.want { + t.Errorf("extractStringArg(%q, %q) = %q, want %q", tt.line, tt.prefix, got, tt.want) + } + }) + } +} + +func TestExtractIntArg(t *testing.T) { + tests := []struct { + name string + line string + prefix string + want int + }{ + {"simple int", "e2e:delay(100)", "e2e:delay(", 100}, + {"zero", "e2e:delay(0)", "e2e:delay(", 0}, + {"large number", "e2e:delay(5000)", "e2e:delay(", 5000}, + {"invalid returns 0", "e2e:delay(abc)", "e2e:delay(", 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractIntArg(tt.line, tt.prefix) + if got != tt.want { + t.Errorf("extractIntArg(%q, %q) = %d, want %d", tt.line, tt.prefix, got, tt.want) + } + }) + } +} + +func TestExtractParenContent(t *testing.T) { + tests := []struct { + name string + line string + prefix string + want string + }{ + { + name: "simple content", + line: `e2e:tool_use("Read", {"path":"/tmp"})`, + prefix: "e2e:tool_use(", + want: `"Read", {"path":"/tmp"}`, + }, + { + name: "no trailing paren", + line: `e2e:tool_use("Read"`, + prefix: "e2e:tool_use(", + want: `"Read"`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractParenContent(tt.line, tt.prefix) + if got != tt.want { + t.Errorf("extractParenContent(%q, %q) = %q, want %q", tt.line, tt.prefix, got, tt.want) + } + }) + } +} + +func TestExtractFirstStringArg(t *testing.T) { + tests := []struct { + name string + input string + wantVal string + wantRest string + }{ + { + name: "simple string", + input: `"hello", rest`, + wantVal: "hello", + wantRest: ", rest", + }, + { + name: "string with escaped quote", + input: `"say \"hi\"", more`, + wantVal: `say "hi"`, + wantRest: ", more", + }, + { + name: "just a string", + input: `"only"`, + wantVal: "only", + wantRest: "", + }, + { + name: "empty input", + input: "", + wantVal: "", + wantRest: "", + }, + { + name: "non-quoted input", + input: "bare", + wantVal: "bare", + wantRest: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + val, rest := extractFirstStringArg(tt.input) + if val != tt.wantVal { + t.Errorf("extractFirstStringArg(%q) val = %q, want %q", tt.input, val, tt.wantVal) + } + if rest != tt.wantRest { + t.Errorf("extractFirstStringArg(%q) rest = %q, want %q", tt.input, rest, tt.wantRest) + } + }) + } +} + +func TestUnescapeString(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"no escapes", "hello", "hello"}, + {"newline", `line1\nline2`, "line1\nline2"}, + {"tab", `col1\tcol2`, "col1\tcol2"}, + {"escaped quote", `say \"hi\"`, `say "hi"`}, + {"escaped backslash", `path\\file`, `path\file`}, + {"mixed escapes", `a\\b\nc\"d`, "a\\b\nc\"d"}, + {"empty", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := unescapeString(tt.input) + if got != tt.want { + t.Errorf("unescapeString(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSubstituteContextPlaceholders(t *testing.T) { + fullPrompt := ` +Kandev Task ID: task-abc-123 +Kandev Session ID: sess-xyz-789 + +e2e:mcp:kandev:create_task_plan({"task_id":"{task_id}"})` + + t.Run("replaces task_id", func(t *testing.T) { + args := map[string]any{"task_id": "{task_id}"} + substituteContextPlaceholders(args, fullPrompt) + if args["task_id"] != "task-abc-123" { + t.Errorf("task_id = %q, want %q", args["task_id"], "task-abc-123") + } + }) + + t.Run("replaces session_id", func(t *testing.T) { + args := map[string]any{"session_id": "{session_id}"} + substituteContextPlaceholders(args, fullPrompt) + if args["session_id"] != "sess-xyz-789" { + t.Errorf("session_id = %q, want %q", args["session_id"], "sess-xyz-789") + } + }) + + t.Run("replaces both in same map", func(t *testing.T) { + args := map[string]any{ + "task_id": "{task_id}", + "session_id": "{session_id}", + "other": "untouched", + } + substituteContextPlaceholders(args, fullPrompt) + if args["task_id"] != "task-abc-123" { + t.Errorf("task_id = %q, want %q", args["task_id"], "task-abc-123") + } + if args["session_id"] != "sess-xyz-789" { + t.Errorf("session_id = %q, want %q", args["session_id"], "sess-xyz-789") + } + if args["other"] != "untouched" { + t.Errorf("other = %q, want %q", args["other"], "untouched") + } + }) + + t.Run("no placeholders means empty substitution", func(t *testing.T) { + args := map[string]any{"task_id": "{task_id}"} + substituteContextPlaceholders(args, "no kandev system block") + if args["task_id"] != "" { + t.Errorf("task_id should be empty when no context, got %q", args["task_id"]) + } + }) + + t.Run("skips non-string values", func(t *testing.T) { + args := map[string]any{ + "count": 42, + "flag": true, + } + substituteContextPlaceholders(args, fullPrompt) + if args["count"] != 42 { + t.Errorf("count changed unexpectedly") + } + if args["flag"] != true { + t.Errorf("flag changed unexpectedly") + } + }) +} + +func TestExtractRegexMatch(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "task ID found", + input: "Kandev Task ID: abc-123", + want: "abc-123", + }, + { + name: "task ID with extra whitespace", + input: "Kandev Task ID: def-456", + want: "def-456", + }, + { + name: "no match", + input: "no task id here", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractRegexMatch(taskIDRegex, tt.input) + if got != tt.want { + t.Errorf("extractRegexMatch(taskIDRegex, %q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +// TestExecuteScriptMessage verifies e2e:message emits an assistant text block. +func TestExecuteScriptMessage(t *testing.T) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + + // Reset global state + resetState() + + executeScript(enc, "", `e2e:message("hello from script")`) + + msgs := decodeJSONLines(t, buf.Bytes()) + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + assertMsgType(t, msgs[0], TypeAssistant) + assertContentText(t, msgs[0], "hello from script") +} + +// TestExecuteScriptThinking verifies e2e:thinking emits a thinking block. +func TestExecuteScriptThinking(t *testing.T) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + + executeScript(enc, "", `e2e:thinking("deep thought")`) + + msgs := decodeJSONLines(t, buf.Bytes()) + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + assertMsgType(t, msgs[0], TypeAssistant) + assertContentThinking(t, msgs[0], "deep thought") +} + +// TestExecuteScriptMultiLine verifies multiple commands are processed sequentially. +func TestExecuteScriptMultiLine(t *testing.T) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + resetState() + + script := "e2e:thinking(\"step 1\")\ne2e:message(\"step 2\")\ne2e:message(\"step 3\")" + executeScript(enc, "", script) + + msgs := decodeJSONLines(t, buf.Bytes()) + if len(msgs) != 3 { + t.Fatalf("expected 3 messages, got %d", len(msgs)) + } + assertContentThinking(t, msgs[0], "step 1") + assertContentText(t, msgs[1], "step 2") + assertContentText(t, msgs[2], "step 3") +} + +// TestExecuteScriptSkipsEmptyAndComments verifies blank lines and comments are skipped. +func TestExecuteScriptSkipsEmptyAndComments(t *testing.T) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + resetState() + + script := "e2e:message(\"one\")\n\n# this is a comment\n \ne2e:message(\"two\")" + executeScript(enc, "", script) + + msgs := decodeJSONLines(t, buf.Bytes()) + if len(msgs) != 2 { + t.Fatalf("expected 2 messages, got %d", len(msgs)) + } + assertContentText(t, msgs[0], "one") + assertContentText(t, msgs[1], "two") +} + +// TestExecuteScriptToolUse verifies e2e:tool_use emits a tool_use block. +func TestExecuteScriptToolUse(t *testing.T) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + resetState() + + executeScript(enc, "", `e2e:tool_use("Read", {"file_path":"/tmp/test.go"})`) + + msgs := decodeJSONLines(t, buf.Bytes()) + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + assertMsgType(t, msgs[0], TypeAssistant) + content := getFirstContent(t, msgs[0]) + if content["type"] != BlockToolUse { + t.Errorf("content type = %q, want %q", content["type"], BlockToolUse) + } + if content["name"] != "Read" { + t.Errorf("tool name = %q, want %q", content["name"], "Read") + } + input, ok := content["input"].(map[string]any) + if !ok { + t.Fatal("input is not a map") + } + if input["file_path"] != "/tmp/test.go" { + t.Errorf("file_path = %q, want %q", input["file_path"], "/tmp/test.go") + } +} + +// TestExecuteScriptToolUseAndResult verifies tool_use + tool_result pair. +func TestExecuteScriptToolUseAndResult(t *testing.T) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + resetState() + + script := "e2e:tool_use(\"Read\", {\"file_path\":\"/tmp/f.go\"})\ne2e:tool_result(\"file contents here\")" + executeScript(enc, "", script) + + msgs := decodeJSONLines(t, buf.Bytes()) + if len(msgs) != 2 { + t.Fatalf("expected 2 messages, got %d", len(msgs)) + } + + // First: tool_use + toolUseContent := getFirstContent(t, msgs[0]) + toolID := toolUseContent["id"].(string) + if toolID == "" { + t.Fatal("tool_use should have an id") + } + + // Second: tool_result referencing same ID + assertMsgType(t, msgs[1], TypeUser) + resultContent := getFirstContent(t, msgs[1]) + if resultContent["type"] != BlockToolResult { + t.Errorf("result type = %q, want %q", resultContent["type"], BlockToolResult) + } + if resultContent["tool_use_id"] != toolID { + t.Errorf("tool_use_id = %q, want %q", resultContent["tool_use_id"], toolID) + } + if resultContent["content"] != "file contents here" { + t.Errorf("content = %q, want %q", resultContent["content"], "file contents here") + } +} + +// TestExecuteScriptToolUseNoInput verifies tool_use with no input arg. +func TestExecuteScriptToolUseNoInput(t *testing.T) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + resetState() + + executeScript(enc, "", `e2e:tool_use("Glob")`) + + msgs := decodeJSONLines(t, buf.Bytes()) + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + content := getFirstContent(t, msgs[0]) + if content["name"] != "Glob" { + t.Errorf("tool name = %q, want %q", content["name"], "Glob") + } +} + +func TestParseMCPConfigFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "no flag", + args: []string{"mock-agent"}, + want: "", + }, + { + name: "separate flag and value", + args: []string{"mock-agent", "--mcp-config", `{"mcpServers":{"kandev":{"url":"http://localhost:10005/sse"}}}`}, + want: `{"mcpServers":{"kandev":{"url":"http://localhost:10005/sse"}}}`, + }, + { + name: "equals syntax", + args: []string{"mock-agent", `--mcp-config={"mcpServers":{}}`}, + want: `{"mcpServers":{}}`, + }, + { + name: "with other flags", + args: []string{"mock-agent", "--model", "fast", "--mcp-config", `{"mcpServers":{}}`}, + want: `{"mcpServers":{}}`, + }, + { + name: "dangling flag", + args: []string{"mock-agent", "--mcp-config"}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseMCPConfigFromArgs(tt.args) + if got != tt.want { + t.Errorf("parseMCPConfigFromArgs(%v) = %q, want %q", tt.args, got, tt.want) + } + }) + } +} + +// TestExtractMCPResultText verifies the MCP result text extraction. +func TestExtractMCPResultText(t *testing.T) { + t.Run("single text content", func(t *testing.T) { + result := &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{Type: "text", Text: "hello"}, + }, + } + got := extractMCPResultText(result) + if got != "hello" { + t.Errorf("got %q, want %q", got, "hello") + } + }) + + t.Run("multiple text contents joined with newline", func(t *testing.T) { + result := &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{Type: "text", Text: "line1"}, + mcp.TextContent{Type: "text", Text: "line2"}, + }, + } + got := extractMCPResultText(result) + if got != "line1\nline2" { + t.Errorf("got %q, want %q", got, "line1\nline2") + } + }) + + t.Run("empty content returns empty string", func(t *testing.T) { + result := &mcp.CallToolResult{ + Content: []mcp.Content{}, + } + got := extractMCPResultText(result) + if got != "" { + t.Errorf("got %q, want empty", got) + } + }) +} + +// TestExecuteScriptToolUseBadJSON verifies that malformed JSON emits a script error. +func TestExecuteScriptToolUseBadJSON(t *testing.T) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + resetState() + + executeScript(enc, "", `e2e:tool_use("Read", {bad json})`) + + msgs := decodeJSONLines(t, buf.Bytes()) + if len(msgs) != 1 { + t.Fatalf("expected 1 message (error), got %d", len(msgs)) + } + assertMsgType(t, msgs[0], TypeAssistant) + content := getFirstContent(t, msgs[0]) + text, _ := content["text"].(string) + if !strings.Contains(text, "Script error") { + t.Errorf("expected script error message, got %q", text) + } +} + +// --- Test helpers --- + +// decodeJSONLines splits output into JSON-decoded maps. +func decodeJSONLines(t *testing.T, data []byte) []map[string]any { + t.Helper() + var results []map[string]any + dec := json.NewDecoder(bytes.NewReader(data)) + for dec.More() { + var m map[string]any + if err := dec.Decode(&m); err != nil { + t.Fatalf("failed to decode JSON: %v", err) + } + results = append(results, m) + } + return results +} + +func assertMsgType(t *testing.T, msg map[string]any, wantType string) { + t.Helper() + if msg["type"] != wantType { + t.Errorf("message type = %q, want %q", msg["type"], wantType) + } +} + +func getFirstContent(t *testing.T, msg map[string]any) map[string]any { + t.Helper() + message, ok := msg["message"].(map[string]any) + if !ok { + t.Fatal("message field is not a map") + } + contentArr, ok := message["content"].([]any) + if !ok || len(contentArr) == 0 { + t.Fatal("content is not an array or is empty") + } + block, ok := contentArr[0].(map[string]any) + if !ok { + t.Fatal("first content block is not a map") + } + return block +} + +func assertContentText(t *testing.T, msg map[string]any, wantText string) { + t.Helper() + content := getFirstContent(t, msg) + if content["type"] != BlockText { + t.Errorf("content type = %q, want %q", content["type"], BlockText) + } + if content["text"] != wantText { + t.Errorf("text = %q, want %q", content["text"], wantText) + } +} + +func assertContentThinking(t *testing.T, msg map[string]any, wantThought string) { + t.Helper() + content := getFirstContent(t, msg) + if content["type"] != BlockThinking { + t.Errorf("content type = %q, want %q", content["type"], BlockThinking) + } + if content["thinking"] != wantThought { + t.Errorf("thinking = %q, want %q", content["thinking"], wantThought) + } +} diff --git a/apps/backend/cmd/mock-agent/sequences.go b/apps/backend/cmd/mock-agent/sequences.go index b1ed554504..3d87d48d3d 100644 --- a/apps/backend/cmd/mock-agent/sequences.go +++ b/apps/backend/cmd/mock-agent/sequences.go @@ -7,11 +7,23 @@ import ( "strings" ) -var toolCallCounter int +// scriptState holds mutable state shared across script commands and sequence emitters. +type scriptState struct { + toolCallCounter int + lastToolID string +} + +// state is the process-wide script state. Use resetState() in tests +// to get a clean starting point instead of touching fields directly. +var state = &scriptState{} + +func resetState() { + state = &scriptState{} +} func nextToolID() string { - toolCallCounter++ - return fmt.Sprintf("mock_tool_%04d", toolCallCounter) + state.toolCallCounter++ + return fmt.Sprintf("mock_tool_%04d", state.toolCallCounter) } func defaultUsage() *Usage { @@ -213,7 +225,7 @@ func emitCodeSearch(enc *json.Encoder, model string) { randomDelay(model) searchPatterns := []string{"func ", "import ", "TODO", "return ", "error", "type "} - pattern := searchPatterns[toolCallCounter%len(searchPatterns)] + pattern := searchPatterns[state.toolCallCounter%len(searchPatterns)] f := randomFile() _ = enc.Encode(AssistantMsg{ @@ -441,6 +453,35 @@ func emitMermaidSequence(enc *json.Encoder, model string) { "- Database connections use a **connection pool** (max 50)\n", "") } +// emitToolUseBlock emits an assistant message with a single tool_use content block. +func emitToolUseBlock(enc *json.Encoder, toolID, toolName string, input map[string]any) { + _ = enc.Encode(AssistantMsg{ + Type: TypeAssistant, + Message: AssistantBody{ + Role: "assistant", + Content: []ContentBlock{ + {Type: BlockToolUse, ID: toolID, Name: toolName, Input: input}, + }, + Model: "mock-default", + StopReason: "tool_use", + Usage: defaultUsage(), + }, + }) +} + +// emitToolResultBlock emits a user message with a tool_result content block. +func emitToolResultBlock(enc *json.Encoder, toolID, content string, isError bool) { + _ = enc.Encode(UserMsg{ + Type: TypeUser, + Message: UserMsgBody{ + Role: "user", + Content: []ContentBlock{ + {Type: BlockToolResult, ToolUseID: toolID, Content: content, IsError: isError}, + }, + }, + }) +} + // emitWebFetch emits a WebFetch tool_use followed by a tool_result. func emitWebFetch(enc *json.Encoder, model string) { toolID := nextToolID() diff --git a/apps/backend/internal/agent/agents/mock.go b/apps/backend/internal/agent/agents/mock.go index 1983f91d49..ff39757a4d 100644 --- a/apps/backend/internal/agent/agents/mock.go +++ b/apps/backend/internal/agent/agents/mock.go @@ -17,11 +17,12 @@ var mockLogoDark []byte var _ Agent = (*MockAgent)(nil) type MockAgent struct { - enabled bool - binaryPath string + enabled bool + binaryPath string + supportsMCP bool } -func NewMockAgent() *MockAgent { return &MockAgent{} } +func NewMockAgent() *MockAgent { return &MockAgent{supportsMCP: true} } // SetEnabled enables or disables the mock agent at runtime. func (a *MockAgent) SetEnabled(enabled bool) { a.enabled = enabled } @@ -29,6 +30,13 @@ func (a *MockAgent) SetEnabled(enabled bool) { a.enabled = enabled } // SetBinaryPath overrides the mock-agent binary path. func (a *MockAgent) SetBinaryPath(path string) { a.binaryPath = path } +// SetSupportsMCP controls whether the mock agent reports MCP support. +// Defaults to true so plan mode workflow events work in E2E tests. +func (a *MockAgent) SetSupportsMCP(v bool) { a.supportsMCP = v } + +// SupportsMCPEnabled reports the current MCP support setting. +func (a *MockAgent) SupportsMCPEnabled() bool { return a.supportsMCP } + func (a *MockAgent) ID() string { return "mock-agent" } func (a *MockAgent) Name() string { return "Mock Agent" } func (a *MockAgent) DisplayName() string { return "Mock" } @@ -46,8 +54,8 @@ func (a *MockAgent) Logo(v LogoVariant) []byte { } func (a *MockAgent) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { - // Mock agent is always "available" when enabled (forced by settings controller) - return &DiscoveryResult{Available: false}, nil + // Mock agent is always "available" when enabled (forced by settings controller). + return &DiscoveryResult{Available: true, SupportsMCP: a.supportsMCP}, nil } func (a *MockAgent) DefaultModel() string { return "mock-default" } diff --git a/apps/backend/internal/agent/lifecycle/manager_events.go b/apps/backend/internal/agent/lifecycle/manager_events.go index 71b108efdd..67826691b6 100644 --- a/apps/backend/internal/agent/lifecycle/manager_events.go +++ b/apps/backend/internal/agent/lifecycle/manager_events.go @@ -190,8 +190,15 @@ func (m *Manager) handleCompleteEvent(execution *AgentExecution, event *agentctl zap.String("execution_id", execution.ID), zap.String("operation_id", event.OperationID)) - m.handleCompleteEventMarkState(execution, event, isError) + // Signal promptDoneCh BEFORE publishing agent.ready via MarkReady. + // MarkReady publishes agent.ready synchronously, which triggers handleAgentReady + // in the orchestrator. If handleAgentReady launches a follow-up prompt (queued + // message), the new SendPrompt drains promptDoneCh. If we signal AFTER MarkReady, + // the drain races with the first SendPrompt's receive and can steal the signal, + // leaving the first SendPrompt hung and the second prompt's completion event + // never reaching the event bus. handleCompleteEventSignal(execution, event, isError) + m.handleCompleteEventMarkState(execution, event, isError) } // handleToolCallEvent processes the "tool_call" agent event: flushes the message buffer diff --git a/apps/backend/internal/agent/lifecycle/session.go b/apps/backend/internal/agent/lifecycle/session.go index 4456ef0958..4717108e82 100644 --- a/apps/backend/internal/agent/lifecycle/session.go +++ b/apps/backend/internal/agent/lifecycle/session.go @@ -519,12 +519,6 @@ func (sm *SessionManager) SendPrompt( execution.lastActivityAt = time.Now() execution.lastActivityAtMu.Unlock() - // Drain any stale signal from a previous prompt cycle - select { - case <-execution.promptDoneCh: - default: - } - // Fire the prompt (returns immediately now — completion comes via WebSocket complete event) if err := execution.agentctl.Prompt(ctx, effectivePrompt, attachments); err != nil { if isAgentStreamNotConnectedErr(err) && sm.streamManager != nil { diff --git a/apps/backend/internal/agent/registry/provider.go b/apps/backend/internal/agent/registry/provider.go index 7a95935b30..c99fc6c788 100644 --- a/apps/backend/internal/agent/registry/provider.go +++ b/apps/backend/internal/agent/registry/provider.go @@ -12,16 +12,20 @@ import ( // Provide creates and loads the agent registry. func Provide(log *logger.Logger) (*Registry, func() error, error) { reg := NewRegistry(log) - reg.LoadDefaults() if os.Getenv("KANDEV_MOCK_AGENT") == "true" { + // Only register mock agent — skip slow agent discovery for all others + _ = reg.Register(agents.NewMockAgent()) configureMockAgent(reg, log) + } else { + reg.LoadDefaults() } return reg, func() error { return nil }, nil } -// configureMockAgent enables and configures the mock agent binary path. +// configureMockAgent enables and configures the mock agent binary path and capabilities. +// KANDEV_MOCK_AGENT_MCP=false disables MCP support (defaults to enabled). func configureMockAgent(reg *Registry, log *logger.Logger) { ag, ok := reg.Get("mock-agent") if !ok { @@ -32,6 +36,9 @@ func configureMockAgent(reg *Registry, log *logger.Logger) { return } mock.SetEnabled(true) + if os.Getenv("KANDEV_MOCK_AGENT_MCP") == "false" { + mock.SetSupportsMCP(false) + } // Resolve binary path: same directory as the running executable exePath, err := os.Executable() if err != nil { @@ -39,5 +46,7 @@ func configureMockAgent(reg *Registry, log *logger.Logger) { } binaryPath := filepath.Join(filepath.Dir(exePath), "mock-agent") mock.SetBinaryPath(binaryPath) - log.Info("mock agent enabled", zap.String("cmd", binaryPath)) + log.Info("mock agent enabled", + zap.String("cmd", binaryPath), + zap.Bool("supports_mcp", mock.SupportsMCPEnabled())) } diff --git a/apps/backend/internal/github/factory.go b/apps/backend/internal/github/factory.go index b6bc422436..5b4e60f781 100644 --- a/apps/backend/internal/github/factory.go +++ b/apps/backend/internal/github/factory.go @@ -3,6 +3,7 @@ package github import ( "context" "fmt" + "os" "go.uber.org/zap" @@ -25,6 +26,12 @@ type SecretListItem struct { // NewClient creates a GitHub client using the best available auth method. // It tries the gh CLI first, then falls back to a PAT from the secret store. func NewClient(ctx context.Context, secrets SecretProvider, log *logger.Logger) (Client, string, error) { + // Mock client for E2E testing + if os.Getenv("KANDEV_MOCK_GITHUB") == "true" { + log.Info("using mock client for GitHub integration") + return NewMockClient(), "mock", nil + } + // Try gh CLI first if GHAvailable() { ghClient := NewGHClient() @@ -48,7 +55,7 @@ func NewClient(ctx context.Context, secrets SecretProvider, log *logger.Logger) } } - return nil, "none", fmt.Errorf("no GitHub auth: gh CLI not authenticated and no GITHUB_TOKEN secret found") + return &NoopClient{}, "none", nil } // findGitHubPAT looks for a secret named "GITHUB_TOKEN" or "github_token". diff --git a/apps/backend/internal/github/factory_test.go b/apps/backend/internal/github/factory_test.go new file mode 100644 index 0000000000..9367f71d56 --- /dev/null +++ b/apps/backend/internal/github/factory_test.go @@ -0,0 +1,51 @@ +package github + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/common/logger" +) + +func newTestLogger() *logger.Logger { + log, _ := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "json", + OutputPath: "stdout", + }) + return log +} + +func TestNewClient_MockEnvVar(t *testing.T) { + t.Setenv("KANDEV_MOCK_GITHUB", "true") + + client, method, err := NewClient(context.Background(), nil, newTestLogger()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if method != "mock" { + t.Errorf("method = %q, want %q", method, "mock") + } + if _, ok := client.(*MockClient); !ok { + t.Errorf("expected *MockClient, got %T", client) + } +} + +func TestNewClient_NoAuth_ReturnsNoop(t *testing.T) { + t.Setenv("KANDEV_MOCK_GITHUB", "") + + client, method, err := NewClient(context.Background(), nil, newTestLogger()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // If gh CLI is installed and authenticated, we get gh_cli — skip that case. + if method == "gh_cli" { + t.Skip("gh CLI is authenticated on this machine, skipping noop test") + } + if method != "none" { + t.Errorf("method = %q, want %q", method, "none") + } + if _, ok := client.(*NoopClient); !ok { + t.Errorf("expected *NoopClient, got %T", client) + } +} diff --git a/apps/backend/internal/github/handlers.go b/apps/backend/internal/github/handlers.go index 34a885a7d8..b590045e96 100644 --- a/apps/backend/internal/github/handlers.go +++ b/apps/backend/internal/github/handlers.go @@ -18,6 +18,18 @@ func RegisterRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *Service, registerWSHandlers(dispatcher, svc, log) } +// RegisterMockRoutes registers mock control endpoints if the GitHub client is a MockClient. +// This is a no-op when the underlying client is not a MockClient. +func RegisterMockRoutes(router *gin.Engine, svc *Service, log *logger.Logger) { + mock, ok := svc.Client().(*MockClient) + if !ok { + return + } + ctrl := NewMockController(mock, log) + ctrl.RegisterRoutes(router) + log.Info("registered GitHub mock control endpoints") +} + func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) { dispatcher.RegisterFunc(ws.ActionGitHubStatus, wsStatus(svc, log)) dispatcher.RegisterFunc(ws.ActionGitHubTaskPRsList, wsListTaskPRs(svc, log)) diff --git a/apps/backend/internal/github/mock_client.go b/apps/backend/internal/github/mock_client.go new file mode 100644 index 0000000000..146fabb1d4 --- /dev/null +++ b/apps/backend/internal/github/mock_client.go @@ -0,0 +1,303 @@ +package github + +import ( + "context" + "fmt" + "strings" + "sync" + "time" +) + +const mockDefaultUser = "mock-user" + +// prKey is a composite key for PR lookups by owner/repo/number. +type prKey struct { + Owner string + Repo string + Number int +} + +// branchKey is a composite key for PR lookups by owner/repo/branch. +type branchKey struct { + Owner string + Repo string + Branch string +} + +// checkKey is a composite key for check-run lookups by owner/repo/ref. +type checkKey struct { + Owner string + Repo string + Ref string +} + +// submittedReview records a SubmitReview call for test assertions. +type submittedReview struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + Event string `json:"event"` + Body string `json:"body"` +} + +// MockClient implements Client with in-memory configurable data for E2E testing. +// All data is protected by a sync.RWMutex for thread safety. +type MockClient struct { + mu sync.RWMutex + user string + prs map[prKey]*PR + prsByBranch map[branchKey]*PR + orgs []GitHubOrg + repos map[string][]GitHubRepo + reviews map[prKey][]PRReview + comments map[prKey][]PRComment + checks map[checkKey][]CheckRun + files map[prKey][]PRFile + commits map[prKey][]PRCommitInfo + submittedReviews []submittedReview +} + +// NewMockClient creates a new MockClient with default values. +func NewMockClient() *MockClient { + return &MockClient{ + user: mockDefaultUser, + prs: make(map[prKey]*PR), + prsByBranch: make(map[branchKey]*PR), + repos: make(map[string][]GitHubRepo), + reviews: make(map[prKey][]PRReview), + comments: make(map[prKey][]PRComment), + checks: make(map[checkKey][]CheckRun), + files: make(map[prKey][]PRFile), + commits: make(map[prKey][]PRCommitInfo), + } +} + +// --- Client interface implementation --- + +func (m *MockClient) IsAuthenticated(context.Context) (bool, error) { + return true, nil +} + +func (m *MockClient) GetAuthenticatedUser(context.Context) (string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.user, nil +} + +func (m *MockClient) GetPR(_ context.Context, owner, repo string, number int) (*PR, error) { + m.mu.RLock() + defer m.mu.RUnlock() + pr, ok := m.prs[prKey{owner, repo, number}] + if !ok { + return nil, fmt.Errorf("mock: PR %s/%s#%d not found", owner, repo, number) + } + return pr, nil +} + +func (m *MockClient) FindPRByBranch(_ context.Context, owner, repo, branch string) (*PR, error) { + m.mu.RLock() + defer m.mu.RUnlock() + pr := m.prsByBranch[branchKey{owner, repo, branch}] + return pr, nil +} + +func (m *MockClient) ListAuthoredPRs(_ context.Context, owner, repo string) ([]*PR, error) { + m.mu.RLock() + defer m.mu.RUnlock() + var result []*PR + for k, pr := range m.prs { + if k.Owner == owner && k.Repo == repo && pr.AuthorLogin == m.user { + result = append(result, pr) + } + } + return result, nil +} + +func (m *MockClient) ListReviewRequestedPRs(context.Context, string, string, string) ([]*PR, error) { + m.mu.RLock() + defer m.mu.RUnlock() + var result []*PR + for _, pr := range m.prs { + if len(pr.RequestedReviewers) > 0 { + result = append(result, pr) + } + } + return result, nil +} + +func (m *MockClient) ListUserOrgs(context.Context) ([]GitHubOrg, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.orgs == nil { + return []GitHubOrg{}, nil + } + return m.orgs, nil +} + +func (m *MockClient) SearchOrgRepos(_ context.Context, org, query string, _ int) ([]GitHubRepo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + repos := m.repos[org] + if query == "" { + return repos, nil + } + var filtered []GitHubRepo + for _, r := range repos { + if strings.Contains(strings.ToLower(r.FullName), strings.ToLower(query)) { + filtered = append(filtered, r) + } + } + return filtered, nil +} + +func (m *MockClient) ListPRReviews(_ context.Context, owner, repo string, number int) ([]PRReview, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.reviews[prKey{owner, repo, number}], nil +} + +func (m *MockClient) ListPRComments(_ context.Context, owner, repo string, number int, since *time.Time) ([]PRComment, error) { + m.mu.RLock() + defer m.mu.RUnlock() + all := m.comments[prKey{owner, repo, number}] + if since == nil { + return all, nil + } + var filtered []PRComment + for _, c := range all { + if c.UpdatedAt.After(*since) { + filtered = append(filtered, c) + } + } + return filtered, nil +} + +func (m *MockClient) ListCheckRuns(_ context.Context, owner, repo, ref string) ([]CheckRun, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.checks[checkKey{owner, repo, ref}], nil +} + +func (m *MockClient) GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error) { + return getPRFeedback(ctx, m, owner, repo, number) +} + +func (m *MockClient) ListPRFiles(_ context.Context, owner, repo string, number int) ([]PRFile, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.files[prKey{owner, repo, number}], nil +} + +func (m *MockClient) ListPRCommits(_ context.Context, owner, repo string, number int) ([]PRCommitInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.commits[prKey{owner, repo, number}], nil +} + +func (m *MockClient) SubmitReview(_ context.Context, owner, repo string, number int, event, body string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.submittedReviews = append(m.submittedReviews, submittedReview{ + Owner: owner, Repo: repo, Number: number, Event: event, Body: body, + }) + return nil +} + +// --- Setter methods for HTTP control endpoints --- + +// SetUser sets the authenticated username. +func (m *MockClient) SetUser(username string) { + m.mu.Lock() + defer m.mu.Unlock() + m.user = username +} + +// AddPR adds a PR to the mock data store, indexed by owner/repo/number and branch. +func (m *MockClient) AddPR(pr *PR) { + m.mu.Lock() + defer m.mu.Unlock() + m.prs[prKey{pr.RepoOwner, pr.RepoName, pr.Number}] = pr + if pr.HeadBranch != "" { + m.prsByBranch[branchKey{pr.RepoOwner, pr.RepoName, pr.HeadBranch}] = pr + } +} + +// AddOrgs appends organizations to the mock data store. +func (m *MockClient) AddOrgs(orgs []GitHubOrg) { + m.mu.Lock() + defer m.mu.Unlock() + m.orgs = append(m.orgs, orgs...) +} + +// AddRepos appends repositories for an organization. +func (m *MockClient) AddRepos(org string, repos []GitHubRepo) { + m.mu.Lock() + defer m.mu.Unlock() + m.repos[org] = append(m.repos[org], repos...) +} + +// AddReviews appends reviews for a PR. +func (m *MockClient) AddReviews(owner, repo string, number int, reviews []PRReview) { + m.mu.Lock() + defer m.mu.Unlock() + k := prKey{owner, repo, number} + m.reviews[k] = append(m.reviews[k], reviews...) +} + +// AddComments appends comments for a PR. +func (m *MockClient) AddComments(owner, repo string, number int, comments []PRComment) { + m.mu.Lock() + defer m.mu.Unlock() + k := prKey{owner, repo, number} + m.comments[k] = append(m.comments[k], comments...) +} + +// AddCheckRuns appends check runs for a ref. +func (m *MockClient) AddCheckRuns(owner, repo, ref string, checks []CheckRun) { + m.mu.Lock() + defer m.mu.Unlock() + k := checkKey{owner, repo, ref} + m.checks[k] = append(m.checks[k], checks...) +} + +// AddPRFiles appends files for a PR. +func (m *MockClient) AddPRFiles(owner, repo string, number int, files []PRFile) { + m.mu.Lock() + defer m.mu.Unlock() + k := prKey{owner, repo, number} + m.files[k] = append(m.files[k], files...) +} + +// AddPRCommits appends commits for a PR. +func (m *MockClient) AddPRCommits(owner, repo string, number int, commits []PRCommitInfo) { + m.mu.Lock() + defer m.mu.Unlock() + k := prKey{owner, repo, number} + m.commits[k] = append(m.commits[k], commits...) +} + +// Reset clears all mock data and resets the user to the default. +func (m *MockClient) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.user = mockDefaultUser + m.prs = make(map[prKey]*PR) + m.prsByBranch = make(map[branchKey]*PR) + m.orgs = nil + m.repos = make(map[string][]GitHubRepo) + m.reviews = make(map[prKey][]PRReview) + m.comments = make(map[prKey][]PRComment) + m.checks = make(map[checkKey][]CheckRun) + m.files = make(map[prKey][]PRFile) + m.commits = make(map[prKey][]PRCommitInfo) + m.submittedReviews = nil +} + +// SubmittedReviews returns all recorded SubmitReview calls. +func (m *MockClient) SubmittedReviews() []submittedReview { + m.mu.RLock() + defer m.mu.RUnlock() + result := make([]submittedReview, len(m.submittedReviews)) + copy(result, m.submittedReviews) + return result +} diff --git a/apps/backend/internal/github/mock_client_test.go b/apps/backend/internal/github/mock_client_test.go new file mode 100644 index 0000000000..6f677e8818 --- /dev/null +++ b/apps/backend/internal/github/mock_client_test.go @@ -0,0 +1,220 @@ +package github + +import ( + "context" + "testing" + "time" +) + +// Compile-time interface check. +var _ Client = (*MockClient)(nil) + +func TestMockClient_IsAuthenticated(t *testing.T) { + m := NewMockClient() + ok, err := m.IsAuthenticated(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected authenticated=true") + } +} + +func TestMockClient_GetAuthenticatedUser(t *testing.T) { + m := NewMockClient() + user, err := m.GetAuthenticatedUser(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if user != mockDefaultUser { + t.Fatalf("expected mock-user, got %q", user) + } +} + +func TestMockClient_SetUser(t *testing.T) { + m := NewMockClient() + m.SetUser("custom-user") + user, _ := m.GetAuthenticatedUser(context.Background()) + if user != "custom-user" { + t.Fatalf("expected custom-user, got %q", user) + } +} + +func TestMockClient_AddPR_GetPR(t *testing.T) { + m := NewMockClient() + ctx := context.Background() + + // Not found + _, err := m.GetPR(ctx, "owner", "repo", 1) + if err == nil { + t.Fatal("expected error for missing PR") + } + + // Add and retrieve + m.AddPR(&PR{ + Number: 1, + Title: "Test PR", + RepoOwner: "owner", + RepoName: "repo", + HeadBranch: "feature", + }) + + pr, err := m.GetPR(ctx, "owner", "repo", 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pr.Title != "Test PR" { + t.Fatalf("expected title 'Test PR', got %q", pr.Title) + } +} + +func TestMockClient_FindPRByBranch(t *testing.T) { + m := NewMockClient() + ctx := context.Background() + + // Not found returns nil, nil + pr, err := m.FindPRByBranch(ctx, "owner", "repo", "feature") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pr != nil { + t.Fatal("expected nil PR for missing branch") + } + + // Add and find + m.AddPR(&PR{ + Number: 1, + RepoOwner: "owner", + RepoName: "repo", + HeadBranch: "feature", + Title: "Branch PR", + }) + + pr, err = m.FindPRByBranch(ctx, "owner", "repo", "feature") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pr == nil || pr.Title != "Branch PR" { + t.Fatalf("expected Branch PR, got %v", pr) + } +} + +func TestMockClient_ListAuthoredPRs(t *testing.T) { + m := NewMockClient() + ctx := context.Background() + + m.AddPR(&PR{Number: 1, RepoOwner: "o", RepoName: "r", AuthorLogin: mockDefaultUser}) + m.AddPR(&PR{Number: 2, RepoOwner: "o", RepoName: "r", AuthorLogin: "other"}) + m.AddPR(&PR{Number: 3, RepoOwner: "x", RepoName: "y", AuthorLogin: mockDefaultUser}) + + prs, err := m.ListAuthoredPRs(ctx, "o", "r") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(prs) != 1 { + t.Fatalf("expected 1 authored PR, got %d", len(prs)) + } + if prs[0].Number != 1 { + t.Fatalf("expected PR #1, got #%d", prs[0].Number) + } +} + +func TestMockClient_ListReviewRequestedPRs(t *testing.T) { + m := NewMockClient() + ctx := context.Background() + + m.AddPR(&PR{Number: 1, RepoOwner: "o", RepoName: "r"}) + m.AddPR(&PR{ + Number: 2, + RepoOwner: "o", + RepoName: "r", + RequestedReviewers: []RequestedReviewer{{Login: "user1", Type: "user"}}, + }) + + prs, err := m.ListReviewRequestedPRs(ctx, "", "", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(prs) != 1 { + t.Fatalf("expected 1 review-requested PR, got %d", len(prs)) + } + if prs[0].Number != 2 { + t.Fatalf("expected PR #2, got #%d", prs[0].Number) + } +} + +func TestMockClient_ListPRComments_Since(t *testing.T) { + m := NewMockClient() + ctx := context.Background() + + now := time.Now() + old := now.Add(-1 * time.Hour) + recent := now.Add(1 * time.Hour) + + m.AddComments("o", "r", 1, []PRComment{ + {ID: 1, UpdatedAt: old}, + {ID: 2, UpdatedAt: recent}, + }) + + // Without since — all + all, err := m.ListPRComments(ctx, "o", "r", 1, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(all) != 2 { + t.Fatalf("expected 2 comments, got %d", len(all)) + } + + // With since — only recent + filtered, err := m.ListPRComments(ctx, "o", "r", 1, &now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(filtered) != 1 || filtered[0].ID != 2 { + t.Fatalf("expected 1 filtered comment (ID=2), got %v", filtered) + } +} + +func TestMockClient_SubmitReview(t *testing.T) { + m := NewMockClient() + ctx := context.Background() + + err := m.SubmitReview(ctx, "o", "r", 1, "APPROVE", "LGTM") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + reviews := m.SubmittedReviews() + if len(reviews) != 1 { + t.Fatalf("expected 1 submitted review, got %d", len(reviews)) + } + if reviews[0].Event != "APPROVE" || reviews[0].Body != "LGTM" { + t.Fatalf("unexpected review: %+v", reviews[0]) + } +} + +func TestMockClient_Reset(t *testing.T) { + m := NewMockClient() + ctx := context.Background() + + m.SetUser("custom") + m.AddPR(&PR{Number: 1, RepoOwner: "o", RepoName: "r", HeadBranch: "b"}) + m.AddOrgs([]GitHubOrg{{Login: "org1"}}) + + m.Reset() + + user, _ := m.GetAuthenticatedUser(ctx) + if user != mockDefaultUser { + t.Fatalf("expected user reset to mock-user, got %q", user) + } + + _, err := m.GetPR(ctx, "o", "r", 1) + if err == nil { + t.Fatal("expected error after reset") + } + + orgs, _ := m.ListUserOrgs(ctx) + if len(orgs) != 0 { + t.Fatalf("expected 0 orgs after reset, got %d", len(orgs)) + } +} diff --git a/apps/backend/internal/github/mock_controller.go b/apps/backend/internal/github/mock_controller.go new file mode 100644 index 0000000000..1ed19de1c5 --- /dev/null +++ b/apps/backend/internal/github/mock_controller.go @@ -0,0 +1,165 @@ +package github + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" +) + +// MockController handles HTTP endpoints for controlling the MockClient in E2E tests. +type MockController struct { + mock *MockClient +} + +// NewMockController creates a new MockController. +func NewMockController(mock *MockClient, _ *logger.Logger) *MockController { + return &MockController{mock: mock} +} + +// RegisterRoutes registers all mock control HTTP routes. +func (c *MockController) RegisterRoutes(router *gin.Engine) { + api := router.Group("/api/v1/github/mock") + api.PUT("/user", c.setUser) + api.POST("/prs", c.addPRs) + api.POST("/orgs", c.addOrgs) + api.POST("/repos", c.addRepos) + api.POST("/reviews", c.addReviews) + api.POST("/comments", c.addComments) + api.POST("/checks", c.addCheckRuns) + api.POST("/files", c.addPRFiles) + api.POST("/commits", c.addPRCommits) + api.DELETE("/reset", c.reset) +} + +func (c *MockController) setUser(ctx *gin.Context) { + var req struct { + Username string `json:"username"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + c.mock.SetUser(req.Username) + ctx.JSON(http.StatusOK, gin.H{"username": req.Username}) +} + +func (c *MockController) addPRs(ctx *gin.Context) { + var req struct { + PRs []PR `json:"prs"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + for i := range req.PRs { + c.mock.AddPR(&req.PRs[i]) + } + ctx.JSON(http.StatusOK, gin.H{"added": len(req.PRs)}) +} + +func (c *MockController) addOrgs(ctx *gin.Context) { + var req struct { + Orgs []GitHubOrg `json:"orgs"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + c.mock.AddOrgs(req.Orgs) + ctx.JSON(http.StatusOK, gin.H{"added": len(req.Orgs)}) +} + +func (c *MockController) addRepos(ctx *gin.Context) { + var req struct { + Org string `json:"org"` + Repos []GitHubRepo `json:"repos"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + c.mock.AddRepos(req.Org, req.Repos) + ctx.JSON(http.StatusOK, gin.H{"added": len(req.Repos)}) +} + +func (c *MockController) addReviews(ctx *gin.Context) { + var req struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + Reviews []PRReview `json:"reviews"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + c.mock.AddReviews(req.Owner, req.Repo, req.Number, req.Reviews) + ctx.JSON(http.StatusOK, gin.H{"added": len(req.Reviews)}) +} + +func (c *MockController) addComments(ctx *gin.Context) { + var req struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + Comments []PRComment `json:"comments"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + c.mock.AddComments(req.Owner, req.Repo, req.Number, req.Comments) + ctx.JSON(http.StatusOK, gin.H{"added": len(req.Comments)}) +} + +func (c *MockController) addCheckRuns(ctx *gin.Context) { + var req struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Ref string `json:"ref"` + Checks []CheckRun `json:"checks"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + c.mock.AddCheckRuns(req.Owner, req.Repo, req.Ref, req.Checks) + ctx.JSON(http.StatusOK, gin.H{"added": len(req.Checks)}) +} + +func (c *MockController) addPRFiles(ctx *gin.Context) { + var req struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + Files []PRFile `json:"files"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + c.mock.AddPRFiles(req.Owner, req.Repo, req.Number, req.Files) + ctx.JSON(http.StatusOK, gin.H{"added": len(req.Files)}) +} + +func (c *MockController) addPRCommits(ctx *gin.Context) { + var req struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + Commits []PRCommitInfo `json:"commits"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + c.mock.AddPRCommits(req.Owner, req.Repo, req.Number, req.Commits) + ctx.JSON(http.StatusOK, gin.H{"added": len(req.Commits)}) +} + +func (c *MockController) reset(ctx *gin.Context) { + c.mock.Reset() + ctx.JSON(http.StatusOK, gin.H{"reset": true}) +} diff --git a/apps/backend/internal/github/noop_client.go b/apps/backend/internal/github/noop_client.go new file mode 100644 index 0000000000..c8e6b54d38 --- /dev/null +++ b/apps/backend/internal/github/noop_client.go @@ -0,0 +1,74 @@ +package github + +import ( + "context" + "errors" + "time" +) + +// ErrNoClient is returned by NoopClient methods that cannot provide meaningful data. +var ErrNoClient = errors.New("github client not configured") + +// NoopClient is a GitHub client that returns empty results for all operations. +// Used when GitHub integration is not configured or not needed. +type NoopClient struct{} + +func (c *NoopClient) IsAuthenticated(context.Context) (bool, error) { + return false, nil +} + +func (c *NoopClient) GetAuthenticatedUser(context.Context) (string, error) { + return "", nil +} + +func (c *NoopClient) GetPR(context.Context, string, string, int) (*PR, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) FindPRByBranch(context.Context, string, string, string) (*PR, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) ListAuthoredPRs(context.Context, string, string) ([]*PR, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) ListReviewRequestedPRs(context.Context, string, string, string) ([]*PR, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) ListUserOrgs(context.Context) ([]GitHubOrg, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) SearchOrgRepos(context.Context, string, string, int) ([]GitHubRepo, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) ListPRReviews(context.Context, string, string, int) ([]PRReview, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) ListPRComments(context.Context, string, string, int, *time.Time) ([]PRComment, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) ListCheckRuns(context.Context, string, string, string) ([]CheckRun, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) GetPRFeedback(context.Context, string, string, int) (*PRFeedback, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) ListPRFiles(context.Context, string, string, int) ([]PRFile, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) ListPRCommits(context.Context, string, string, int) ([]PRCommitInfo, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) SubmitReview(context.Context, string, string, int, string, string) error { + return ErrNoClient +} diff --git a/apps/backend/internal/github/noop_client_test.go b/apps/backend/internal/github/noop_client_test.go new file mode 100644 index 0000000000..0146b57558 --- /dev/null +++ b/apps/backend/internal/github/noop_client_test.go @@ -0,0 +1,46 @@ +package github + +import ( + "context" + "errors" + "testing" +) + +// Compile-time interface check. +var _ Client = (*NoopClient)(nil) + +func TestNoopClient_IsAuthenticated(t *testing.T) { + c := &NoopClient{} + ok, err := c.IsAuthenticated(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Fatal("expected authenticated=false for NoopClient") + } +} + +func TestNoopClient_ReturnsErrNoClient(t *testing.T) { + c := &NoopClient{} + ctx := context.Background() + + _, err := c.GetPR(ctx, "o", "r", 1) + if !errors.Is(err, ErrNoClient) { + t.Errorf("GetPR error = %v, want ErrNoClient", err) + } + + _, err = c.FindPRByBranch(ctx, "o", "r", "b") + if !errors.Is(err, ErrNoClient) { + t.Errorf("FindPRByBranch error = %v, want ErrNoClient", err) + } + + _, err = c.ListAuthoredPRs(ctx, "o", "r") + if !errors.Is(err, ErrNoClient) { + t.Errorf("ListAuthoredPRs error = %v, want ErrNoClient", err) + } + + err = c.SubmitReview(ctx, "o", "r", 1, "APPROVE", "lgtm") + if !errors.Is(err, ErrNoClient) { + t.Errorf("SubmitReview error = %v, want ErrNoClient", err) + } +} diff --git a/apps/backend/internal/integration/orchestrator_interaction_test.go b/apps/backend/internal/integration/orchestrator_interaction_test.go index b7315f0493..b95157d2c0 100644 --- a/apps/backend/internal/integration/orchestrator_interaction_test.go +++ b/apps/backend/internal/integration/orchestrator_interaction_test.go @@ -46,10 +46,12 @@ func TestOrchestratorPromptTask(t *testing.T) { _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) require.NoError(t, err) - // Test 1: Verify that prompting while agent is RUNNING is rejected - time.Sleep(100 * time.Millisecond) // Give agent time to start + // Test 1: Verify that prompting while agent is not yet ready is rejected. + // The simulated agent takes ~310ms (50ms launch + 60ms ACP + 200ms execution) before + // publishing AgentReady, so at 100ms the session is still in CREATED state. + time.Sleep(100 * time.Millisecond) - // Attempt to send prompt while agent is still running - should be rejected + // Attempt to send prompt while agent is still initializing — should be rejected promptResp1, err := client.SendRequest("prompt-rejected", ws.ActionOrchestratorPrompt, map[string]interface{}{ "task_id": taskID, "session_id": sessionID, @@ -57,23 +59,12 @@ func TestOrchestratorPromptTask(t *testing.T) { }) require.NoError(t, err) - // Should receive an error response - var errorPayload map[string]interface{} - require.NoError(t, promptResp1.ParsePayload(&errorPayload)) - t.Logf("Prompt response payload: %+v", errorPayload) - // The response might be an error message type, check for common error fields - if errorMsg, hasError := errorPayload["error"]; hasError { - assert.Contains(t, errorMsg.(string), "processing", "Error message should mention agent is processing") - t.Log("Successfully rejected prompt while agent is running:", errorMsg) - } else if success, hasSuccess := errorPayload["success"]; hasSuccess { - assert.False(t, success.(bool), "Expected prompt to be rejected while agent is running") - } else if message, hasMessage := errorPayload["message"]; hasMessage { - assert.Contains(t, message.(string), "processing", "Error message should mention agent is processing") - t.Log("Successfully rejected prompt while agent is running:", message) - } else { - // Just log the payload and continue - the important thing is that the backend logged the error - t.Logf("Unexpected response format, but backend correctly rejected the prompt (see error log above)") - } + // Should receive an error response (type=error with ErrorPayload) + assert.Equal(t, ws.MessageTypeError, promptResp1.Type, "Expected error response when session is not ready") + var errPayload ws.ErrorPayload + require.NoError(t, promptResp1.ParsePayload(&errPayload)) + t.Logf("Prompt rejection: code=%s message=%s", errPayload.Code, errPayload.Message) + assert.NotEmpty(t, errPayload.Message) // Test 2: Wait for agent to complete, then send a valid prompt // Wait for agent to reach COMPLETED or WAITING_FOR_INPUT state diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index b598c5777c..a6b96c97ac 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -3,6 +3,7 @@ package orchestrator import ( "context" + "time" "go.uber.org/zap" @@ -17,6 +18,22 @@ const ( agentEventError = "error" ) +// buildTaskEventPayload builds the standard map payload for TaskUpdated events. +// This is the single source of truth; all TaskUpdated publishers should call it. +func buildTaskEventPayload(task *models.Task) map[string]interface{} { + return map[string]interface{}{ + "task_id": task.ID, + "workflow_id": task.WorkflowID, + "workflow_step_id": task.WorkflowStepID, + "title": task.Title, + "description": task.Description, + "state": string(task.State), + "priority": task.Priority, + "position": task.Position, + "updated_at": task.UpdatedAt.Format(time.RFC3339Nano), + } +} + // toolKindToMessageType maps the normalized tool kind to a frontend message type. func toolKindToMessageType(normalized *streams.NormalizedPayload) string { if normalized == nil { diff --git a/apps/backend/internal/orchestrator/event_handlers_agent.go b/apps/backend/internal/orchestrator/event_handlers_agent.go index c68166187d..22d1f98376 100644 --- a/apps/backend/internal/orchestrator/event_handlers_agent.go +++ b/apps/backend/internal/orchestrator/event_handlers_agent.go @@ -130,17 +130,8 @@ func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventD return } - if session.State == models.TaskSessionStateStarting { - s.logger.Info("agent.ready received while session is STARTING; marking session idle", - zap.String("task_id", data.TaskID), - zap.String("session_id", data.SessionID)) - s.completeTurnForSession(ctx, data.SessionID) - s.setSessionWaitingForInput(ctx, data.TaskID, data.SessionID) - return - } - - if session.State != models.TaskSessionStateRunning { - s.logger.Debug("ignoring agent.ready while session is not running", + if session.State != models.TaskSessionStateRunning && session.State != models.TaskSessionStateStarting { + s.logger.Debug("ignoring agent.ready while session is not running or starting", zap.String("task_id", data.TaskID), zap.String("session_id", data.SessionID), zap.String("session_state", string(session.State))) @@ -238,6 +229,13 @@ func (s *Service) executeQueuedMessage(callerSessionID string, queuedMsg *messag } } + // Process on_turn_start before sending the queued prompt, just like + // dispatchPromptAsync does for user-initiated messages. This allows + // workflow transitions (e.g. move_to_next) to fire on auto-started prompts. + if session, sErr := s.repo.GetTaskSession(promptCtx, queuedMsg.SessionID); sErr == nil { + s.processOnTurnStartViaEngine(promptCtx, queuedMsg.TaskID, session) + } + // Convert queue attachments to v1 attachments attachments := make([]v1.MessageAttachment, len(queuedMsg.Attachments)) for i, att := range queuedMsg.Attachments { diff --git a/apps/backend/internal/orchestrator/event_handlers_git.go b/apps/backend/internal/orchestrator/event_handlers_git.go index b486b73236..ee172c5c03 100644 --- a/apps/backend/internal/orchestrator/event_handlers_git.go +++ b/apps/backend/internal/orchestrator/event_handlers_git.go @@ -274,29 +274,28 @@ func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.C return } - session, err := s.repo.GetTaskSession(ctx, data.TaskSessionID) - if err != nil { - s.logger.Debug("no task session for context window update", - zap.String("session_id", data.TaskSessionID), - zap.Error(err)) - return - } - - // Update session metadata with context window info - if session.Metadata == nil { - session.Metadata = make(map[string]interface{}) - } contextWindowData := map[string]interface{}{ "size": data.ContextWindowSize, "used": data.ContextWindowUsed, "remaining": data.ContextWindowRemaining, "efficiency": data.ContextEfficiency, } - session.Metadata["context_window"] = contextWindowData - // Persist to database asynchronously + // Persist to database asynchronously. Read the session inside the goroutine + // to get the latest metadata (avoids race with setSessionPlanMode etc.). go func() { - if err := s.repo.UpdateTaskSession(context.Background(), session); err != nil { + session, err := s.repo.GetTaskSession(context.Background(), data.TaskSessionID) + if err != nil { + s.logger.Debug("no task session for context window update", + zap.String("session_id", data.TaskSessionID), + zap.Error(err)) + return + } + if session.Metadata == nil { + session.Metadata = make(map[string]interface{}) + } + session.Metadata["context_window"] = contextWindowData + if err := s.repo.UpdateSessionMetadata(context.Background(), session.ID, session.Metadata); err != nil { s.logger.Error("failed to update session with context window", zap.String("task_id", data.TaskID), zap.String("session_id", session.ID), @@ -308,18 +307,15 @@ func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.C } }() - // Broadcast session state change with metadata so frontend can update - // This uses the existing session.state_changed event with metadata included + // Broadcast context window update so the frontend can update in real-time. + // This uses the existing session.state_changed event with metadata included. if s.eventBus != nil { _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent( events.TaskSessionStateChanged, "orchestrator", map[string]interface{}{ - "task_id": data.TaskID, - "session_id": session.ID, - "new_state": string(session.State), - "agent_profile_id": session.AgentProfileID, - "agent_profile_snapshot": session.AgentProfileSnapshot, + "task_id": data.TaskID, + "session_id": data.TaskSessionID, "metadata": map[string]interface{}{ "context_window": contextWindowData, }, diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index 5cb4c84ba5..35301accb4 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -380,6 +380,11 @@ func (s *Service) updateTaskSessionState(ctx context.Context, taskID, sessionID if session.WorkflowStepID != nil { eventData["workflow_step_id"] = *session.WorkflowStepID } + // Include session metadata (e.g. plan_mode set by workflow events). + // Key is "session_metadata" to avoid conflict with message-level "metadata". + if len(session.Metadata) > 0 { + eventData["session_metadata"] = session.Metadata + } _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(events.TaskSessionStateChanged, "task-session", eventData)) } } diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index 77f0e15312..5465dfc3f5 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -403,7 +403,13 @@ func TestHandleAgentReadyGuards(t *testing.T) { repo := setupTestRepo(t) seedSession(t, repo, "t1", "s1", "step1") taskRepo := newMockTaskRepo() - svc := createTestService(repo, newMockStepGetter(), taskRepo) + + // Register the workflow step so processOnTurnComplete can resolve it. + stepGetter := newMockStepGetter() + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0, + } + svc := createTestService(repo, stepGetter, taskRepo) session, _ := repo.GetTaskSession(ctx, "s1") session.State = models.TaskSessionStateStarting @@ -526,6 +532,133 @@ func TestExecuteQueuedMessage_RequeuesTransientPromptFailure(t *testing.T) { } } +func TestExecuteQueuedMessage_FiresOnTurnStart(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + session, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("failed to get session: %v", err) + } + session.State = models.TaskSessionStateWaitingForInput + session.AgentExecutionID = "exec-1" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to update session: %v", err) + } + + stepGetter := newMockStepGetter() + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0, + Events: wfmodels.StepEvents{ + OnTurnStart: []wfmodels.OnTurnStartAction{ + {Type: wfmodels.OnTurnStartMoveToNext}, + }, + }, + } + stepGetter.steps["step2"] = &wfmodels.WorkflowStep{ + ID: "step2", WorkflowID: "wf1", Name: "Step 2", Position: 1, + Events: wfmodels.StepEvents{}, + } + + taskRepo := newMockTaskRepo() + agentMgr := &mockAgentManager{ + isAgentRunning: true, + // PromptAgent succeeds so the message is consumed normally. + } + log := testLogger() + svc := &Service{ + logger: log, + repo: repo, + taskRepo: taskRepo, + agentManager: agentMgr, + messageQueue: messagequeue.NewService(log), + } + svc.SetWorkflowStepGetter(stepGetter) + svc.executor = executor.NewExecutor(agentMgr, repo, log, executor.ExecutorConfig{}) + + queuedMsg := &messagequeue.QueuedMessage{ + ID: "q1", + SessionID: "s1", + TaskID: "t1", + Content: "auto-start prompt", + QueuedBy: "workflow-auto-start", + } + + svc.executeQueuedMessage("s1", queuedMsg) + + // Verify on_turn_start moved the session from step1 to step2. + updated, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("failed to get session: %v", err) + } + if updated.WorkflowStepID == nil || *updated.WorkflowStepID != "step2" { + got := "" + if updated.WorkflowStepID != nil { + got = *updated.WorkflowStepID + } + t.Errorf("expected session workflow step to be 'step2', got %s", got) + } +} + +func TestExecuteQueuedMessage_NoOnTurnStart_StepUnchanged(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + session, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("failed to get session: %v", err) + } + session.State = models.TaskSessionStateWaitingForInput + session.AgentExecutionID = "exec-1" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to update session: %v", err) + } + + stepGetter := newMockStepGetter() + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0, + Events: wfmodels.StepEvents{}, // no on_turn_start + } + + taskRepo := newMockTaskRepo() + agentMgr := &mockAgentManager{isAgentRunning: true} + log := testLogger() + svc := &Service{ + logger: log, + repo: repo, + taskRepo: taskRepo, + agentManager: agentMgr, + messageQueue: messagequeue.NewService(log), + } + svc.SetWorkflowStepGetter(stepGetter) + svc.executor = executor.NewExecutor(agentMgr, repo, log, executor.ExecutorConfig{}) + + queuedMsg := &messagequeue.QueuedMessage{ + ID: "q1", + SessionID: "s1", + TaskID: "t1", + Content: "auto-start prompt", + QueuedBy: "workflow-auto-start", + } + + svc.executeQueuedMessage("s1", queuedMsg) + + // Verify session stayed on step1 (no on_turn_start actions). + updated, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("failed to get session: %v", err) + } + if updated.WorkflowStepID == nil || *updated.WorkflowStepID != "step1" { + got := "" + if updated.WorkflowStepID != nil { + got = *updated.WorkflowStepID + } + t.Errorf("expected session workflow step to remain 'step1', got %s", got) + } +} + func createTestServiceWithScheduler(repo *sqliterepo.Repository, stepGetter *mockStepGetter, taskRepo *mockTaskRepo, agentMgr executor.AgentManagerClient) *Service { log := testLogger() exec := executor.NewExecutor(agentMgr, repo, log, executor.ExecutorConfig{}) diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index d275f659d0..0d3b5c04b0 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -134,7 +134,7 @@ func (s *Service) processOnTurnStart(ctx context.Context, taskID string, session // Get the current workflow step currentStep, err := s.workflowStepGetter.GetStep(ctx, workflowStepID) - if err != nil { + if err != nil || currentStep == nil { s.logger.Warn("failed to get workflow step for on_turn_start", zap.String("workflow_step_id", workflowStepID), zap.Error(err)) @@ -240,20 +240,10 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s // Publish task updated event if s.eventBus != nil { - taskEventData := map[string]interface{}{ - "task_id": task.ID, - "workflow_id": task.WorkflowID, - "workflow_step_id": task.WorkflowStepID, - "title": task.Title, - "description": task.Description, - "state": string(task.State), - "priority": task.Priority, - "position": task.Position, - } _ = s.eventBus.Publish(ctx, events.TaskUpdated, bus.NewEvent( events.TaskUpdated, "orchestrator", - taskEventData, + buildTaskEventPayload(task), )) } @@ -492,6 +482,7 @@ func (s *Service) autoStartStepPrompt( shouldQueueIfBusy bool, ) error { sessionID := session.ID + if shouldQueueIfBusy { queued, err := s.queueAutoStartPromptIfRunning(ctx, taskID, session, prompt, planMode) if err != nil { @@ -579,7 +570,7 @@ func (s *Service) queueAutoStartPromptIfRunning( taskID string, session *models.TaskSession, prompt string, planMode bool, ) (bool, error) { - if session.State != models.TaskSessionStateRunning { + if session.State != models.TaskSessionStateRunning && session.State != models.TaskSessionStateStarting { return false, nil } if err := s.queueAutoStartPrompt(ctx, taskID, session.ID, prompt, planMode); err != nil { @@ -744,6 +735,10 @@ func (s *Service) publishSessionWaitingEvent(ctx context.Context, taskID, sessio "workflow_step_id": stepID, "new_state": string(models.TaskSessionStateWaitingForInput), } + // Include session metadata (e.g. plan_mode) so the frontend can react. + if session, err := s.repo.GetTaskSession(ctx, sessionID); err == nil && len(session.Metadata) > 0 { + eventData["session_metadata"] = session.Metadata + } _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent( events.TaskSessionStateChanged, "orchestrator", @@ -877,6 +872,31 @@ func (s *Service) applyEngineTransition( result engine.HandleResult, trigger engine.Trigger, taskDescription string, triggerOnEnter bool, ) bool { + // Validate the target step exists BEFORE persisting the transition. + // This prevents the task from being moved to an invalid step_id + // (e.g., a template-level alias like "review" that doesn't resolve to a real UUID). + var targetStep *wfmodels.WorkflowStep + if triggerOnEnter { + var err error + targetStep, err = s.workflowStepGetter.GetStep(ctx, result.ToStepID) + if err != nil { + s.logger.Warn("target step not found, skipping transition", + zap.String("step_id", result.ToStepID), + zap.Error(err)) + s.setSessionWaitingForInput(ctx, taskID, session.ID, session) + return false + } + } else { + // Even without on_enter, verify the target step exists to avoid orphaning the task. + if _, err := s.workflowStepGetter.GetStep(ctx, result.ToStepID); err != nil { + s.logger.Warn("target step not found, skipping transition", + zap.String("step_id", result.ToStepID), + zap.Error(err)) + s.setSessionWaitingForInput(ctx, taskID, session.ID, session) + return false + } + } + fromStep, err := s.workflowStepGetter.GetStep(ctx, result.FromStepID) if err != nil { s.logger.Warn("failed to load from-step for on_exit", @@ -895,6 +915,10 @@ func (s *Service) applyEngineTransition( return false } + // Update in-memory session so subsequent calls (e.g. setSessionWaitingForInput) + // include the new workflow_step_id in the session.state_changed WS event. + session.WorkflowStepID = &result.ToStepID + if len(result.DataPatch) > 0 { if err := s.workflowStore.PersistData(ctx, session.ID, result.DataPatch); err != nil { s.logger.Warn("failed to persist workflow data patch", @@ -908,15 +932,6 @@ func (s *Service) applyEngineTransition( return true } - targetStep, err := s.workflowStepGetter.GetStep(ctx, result.ToStepID) - if err != nil { - s.logger.Warn("failed to load target step for on_enter", - zap.String("step_id", result.ToStepID), - zap.Error(err)) - s.setSessionWaitingForInput(ctx, taskID, session.ID, session) - return true - } - s.processOnEnter(ctx, taskID, session, targetStep, taskDescription) return true } 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 d0c9708516..cb1ddbb947 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go @@ -221,6 +221,20 @@ func TestProcessOnTurnComplete(t *testing.T) { func TestProcessOnTurnStart(t *testing.T) { ctx := context.Background() + t.Run("nil step returns false", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "unknown-step") + + // Step getter returns (nil, nil) for unknown steps — must not panic. + stepGetter := newMockStepGetter() + svc := createTestService(repo, stepGetter, newMockTaskRepo()) + session, _ := repo.GetTaskSession(ctx, "s1") + got := svc.processOnTurnStart(ctx, "t1", session) + if got { + t.Error("expected false when step is nil") + } + }) + t.Run("no actions returns false", func(t *testing.T) { repo := setupTestRepo(t) seedSession(t, repo, "t1", "s1", "step1") diff --git a/apps/backend/internal/orchestrator/session_launch.go b/apps/backend/internal/orchestrator/session_launch.go index 7ecd86e186..26e1955572 100644 --- a/apps/backend/internal/orchestrator/session_launch.go +++ b/apps/backend/internal/orchestrator/session_launch.go @@ -133,7 +133,7 @@ func (s *Service) launchStart(ctx context.Context, req *LaunchSessionRequest) (* func (s *Service) launchStartCreated(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) { execution, err := s.StartCreatedSession( ctx, req.TaskID, req.SessionID, req.AgentProfileID, - req.Prompt, req.SkipMessageRecord, + req.Prompt, req.SkipMessageRecord, req.PlanMode, ) if err != nil { return nil, err diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 77563afbf0..9f9f6efda5 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -108,6 +108,20 @@ func (s *Service) PrepareTaskSession(ctx context.Context, taskID string, agentPr } } + // Fall back to the task's current workflow step when the caller didn't provide one. + // This ensures sessions created via the kanban card (which doesn't send workflow_step_id) + // inherit the task's step and participate in workflow events. + if workflowStepID == "" { + dbTask, err := s.repo.GetTask(ctx, taskID) + if err != nil { + s.logger.Warn("failed to fetch task for workflow step fallback", + zap.String("task_id", taskID), + zap.Error(err)) + } else if dbTask.WorkflowStepID != "" { + workflowStepID = dbTask.WorkflowStepID + } + } + // Create session entry in database sessionID, err := s.executor.PrepareSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID) if err != nil { @@ -149,6 +163,11 @@ func (s *Service) StartTaskWithSession(ctx context.Context, taskID string, sessi zap.String("agent_profile_id", agentProfileID), zap.Bool("plan_mode", planMode)) + // Process on_turn_start before launching the agent. + if session, err := s.repo.GetTaskSession(ctx, sessionID); err == nil { + s.processOnTurnStartViaEngine(ctx, taskID, session) + } + if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateScheduling); err != nil { s.logger.Warn("failed to update task state to SCHEDULING", zap.String("task_id", taskID), @@ -178,6 +197,13 @@ func (s *Service) StartTaskWithSession(ctx context.Context, taskID string, sessi if execution.SessionID != "" { s.recordInitialMessage(ctx, taskID, execution.SessionID, effectivePrompt, planModeActive) + + if planModeActive { + sess, sessErr := s.repo.GetTaskSession(ctx, execution.SessionID) + if sessErr == nil { + s.setSessionPlanMode(ctx, sess, true) + } + } } if execution.WorktreeBranch != "" { go s.ensureSessionPRWatch(context.Background(), taskID, execution.SessionID, execution.WorktreeBranch) @@ -190,7 +216,8 @@ func (s *Service) StartTaskWithSession(ctx context.Context, taskID string, sessi // This is used when a session was prepared (via PrepareSession) but the agent was not launched, // and the user now wants to start the agent with a prompt (e.g., from the plan panel or chat). // When skipMessageRecord is true, only the session state is updated (the caller already stored the user message). -func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord bool) (*executor.TaskExecution, error) { +// When planMode is true, plan mode instructions are injected into the prompt and session metadata is set. +func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode bool) (*executor.TaskExecution, error) { s.logger.Debug("starting created session", zap.String("task_id", taskID), zap.String("session_id", sessionID), @@ -204,8 +231,11 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag if session.TaskID != taskID { return nil, fmt.Errorf("session does not belong to task") } - if session.State != models.TaskSessionStateCreated { - return nil, fmt.Errorf("session is not in CREATED state (current: %s)", session.State) + // Accept CREATED (normal) or WAITING_FOR_INPUT (after on_turn_start step transition). + // When the user sends the first message to a prepared session, on_turn_start may fire + // and move the step, which sets the session to WAITING_FOR_INPUT before we get here. + if session.State != models.TaskSessionStateCreated && session.State != models.TaskSessionStateWaitingForInput { + return nil, fmt.Errorf("session is not in CREATED or WAITING_FOR_INPUT state (current: %s)", session.State) } // Use agent profile from request, fall back to session's stored value @@ -234,6 +264,25 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag effectivePrompt = task.Description } + // Process on_turn_start before launching the agent, just like user-initiated messages. + // This allows workflow transitions (e.g. move_to_next) to fire on the initial prompt. + s.processOnTurnStartViaEngine(ctx, taskID, session) + + // Re-read the session after on_turn_start may have changed the workflow step. + session, err = s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to reload session after on_turn_start: %w", err) + } + + // Apply workflow step prompt wrapping and plan mode injection. + // Called unconditionally so workflow-step prompt composition (prefix/suffix) + // applies even when plan mode is not requested. + stepID := "" + if session.WorkflowStepID != nil { + stepID = *session.WorkflowStepID + } + effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, taskID, stepID, planMode) + executorID := session.ExecutorID execution, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, executor.LaunchOptions{AgentProfileID: effectiveProfileID, ExecutorID: executorID, Prompt: effectivePrompt, StartAgent: true}) @@ -241,17 +290,31 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag return nil, err } - if execution.SessionID != "" { - if skipMessageRecord { - s.updateTaskSessionState(ctx, taskID, execution.SessionID, models.TaskSessionStateRunning, "", true) - } else { - s.recordInitialMessage(ctx, taskID, execution.SessionID, effectivePrompt, false) - } - } + // Record the initial user message and set plan mode metadata after launch. + // Note: we do NOT set session state here — the executor sets it to STARTING, + // and event handlers (handleAgentReady) transition it to WAITING_FOR_INPUT. + s.postLaunchCreated(ctx, taskID, sessionID, effectivePrompt, skipMessageRecord, planModeActive) return execution, nil } +// postLaunchCreated handles post-launch bookkeeping for a created session: +// records the initial user message (unless skipped) and sets plan mode metadata. +// It does NOT modify session state — the executor sets STARTING, and event handlers +// (handleAgentReady) handle the transition to WAITING_FOR_INPUT. +func (s *Service) postLaunchCreated(ctx context.Context, taskID, sessionID, prompt string, skipMessage, planModeActive bool) { + if !skipMessage { + s.recordInitialMessage(ctx, taskID, sessionID, prompt, planModeActive) + } + + if planModeActive { + sess, err := s.repo.GetTaskSession(ctx, sessionID) + if err == nil { + s.setSessionPlanMode(ctx, sess, true) + } + } +} + // StartTask manually starts agent execution for a task. // If workflowStepID is provided and workflowStepGetter is set, the prompt will be built // using the step's prompt_prefix + base prompt + prompt_suffix, and plan mode will be @@ -289,16 +352,7 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s _ = s.eventBus.Publish(ctx, events.TaskUpdated, bus.NewEvent( events.TaskUpdated, "orchestrator", - map[string]interface{}{ - "task_id": dbTask.ID, - "workflow_id": dbTask.WorkflowID, - "workflow_step_id": dbTask.WorkflowStepID, - "title": dbTask.Title, - "description": dbTask.Description, - "state": string(dbTask.State), - "priority": dbTask.Priority, - "position": dbTask.Position, - }, + buildTaskEventPayload(dbTask), )) } } @@ -333,6 +387,16 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s if execution.SessionID != "" { s.recordInitialMessage(ctx, taskID, execution.SessionID, effectivePrompt, planModeActive) + + // Set plan mode in session metadata so the frontend can detect it. + // applyWorkflowAndPlanMode only injects plan mode into the prompt text; + // the session metadata is needed for the frontend to switch the layout. + if planModeActive { + session, err := s.repo.GetTaskSession(ctx, execution.SessionID) + if err == nil { + s.setSessionPlanMode(ctx, session, true) + } + } } if execution.WorktreeBranch != "" { go s.ensureSessionPRWatch(context.Background(), taskID, execution.SessionID, execution.WorktreeBranch) @@ -375,7 +439,6 @@ func (s *Service) applyWorkflowAndPlanMode(ctx context.Context, prompt string, t // recordInitialMessage creates the initial user message and updates session state after launch. func (s *Service) recordInitialMessage(ctx context.Context, taskID, sessionID, prompt string, planModeActive bool) { - s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateRunning, "", true) if s.messageCreator != nil && prompt != "" { meta := NewUserMessageMeta().WithPlanMode(planModeActive) if err := s.messageCreator.CreateUserMessage(ctx, taskID, prompt, sessionID, s.getActiveTurnID(sessionID), meta.ToMap()); err != nil { @@ -953,18 +1016,27 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom return nil, ErrSessionResetInProgress } - // Check if session is already processing a prompt (RUNNING state) - // This prevents concurrent prompts that can cause race conditions + // Only allow prompts when the session is ready for input. + // Reject when the agent is still starting, already processing, or in a terminal state. session, err := s.repo.GetTaskSession(ctx, sessionID) if err != nil { return nil, fmt.Errorf("failed to get session: %w", err) } - if session.State == models.TaskSessionStateRunning { + switch session.State { + case models.TaskSessionStateWaitingForInput, models.TaskSessionStateCompleted: + // OK — session is ready for a new prompt + case models.TaskSessionStateRunning: s.logger.Warn("rejected prompt while agent is already running", zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.String("session_state", string(session.State))) return nil, fmt.Errorf("%w, please wait for completion", ErrAgentPromptInProgress) + default: + s.logger.Warn("rejected prompt: session not ready for input", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("session_state", string(session.State))) + return nil, fmt.Errorf("%w, session is in %s state", ErrAgentPromptInProgress, session.State) } // Ensure the agent process is actually running. After a lazy backend restart, diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index f402ecb3a8..bfbe9c662e 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -10,6 +10,7 @@ import ( sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/orchestrator/dto" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/queue" @@ -142,7 +143,7 @@ func TestStartCreatedSession_WrongTask(t *testing.T) { // Session belongs to "task-other", not "task1" seedTaskAndSession(t, repo, "task-other", "session1", models.TaskSessionStateCreated) - _, err := svc.StartCreatedSession(context.Background(), "task1", "session1", "profile1", "prompt", false) + _, err := svc.StartCreatedSession(context.Background(), "task1", "session1", "profile1", "prompt", false, false) if err == nil { t.Fatal("expected error when session does not belong to task") } @@ -154,12 +155,176 @@ func TestStartCreatedSession_NotInCreatedState(t *testing.T) { seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateRunning) - _, err := svc.StartCreatedSession(context.Background(), "task1", "session1", "profile1", "prompt", false) + _, err := svc.StartCreatedSession(context.Background(), "task1", "session1", "profile1", "prompt", false, false) if err == nil { t.Fatal("expected error when session is not in CREATED state") } } +// --- recordInitialMessage --- + +// mockMessageCreator implements MessageCreator for testing. +// Only CreateUserMessage is tracked; all other methods are no-op stubs. +type mockMessageCreator struct { + userMessages []mockUserMessage +} + +type mockUserMessage struct { + taskID, content, sessionID, turnID string + metadata map[string]interface{} +} + +func (m *mockMessageCreator) CreateUserMessage(_ context.Context, taskID, content, sessionID, turnID string, metadata map[string]interface{}) error { + m.userMessages = append(m.userMessages, mockUserMessage{taskID, content, sessionID, turnID, metadata}) + return nil +} + +func (m *mockMessageCreator) CreateAgentMessage(context.Context, string, string, string, string) error { + return nil +} + +func (m *mockMessageCreator) CreateToolCallMessage(context.Context, string, string, string, string, string, string, string, *streams.NormalizedPayload) error { + return nil +} + +func (m *mockMessageCreator) UpdateToolCallMessage(context.Context, string, string, string, string, string, string, string, string, string, *streams.NormalizedPayload) error { + return nil +} + +func (m *mockMessageCreator) CreateSessionMessage(context.Context, string, string, string, string, string, map[string]interface{}, bool) error { + return nil +} + +func (m *mockMessageCreator) CreatePermissionRequestMessage(context.Context, string, string, string, string, string, string, []map[string]interface{}, string, map[string]interface{}) (string, error) { + return "", nil +} + +func (m *mockMessageCreator) UpdatePermissionMessage(context.Context, string, string, string) error { + return nil +} + +func (m *mockMessageCreator) CreateAgentMessageStreaming(context.Context, string, string, string, string, string) error { + return nil +} + +func (m *mockMessageCreator) AppendAgentMessage(context.Context, string, string) error { + return nil +} + +func (m *mockMessageCreator) CreateThinkingMessageStreaming(context.Context, string, string, string, string, string) error { + return nil +} + +func (m *mockMessageCreator) AppendThinkingMessage(context.Context, string, string) error { + return nil +} + +func TestRecordInitialMessage_DoesNotChangeSessionState(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateStarting) + + mc := &mockMessageCreator{} + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.messageCreator = mc + + svc.recordInitialMessage(ctx, "task1", "session1", "hello world", false) + + // Session state must remain STARTING — recordInitialMessage should not modify state. + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("failed to load session: %v", err) + } + if session.State != models.TaskSessionStateStarting { + t.Fatalf("expected session state %q, got %q", models.TaskSessionStateStarting, session.State) + } + + // User message should have been created. + if len(mc.userMessages) != 1 { + t.Fatalf("expected 1 user message, got %d", len(mc.userMessages)) + } + if mc.userMessages[0].content != "hello world" { + t.Fatalf("expected message content %q, got %q", "hello world", mc.userMessages[0].content) + } +} + +func TestPostLaunchCreated_SkipMessage_DoesNotChangeSessionState(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateStarting) + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + + svc.postLaunchCreated(ctx, "task1", "session1", "prompt", true, false) + + // Session state must remain STARTING when skipMessage=true. + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("failed to load session: %v", err) + } + if session.State != models.TaskSessionStateStarting { + t.Fatalf("expected session state %q, got %q", models.TaskSessionStateStarting, session.State) + } +} + +func TestPostLaunchCreated_WithMessage_DoesNotChangeSessionState(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateStarting) + + mc := &mockMessageCreator{} + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.messageCreator = mc + + svc.postLaunchCreated(ctx, "task1", "session1", "hello", false, false) + + // Session state must remain STARTING — postLaunchCreated delegates to + // recordInitialMessage which only creates the message. + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("failed to load session: %v", err) + } + if session.State != models.TaskSessionStateStarting { + t.Fatalf("expected session state %q, got %q", models.TaskSessionStateStarting, session.State) + } + + if len(mc.userMessages) != 1 { + t.Fatalf("expected 1 user message, got %d", len(mc.userMessages)) + } +} + +func TestPostLaunchCreated_PlanMode_SetsMetadata(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateStarting) + + mc := &mockMessageCreator{} + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.messageCreator = mc + + svc.postLaunchCreated(ctx, "task1", "session1", "plan this", false, true) + + // User message should have plan_mode metadata. + if len(mc.userMessages) != 1 { + t.Fatalf("expected 1 user message, got %d", len(mc.userMessages)) + } + if mc.userMessages[0].metadata["plan_mode"] != true { + t.Fatalf("expected plan_mode=true in metadata, got %v", mc.userMessages[0].metadata) + } + + // Session metadata should contain plan_mode. + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("failed to load session: %v", err) + } + if session.Metadata == nil { + t.Fatal("expected session metadata to be set") + } + if session.Metadata["plan_mode"] != true { + t.Fatalf("expected plan_mode=true in session metadata, got %v", session.Metadata["plan_mode"]) + } +} + // --- ResumeTaskSession --- func TestResumeTaskSession_WrongTask(t *testing.T) { diff --git a/apps/backend/internal/orchestrator/workflow_store.go b/apps/backend/internal/orchestrator/workflow_store.go index 11146ee63b..ee00c1ddc6 100644 --- a/apps/backend/internal/orchestrator/workflow_store.go +++ b/apps/backend/internal/orchestrator/workflow_store.go @@ -104,20 +104,10 @@ func (s *workflowStore) ApplyTransition(ctx context.Context, taskID, sessionID, } if s.eventBus != nil { - taskEventData := map[string]interface{}{ - "task_id": task.ID, - "workflow_id": task.WorkflowID, - "workflow_step_id": task.WorkflowStepID, - "title": task.Title, - "description": task.Description, - "state": string(task.State), - "priority": task.Priority, - "position": task.Position, - } _ = s.eventBus.Publish(ctx, events.TaskUpdated, bus.NewEvent( events.TaskUpdated, "orchestrator", - taskEventData, + buildTaskEventPayload(task), )) } diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index 874aabb144..77e9e282ad 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -26,7 +26,7 @@ import ( type OrchestratorService interface { PromptTask(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment) (*orchestrator.PromptResult, error) ResumeTaskSession(ctx context.Context, taskID, taskSessionID string) error - StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord bool) error + StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode bool) error ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error } @@ -310,15 +310,15 @@ func (h *MessageHandlers) ensureTaskInProgress(ctx context.Context, taskID strin // agent as a prompt in a background goroutine. func (h *MessageHandlers) dispatchPromptAsync(ctx context.Context, req wsAddMessageRequest, agentProfileID string, isCreatedSession bool) { // Process on_turn_start events synchronously BEFORE sending the prompt. - // This transitions the task to the right step (e.g., Review → In Progress) - // before the agent receives the message. - if !isCreatedSession { - if err := h.orchestrator.ProcessOnTurnStart(ctx, req.TaskID, req.TaskSessionID); err != nil { - h.logger.Warn("failed to process on_turn_start", - zap.String("task_id", req.TaskID), - zap.String("session_id", req.TaskSessionID), - zap.Error(err)) - } + // This transitions the task to the right step (e.g., Todo → In Progress + // or Review → In Progress) before the agent receives the message. + // This applies to all sessions, including CREATED sessions from plan mode + // where the user sends the first message to start the agent. + if err := h.orchestrator.ProcessOnTurnStart(ctx, req.TaskID, req.TaskSessionID); err != nil { + h.logger.Warn("failed to process on_turn_start", + zap.String("task_id", req.TaskID), + zap.String("session_id", req.TaskSessionID), + zap.Error(err)) } taskID := req.TaskID sessionID := req.TaskSessionID @@ -344,7 +344,7 @@ func (h *MessageHandlers) forwardMessageAsPrompt( ) { // For CREATED sessions, start the agent with this message as the initial prompt if startCreated { - if err := h.orchestrator.StartCreatedSession(ctx, taskID, sessionID, agentProfileID, content, true); err != nil { + if err := h.orchestrator.StartCreatedSession(ctx, taskID, sessionID, agentProfileID, content, true, planMode); err != nil { h.logger.Warn("failed to start created session from message", zap.String("task_id", taskID), zap.String("session_id", sessionID), diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index f1bec9b38f..a7ce19972a 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -350,6 +350,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { Repositories: convertToServiceRepos(repos), Position: body.Position, Metadata: body.Metadata, + PlanMode: body.PlanMode && !body.StartAgent, }) if err != nil { handleNotFound(c, h.logger, err, "task not created") @@ -456,7 +457,7 @@ func (h *TaskHandlers) startAgentForNewTask( SessionID: sessionID, AgentProfileID: body.AgentProfileID, Prompt: description, - SkipMessageRecord: true, + SkipMessageRecord: false, PlanMode: body.PlanMode, }) if err != nil { diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index a30dd96e7f..6bf55bfa79 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -138,6 +138,7 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M Repositories: convertToServiceRepos(repos), Position: req.Position, Metadata: req.Metadata, + PlanMode: req.PlanMode && !req.StartAgent, }) if err != nil { h.logger.Error("failed to create task", zap.Error(err)) diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index ba62eefaf4..a6810cbd4f 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -96,6 +96,17 @@ func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error { return nil } +// DeleteTasksByWorkspace deletes all tasks for a workspace (E2E cleanup). +// Relies on CASCADE foreign keys to remove sessions, messages, turns, etc. +func (r *Repository) DeleteTasksByWorkspace(ctx context.Context, workspaceID string) (int64, error) { + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM tasks WHERE workspace_id = ?`), workspaceID) + if err != nil { + return 0, err + } + rows, _ := result.RowsAffected() + return rows, nil +} + // DeleteTask deletes a task by ID func (r *Repository) DeleteTask(ctx context.Context, id string) error { result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM tasks WHERE id = ?`), id) diff --git a/apps/backend/internal/task/repository/sqlite/workflow.go b/apps/backend/internal/task/repository/sqlite/workflow.go index 3b113ff2d1..58edb6ad9c 100644 --- a/apps/backend/internal/task/repository/sqlite/workflow.go +++ b/apps/backend/internal/task/repository/sqlite/workflow.go @@ -7,6 +7,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/task/models" ) @@ -87,6 +88,30 @@ func (r *Repository) UpdateWorkflow(ctx context.Context, workflow *models.Workfl return nil } +// DeleteWorkflowsByWorkspace deletes all workflows for a workspace except the excluded IDs (E2E cleanup). +// Relies on CASCADE foreign keys to remove workflow_steps. +func (r *Repository) DeleteWorkflowsByWorkspace(ctx context.Context, workspaceID string, excludeIDs []string) (int64, error) { + if len(excludeIDs) == 0 { + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM workflows WHERE workspace_id = ?`), workspaceID) + if err != nil { + return 0, err + } + rows, _ := result.RowsAffected() + return rows, nil + } + + query, args, err := sqlx.In(`DELETE FROM workflows WHERE workspace_id = ? AND id NOT IN (?)`, workspaceID, excludeIDs) + if err != nil { + return 0, err + } + result, err := r.db.ExecContext(ctx, r.db.Rebind(query), args...) + if err != nil { + return 0, err + } + rows, _ := result.RowsAffected() + return rows, nil +} + // DeleteWorkflow deletes a workflow by ID func (r *Repository) DeleteWorkflow(ctx context.Context, id string) error { result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM workflows WHERE id = ?`), id) diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 1be6575824..d5b5cc9c56 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -58,6 +58,7 @@ type WorkflowStepGetter interface { // StartStepResolver resolves the starting step for a workflow. type StartStepResolver interface { ResolveStartStep(ctx context.Context, workflowID string) (string, error) + ResolveFirstStep(ctx context.Context, workflowID string) (string, error) } var ( diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 6c359b96d3..66b7f3bb0f 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -29,6 +29,7 @@ type CreateTaskRequest struct { Repositories []TaskRepositoryInput `json:"repositories,omitempty"` Position int `json:"position"` Metadata map[string]interface{} `json:"metadata,omitempty"` + PlanMode bool `json:"plan_mode,omitempty"` } // UpdateTaskRequest contains the data for updating a task diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index c9e76b53e5..36aeaaf492 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -28,7 +28,13 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*mode // Auto-resolve start step if not provided workflowStepID := req.WorkflowStepID if workflowStepID == "" && req.WorkflowID != "" && s.startStepResolver != nil { - resolvedID, err := s.startStepResolver.ResolveStartStep(ctx, req.WorkflowID) + var resolvedID string + var err error + if req.PlanMode { + resolvedID, err = s.startStepResolver.ResolveFirstStep(ctx, req.WorkflowID) + } else { + resolvedID, err = s.startStepResolver.ResolveStartStep(ctx, req.WorkflowID) + } if err != nil { s.logger.Warn("failed to resolve start step, using empty", zap.String("workflow_id", req.WorkflowID), diff --git a/apps/backend/internal/workflow/service/service.go b/apps/backend/internal/workflow/service/service.go index 2c7fbc1b40..8d4bf9012f 100644 --- a/apps/backend/internal/workflow/service/service.go +++ b/apps/backend/internal/workflow/service/service.go @@ -203,9 +203,8 @@ func (s *Service) CreateStepsFromTemplate(ctx context.Context, workflowID, templ } // ResolveStartStep resolves which step a task should start in for a workflow. -// Fallback chain: is_start_step=true → first step with auto_start_agent → first step by position. +// Fallback chain: is_start_step=true → first step by position. func (s *Service) ResolveStartStep(ctx context.Context, workflowID string) (*models.WorkflowStep, error) { - // Try explicit start step startStep, err := s.repo.GetStartStep(ctx, workflowID) if err != nil { s.logger.Error("failed to get start step", zap.String("workflow_id", workflowID), zap.Error(err)) @@ -215,23 +214,20 @@ func (s *Service) ResolveStartStep(ctx context.Context, workflowID string) (*mod return startStep, nil } - // Fallback: first step with auto_start_agent + // Fallback: first step by position + return s.ResolveFirstStep(ctx, workflowID) +} + +// ResolveFirstStep always returns the first step by position, ignoring is_start_step. +func (s *Service) ResolveFirstStep(ctx context.Context, workflowID string) (*models.WorkflowStep, error) { steps, err := s.repo.ListStepsByWorkflow(ctx, workflowID) if err != nil { - s.logger.Error("failed to list steps for start step resolution", zap.String("workflow_id", workflowID), zap.Error(err)) + s.logger.Error("failed to list steps for first step resolution", zap.String("workflow_id", workflowID), zap.Error(err)) return nil, err } if len(steps) == 0 { return nil, fmt.Errorf("workflow %s has no steps", workflowID) } - - for _, step := range steps { - if step.HasOnEnterAction(models.OnEnterAutoStartAgent) { - return step, nil - } - } - - // Fallback: first step by position return steps[0], nil } diff --git a/apps/backend/internal/workflow/service/service_test.go b/apps/backend/internal/workflow/service/service_test.go index 8e48fe6aa9..5fffad897c 100644 --- a/apps/backend/internal/workflow/service/service_test.go +++ b/apps/backend/internal/workflow/service/service_test.go @@ -257,7 +257,7 @@ func TestResolveStartStep(t *testing.T) { assert.True(t, start.IsStartStep) }) - t.Run("fallback to first step with auto_start_agent", func(t *testing.T) { + t.Run("auto_start_agent does not affect fallback (uses position 0)", func(t *testing.T) { svc, db := setupTestService(t) ctx := context.Background() @@ -279,7 +279,8 @@ func TestResolveStartStep(t *testing.T) { start, err := svc.ResolveStartStep(ctx, "wf-1") require.NoError(t, err) assert.NotNil(t, start) - assert.Equal(t, "Auto Start", start.Name) + assert.Equal(t, "Todo", start.Name) + assert.Equal(t, 0, start.Position) }) t.Run("fallback to first step by position", func(t *testing.T) { @@ -312,6 +313,37 @@ func TestResolveStartStep(t *testing.T) { }) } +func TestResolveFirstStep(t *testing.T) { + t.Run("returns first step by position ignoring is_start_step", func(t *testing.T) { + svc, db := setupTestService(t) + ctx := context.Background() + + insertWorkflow(t, db, "wf-1", "Test Workflow") + + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Start Here", Position: 1, IsStartStep: true}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 2}) + + first, err := svc.ResolveFirstStep(ctx, "wf-1") + require.NoError(t, err) + assert.NotNil(t, first) + assert.Equal(t, "Todo", first.Name) + assert.Equal(t, 0, first.Position) + }) + + t.Run("empty workflow returns error", func(t *testing.T) { + svc, db := setupTestService(t) + ctx := context.Background() + + insertWorkflow(t, db, "wf-empty", "Empty Workflow") + + first, err := svc.ResolveFirstStep(ctx, "wf-empty") + assert.Error(t, err) + assert.Nil(t, first) + assert.Contains(t, err.Error(), "has no steps") + }) +} + func TestCreateStepsFromTemplate_RemapsStepIDs(t *testing.T) { svc, db := setupTestService(t) ctx := context.Background() diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 8cc471133a..b22e42d24c 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -394,7 +394,7 @@ importers: version: 0.55.1 next: specifier: 16.1.5 - version: 16.1.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.5(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -459,6 +459,9 @@ importers: specifier: ^5.0.8 version: 5.0.10(@types/react@19.2.8)(immer@10.2.0)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) devDependencies: + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 '@tailwindcss/postcss': specifier: ^4 version: 4.1.18 @@ -1265,89 +1268,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1544,24 +1563,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.5': resolution: {integrity: sha512-U+kBxGUY1xMAzDTXmuVMfhaWUZQAwzRaHJ/I6ihtR5SbTVUEaDRiEU9YMjy1obBWpdOBuk1bcm+tsmifYSygfw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.5': resolution: {integrity: sha512-gq2UtoCpN7Ke/7tKaU7i/1L7eFLfhMbXjNghSv0MVGF1dmuoaPeEVDvkDuO/9LVa44h5gqpWeJ4mRRznjDv7LA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.5': resolution: {integrity: sha512-bQWSE729PbXT6mMklWLf8dotislPle2L70E9q6iwETYEOt092GDn0c+TTNj26AjmeceSsC4ndyGsK5nKqHYXjQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.5': resolution: {integrity: sha512-LZli0anutkIllMtTAWZlDqdfvjWX/ch8AFK5WgkNTvaqwlouiD1oHM+WW8RXMiL0+vAkAJyAGEzPPjO+hnrSNQ==} @@ -1618,6 +1641,11 @@ packages: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2393,66 +2421,79 @@ packages: resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.55.1': resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.55.1': resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.55.1': resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.55.1': resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.55.1': resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.55.1': resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.55.1': resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.55.1': resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.55.1': resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.55.1': resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.55.1': resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.55.1': resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.55.1': resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} @@ -2570,24 +2611,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -3214,41 +3259,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -4389,6 +4442,11 @@ packages: resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} engines: {node: '>=14.14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4999,24 +5057,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -5604,6 +5666,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -7882,6 +7954,10 @@ snapshots: react-dom: 19.2.3(react@19.2.3) shiki: 3.22.0 + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -10535,7 +10611,7 @@ snapshots: eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -10568,7 +10644,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -10583,7 +10659,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10940,6 +11016,9 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -12089,7 +12168,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - next@16.1.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.5(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.5 '@swc/helpers': 0.5.15 @@ -12108,6 +12187,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.1.5 '@next/swc-win32-arm64-msvc': 16.1.5 '@next/swc-win32-x64-msvc': 16.1.5 + '@playwright/test': 1.58.2 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -12323,6 +12403,14 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + points-on-curve@0.2.0: {} points-on-path@0.2.1: diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 5ef6a52078..38e2c5dbf8 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -12,6 +12,8 @@ # testing /coverage +test-results/ +playwright-report/ # next.js /.next/ diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index ffec6b4dfe..67082a6ee2 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -253,7 +253,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} })); return ( -
+
)}
- @@ -193,7 +198,7 @@ function DesktopHeader({
)}
- diff --git a/apps/web/components/settings/workflow-card-actions.ts b/apps/web/components/settings/workflow-card-actions.ts index b894e1fc20..238d350897 100644 --- a/apps/web/components/settings/workflow-card-actions.ts +++ b/apps/web/components/settings/workflow-card-actions.ts @@ -397,21 +397,28 @@ export function useWorkflowSaveActions({ const saveWorkflowRequest = useRequest(onSaveWorkflow); const saveNewWorkflowRequest = useRequest(async () => { + const templateId = workflow.workflow_template_id; const created = await createWorkflowAction({ workspace_id: workflow.workspace_id, name: workflow.name.trim() || "New Workflow", + workflow_template_id: templateId || undefined, }); - for (const step of workflowSteps) { - await createWorkflowStepAction({ - workflow_id: created.id, - name: step.name, - position: step.position, - color: step.color, - prompt: step.prompt, - events: step.events, - is_start_step: step.is_start_step, - allow_manual_move: step.allow_manual_move, - }); + // When a template is used, the backend creates steps with properly + // remapped step_id references (template aliases → real UUIDs). + // Only create steps manually when there's no template. + if (!templateId) { + for (const step of workflowSteps) { + await createWorkflowStepAction({ + workflow_id: created.id, + name: step.name, + position: step.position, + color: step.color, + prompt: step.prompt, + events: step.events, + is_start_step: step.is_start_step, + allow_manual_move: step.allow_manual_move, + }); + } } onWorkflowCreated?.(created); }); diff --git a/apps/web/components/task-create-dialog-footer.tsx b/apps/web/components/task-create-dialog-footer.tsx index 55ea54305d..193fd8b9d7 100644 --- a/apps/web/components/task-create-dialog-footer.tsx +++ b/apps/web/components/task-create-dialog-footer.tsx @@ -62,6 +62,7 @@ function StartTaskSplitButton({ variant="default" className="rounded-none border-0 cursor-pointer gap-1.5 h-full" disabled={disabled} + data-testid="submit-start-agent" > {isCreatingTask ? ( @@ -77,6 +78,7 @@ function StartTaskSplitButton({ variant="default" className="rounded-none border-0 border-l border-primary-foreground/20 px-2 cursor-pointer h-full" disabled={disabled} + data-testid="submit-start-agent-chevron" > @@ -86,6 +88,7 @@ function StartTaskSplitButton({ Start task in plan mode @@ -217,8 +220,7 @@ function computeFooterState(props: TaskCreateDialogFooterProps) { const splitDisabled = isCreatingTask || !hasTitle || - !hasRepositorySelection || - !branch || + (hasRepositorySelection && !branch) || !agentProfileId || missingCtx; const defaultDisabled = isSessionMode diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index c2f5dac687..2c421eecf7 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -123,7 +123,6 @@ export function validateCreateInputs(inputs: { inputs.trimmedTitle && inputs.workspaceId && inputs.effectiveWorkflowId && - (inputs.repositoryId || inputs.selectedLocalRepo) && inputs.agentProfileId, ); } diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index fff3b46499..ebff394a4e 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -215,6 +215,7 @@ export const InlineTaskName = memo(function InlineTaskName({ value={value} onChange={(e) => onChange(e.target.value)} placeholder="task-name" + data-testid="task-title-input" size={Math.max(value.length, 9)} className="bg-transparent border-none outline-none focus:ring-0 text-sm font-medium min-w-0 rounded-md px-1.5 py-0.5 -mx-1.5 hover:bg-muted focus:bg-muted transition-colors" /> @@ -295,6 +296,7 @@ export const TaskFormInputs = memo(function TaskFormInputs({ value={description} onChange={handleDescriptionChange} onKeyDown={onKeyDown} + data-testid="task-description-input" rows={2} className={ isSessionMode diff --git a/apps/web/components/task-create-dialog-submit.tsx b/apps/web/components/task-create-dialog-submit.tsx index 78f1a5dbd3..827ebbf6e1 100644 --- a/apps/web/components/task-create-dialog-submit.tsx +++ b/apps/web/components/task-create-dialog-submit.tsx @@ -343,6 +343,7 @@ export function useTaskSubmitHandlers({ executorId, executorProfileId, withAgent: false, + planMode: true, }), ); const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null; diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 098ce84f61..0e8cc213c5 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -390,7 +390,10 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) { const { handleCreateWithPlanMode, handleCancel } = setup.submitHandlers; return ( - + +
{message}
diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts index bf59c9281d..1e30285246 100644 --- a/apps/web/components/task/chat/use-chat-panel-state.ts +++ b/apps/web/components/task/chat/use-chat-panel-state.ts @@ -24,6 +24,8 @@ import { buildContextItems } from "../chat-context-items"; import type { ContextItem } from "@/lib/types/context"; import type { DiffComment } from "@/lib/diff/types"; import type { PlanComment, PRFeedbackComment } from "@/lib/state/slices/comments"; +import type { ActiveDocument } from "@/lib/state/slices/ui/types"; +import type { BuiltInPreset } from "@/lib/state/layout-manager/presets"; const EMPTY_CONTEXT_FILES: ContextFile[] = []; const PLAN_CONTEXT_PATH = "plan:context"; @@ -40,6 +42,63 @@ export type CommentsState = { clearSessionPlanComments: () => void; }; +/** Re-focus chat input after dockview layout rebuild. */ +function useRefocusChatAfterLayout() { + return useCallback(() => { + const unsub = useDockviewStore.subscribe((state) => { + if (!state.isRestoringLayout) { + unsub(); + requestAnimationFrame(() => { + document.querySelector(".tiptap.ProseMirror")?.focus(); + }); + } + }); + }, []); +} + +type AutoApplyPlanLayoutOpts = { + resolvedSessionId: string | null; + taskId: string | null; + sessionMetaPlanMode: boolean; + setActiveDocument: (sid: string, doc: ActiveDocument | null) => void; + applyBuiltInPreset: (preset: BuiltInPreset) => void; + setPlanMode: (sid: string, enabled: boolean) => void; + addContextFile: (sid: string, file: { path: string; name: string }) => void; +}; + +/** Auto-apply plan layout when session metadata has plan_mode enabled. */ +function useAutoApplyPlanLayout(opts: AutoApplyPlanLayoutOpts) { + const { + resolvedSessionId, + taskId, + sessionMetaPlanMode, + setActiveDocument, + applyBuiltInPreset, + setPlanMode, + addContextFile, + } = opts; + const autoAppliedPlanSessionRef = useRef(null); + useEffect(() => { + if (!resolvedSessionId || !taskId) return; + if (autoAppliedPlanSessionRef.current === resolvedSessionId) return; + if (sessionMetaPlanMode) { + autoAppliedPlanSessionRef.current = resolvedSessionId; + setActiveDocument(resolvedSessionId, { type: "plan", taskId }); + applyBuiltInPreset("plan"); + setPlanMode(resolvedSessionId, true); + addContextFile(resolvedSessionId, { path: PLAN_CONTEXT_PATH, name: "Plan" }); + } + }, [ + resolvedSessionId, + taskId, + sessionMetaPlanMode, + setActiveDocument, + applyBuiltInPreset, + setPlanMode, + addContextFile, + ]); +} + export function usePlanMode(resolvedSessionId: string | null, taskId: string | null) { const activeDocument = useAppStore((state) => resolvedSessionId @@ -57,6 +116,12 @@ export function usePlanMode(resolvedSessionId: string | null, taskId: string | n resolvedSessionId ? (state.chatInput.planModeBySessionId[resolvedSessionId] ?? false) : false, ); + const sessionMetaPlanMode = useAppStore((state) => + resolvedSessionId + ? state.taskSessions.items[resolvedSessionId]?.metadata?.plan_mode === true + : false, + ); + const planModeEnabled = planModeFromStore; const planLayoutVisible = activeDocument?.type === "plan"; @@ -69,18 +134,17 @@ export function usePlanMode(resolvedSessionId: string | null, taskId: string | n } }, [resolvedSessionId, setPlanMode, addContextFile]); - // Re-focus chat input after dockview layout rebuild. - // Scroll preservation is handled by the store layout actions (before DOM rebuild). - const refocusChatAfterLayout = useCallback(() => { - const unsub = useDockviewStore.subscribe((state) => { - if (!state.isRestoringLayout) { - unsub(); - requestAnimationFrame(() => { - document.querySelector(".tiptap.ProseMirror")?.focus(); - }); - } - }); - }, []); + useAutoApplyPlanLayout({ + resolvedSessionId, + taskId, + sessionMetaPlanMode, + setActiveDocument, + applyBuiltInPreset, + setPlanMode, + addContextFile, + }); + + const refocusChatAfterLayout = useRefocusChatAfterLayout(); // Toggle only the plan layout (plan panel + preset) without changing chat input plan mode state const togglePlanLayout = useCallback( diff --git a/apps/web/components/task/chat/use-tiptap-editor.ts b/apps/web/components/task/chat/use-tiptap-editor.ts index 75d18ab1fd..982745508c 100644 --- a/apps/web/components/task/chat/use-tiptap-editor.ts +++ b/apps/web/components/task/chat/use-tiptap-editor.ts @@ -7,11 +7,13 @@ import Paragraph from "@tiptap/extension-paragraph"; import Text from "@tiptap/extension-text"; import HardBreak from "@tiptap/extension-hard-break"; import History from "@tiptap/extension-history"; -import Placeholder from "@tiptap/extension-placeholder"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { Extension, isNodeEmpty } from "@tiptap/core"; import Code from "@tiptap/extension-code"; import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import { common, createLowlight } from "lowlight"; -import { Extension } from "@tiptap/core"; import { cn } from "@/lib/utils"; import { getChatDraftContent, setChatDraftContent } from "@/lib/local-storage"; import { getMarkdownText, textToHtml, handleEditorPaste } from "./tiptap-helpers"; @@ -33,6 +35,65 @@ export type TipTapInputHandle = { const lowlightInstance = createLowlight(common); +/** + * Custom Placeholder extension that reads the current placeholder from + * editor.storage instead of from the captured options object. TipTap's + * extension.options is a getter returning a fresh object each call, so the + * built-in Placeholder plugin's closure captures a stale options snapshot. + * This wrapper uses editor.storage.dynamicPlaceholder.text as the live source. + */ +const DynamicPlaceholder = Extension.create({ + name: "dynamicPlaceholder", + + addStorage() { + return { text: "" }; + }, + + addProseMirrorPlugins() { + const editor = this.editor; + return [ + new Plugin({ + key: new PluginKey("dynamicPlaceholder"), + props: { + decorations: ({ doc, selection }) => { + if (!editor.isEditable) return null; + const { anchor } = selection; + const decorations: InstanceType[] = []; + const isEmptyDoc = editor.isEmpty; + doc.descendants((node: ProseMirrorNode, pos: number) => { + const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize; + const isEmpty = !node.isLeaf && isNodeEmpty(node); + if (hasAnchor && isEmpty) { + const classes = ["is-empty"]; + if (isEmptyDoc) classes.push("is-editor-empty"); + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + class: classes.join(" "), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "data-placeholder": (editor.storage as any).dynamicPlaceholder.text, + }), + ); + } + return false; + }); + return DecorationSet.create(doc, decorations); + }, + }, + }), + ]; + }, +}); + +/** Update the dynamic placeholder text and trigger a redecoration. Extracted to a + * standalone function so that the eslint react-hooks/immutability rule does not + * flag it as a mutation of the `editor` hook argument. */ +function updateDynamicPlaceholder(ed: ReturnType, text: string) { + if (!ed) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ed.storage as any).dynamicPlaceholder.text = text; + ed.view.dispatch(ed.state.tr); +} + type UseTipTapEditorOptions = { value: string; onChange: (value: string) => void; @@ -124,7 +185,7 @@ export function useTipTapEditor(opts: UseTipTapEditorOptions) { return ReactNodeViewRenderer(CodeBlockView); }, }).configure({ lowlight: lowlightInstance }), - Placeholder.configure({ placeholder }), + DynamicPlaceholder, ContextMention.configure({ suggestions: [mentionSuggestion, slashSuggestion] }), SubmitKeymap, ], @@ -203,15 +264,11 @@ function useSyncEditor({ if (editor) editor.setEditable(!disabled); }, [editor, disabled]); - // Sync placeholder + // Sync placeholder via editor.storage. The DynamicPlaceholder extension reads + // from editor.storage.dynamicPlaceholder.text at decoration time. useEffect(() => { if (!editor) return; - editor.extensionManager.extensions.forEach((ext) => { - if (ext.name === "placeholder") { - ext.options.placeholder = placeholder; - editor.view.dispatch(editor.state.tr); - } - }); + updateDynamicPlaceholder(editor, placeholder); }, [editor, placeholder]); // Reset sync flag when session changes diff --git a/apps/web/components/task/files-panel.tsx b/apps/web/components/task/files-panel.tsx index b5e7b73988..404b217ebc 100644 --- a/apps/web/components/task/files-panel.tsx +++ b/apps/web/components/task/files-panel.tsx @@ -43,7 +43,7 @@ const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) { if (isArchived) return ; return ( - + {activeSessionId ? ( & { children: ReactNode; className?: string; }; /** Fills the dockview content slot. Use as the outermost element in every panel. */ -export function PanelRoot({ children, className }: PanelRootProps) { - return
{children}
; +export function PanelRoot({ children, className, ...rest }: PanelRootProps) { + return ( +
+ {children} +
+ ); } type PanelBodyProps = Omit, "className"> & { diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 14a0089541..fee2cb810f 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -109,7 +109,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ const handleClarificationResolved = useCallback(() => setClarificationKey((k) => k + 1), []); return ( - + + effectiveSessionId + ? (state.taskSessions.items[effectiveSessionId]?.workflow_step_id ?? null) + : null, + ); const previewOpen = useAppStore((state) => effectiveSessionId ? (state.previewPanel.openBySessionId[effectiveSessionId] ?? false) : false, ); @@ -141,6 +146,7 @@ function useSessionPanelState(effectiveSessionId: string | null | undefined) { return { storeSessionState, isSessionPassthrough, + sessionWorkflowStepId, previewOpen, previewStage, previewUrl, @@ -224,6 +230,17 @@ function resolveRemoteExecutor(status?: RemoteExecutorStatus | null) { }; } +/** Pick the best step ID. Both session-level (from session.state_changed WS event) and + * task-level (from task.updated WS event + kanban snapshot) carry the same value, but + * session.state_changed is delivered directly while task.updated goes through the hub's + * broadcast channel, so the session value arrives first and is less likely to be stale. */ +function resolveCurrentStepId( + sessionStepId: string | null, + taskStepId: string | null, +): string | null { + return sessionStepId || taskStepId || null; +} + function buildTaskTopBarProps(params: { taskProps: ReturnType; agent: ReturnType; @@ -233,6 +250,7 @@ function buildTaskTopBarProps(params: { onToggleDebugOverlay: () => void; effectiveSessionId: string | null; remote: ReturnType; + sessionWorkflowStepId: string | null; }) { const { taskProps, agent, merged, workflowSteps, showDebugOverlay, onToggleDebugOverlay } = params; @@ -252,7 +270,7 @@ function buildTaskTopBarProps(params: { showDebugOverlay, onToggleDebugOverlay, workflowSteps, - currentStepId: taskProps.workflowStepId, + currentStepId: resolveCurrentStepId(params.sessionWorkflowStepId, taskProps.workflowStepId), workflowId: taskProps.workflowId, isArchived: taskProps.isArchived, isRemoteExecutor: params.remote.isRemoteExecutor, @@ -368,6 +386,7 @@ function TaskPageInner({ onToggleDebugOverlay, effectiveSessionId, remote, + sessionWorkflowStepId: sessionPanel.sessionWorkflowStepId, }); const layoutProps = buildTaskLayoutProps({ taskProps, diff --git a/apps/web/components/task/task-plan-panel.tsx b/apps/web/components/task/task-plan-panel.tsx index 2d3a91de49..215696e3d2 100644 --- a/apps/web/components/task/task-plan-panel.tsx +++ b/apps/web/components/task/task-plan-panel.tsx @@ -159,7 +159,7 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ const { textSelection, setTextSelection } = selectionState; return ( - + + +
{icon && } {label} diff --git a/apps/web/components/task/terminal-panel.tsx b/apps/web/components/task/terminal-panel.tsx index 303dca9e36..541c80167e 100644 --- a/apps/web/components/task/terminal-panel.tsx +++ b/apps/web/components/task/terminal-panel.tsx @@ -36,7 +36,7 @@ export const TerminalPanel = memo(function TerminalPanel({ params }: TerminalPan if (type === "dev-server" && processId) { return ( - + @@ -45,7 +45,7 @@ export const TerminalPanel = memo(function TerminalPanel({ params }: TerminalPan } return ( - + +
{sortedSteps.map((step, index) => (
{ + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(url); + if (res.ok) return; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, HEALTH_POLL_MS)); + } + throw new Error(`Service did not become healthy at ${url} within ${timeoutMs}ms`); +} + +function killProcess(proc: ChildProcess): Promise { + return new Promise((resolve) => { + proc.kill("SIGTERM"); + const timeout = setTimeout(() => { + proc.kill("SIGKILL"); + resolve(); + }, 5_000); + proc.on("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); +} + +/** + * Worker-scoped fixture that spawns an isolated backend process and + * a dedicated Next.js frontend. Each Playwright worker gets its own + * backend on a unique port with an isolated HOME, database, and data + * directory, plus its own frontend with SSR routed to that backend. + */ +export const backendFixture = base.extend({ + backend: [ + async ({}, use, workerInfo) => { + const backendPort = BACKEND_BASE_PORT + workerInfo.workerIndex; + const frontendPort = FRONTEND_BASE_PORT + workerInfo.workerIndex; + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), `kandev-e2e-${workerInfo.workerIndex}-`), + ); + const dataDir = path.join(tmpDir, ".kandev"); + const dbPath = path.join(tmpDir, "kandev.db"); + const worktreeBase = path.join(tmpDir, "worktrees"); + const repoCloneBase = path.join(tmpDir, "repos"); + + fs.mkdirSync(dataDir, { recursive: true }); + fs.mkdirSync(worktreeBase, { recursive: true }); + fs.mkdirSync(repoCloneBase, { recursive: true }); + + // Write a minimal .gitconfig so git doesn't prompt for identity + // and disable signing to avoid SSH/GPG key lookups in the isolated HOME. + fs.writeFileSync( + path.join(tmpDir, ".gitconfig"), + "[user]\n name = E2E Test\n email = e2e@test.local\n[commit]\n gpgsign = false\n[tag]\n gpgsign = false\n", + ); + + // Give each worker its own agentctl port range, offset from the default + // range (10001-10100) to avoid conflicts with a running dev instance. + const agentctlPortBase = 30001 + workerInfo.workerIndex * 50; + const agentctlPortMax = agentctlPortBase + 49; + + const backendEnv = { + ...stripGitHubTokens(process.env as Record), + HOME: tmpDir, + KANDEV_DATA_DIR: dataDir, + KANDEV_SERVER_PORT: String(backendPort), + KANDEV_DATABASE_PATH: dbPath, + KANDEV_MOCK_AGENT: "true", + KANDEV_MOCK_GITHUB: "true", + KANDEV_DOCKER_ENABLED: "false", + KANDEV_WORKTREE_ENABLED: "false", + KANDEV_WORKTREE_BASEPATH: worktreeBase, + KANDEV_REPOCLONE_BASEPATH: repoCloneBase, + KANDEV_LOG_LEVEL: "warn", + AGENTCTL_INSTANCE_PORT_BASE: String(agentctlPortBase), + AGENTCTL_INSTANCE_PORT_MAX: String(agentctlPortMax), + GIT_AUTHOR_NAME: "E2E Test", + GIT_AUTHOR_EMAIL: "e2e@test.local", + GIT_COMMITTER_NAME: "E2E Test", + GIT_COMMITTER_EMAIL: "e2e@test.local", + }; + + const debug = !!process.env.E2E_DEBUG; + + // --- Spawn backend --- + const backendProc: ChildProcess = spawn(KANDEV_BIN, [], { + env: backendEnv as unknown as NodeJS.ProcessEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + + const logFile = debug ? fs.createWriteStream(`/tmp/e2e-backend-${backendPort}.log`) : null; + backendProc.stderr?.on("data", (chunk: Buffer) => { + if (debug) { + process.stderr.write(`[backend:${backendPort}] ${chunk.toString()}`); + logFile?.write(chunk); + } + }); + backendProc.stdout?.on("data", (chunk: Buffer) => { + if (debug) { + process.stderr.write(`[backend-log:${backendPort}] ${chunk.toString()}`); + logFile?.write(chunk); + } + }); + + const baseUrl = `http://localhost:${backendPort}`; + await waitForHealth(`${baseUrl}/health`, HEALTH_TIMEOUT_MS); + + // Ensure Next.js static assets are available to the standalone server. + // The CLI start command does this via symlink; replicate it here. + // Use try/catch to handle concurrent workers racing to create the same symlink. + if (fs.existsSync(SOURCE_STATIC_DIR) && !fs.existsSync(STANDALONE_STATIC_DIR)) { + fs.mkdirSync(path.dirname(STANDALONE_STATIC_DIR), { recursive: true }); + try { + fs.symlinkSync(SOURCE_STATIC_DIR, STANDALONE_STATIC_DIR, "junction"); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + } + } + + // --- Spawn frontend (Next.js standalone server) --- + const frontendProc: ChildProcess = spawn("node", [STANDALONE_SERVER], { + cwd: WEB_DIR, + env: { + ...(process.env as unknown as Record), + KANDEV_API_BASE_URL: baseUrl, + PORT: String(frontendPort), + HOSTNAME: "localhost", + NODE_ENV: "production", + } as unknown as NodeJS.ProcessEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + + frontendProc.stderr?.on("data", (chunk: Buffer) => { + if (debug) process.stderr.write(`[frontend:${frontendPort}] ${chunk.toString()}`); + }); + + const frontendUrl = `http://localhost:${frontendPort}`; + await waitForHealth(frontendUrl, HEALTH_TIMEOUT_MS); + + try { + await use({ port: backendPort, baseUrl, frontendPort, frontendUrl, tmpDir }); + } finally { + // Shutdown frontend first, then backend + await killProcess(frontendProc); + await killProcess(backendProc); + + // Cleanup temp directory — ignore errors (backend may still hold files briefly) + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Non-fatal: OS may not have released all file handles yet + } + } + }, + { scope: "worker", timeout: 60_000 }, + ], +}); + +/** Strip GH_TOKEN / GITHUB_TOKEN so the mock client is used. */ +function stripGitHubTokens(env: Record): Record { + const cleaned = { ...env }; + delete cleaned.GH_TOKEN; + delete cleaned.GITHUB_TOKEN; + return cleaned; +} diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts new file mode 100644 index 0000000000..4ceae43cda --- /dev/null +++ b/apps/web/e2e/fixtures/test-base.ts @@ -0,0 +1,104 @@ +import { type Page } from "@playwright/test"; +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { backendFixture, type BackendContext } from "./backend"; +import { ApiClient } from "../helpers/api-client"; +import type { WorkflowStep } from "../../lib/types/http"; + +export type SeedData = { + workspaceId: string; + workflowId: string; + startStepId: string; + steps: WorkflowStep[]; + repositoryId: string; +}; + +export const test = backendFixture.extend< + { testPage: Page }, + { apiClient: ApiClient; seedData: SeedData } +>({ + // Worker-scoped API client + apiClient: [ + async ({ backend }, use) => { + const client = new ApiClient(backend.baseUrl); + await use(client); + }, + { scope: "worker" }, + ], + + // Worker-scoped seed data: creates workspace, workflow (from template), discovers steps, + // and sets up a local git repository for agent execution workspace. + // The repo is created inside backend.tmpDir (the backend's HOME) so that + // discoveryRoots() allows branch listing (isPathAllowed check). + seedData: [ + async ({ apiClient, backend }, use) => { + const workspace = await apiClient.createWorkspace("E2E Workspace"); + const workflow = await apiClient.createWorkflow(workspace.id, "E2E Workflow", "simple"); + + const { steps } = await apiClient.listWorkflowSteps(workflow.id); + const sorted = steps.sort((a, b) => a.position - b.position); + const startStep = sorted.find((s) => s.is_start_step) ?? sorted[0]; + + // Create a minimal git repository inside backend.tmpDir (the backend's HOME). + // This ensures discoveryRoots() allows the path for branch listing. + const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); + fs.mkdirSync(repoDir, { recursive: true }); + const gitEnv = { + ...process.env, + HOME: backend.tmpDir, + GIT_AUTHOR_NAME: "E2E Test", + GIT_AUTHOR_EMAIL: "e2e@test.local", + GIT_COMMITTER_NAME: "E2E Test", + GIT_COMMITTER_EMAIL: "e2e@test.local", + }; + execSync("git init -b main", { cwd: repoDir, env: gitEnv }); + execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env: gitEnv }); + const repo = await apiClient.createRepository(workspace.id, repoDir); + + await use({ + workspaceId: workspace.id, + workflowId: workflow.id, + startStepId: startStep.id, + steps: sorted, + repositoryId: repo.id, + }); + }, + { scope: "worker" }, + ], + + // Per-test page with baseURL pointing to worker's frontend. + // Resets user settings to the E2E workspace/workflow before each test so that + // SSR always resolves to the correct workspace regardless of what commitSettings + // may have written during previous tests. + testPage: async ({ browser, backend, apiClient, seedData }, use) => { + // Clean up tasks and test-created workflows from previous tests in this worker. + // Keep the seeded workflow so the worker-scoped seedData fixture remains valid. + await apiClient.e2eReset(seedData.workspaceId, [seedData.workflowId]); + + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: seedData.workflowId, + }); + const context = await browser.newContext({ + baseURL: backend.frontendUrl, + }); + const page = await context.newPage(); + await setupPage(page, backend); + await use(page); + await context.close(); + }, +}); + +export { expect } from "@playwright/test"; + +async function setupPage(page: Page, backend: BackendContext): Promise { + await page.addInitScript( + ({ backendUrl }: { backendUrl: string }) => { + localStorage.setItem("kandev.onboarding.completed", "true"); + // Set the window global that getBackendConfig() reads for API/WS connections + window.__KANDEV_API_BASE_URL = backendUrl; + }, + { backendUrl: backend.baseUrl }, + ); +} diff --git a/apps/web/e2e/global-setup.ts b/apps/web/e2e/global-setup.ts new file mode 100644 index 0000000000..1c718fdaa2 --- /dev/null +++ b/apps/web/e2e/global-setup.ts @@ -0,0 +1,37 @@ +import fs from "node:fs"; +import path from "node:path"; + +const BACKEND_DIR = path.resolve(__dirname, "../../../apps/backend"); +const WEB_DIR = path.resolve(__dirname, ".."); + +export default function globalSetup() { + const kandevBin = path.join(BACKEND_DIR, "bin", "kandev"); + const mockAgentBin = path.join(BACKEND_DIR, "bin", "mock-agent"); + + for (const bin of [kandevBin, mockAgentBin]) { + if (!fs.existsSync(bin)) { + throw new Error(`Required binary not found: ${bin}\nRun "make build-backend" first.`); + } + } + + const standaloneServer = path.join(WEB_DIR, ".next/standalone/web/server.js"); + if (!fs.existsSync(standaloneServer)) { + throw new Error( + `Next.js standalone build not found: ${standaloneServer}\nRun "make build-web" first.`, + ); + } + + // Standalone builds don't include static assets or public dir. + // Symlink them so the standalone server can serve them. + const staticSource = path.join(WEB_DIR, ".next/static"); + const staticTarget = path.join(WEB_DIR, ".next/standalone/web/.next/static"); + if (fs.existsSync(staticSource) && !fs.existsSync(staticTarget)) { + fs.symlinkSync(staticSource, staticTarget, "dir"); + } + + const publicSource = path.join(WEB_DIR, "public"); + const publicTarget = path.join(WEB_DIR, ".next/standalone/web/public"); + if (fs.existsSync(publicSource) && !fs.existsSync(publicTarget)) { + fs.symlinkSync(publicSource, publicTarget, "dir"); + } +} diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts new file mode 100644 index 0000000000..03cec2ff4e --- /dev/null +++ b/apps/web/e2e/helpers/api-client.ts @@ -0,0 +1,282 @@ +import type { + Workspace, + Workflow, + CreateTaskResponse, + ListWorkflowsResponse, + ListWorkflowStepsResponse, +} from "../../lib/types/http"; +import type { Agent } from "../../lib/types/http-agents"; + +// --- GitHub Mock Types --- + +export type MockPR = { + number: number; + title: string; + state: string; + head_branch: string; + head_sha?: string; + base_branch: string; + author_login: string; + repo_owner: string; + repo_name: string; + html_url?: string; + url?: string; + body?: string; + draft?: boolean; + additions?: number; + deletions?: number; + requested_reviewers?: Array<{ login: string; type: string }>; +}; + +export type MockOrg = { + login: string; + avatar_url?: string; +}; + +export type MockRepo = { + full_name: string; + owner: string; + name: string; + private?: boolean; +}; + +export type MockReview = { + id: number; + author: string; + author_avatar?: string; + state: string; + body?: string; + created_at?: string; +}; + +export type MockCheckRun = { + name: string; + source?: string; + status: string; + conclusion?: string; + html_url?: string; +}; + +/** + * HTTP API client for seeding test data via the backend REST API. + */ +export class ApiClient { + constructor(private baseUrl: string) {} + + private async request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`API ${method} ${path} failed (${res.status}): ${text}`); + } + return res.json() as Promise; + } + + async healthCheck(): Promise { + await this.request("GET", "/health"); + } + + async createWorkspace(name: string): Promise { + return this.request("POST", "/api/v1/workspaces", { name }); + } + + async createWorkflow(workspaceId: string, name: string, templateId?: string): Promise { + return this.request("POST", "/api/v1/workflows", { + workspace_id: workspaceId, + name, + ...(templateId ? { workflow_template_id: templateId } : {}), + }); + } + + async createTask( + workspaceId: string, + title: string, + opts?: { + description?: string; + workflow_id?: string; + workflow_step_id?: string; + /** Stored in task.Metadata so auto_start_agent can pick it up on on_enter. */ + agent_profile_id?: string; + /** Repository IDs to associate with the task (required for agent execution). */ + repository_ids?: string[]; + /** When true, task is placed at position 0 regardless of is_start_step. */ + plan_mode?: boolean; + }, + ): Promise { + return this.request("POST", "/api/v1/tasks", { + workspace_id: workspaceId, + title, + description: opts?.description ?? "", + ...(opts?.workflow_id ? { workflow_id: opts.workflow_id } : {}), + ...(opts?.workflow_step_id ? { workflow_step_id: opts.workflow_step_id } : {}), + ...(opts?.agent_profile_id ? { metadata: { agent_profile_id: opts.agent_profile_id } } : {}), + ...(opts?.repository_ids + ? { repositories: opts.repository_ids.map((id) => ({ repository_id: id })) } + : {}), + ...(opts?.plan_mode ? { plan_mode: true } : {}), + }); + } + + async listAgents(): Promise<{ agents: Agent[]; total: number }> { + return this.request("GET", "/api/v1/agents"); + } + + async createTaskWithAgent( + workspaceId: string, + title: string, + agentProfileId: string, + opts?: { + description?: string; + workflow_id?: string; + workflow_step_id?: string; + repository_ids?: string[]; + }, + ): Promise { + return this.request("POST", "/api/v1/tasks", { + workspace_id: workspaceId, + title, + description: opts?.description ?? "", + start_agent: true, + agent_profile_id: agentProfileId, + ...(opts?.workflow_id ? { workflow_id: opts.workflow_id } : {}), + ...(opts?.workflow_step_id ? { workflow_step_id: opts.workflow_step_id } : {}), + ...(opts?.repository_ids + ? { repositories: opts.repository_ids.map((id) => ({ repository_id: id })) } + : {}), + }); + } + + async listWorkflows(workspaceId: string): Promise { + return this.request("GET", `/api/v1/workspaces/${workspaceId}/workflows`); + } + + async listWorkflowSteps(workflowId: string): Promise { + return this.request("GET", `/api/v1/workflows/${workflowId}/workflow/steps`); + } + + async createWorkflowStep( + workflowId: string, + name: string, + position: number, + opts?: { is_start_step?: boolean }, + ): Promise<{ id: string }> { + return this.request("POST", `/api/v1/workflow/steps`, { + workflow_id: workflowId, + name, + position, + ...(opts?.is_start_step != null ? { is_start_step: opts.is_start_step } : {}), + }); + } + + async createRepository( + workspaceId: string, + localPath: string, + defaultBranch = "main", + ): Promise<{ id: string }> { + return this.request("POST", `/api/v1/workspaces/${workspaceId}/repositories`, { + name: "E2E Repo", + source_type: "local", + local_path: localPath, + default_branch: defaultBranch, + }); + } + + async saveUserSettings(settings: { + enable_preview_on_click?: boolean; + workspace_id?: string; + workflow_filter_id?: string; + }): Promise { + await this.request("PATCH", "/api/v1/user/settings", settings); + } + + async moveTask(taskId: string, workflowId: string, workflowStepId: string): Promise { + await this.request("POST", `/api/v1/tasks/${taskId}/move`, { + workflow_id: workflowId, + workflow_step_id: workflowStepId, + }); + } + + async updateWorkflowStep( + stepId: string, + updates: { + prompt?: string; + events?: { + on_enter?: Array<{ type: string; config?: Record }>; + on_turn_start?: Array<{ type: string; config?: Record }>; + on_turn_complete?: Array<{ type: string; config?: Record }>; + on_exit?: Array<{ type: string; config?: Record }>; + }; + }, + ): Promise { + await this.request("PUT", `/api/v1/workflow/steps/${stepId}`, { id: stepId, ...updates }); + } + + // --- E2E Test Reset --- + + async e2eReset(workspaceId: string, keepWorkflowIds?: string[]): Promise { + const params = keepWorkflowIds?.length ? `?keep_workflows=${keepWorkflowIds.join(",")}` : ""; + await this.request("DELETE", `/api/v1/e2e/reset/${workspaceId}${params}`); + } + + // --- GitHub Mock Control --- + + async mockGitHubReset(): Promise { + await this.request("DELETE", "/api/v1/github/mock/reset"); + } + + async mockGitHubSetUser(username: string): Promise { + await this.request("PUT", "/api/v1/github/mock/user", { username }); + } + + async mockGitHubAddPRs(prs: MockPR[]): Promise { + await this.request("POST", "/api/v1/github/mock/prs", { prs }); + } + + async mockGitHubAddOrgs(orgs: MockOrg[]): Promise { + await this.request("POST", "/api/v1/github/mock/orgs", { orgs }); + } + + async mockGitHubAddRepos(org: string, repos: MockRepo[]): Promise { + await this.request("POST", "/api/v1/github/mock/repos", { org, repos }); + } + + async mockGitHubAddReviews( + owner: string, + repo: string, + number: number, + reviews: MockReview[], + ): Promise { + await this.request("POST", "/api/v1/github/mock/reviews", { + owner, + repo, + number, + reviews, + }); + } + + async mockGitHubAddCheckRuns( + owner: string, + repo: string, + ref: string, + checks: MockCheckRun[], + ): Promise { + await this.request("POST", "/api/v1/github/mock/checks", { + owner, + repo, + ref, + checks, + }); + } + + async mockGitHubGetStatus(): Promise<{ + authenticated: boolean; + username: string; + auth_method: string; + }> { + return this.request("GET", "/api/v1/github/status"); + } +} diff --git a/apps/web/e2e/pages/kanban-page.ts b/apps/web/e2e/pages/kanban-page.ts new file mode 100644 index 0000000000..5d8a9a806e --- /dev/null +++ b/apps/web/e2e/pages/kanban-page.ts @@ -0,0 +1,36 @@ +import { type Locator, type Page } from "@playwright/test"; + +export class KanbanPage { + readonly board: Locator; + readonly createTaskButton: Locator; + + constructor(private page: Page) { + this.board = page.getByTestId("kanban-board"); + this.createTaskButton = page.getByTestId("create-task-button"); + } + + async goto() { + await this.page.goto("/"); + await this.board.waitFor({ state: "visible" }); + } + + taskCard(taskId: string): Locator { + return this.page.getByTestId(`task-card-${taskId}`); + } + + taskCardByTitle(title: string): Locator { + return this.board.locator(`[data-testid^="task-card-"]`, { + hasText: title, + }); + } + + columnByStepId(stepId: string): Locator { + return this.page.getByTestId(`kanban-column-${stepId}`); + } + + taskCardInColumn(title: string, stepId: string): Locator { + return this.columnByStepId(stepId).locator('[data-testid^="task-card-"]', { + hasText: title, + }); + } +} diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts new file mode 100644 index 0000000000..562bb0042c --- /dev/null +++ b/apps/web/e2e/pages/session-page.ts @@ -0,0 +1,86 @@ +import { type Locator, type Page } from "@playwright/test"; + +export class SessionPage { + readonly chat: Locator; + readonly sidebar: Locator; + readonly terminal: Locator; + readonly files: Locator; + readonly planPanel: Locator; + readonly stepper: Locator; + + constructor(private readonly page: Page) { + this.chat = page.getByTestId("session-chat"); + this.sidebar = page.getByTestId("task-sidebar"); + this.terminal = page.getByTestId("terminal-panel"); + this.files = page.getByTestId("files-panel"); + this.planPanel = page.getByTestId("plan-panel"); + this.stepper = page.getByTestId("workflow-stepper"); + } + + async waitForLoad(timeout = 15_000) { + await this.chat.waitFor({ state: "visible", timeout }); + } + + /** Scoped to the sidebar — finds task title text rendered by TaskItem. */ + taskInSidebar(title: string): Locator { + return this.sidebar.getByText(title, { exact: false }); + } + + /** Sidebar section header (e.g. "Review", "In Progress", "Backlog"). */ + sidebarSection(label: string): Locator { + return this.sidebar.getByTestId(`sidebar-section-${label}`); + } + + /** Agent STARTING or RUNNING status indicator. */ + agentStatus(): Locator { + return this.page.getByRole("status", { name: /Agent is (starting|running)/ }); + } + + /** Divider that appears after the "New session started" status message is rendered. */ + turnComplete(): Locator { + return this.page.getByTestId("agent-turn-complete"); + } + + /** Chat input placeholder when agent is idle (default mode). */ + idleInput(): Locator { + return this.page.locator('[data-placeholder="Continue working on the task..."]'); + } + + /** Chat input placeholder when agent is idle (plan mode). */ + planModeInput(): Locator { + return this.page.locator('[data-placeholder="Continue working on the plan..."]'); + } + + /** + * "Plan mode" badge shown on a message that was sent with plan mode active. + * Appears when message.metadata.plan_mode = true, which the backend sets when + * a session is auto-started via the enable_plan_mode workflow event. + */ + planModeBadge(): Locator { + return this.chat.getByText("Plan mode", { exact: true }); + } + + stepperStep(name: string): Locator { + return this.page.getByTestId(`workflow-step-${name}`); + } + + /** + * Types a message into the TipTap chat input and sends it. + * Default submit key is Cmd+Enter (chatSubmitKey = "cmd_enter"). + * TipTap maps "Mod" to Meta on macOS and Control on Linux/Windows. + */ + async sendMessage(text: string) { + const editor = this.page.locator(".tiptap.ProseMirror").first(); + await editor.click(); + await editor.fill(text); + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await editor.press(`${modifier}+Enter`); + } + + /** Toggle plan mode on/off via Shift+Tab in the TipTap editor. */ + async togglePlanMode() { + const editor = this.page.locator(".tiptap.ProseMirror").first(); + await editor.click(); + await editor.press("Shift+Tab"); + } +} diff --git a/apps/web/e2e/pages/task-detail-page.ts b/apps/web/e2e/pages/task-detail-page.ts new file mode 100644 index 0000000000..a127eac514 --- /dev/null +++ b/apps/web/e2e/pages/task-detail-page.ts @@ -0,0 +1,22 @@ +import { type Locator, type Page } from "@playwright/test"; + +export class TaskDetailPage { + readonly sessionChat: Locator; + readonly turnCompleteIndicator: Locator; + + constructor(private page: Page) { + this.sessionChat = page.getByTestId("session-chat"); + this.turnCompleteIndicator = page.getByTestId("agent-turn-complete"); + } + + async goto(taskId: string, sessionId: string) { + await this.page.goto(`/task/${taskId}/${sessionId}`); + } + + async waitForAgentResponse(text: string, timeoutMs = 30_000) { + await this.sessionChat.locator(`text=${text}`).waitFor({ + state: "visible", + timeout: timeoutMs, + }); + } +} diff --git a/apps/web/e2e/playwright.config.ts b/apps/web/e2e/playwright.config.ts new file mode 100644 index 0000000000..8870a1ce28 --- /dev/null +++ b/apps/web/e2e/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test"; + +const CI = !!process.env.CI; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + forbidOnly: CI, + retries: CI ? 2 : 0, + workers: CI ? 2 : 1, + timeout: 60_000, + reporter: CI ? [["html", { outputFolder: "./playwright-report" }]] : "list", + outputDir: "./test-results", + + use: { + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "on-first-retry", + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + + // No webServer — each Playwright worker spawns its own frontend + // via the backend fixture (see fixtures/backend.ts) + + globalSetup: "./global-setup.ts", +}); diff --git a/apps/web/e2e/tests/create-task.spec.ts b/apps/web/e2e/tests/create-task.spec.ts new file mode 100644 index 0000000000..622b5a40c0 --- /dev/null +++ b/apps/web/e2e/tests/create-task.spec.ts @@ -0,0 +1,224 @@ +import { test, expect } from "../fixtures/test-base"; +import { KanbanPage } from "../pages/kanban-page"; +import { SessionPage } from "../pages/session-page"; + +test.describe("Task creation", () => { + test("opens create task dialog from kanban header", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + }); + + test("can fill in task title and description", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + const titleInput = testPage.getByTestId("task-title-input"); + await titleInput.fill("My E2E Test Task"); + await expect(titleInput).toHaveValue("My E2E Test Task"); + + const descInput = testPage.getByTestId("task-description-input"); + await descInput.fill("This is a test description"); + await expect(descInput).toHaveValue("This is a test description"); + }); + + test("start agent: creates task, starts session, navigates to session", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + // Fill in title and description — description enables the "Start task" button + await testPage.getByTestId("task-title-input").fill("Start Agent Task"); + await testPage.getByTestId("task-description-input").fill("/e2e:simple-message"); + + // The dialog auto-selects the E2E Repo (first available repository) and the main branch. + // Wait for the button to become enabled (repo + branch + agent profile all resolved). + // Under load the branch/profile resolution can take a moment. + const startBtn = testPage.getByTestId("submit-start-agent"); + await expect(startBtn).toBeEnabled({ timeout: 15_000 }); + + // Click "Start task" — the agent starts, the dialog closes, we stay on kanban + await startBtn.click(); + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + + // The new task card appears on the kanban board (pushed via WS) + const card = kanban.taskCardByTitle("Start Agent Task"); + await expect(card).toBeVisible({ timeout: 10_000 }); + + // Clicking the card fetches the session and navigates to /s/ + await card.click(); + await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Default layout: sidebar, terminal, and file tree are all visible + await expect(session.sidebar).toBeVisible(); + await expect(session.terminal).toBeVisible(); + await expect(session.files).toBeVisible(); + await expect(session.chat).toBeVisible(); + + // Task title appears in the sidebar + await expect(session.taskInSidebar("Start Agent Task")).toBeVisible(); + + // The mock agent's simple-message scenario emits this response text — + // waiting for it confirms the agent ran and completed its turn. + await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ + timeout: 30_000, + }); + + // The first user message (task description) is visible in the chat + await expect(session.chat.getByText("/e2e:simple-message")).toBeVisible(); + + // Session transitions to idle — input placeholder changes from "Queue instructions..." + await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); + + // Sidebar shows the task under "Review" section — this uses kanban tasks which update + // via task.updated WS event. Check this first to give more time for the stepper data + // (session.state_changed) to propagate through the store. + await expect(session.sidebarSection("Review")).toBeVisible({ timeout: 15_000 }); + + // After the agent completes, the workflow step transitions from "In Progress" to "Review". + // The step update arrives via WS (task.updated and session.state_changed events) which may + // take a moment to propagate through the store. + await expect(session.stepperStep("Review")).toHaveAttribute("aria-current", "step", { + timeout: 15_000, + }); + }); + + test("plan mode with MCP tool: creates plan via create_task_plan", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + // Multi-line script: thinking → MCP create plan → text message + const script = [ + 'e2e:thinking("Analyzing task and creating plan...")', + "e2e:delay(100)", + 'e2e:mcp:kandev:create_task_plan({"task_id":"{task_id}","content":"## Plan\\n\\n1. Analyze requirements\\n2. Implement solution\\n3. Write tests","title":"Implementation Plan"})', + "e2e:delay(100)", + 'e2e:message("I\'ve created an implementation plan for this task.")', + ].join("\n"); + + await testPage.getByTestId("task-title-input").fill("Plan MCP Task"); + await testPage.getByTestId("task-description-input").fill(script); + + const startBtn = testPage.getByTestId("submit-start-agent"); + await expect(startBtn).toBeEnabled({ timeout: 15_000 }); + + await testPage.getByTestId("submit-start-agent-chevron").click(); + await expect(testPage.getByTestId("submit-plan-mode")).toBeVisible({ timeout: 5_000 }); + await testPage.getByTestId("submit-plan-mode").click(); + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + + await expect(testPage).toHaveURL(/\/s\/.*layout=plan/, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Plan panel visible (plan layout) + await expect(session.planPanel).toBeVisible({ timeout: 10_000 }); + + // The first user message (script content) is visible in the chat with plan mode badge + await expect(session.chat.getByText("e2e:thinking", { exact: false })).toBeVisible({ + timeout: 15_000, + }); + await expect(session.planModeBadge()).toBeVisible(); + + // Agent completion text visible in chat (use .last() because the user message + // paragraph also contains this text inside the e2e:message(...) script line) + await expect( + session.chat.getByText("I've created an implementation plan for this task.").last(), + ).toBeVisible({ timeout: 30_000 }); + + // Plan content appears in plan panel (created via real MCP call to create_task_plan) + await expect(session.planPanel.getByText("Analyze requirements", { exact: false })).toBeVisible( + { + timeout: 15_000, + }, + ); + await expect(session.planPanel.getByText("Implement solution", { exact: false })).toBeVisible(); + await expect(session.planPanel.getByText("Write tests", { exact: false })).toBeVisible(); + + // Session transitions to idle — plan mode input placeholder visible + await expect(session.planModeInput()).toBeVisible({ timeout: 15_000 }); + + // After the agent completes, the workflow step transitions to "Review" + await expect(session.stepperStep("Review")).toHaveAttribute("aria-current", "step", { + timeout: 10_000, + }); + + // Sidebar shows the task under "Review" section + await expect(session.sidebarSection("Review")).toBeVisible(); + }); + + test("start task in plan mode: opens session with plan panel visible", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + await testPage.getByTestId("task-title-input").fill("Plan Mode Task"); + await testPage.getByTestId("task-description-input").fill("/e2e:simple-message"); + + // Wait for the main submit button to be enabled (repo + agent profile resolved), + // then open the dropdown chevron to reveal the "Start task in plan mode" option. + const startBtn = testPage.getByTestId("submit-start-agent"); + await expect(startBtn).toBeEnabled({ timeout: 15_000 }); + + // The split-button group wraps "Start task" + a chevron-only dropdown trigger. + // Click the chevron to open the dropdown. + await testPage.getByTestId("submit-start-agent-chevron").click(); + + const planModeBtn = testPage.getByTestId("submit-plan-mode"); + await expect(planModeBtn).toBeVisible({ timeout: 5_000 }); + await planModeBtn.click(); + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + + // activatePlanMode navigates to /s/?layout=plan which applies the plan preset + await expect(testPage).toHaveURL(/\/s\/.*layout=plan/, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Plan panel is visible — the layout preset shows it instead of the default file tree + await expect(session.planPanel).toBeVisible({ timeout: 10_000 }); + + // Chat is still accessible in plan mode + await expect(session.chat).toBeVisible(); + + // Wait for the agent to complete so messages are loaded + await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ + timeout: 30_000, + }); + + // The first user message (task description) is visible with plan mode badge + await expect(session.chat.getByText("/e2e:simple-message")).toBeVisible(); + await expect(session.planModeBadge()).toBeVisible(); + + // Session transitions to idle — plan mode input placeholder visible + await expect(session.planModeInput()).toBeVisible({ timeout: 15_000 }); + + // After the agent completes, the workflow step transitions to "Review" + await expect(session.stepperStep("Review")).toHaveAttribute("aria-current", "step", { + timeout: 10_000, + }); + + // Sidebar shows the task under "Review" section + await expect(session.sidebarSection("Review")).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/tests/kanban-board.spec.ts b/apps/web/e2e/tests/kanban-board.spec.ts new file mode 100644 index 0000000000..ad9edb85d2 --- /dev/null +++ b/apps/web/e2e/tests/kanban-board.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from "../fixtures/test-base"; +import { KanbanPage } from "../pages/kanban-page"; + +test.describe("Kanban board", () => { + test("displays a seeded task card", async ({ testPage, apiClient, seedData }) => { + const task = await apiClient.createTask(seedData.workspaceId, "E2E Kanban Test Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const kanban = new KanbanPage(testPage); + + await kanban.goto(); + + const card = kanban.taskCardByTitle("E2E Kanban Test Task"); + await expect(card).toBeVisible(); + await expect(kanban.taskCard(task.id)).toBeVisible(); + }); + + test("shows create task button", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await expect(kanban.createTaskButton.first()).toBeVisible(); + }); + + test("opens task preview from kanban card", async ({ testPage, apiClient, seedData }) => { + // Enable preview-on-click so clicking a card opens the preview panel + await apiClient.saveUserSettings({ enable_preview_on_click: true }); + + const task = await apiClient.createTask(seedData.workspaceId, "Detail View Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const kanban = new KanbanPage(testPage); + + await kanban.goto(); + + const card = kanban.taskCard(task.id); + await expect(card).toBeVisible(); + await card.click(); + + // After clicking a card with preview enabled, URL gets ?taskId=... param. + // Use toHaveURL (polling assertion) since replaceState doesn't fire navigation events. + await expect(testPage).toHaveURL(/taskId=/, { timeout: 10000 }); + }); +}); diff --git a/apps/web/e2e/tests/task-list.spec.ts b/apps/web/e2e/tests/task-list.spec.ts new file mode 100644 index 0000000000..3c41e82c06 --- /dev/null +++ b/apps/web/e2e/tests/task-list.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from "../fixtures/test-base"; + +test.describe("Task List", () => { + test("seeded task appears in task list", async ({ testPage, apiClient, seedData }) => { + await apiClient.createTask(seedData.workspaceId, "Direct Navigate Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + // The /tasks page shows all tasks for the workspace in a data table (SSR) + await testPage.goto("/tasks"); + await testPage.waitForLoadState("networkidle"); + + await expect(testPage.getByText("Direct Navigate Task")).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/tests/workflow-automation.spec.ts b/apps/web/e2e/tests/workflow-automation.spec.ts new file mode 100644 index 0000000000..15886e071c --- /dev/null +++ b/apps/web/e2e/tests/workflow-automation.spec.ts @@ -0,0 +1,558 @@ +import { test, expect } from "../fixtures/test-base"; +import { KanbanPage } from "../pages/kanban-page"; +import { SessionPage } from "../pages/session-page"; + +test.describe("Workflow automation", () => { + /** + * Seeds a 3-step workflow (Inbox → In Progress → Done). + * Configures In Progress with on_enter: auto_start_agent and a custom step + * prompt routed to the mock agent, plus on_turn_complete: move_to_step(Done). + * + * Verifies: + * - auto_start_agent fires when task is moved into In Progress + * - task appears in In Progress column before agent completes + * - step custom prompt is used (not the task description) + * - on_turn_complete advances task to Done column on the kanban board + * - session stepper shows Done as the current step + */ + test("auto_start_agent with step custom prompt; on_turn_complete advances to next column", async ({ + testPage, + apiClient, + seedData, + }) => { + // --- Seed a workflow with explicit step configuration --- + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Automation Test Workflow", + ); + + const inboxStep = await apiClient.createWorkflowStep(workflow.id, "Inbox", 0); + const inProgressStep = await apiClient.createWorkflowStep(workflow.id, "In Progress", 1); + const doneStep = await apiClient.createWorkflowStep(workflow.id, "Done", 2); + + // Configure In Progress: auto_start_agent on enter, custom step prompt with delay + // so we can observe the task in In Progress before on_turn_complete moves it to Done. + await apiClient.updateWorkflowStep(inProgressStep.id, { + prompt: 'e2e:delay(3000)\ne2e:message("delayed step response")\n{{task_prompt}}', + events: { + on_enter: [{ type: "auto_start_agent" }], + on_turn_complete: [{ type: "move_to_step", config: { step_id: doneStep.id } }], + }, + }); + + // Point the kanban page at our custom workflow + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + enable_preview_on_click: false, + }); + + // Resolve an agent profile so auto_start_agent can pick it up from task metadata + const { agents } = await apiClient.listAgents(); + const agentProfileId = agents[0]?.profiles[0]?.id ?? ""; + + // Create task in Inbox — does NOT trigger auto_start_agent + const task = await apiClient.createTask(seedData.workspaceId, "Auto Agent Workflow Task", { + workflow_id: workflow.id, + workflow_step_id: inboxStep.id, + agent_profile_id: agentProfileId, + repository_ids: [seedData.repositoryId], + }); + + // Move task to In Progress → triggers on_enter: auto_start_agent + await apiClient.moveTask(task.id, workflow.id, inProgressStep.id); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + // Task should appear in In Progress while the agent is working (3s delay) + const cardInProgress = kanban.taskCardInColumn("Auto Agent Workflow Task", inProgressStep.id); + await expect(cardInProgress).toBeVisible({ timeout: 15_000 }); + + // After the agent completes its turn, on_turn_complete fires and moves the + // task to Done. The kanban board receives a WS push. + const cardInDone = kanban.taskCardInColumn("Auto Agent Workflow Task", doneStep.id); + await expect(cardInDone).toBeVisible({ timeout: 30_000 }); + + // Navigate to the session page + await cardInDone.click(); + await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Stepper shows Done as current step + await expect(session.stepperStep("Done")).toHaveAttribute("aria-current", "step", { + timeout: 10_000, + }); + + // Mock agent response confirms the step custom prompt was executed + await expect(session.chat.getByText("delayed step response", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + + // Session transitions to idle after agent completes + await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); + + // Sidebar shows the task under "Review" section + await expect(session.sidebarSection("Review")).toBeVisible(); + }); + + /** + * Seeds a 4-step workflow (Todo → In Progress → Review → Done) and validates + * the full lifecycle from both the kanban page and the session details page. + * + * Step configuration: + * - Todo: on_turn_complete: [move_to_next] + * - In Progress: on_enter: [auto_start_agent], custom step prompt, + * on_turn_complete: [move_to_next] + * - Review: no events (manual step) + * - Done: no events + * + * Flow: + * 1. Task is created in Todo + * 2. Navigate to kanban → verify task is in Todo → click card to session page + * 3. Send a message → agent responds → on_turn_complete fires → task moves to In Progress + * 4. on_enter: auto_start_agent queues step prompt (/e2e:multi-turn) + * 5. Step prompt runs → "Multi-turn response ready" → on_turn_complete → Review + * 6. Stepper shows Review + * 7. Send /e2e:simple-message in Review → "simple mock response" → stays in Review + * 8. Move task to Done via API → stepper shows Done + */ + test("full workflow lifecycle: kanban transitions and session stepper updates", async ({ + testPage, + apiClient, + seedData, + }) => { + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Lifecycle Test Workflow", + ); + + const todoStep = await apiClient.createWorkflowStep(workflow.id, "Todo", 0); + const inProgressStep = await apiClient.createWorkflowStep(workflow.id, "In Progress", 1); + await apiClient.createWorkflowStep(workflow.id, "Review", 2); + const doneStep = await apiClient.createWorkflowStep(workflow.id, "Done", 3); + + // Todo: on_turn_complete moves task to In Progress after the first agent turn. + await apiClient.updateWorkflowStep(todoStep.id, { + events: { + on_turn_complete: [{ type: "move_to_next" }], + }, + }); + + // In Progress: auto_start_agent on enter with custom step prompt (multi-turn scenario), + // move_to_next on turn complete. + // Using /e2e:multi-turn (not simple-message) so the auto-start response is distinguishable + // from the user's manually sent /e2e:simple-message later in the test. + await apiClient.updateWorkflowStep(inProgressStep.id, { + prompt: "/e2e:multi-turn {{task_prompt}}", + events: { + on_enter: [{ type: "auto_start_agent" }], + on_turn_complete: [{ type: "move_to_next" }], + }, + }); + + // Review: no events — manual step (agent turns don't trigger transitions) + // Done: no events (default, no update needed) + + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + enable_preview_on_click: false, + }); + + const { agents } = await apiClient.listAgents(); + const agentProfileId = agents[0]?.profiles[0]?.id ?? ""; + + // Create task in Todo without starting an agent. + const task = await apiClient.createTask(seedData.workspaceId, "Lifecycle Workflow Task", { + workflow_id: workflow.id, + workflow_step_id: todoStep.id, + agent_profile_id: agentProfileId, + repository_ids: [seedData.repositoryId], + }); + + // --- Kanban page: verify task starts in Todo --- + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + const cardInTodo = kanban.taskCardInColumn("Lifecycle Workflow Task", todoStep.id); + await expect(cardInTodo).toBeVisible({ timeout: 10_000 }); + + // Click the card — creates a session (with workflow_step_id from the task) + // and navigates to the session page. + await cardInTodo.click(); + await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Stepper initially shows Todo + await expect(session.stepperStep("Todo")).toHaveAttribute("aria-current", "step", { + timeout: 10_000, + }); + + // --- Send a message: agent responds → on_turn_complete fires → cascade --- + // Todo on_turn_complete: move_to_next → In Progress + // In Progress on_enter: auto_start_agent queues /e2e:multi-turn step prompt + // Step prompt runs → on_turn_complete: move_to_next → Review + await session.sendMessage("/e2e:simple-message"); + + // Wait for the first mock response (from user's /e2e:simple-message on Todo step) + await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ + timeout: 30_000, + }); + + // The auto-start step prompt (/e2e:multi-turn) produces a distinct response + await expect(session.chat.getByText("Multi-turn response ready", { exact: false })).toBeVisible( + { timeout: 30_000 }, + ); + + // Stepper should show Review after the full cascade completes + await expect(session.stepperStep("Review")).toHaveAttribute("aria-current", "step", { + timeout: 30_000, + }); + + // --- Send another message in Review: verify manual step stays --- + // Wait for agent to become idle before sending (auto-start cascade may still be settling) + await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); + + await session.sendMessage("/e2e:simple-message"); + + // Wait for the agent to respond (second "simple mock response") + await expect( + session.chat.getByText("simple mock response", { exact: false }).nth(1), + ).toBeVisible({ timeout: 30_000 }); + + // Stepper still shows Review (no on_turn_complete events on Review) + await expect(session.stepperStep("Review")).toHaveAttribute("aria-current", "step", { + timeout: 10_000, + }); + + // Session is idle after agent completes the turn in Review + await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); + + // Sidebar shows the task under "Review" section + await expect(session.sidebarSection("Review")).toBeVisible(); + + // --- Move task to Done manually via API --- + await apiClient.moveTask(task.id, workflow.id, doneStep.id); + + // Stepper updates to Done via WS push + await expect(session.stepperStep("Done")).toHaveAttribute("aria-current", "step", { + timeout: 10_000, + }); + }); + + /** + * Seeds a 2-step workflow (Inbox → Planning). + * Configures Planning with on_enter: [auto_start_agent, enable_plan_mode] + * and a custom prompt. No on_turn_complete so task stays in Planning. + * + * Verifies: + * - enable_plan_mode activates plan mode UI + * - Plan mode chat input placeholder is visible in the session + */ + test("enable_plan_mode event activates plan mode UI in session", async ({ + testPage, + apiClient, + seedData, + }) => { + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Plan Mode Test Workflow", + ); + + const inboxStep = await apiClient.createWorkflowStep(workflow.id, "Inbox", 0); + const planningStep = await apiClient.createWorkflowStep(workflow.id, "Planning", 1); + + // Configure Planning: auto_start_agent + enable_plan_mode on enter. + // No on_turn_complete so the task remains in Planning after the agent turn. + await apiClient.updateWorkflowStep(planningStep.id, { + prompt: "/e2e:simple-message {{task_prompt}}", + events: { + on_enter: [{ type: "auto_start_agent" }, { type: "enable_plan_mode" }], + }, + }); + + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + enable_preview_on_click: false, + }); + + const { agents } = await apiClient.listAgents(); + const agentProfileId = agents[0]?.profiles[0]?.id ?? ""; + + const task = await apiClient.createTask(seedData.workspaceId, "Plan Mode Workflow Task", { + workflow_id: workflow.id, + workflow_step_id: inboxStep.id, + agent_profile_id: agentProfileId, + repository_ids: [seedData.repositoryId], + }); + + // Move task to Planning → triggers auto_start_agent + enable_plan_mode + await apiClient.moveTask(task.id, workflow.id, planningStep.id); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + // Task stays in Planning (no on_turn_complete configured) + const card = kanban.taskCardInColumn("Plan Mode Workflow Task", planningStep.id); + await expect(card).toBeVisible({ timeout: 30_000 }); + + // Navigate to session page + await card.click(); + await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Stepper shows Planning as current step + await expect(session.stepperStep("Planning")).toHaveAttribute("aria-current", "step", { + timeout: 10_000, + }); + + // Plan panel is visible — the layout switches to plan mode when the session + // is created with enable_plan_mode. + await expect(session.planPanel).toBeVisible({ timeout: 15_000 }); + + // "Plan mode" badge on the message confirms plan mode was active for this session. + // The badge appears when message.metadata.plan_mode = true, which the backend sets + // when the session is auto-started via the enable_plan_mode workflow event. + await expect(session.planModeBadge()).toBeVisible({ timeout: 15_000 }); + + // Session transitions to idle — plan mode input placeholder visible + await expect(session.planModeInput()).toBeVisible({ timeout: 15_000 }); + + // Sidebar shows the task under "Review" section + await expect(session.sidebarSection("Review")).toBeVisible(); + }); + + /** + * Seeds a 5-step workflow (Backlog → Analyze → Implement → Test → Deploy). + * Steps Analyze, Implement, and Test each have: + * on_enter: [auto_start_agent], custom prompt with {{task_prompt}}, + * on_turn_complete: [move_to_next] + * Deploy has on_enter: [auto_start_agent] and a custom prompt but no on_turn_complete + * (terminal step). + * + * A single moveTask(Backlog → Analyze) triggers a full cascade through all 4 + * active steps. Each step's custom prompt is sent to the mock agent, which + * falls through to emitRandomResponse and echoes the prompt text back. + * + * Verifies: + * - auto_start_agent fires on each step entry + * - task cascades through all steps via on_turn_complete: move_to_next + * - each step's agent response includes that step's custom prompt text + * - the task description is included in every response (via {{task_prompt}}) + * - task lands in Deploy (terminal step) on the kanban board + * - session stepper shows Deploy as the current step + */ + test("multi-step cascade: each step uses its own custom prompt with task description", async ({ + testPage, + apiClient, + seedData, + }) => { + const taskTitle = "Cascade Workflow Task"; + const taskDescription = "Build the login page"; + + const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Cascade Test Workflow"); + + const backlogStep = await apiClient.createWorkflowStep(workflow.id, "Backlog", 0); + const analyzeStep = await apiClient.createWorkflowStep(workflow.id, "Analyze", 1); + const implementStep = await apiClient.createWorkflowStep(workflow.id, "Implement", 2); + const testStep = await apiClient.createWorkflowStep(workflow.id, "Test", 3); + const deployStep = await apiClient.createWorkflowStep(workflow.id, "Deploy", 4); + + // Each active step has a unique prompt prefix so we can assert per-step responses. + // {{task_prompt}} is replaced with the task description by buildWorkflowPrompt. + // Without a /e2e: prefix the mock agent falls to emitRandomResponse which echoes + // the full prompt: "I've completed the analysis of your request: \"\". ..." + const stepConfigs = [ + { step: analyzeStep, prompt: "Analyze requirements for: {{task_prompt}}" }, + { step: implementStep, prompt: "Implement solution for: {{task_prompt}}" }, + { step: testStep, prompt: "Write tests for: {{task_prompt}}" }, + { step: deployStep, prompt: "Deploy changes for: {{task_prompt}}" }, + ]; + + for (const { step, prompt } of stepConfigs) { + const isTerminal = step.id === deployStep.id; + await apiClient.updateWorkflowStep(step.id, { + prompt, + events: { + on_enter: [{ type: "auto_start_agent" }], + ...(isTerminal ? {} : { on_turn_complete: [{ type: "move_to_next" }] }), + }, + }); + } + + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + enable_preview_on_click: false, + }); + + const { agents } = await apiClient.listAgents(); + const agentProfileId = agents[0]?.profiles[0]?.id ?? ""; + + // Create task in Backlog (staging step, no events). + // The description (not title) is what {{task_prompt}} resolves to in step prompts. + const task = await apiClient.createTask(seedData.workspaceId, taskTitle, { + description: taskDescription, + workflow_id: workflow.id, + workflow_step_id: backlogStep.id, + agent_profile_id: agentProfileId, + repository_ids: [seedData.repositoryId], + }); + + // Move to Analyze → triggers on_enter: auto_start_agent → cascade begins + await apiClient.moveTask(task.id, workflow.id, analyzeStep.id); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + // After the full cascade, the task should land in Deploy (terminal step). + const cardInDeploy = kanban.taskCardInColumn(taskTitle, deployStep.id); + await expect(cardInDeploy).toBeVisible({ timeout: 60_000 }); + + // Navigate to the session page to verify per-step responses + await cardInDeploy.click(); + await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Stepper shows Deploy as current step + await expect(session.stepperStep("Deploy")).toHaveAttribute("aria-current", "step", { + timeout: 10_000, + }); + + // Each step's agent response echoes the full prompt via emitRandomResponse: + // "I've completed the analysis of your request: \"\". ..." + // We match the echoed prompt inside the response text to confirm both the step-specific + // prefix and the task description were received by the agent. + const expectedResponseFragments = [ + `Analyze requirements for: ${taskDescription}`, + `Implement solution for: ${taskDescription}`, + `Write tests for: ${taskDescription}`, + `Deploy changes for: ${taskDescription}`, + ]; + + for (const fragment of expectedResponseFragments) { + await expect(session.chat.getByText(fragment, { exact: false }).first()).toBeVisible({ + timeout: 10_000, + }); + } + + // Session transitions to idle after the cascade completes. + // The cascade runs 4 sequential agent turns; session state may lag message display. + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + // Sidebar shows the task under "Review" section + await expect(session.sidebarSection("Review")).toBeVisible(); + }); + + /** + * Kanban board workflow with on_turn_start: move_to_next on Backlog. + * + * Workflow: + * Backlog (on_turn_start: move_to_next, on_turn_complete: move_to_step("review")) + * In Progress (on_enter: auto_start_agent, on_turn_complete: move_to_step("review")) + * Review (on_turn_start: move_to_previous) + * Done (on_turn_start: move_to_step("in-progress")) + * + * No step has is_start_step, so the task lands at position 0 (Backlog). + * The on_turn_complete events use template-level step_id references ("review", + * "in-progress") which don't resolve to actual step UUIDs — they're no-ops. + * + * Flow: + * 1. Create task with description via dialog + Start Agent → task starts in Backlog + * 2. on_turn_start fires (move_to_next) → task moves to In Progress + * 3. Agent completes → on_turn_complete fires with move_to_step("review") + * which is gracefully skipped (invalid step_id) → task stays in In Progress + */ + test("kanban workflow: on_turn_start moves task, invalid step_id in on_turn_complete is skipped", async ({ + testPage, + apiClient, + seedData, + }) => { + const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Kanban Board Workflow"); + + await apiClient.createWorkflowStep(workflow.id, "Backlog", 0); + await apiClient.createWorkflowStep(workflow.id, "In Progress", 1); + await apiClient.createWorkflowStep(workflow.id, "Review", 2); + await apiClient.createWorkflowStep(workflow.id, "Done", 3); + + // Fetch step IDs after creation to set up events + const { steps } = await apiClient.listWorkflowSteps(workflow.id); + const backlogStep = steps.find((s) => s.name === "Backlog")!; + const inProgressStep = steps.find((s) => s.name === "In Progress")!; + + // Backlog: on_turn_start moves to next (In Progress), + // on_turn_complete uses template-level step_id (no-op in API-created steps) + await apiClient.updateWorkflowStep(backlogStep.id, { + events: { + on_turn_start: [{ type: "move_to_next" }], + on_turn_complete: [{ type: "move_to_step", config: { step_id: "review" } }], + }, + }); + + // In Progress: on_turn_complete with invalid step_id (should be skipped) + await apiClient.updateWorkflowStep(inProgressStep.id, { + events: { + on_turn_complete: [{ type: "move_to_step", config: { step_id: "review" } }], + }, + }); + + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + enable_preview_on_click: false, + }); + + // --- Create task via dialog with description + Start Agent --- + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + await testPage.getByTestId("task-title-input").fill("Kanban Flow Task"); + await testPage.getByTestId("task-description-input").fill("/e2e:simple-message"); + + const startBtn = testPage.getByTestId("submit-start-agent"); + await expect(startBtn).toBeEnabled({ timeout: 15_000 }); + await startBtn.click(); + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + + // Task should be in In Progress: on_turn_start fires on the initial prompt + // and moves the task from Backlog → In Progress via move_to_next. + // After the agent completes, on_turn_complete's move_to_step("review") is + // gracefully skipped (invalid step_id), so the task stays in In Progress. + const card = kanban.taskCardInColumn("Kanban Flow Task", inProgressStep.id); + await expect(card).toBeVisible({ timeout: 15_000 }); + + // Navigate to session to verify agent completed successfully + await card.click(); + await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Agent response confirms the mock agent ran + await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ + timeout: 30_000, + }); + + // Session transitions to idle — task is still functional + await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); + + // Stepper shows In Progress as current step + await expect(session.stepperStep("In Progress")).toHaveAttribute("aria-current", "step", { + timeout: 15_000, + }); + }); +}); diff --git a/apps/web/e2e/tests/workflow-start-step.spec.ts b/apps/web/e2e/tests/workflow-start-step.spec.ts new file mode 100644 index 0000000000..7b77b0ff7c --- /dev/null +++ b/apps/web/e2e/tests/workflow-start-step.spec.ts @@ -0,0 +1,303 @@ +import { test, expect } from "../fixtures/test-base"; +import { KanbanPage } from "../pages/kanban-page"; +import { SessionPage } from "../pages/session-page"; + +test.describe("Workflow start step placement", () => { + test.describe("without explicit start step (Todo -> In Progress -> Done)", () => { + test("task lands in first step by position (Todo)", async ({ + testPage, + apiClient, + seedData, + }) => { + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + "No Start Step Workflow", + ); + const todoStep = await apiClient.createWorkflowStep(workflow.id, "Todo", 0); + await apiClient.createWorkflowStep(workflow.id, "In Progress", 1); + await apiClient.createWorkflowStep(workflow.id, "Done", 2); + + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + enable_preview_on_click: false, + }); + + // Create task without explicit step — should resolve to position 0 (Todo) + await apiClient.createTask(seedData.workspaceId, "No Start Step Task", { + workflow_id: workflow.id, + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + const card = kanban.taskCardInColumn("No Start Step Task", todoStep.id); + await expect(card).toBeVisible({ timeout: 10_000 }); + }); + + test("plan mode task also lands in first step (Todo)", async ({ + testPage, + apiClient, + seedData, + }) => { + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + "No Start Step Plan Workflow", + ); + const todoStep = await apiClient.createWorkflowStep(workflow.id, "Todo", 0); + await apiClient.createWorkflowStep(workflow.id, "In Progress", 1); + await apiClient.createWorkflowStep(workflow.id, "Done", 2); + + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + enable_preview_on_click: false, + }); + + // plan_mode task with no explicit step — both modes resolve to position 0 + await apiClient.createTask(seedData.workspaceId, "Plan No Start Step Task", { + workflow_id: workflow.id, + plan_mode: true, + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + const card = kanban.taskCardInColumn("Plan No Start Step Task", todoStep.id); + await expect(card).toBeVisible({ timeout: 10_000 }); + }); + }); + + test.describe("with explicit start step (Todo -> In Progress [start] -> Done)", () => { + test("task lands on start step (In Progress)", async ({ testPage, apiClient, seedData }) => { + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Explicit Start Step Workflow", + ); + await apiClient.createWorkflowStep(workflow.id, "Todo", 0); + const inProgressStep = await apiClient.createWorkflowStep(workflow.id, "In Progress", 1, { + is_start_step: true, + }); + await apiClient.createWorkflowStep(workflow.id, "Done", 2); + + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + enable_preview_on_click: false, + }); + + // No plan_mode, no explicit step — should resolve via is_start_step (In Progress) + await apiClient.createTask(seedData.workspaceId, "Start Step Task", { + workflow_id: workflow.id, + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + const card = kanban.taskCardInColumn("Start Step Task", inProgressStep.id); + await expect(card).toBeVisible({ timeout: 10_000 }); + }); + + test("plan mode task lands in first step (Todo), ignoring start step", async ({ + testPage, + apiClient, + seedData, + }) => { + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Plan Mode Start Step Workflow", + ); + const todoStep = await apiClient.createWorkflowStep(workflow.id, "Todo", 0); + await apiClient.createWorkflowStep(workflow.id, "In Progress", 1, { + is_start_step: true, + }); + await apiClient.createWorkflowStep(workflow.id, "Done", 2); + + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + enable_preview_on_click: false, + }); + + // plan_mode: true — should ignore is_start_step and use position 0 (Todo) + await apiClient.createTask(seedData.workspaceId, "Plan Mode Start Step Task", { + workflow_id: workflow.id, + plan_mode: true, + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + const card = kanban.taskCardInColumn("Plan Mode Start Step Task", todoStep.id); + await expect(card).toBeVisible({ timeout: 10_000 }); + }); + }); + + /** + * Full plan-mode-to-agent lifecycle: + * + * Workflow: Todo (on_turn_start: move_to_next) → In Progress (on_turn_complete: move_to_next) → Done + * + * 1. Create task via dialog in plan mode (title only, no description) + * 2. Session page opens with plan panel visible + * 3. Toggle off plan mode → plan panel disappears + * 4. Send a delayed mock script to start the agent + * 5. on_turn_start fires on Todo → task moves to In Progress (assert before response) + * 6. Agent responds after 3s delay → on_turn_complete fires → task moves to Done + */ + test("plan mode to agent: create in plan mode, toggle off, send message, cascades through steps", async ({ + testPage, + apiClient, + seedData, + }) => { + const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Plan To Agent Workflow"); + + const todoStep = await apiClient.createWorkflowStep(workflow.id, "Todo", 0); + const inProgressStep = await apiClient.createWorkflowStep(workflow.id, "In Progress", 1); + await apiClient.createWorkflowStep(workflow.id, "Done", 2); + + // Todo: move to next step when agent turn starts + await apiClient.updateWorkflowStep(todoStep.id, { + events: { + on_turn_start: [{ type: "move_to_next" }], + }, + }); + + // In Progress: move to next step when agent turn completes + await apiClient.updateWorkflowStep(inProgressStep.id, { + events: { + on_turn_complete: [{ type: "move_to_next" }], + }, + }); + + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + enable_preview_on_click: false, + }); + + // --- Create task via dialog in plan mode --- + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + // Fill title only — no description triggers plan mode as the primary submit action + await testPage.getByTestId("task-title-input").fill("Plan To Agent Task"); + + // The default submit button shows "Start Plan Mode" when there's no description. + // It's a type="submit" button so clicking it submits the form. + const submitBtn = dialog.getByRole("button", { name: /Start Plan Mode/ }); + await expect(submitBtn).toBeEnabled({ timeout: 10_000 }); + await submitBtn.click(); + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + + // Navigates to session page with plan layout + await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Plan panel is visible (plan layout was activated) + await expect(session.planPanel).toBeVisible({ timeout: 10_000 }); + + // Stepper shows Todo as current step (task starts at position 0) + await expect(session.stepperStep("Todo")).toHaveAttribute("aria-current", "step", { + timeout: 10_000, + }); + + // --- Toggle off plan mode --- + await session.togglePlanMode(); + await expect(session.planPanel).not.toBeVisible({ timeout: 10_000 }); + + // --- Send message to start the agent --- + // Use script mode with a delay so we can observe the In Progress intermediate state. + // on_turn_start fires immediately → task moves to In Progress. + // The 3s delay gives us time to assert before on_turn_complete fires. + await session.sendMessage('e2e:delay(3000)\ne2e:message("delayed mock response")'); + + // on_turn_start fires on Todo → task moves to In Progress + await expect(session.stepperStep("In Progress")).toHaveAttribute("aria-current", "step", { + timeout: 15_000, + }); + + // Agent responds after the delay + await expect(session.chat.getByText("delayed mock response", { exact: false })).toBeVisible({ + timeout: 30_000, + }); + + // on_turn_complete fires on In Progress → task moves to Done + await expect(session.stepperStep("Done")).toHaveAttribute("aria-current", "step", { + timeout: 15_000, + }); + + // Session transitions to idle + await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); + }); + + /** + * Plan mode bypasses is_start_step — task lands in Backlog (position 0): + * + * Workflow: Backlog → In Progress [start] (auto_start_agent) → Review → Done + * + * 1. Create task via dialog in plan mode (title only, no description) + * 2. Session page opens with plan panel visible + * 3. Task lands in Backlog, not In Progress — plan mode ignores is_start_step + */ + test("plan mode with start step: task lands in Backlog, not In Progress", async ({ + testPage, + apiClient, + seedData, + }) => { + const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Plan Mode Dev Workflow"); + + const backlogStep = await apiClient.createWorkflowStep(workflow.id, "Backlog", 0); + await apiClient.createWorkflowStep(workflow.id, "In Progress", 1, { is_start_step: true }); + await apiClient.createWorkflowStep(workflow.id, "Review", 2); + await apiClient.createWorkflowStep(workflow.id, "Done", 3); + + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + enable_preview_on_click: false, + }); + + // --- Create task via dialog in plan mode --- + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + // Fill title only — "Start Plan Mode" becomes the default submit action + await testPage.getByTestId("task-title-input").fill("Plan Dev Task"); + + const submitBtn = dialog.getByRole("button", { name: /Start Plan Mode/ }); + await expect(submitBtn).toBeEnabled({ timeout: 10_000 }); + await submitBtn.click(); + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + + // Navigates to session page with plan layout + await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Plan panel is visible + await expect(session.planPanel).toBeVisible({ timeout: 10_000 }); + + // Stepper shows Backlog as current step — plan mode ignores is_start_step + await expect(session.stepperStep("Backlog")).toHaveAttribute("aria-current", "step", { + timeout: 10_000, + }); + + // Task card is in the Backlog column on the kanban board + await testPage.goto("/"); + await kanban.board.waitFor({ state: "visible" }); + const card = kanban.taskCardInColumn("Plan Dev Task", backlogStep.id); + await expect(card).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/apps/web/e2e/tests/workflow-steps.spec.ts b/apps/web/e2e/tests/workflow-steps.spec.ts new file mode 100644 index 0000000000..e8b315a793 --- /dev/null +++ b/apps/web/e2e/tests/workflow-steps.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from "../fixtures/test-base"; +import { KanbanPage } from "../pages/kanban-page"; + +test.describe("Workflow steps", () => { + test("task appears in correct column after API move", async ({ + testPage, + apiClient, + seedData, + }) => { + // Seed a task (lands in the start step by default) + const task = await apiClient.createTask(seedData.workspaceId, "Workflow Move Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + // Find a non-start step to move to + const targetStep = seedData.steps.find((s) => !s.is_start_step); + if (!targetStep) { + test.skip(true, "No non-start step available to test move"); + return; + } + + // Move the task via API + await apiClient.moveTask(task.id, seedData.workflowId, targetStep.id); + + // Navigate to kanban and verify the card is visible + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + const card = kanban.taskCardByTitle("Workflow Move Task"); + await expect(card).toBeVisible(); + }); + + test("multiple tasks can be seeded into different steps", async ({ + testPage, + apiClient, + seedData, + }) => { + const startStep = seedData.steps.find((s) => s.is_start_step) ?? seedData.steps[0]; + const otherStep = seedData.steps.find((s) => s.id !== startStep.id); + if (!otherStep) { + test.skip(true, "Need at least 2 workflow steps"); + return; + } + + // Create tasks and move one + await apiClient.createTask(seedData.workspaceId, "Step A Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const taskB = await apiClient.createTask(seedData.workspaceId, "Step B Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await apiClient.moveTask(taskB.id, seedData.workflowId, otherStep.id); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await expect(kanban.taskCardByTitle("Step A Task")).toBeVisible(); + await expect(kanban.taskCardByTitle("Step B Task")).toBeVisible(); + }); +}); diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index 118c62c60a..1d18cf0ac1 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -33,6 +33,18 @@ const eslintConfig = defineConfig([ "unused-imports/no-unused-imports": "warn", }, }, + // E2E tests (Playwright): disable React hooks rules since Playwright's `use()` and + // `test.extend()` patterns are falsely flagged, and relax test-specific limits. + { + files: ["e2e/**/*.ts"], + rules: { + "react-hooks/rules-of-hooks": "off", + "react-hooks/exhaustive-deps": "off", + "max-lines-per-function": "off", + "max-lines": "off", + "sonarjs/no-duplicate-string": "off", + }, + }, ]); export default eslintConfig; diff --git a/apps/web/lib/state/hydration/hydrator.ts b/apps/web/lib/state/hydration/hydrator.ts index 47915b1bc4..ac7c3fc316 100644 --- a/apps/web/lib/state/hydration/hydrator.ts +++ b/apps/web/lib/state/hydration/hydrator.ts @@ -22,9 +22,40 @@ function mergeWithLoading(draft: any, source: any | undefined): void { mergeLoadingState(draft, source); } +/** Merge kanban tasks by ID, keeping the version with the newer updatedAt timestamp. */ +function mergeKanbanTasks( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + draft: Draft, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + source: any[] | undefined, +): void { + if (!source || source.length === 0) return; + const draftTasks = draft.tasks as Array<{ id: string; updatedAt?: string }>; + const existingById = new Map(draftTasks.map((t) => [t.id, t])); + + for (const incoming of source) { + const existing = existingById.get(incoming.id); + if (!existing) { + draftTasks.push(incoming); + } else { + const existingTime = existing.updatedAt ? new Date(existing.updatedAt).getTime() : 0; + const incomingTime = incoming.updatedAt ? new Date(incoming.updatedAt).getTime() : 0; + if (incomingTime >= existingTime) { + const idx = draftTasks.findIndex((t) => t.id === incoming.id); + if (idx >= 0) draftTasks[idx] = incoming; + } + } + } +} + /** Hydrate kanban and workspace slices. */ function hydrateKanbanAndWorkspace(draft: Draft, state: Partial): void { - if (state.kanban) deepMerge(draft.kanban, state.kanban); + if (state.kanban) { + // Merge tasks by ID with timestamp comparison to avoid overwriting fresher WS data + const { tasks, ...kanbanRest } = state.kanban; + if (Object.keys(kanbanRest).length > 0) deepMerge(draft.kanban, kanbanRest); + mergeKanbanTasks(draft.kanban, tasks); + } if (state.kanbanMulti) deepMerge(draft.kanbanMulti, state.kanbanMulti); if (state.workflows) deepMerge(draft.workflows, state.workflows); if (state.tasks) deepMerge(draft.tasks, state.tasks); diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index 6ca21b9934..761a53e9df 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -15,6 +15,7 @@ function buildSessionUpdate(payload: any): Record { if (payload.agent_profile_snapshot) update.agent_profile_snapshot = payload.agent_profile_snapshot; if (payload.is_passthrough !== undefined) update.is_passthrough = payload.is_passthrough; + if (payload.session_metadata !== undefined) update.metadata = payload.session_metadata; return update; } diff --git a/apps/web/package.json b/apps/web/package.json index 040ac66fe2..fac7f21df6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,10 @@ "build": "cross-env NODE_ENV=production next build", "start": "cross-env NODE_ENV=production next start", "lint": "eslint --max-warnings 0", - "test": "vitest run" + "test": "vitest run", + "e2e": "playwright test --config e2e/playwright.config.ts", + "e2e:ui": "playwright test --config e2e/playwright.config.ts --ui", + "e2e:headed": "playwright test --config e2e/playwright.config.ts --headed" }, "dependencies": { "@base-ui/react": "^1.0.0", @@ -110,6 +113,7 @@ "zustand": "^5.0.8" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4", "@types/diff": "^8.0.0", "@types/node": "^20", diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 6571964f8b..0f3636f7f7 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -11,5 +11,6 @@ export default defineConfig({ }, test: { environment: "happy-dom", + exclude: ["e2e/**", "node_modules/**"], }, }); diff --git a/docs/test_e2e.md b/docs/test_e2e_agents.md similarity index 100% rename from docs/test_e2e.md rename to docs/test_e2e_agents.md diff --git a/docs/test_e2e_web.md b/docs/test_e2e_web.md new file mode 100644 index 0000000000..5fb8909d00 --- /dev/null +++ b/docs/test_e2e_web.md @@ -0,0 +1,303 @@ +# Web E2E Tests + +Browser-based end-to-end tests that exercise the full stack: Next.js frontend, Go backend, and mock agent. Tests run in parallel with complete isolation — each Playwright worker gets its own backend process, frontend process, database, and temp directory. + +**Location:** `apps/web/e2e/` + +## Architecture + +``` +┌─ Playwright ──────────────────────────────────────────────┐ +│ Worker 0 Worker 1 │ +│ ┌──────────────────────┐ ┌──────────────────────┐│ +│ │ Backend :18080 │ │ Backend :18081 ││ +│ │ Frontend :13000 │ │ Frontend :13001 ││ +│ │ SSR → localhost:18080│ │ SSR → localhost:18081││ +│ │ HOME=/tmp/e2e-0 │ │ HOME=/tmp/e2e-1 ││ +│ │ own SQLite DB │ │ own SQLite DB ││ +│ │ mock-agent only │ │ mock-agent only ││ +│ │ mock GitHub │ │ mock GitHub ││ +│ └──────────────────────┘ └──────────────────────┘│ +└───────────────────────────────────────────────────────────┘ +``` + +- **One backend per worker** — port `18080 + workerIndex`, isolated temp HOME, own SQLite DB +- **One frontend per worker** — port `13000 + workerIndex`, SSR routed to that worker's backend via `KANDEV_API_BASE_URL` +- **Shared `.next/` build** — all frontend processes serve from the same pre-built output (read-only) +- **Mock agent** — deterministic responses, no API keys needed +- **Mock GitHub client** — reports as authenticated, returns configurable data via REST API +- **No external dependencies** — no Docker, no GitHub tokens, no real git credentials + +## Prerequisites + +Build the backend binaries and web app before running tests: + +```bash +make build-backend build-web +``` + +This produces `apps/backend/bin/kandev`, `apps/backend/bin/mock-agent`, and `apps/web/.next/`. The global setup script verifies all exist. + +## Running Tests + +All commands run from the repo root via Make targets (these build automatically): + +```bash +make test-e2e # Headless, parallel (default) +make test-e2e-headed # Visible browser window +make test-e2e-ui # Playwright UI mode (step through, inspect) +make test-e2e-report # Open HTML report from last run +``` + +Or directly via pnpm (requires pre-built binaries and web app): + +```bash +cd apps +pnpm --filter @kandev/web e2e # All tests +pnpm --filter @kandev/web e2e -- --grep "task creation" # By name +pnpm --filter @kandev/web e2e -- tests/create-task.spec.ts # Single file +pnpm --filter @kandev/web e2e:headed # With browser +pnpm --filter @kandev/web e2e:ui # UI mode +``` + +### Debug output + +Set `E2E_DEBUG=1` to see backend and frontend stderr in the terminal: + +```bash +E2E_DEBUG=1 make test-e2e +``` + +## Directory Structure + +``` +apps/web/e2e/ +├── playwright.config.ts # Playwright configuration +├── global-setup.ts # Verifies backend binaries and web build exist +├── fixtures/ +│ ├── backend.ts # Worker-scoped backend + frontend process fixture +│ └── test-base.ts # Extended test fixture (apiClient, seedData, testPage) +├── helpers/ +│ └── api-client.ts # HTTP client for API seeding +├── pages/ +│ ├── kanban-page.ts # Kanban board page object +│ └── task-detail-page.ts # Task detail page object +└── tests/ + ├── kanban-board.spec.ts # Kanban board display + ├── create-task.spec.ts # Task creation flows + ├── task-detail.spec.ts # Task detail views + └── workflow-steps.spec.ts # Workflow step progression +``` + +## How It Works + +### Backend + frontend fixture (`fixtures/backend.ts`) + +Each Playwright worker spawns an isolated `kandev` backend and a dedicated `next start` frontend: + +**Backend:** +1. Creates a temp directory as `HOME` with its own `.gitconfig`, data dir, and SQLite DB +2. Sets environment variables for isolation: + - `KANDEV_DATA_DIR` — temp data directory + - `KANDEV_SERVER_PORT` — unique port per worker (`18080 + workerIndex`) + - `KANDEV_DATABASE_PATH` — temp SQLite path + - `KANDEV_MOCK_AGENT=true` — only loads mock agent, skips agent discovery + - `KANDEV_MOCK_GITHUB=true` — uses in-memory MockClient instead of real GitHub API + - `KANDEV_DOCKER_ENABLED=false` — no Docker + - `KANDEV_WORKTREE_ENABLED=false` — no worktrees + - `GH_TOKEN` / `GITHUB_TOKEN` stripped — prevents accidental real API calls +3. Spawns `apps/backend/bin/kandev` and waits for `/health` to return 200 + +**Frontend:** +1. Spawns `npx next start --port {13000 + workerIndex}` from `apps/web/` +2. Sets `KANDEV_API_BASE_URL` to the worker's backend URL so SSR routes correctly +3. Waits for the frontend to become healthy +4. All `next start` processes share the same `.next/` build output (read-only) + +**Teardown:** sends SIGTERM to frontend then backend, falls back to SIGKILL after 5s, cleans up temp dir. + +### Test base (`fixtures/test-base.ts`) + +Extends the backend fixture with: + +- **`apiClient`** (worker-scoped) — HTTP client for seeding data via the backend API +- **`seedData`** (worker-scoped) — creates a workspace, discovers the default workflow and its steps +- **`testPage`** (per-test) — Playwright page with a browser context whose `baseURL` points to the worker's frontend, plus `localStorage` pre-configured: + - `kandev.onboarding.completed = "true"` — skips onboarding + - `window.__KANDEV_API_BASE_URL` — points to the worker's backend (client-side override) + +### API client (`helpers/api-client.ts`) + +Seeds test data via REST API. Available methods: + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| `createWorkspace(name)` | `POST /api/workspaces` | Create workspace | +| `createTask(workspaceId, title, desc)` | `POST /api/tasks` | Create task | +| `listWorkflows(workspaceId)` | `GET /api/workspaces/:id/workflows` | List workflows | +| `listWorkflowSteps(workflowId)` | `GET /api/workflows/:id/workflow/steps` | List steps | +| `createWorkflowStep(name, pos)` | `POST /api/workflow/steps` | Create step | +| `moveTask(taskId, stepId)` | `POST /api/tasks/:id/move` | Move task to step | + +**GitHub mock control:** + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| `mockGitHubReset()` | `DELETE /api/v1/github/mock/reset` | Clear all mock data | +| `mockGitHubSetUser(username)` | `PUT /api/v1/github/mock/user` | Set authenticated user | +| `mockGitHubAddPRs(prs)` | `POST /api/v1/github/mock/prs` | Add mock PRs | +| `mockGitHubAddOrgs(orgs)` | `POST /api/v1/github/mock/orgs` | Add mock organizations | +| `mockGitHubAddRepos(org, repos)` | `POST /api/v1/github/mock/repos` | Add mock repositories | +| `mockGitHubAddReviews(owner, repo, num, reviews)` | `POST /api/v1/github/mock/reviews` | Add PR reviews | +| `mockGitHubAddCheckRuns(owner, repo, ref, checks)` | `POST /api/v1/github/mock/checks` | Add CI check runs | +| `mockGitHubGetStatus()` | `GET /api/v1/github/status` | Verify auth status | + +### Page objects (`pages/`) + +Page objects encapsulate selectors and navigation: + +- **`KanbanPage`** — `goto()`, `board`, `createTaskButton`, `taskCard(id)`, `taskCardByTitle(title)` +- **`TaskDetailPage`** — `goto(taskId, sessionId)`, `sessionChat`, `turnCompleteIndicator`, `waitForAgentResponse(text)` + +### Selectors + +Tests use `data-testid` attributes for stability across layout/styling changes: + +| `data-testid` | Component | +|----------------|-----------| +| `kanban-board` | Kanban board container | +| `task-card-{id}` | Individual task card | +| `create-task-button` | Create task button | +| `create-task-dialog` | Task creation dialog | +| `task-title-input` | Title input in dialog | +| `task-description-input` | Description textarea in dialog | +| `submit-start-agent` | Start agent button | +| `submit-plan-mode` | Plan mode option | +| `session-chat` | Session chat panel | +| `agent-turn-complete` | Agent turn complete indicator | + +## Writing New Tests + +### 1. Create a spec file + +```typescript +// apps/web/e2e/tests/my-feature.spec.ts +import { test, expect } from "../fixtures/test-base"; +import { KanbanPage } from "../pages/kanban-page"; + +test.describe("my feature", () => { + test("does something", async ({ testPage, seedData, apiClient }) => { + // Seed data via API + const task = await apiClient.createTask( + seedData.workspaceId, + "Test Task", + "Description", + ); + + // Navigate and interact + const kanban = new KanbanPage(testPage); + await kanban.goto(seedData.workspaceId); + + // Assert + await expect(kanban.taskCardByTitle("Test Task")).toBeVisible(); + }); +}); +``` + +### 2. Add data-testid attributes + +When testing new UI components, add `data-testid` to the relevant elements: + +```tsx +
...
+``` + +Then reference them in page objects or directly: + +```typescript +page.getByTestId("my-component"); +``` + +### 3. Seed mock GitHub data + +The mock GitHub client (`KANDEV_MOCK_GITHUB=true`) reports as authenticated and serves data seeded via the `/api/v1/github/mock/` endpoints. Use the `apiClient` helper methods: + +```typescript +test("shows PR feedback in settings", async ({ testPage, apiClient, seedData }) => { + // Seed a mock PR + await apiClient.mockGitHubAddPRs([{ + number: 42, + title: "Add feature X", + state: "open", + head_branch: "feature-x", + base_branch: "main", + author_login: "mock-user", + repo_owner: "myorg", + repo_name: "myrepo", + html_url: "https://github.com/myorg/myrepo/pull/42", + }]); + + // Seed orgs for the org picker + await apiClient.mockGitHubAddOrgs([{ login: "myorg" }]); + + // Seed repos for the repo search + await apiClient.mockGitHubAddRepos("myorg", [ + { full_name: "myorg/myrepo", owner: "myorg", name: "myrepo" }, + ]); + + // Navigate and test GitHub UI features... +}); +``` + +Call `mockGitHubReset()` between tests if you need a clean slate (each worker already starts fresh). + +### 4. Extend the API client + +If your test needs new seed data, add methods to `helpers/api-client.ts`: + +```typescript +async createMyEntity(params: { ... }): Promise { + return this.post("/api/my-entities", params); +} +``` + +## Configuration + +| Setting | Value | Notes | +|---------|-------|-------| +| `fullyParallel` | `true` | Tests run in parallel across workers | +| `workers` | auto (local), 2 (CI) | Worker count | +| `timeout` | 60s | Per-test timeout | +| `retries` | 0 (local), 2 (CI) | Retry count | +| `trace` | on-first-retry | Playwright trace recording | +| `screenshot` | only-on-failure | Auto-screenshot | +| `video` | on-first-retry | Video recording | +| Browser | Chromium only | Single browser project | + +## CI + +The GitHub Actions workflow (`.github/workflows/e2e-tests.yml`) runs on pushes and PRs to `main` that touch `apps/backend/`, `apps/web/`, or `apps/packages/`. It: + +1. Builds Go binaries (`make build-backend`) +2. Installs pnpm dependencies +3. Installs Playwright Chromium +4. Runs `make test-e2e` +5. Uploads `playwright-report/` and `test-results/` as artifacts on failure + +## Troubleshooting + +**Tests fail with "Backend did not become healthy" or "Service did not become healthy"** +- Ensure `make build-backend build-web` completed successfully +- Check that `apps/backend/bin/kandev`, `apps/backend/bin/mock-agent`, and `apps/web/.next/` exist +- Run with `E2E_DEBUG=1` to see backend and frontend stderr + +**Tests fail with "Cannot find module" or import errors** +- Run `cd apps && pnpm install` to ensure dependencies are up to date + +**Port conflicts** +- Tests use ports 18080+ for backends and 13000+ for frontends (one per worker) +- Ensure nothing else is listening on those ports + +**Flaky timeouts** +- Backend and frontend health checks have a 30s timeout; if your machine is slow, this may need increasing in `fixtures/backend.ts` +- Per-test timeout is 60s; adjust in `playwright.config.ts` if needed