diff --git a/Makefile b/Makefile index 9d6f5239b7..ea28b785a6 100644 --- a/Makefile +++ b/Makefile @@ -333,6 +333,5 @@ clean-web: .PHONY: clean-db clean-db: - @printf "$(CYAN)Removing local SQLite database...$(RESET)\n" - @$(RM) kandev.db kandev.db-wal kandev.db-shm \ - $(BACKEND_DIR)/kandev.db $(BACKEND_DIR)/kandev.db-wal $(BACKEND_DIR)/kandev.db-shm + @printf "$(CYAN)Removing dev database (.kandev-dev/)...$(RESET)\n" + @$(RMDIR) .kandev-dev diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index b7581511db..2a65e88b90 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -14,6 +14,7 @@ import ( "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/workflow/engine" wfmodels "github.com/kandev/kandev/internal/workflow/models" + v1 "github.com/kandev/kandev/pkg/api/v1" ) // processOnTurnComplete processes the on_turn_complete events for the current step. @@ -260,7 +261,20 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s s.finalizeStepEnter(ctx, taskID, sessionID, targetStep, task.Description) } else { // on_turn_start transitions: user is about to send a message, no on_enter needed. - s.setSessionWaitingForInput(ctx, taskID, sessionID) + // However, we still need to switch the agent profile if the target step requires + // a different one — the user's prompt should go to the correct agent. + currentSession, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + s.logger.Warn("failed to load session for profile switch", + zap.String("session_id", sessionID), zap.Error(err)) + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return + } + effectiveSession, ok := s.maybySwitchSessionForProfile(ctx, taskID, currentSession, targetStep) + if !ok { + return + } + s.setSessionWaitingForInput(ctx, taskID, effectiveSession.ID) } } @@ -451,6 +465,12 @@ func (s *Service) switchSessionForStep(ctx context.Context, taskID string, curre zap.String("current_profile", currentSession.AgentProfileID), zap.String("new_profile", newAgentProfileID)) + // Signal to the frontend that the task is preparing a new agent. + if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateScheduling); err != nil { + s.logger.Warn("failed to set task SCHEDULING during agent switch", + zap.String("task_id", taskID), zap.Error(err)) + } + // Prepare the new session BEFORE touching the old one. // If any step below fails, the old session remains active and the task stays recoverable. task, err := s.scheduler.GetTask(ctx, taskID) @@ -477,6 +497,25 @@ func (s *Service) switchSessionForStep(ctx context.Context, taskID string, curre return nil, fmt.Errorf("failed to get new session: %w", err) } + // Inherit the task environment from the old session — the workspace is shared + // across sessions within the same task, so the new session can reuse the + // existing agentctl connection and workspace files. + if currentSession.TaskEnvironmentID != "" && newSession.TaskEnvironmentID == "" { + newSession.TaskEnvironmentID = currentSession.TaskEnvironmentID + newSession.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateTaskSession(ctx, newSession); err != nil { + s.logger.Warn("failed to copy task_environment_id to new session", + zap.String("session_id", newSession.ID), + zap.Error(err)) + } + } + + // Promote the new session to primary so it's loaded when navigating back to this task. + if err := s.repo.SetSessionPrimary(ctx, newSession.ID); err != nil { + s.logger.Warn("failed to set new session as primary", + zap.String("session_id", newSession.ID), zap.Error(err)) + } + // New session is ready — now safe to stop the old agent and complete the old session. if currentSession.AgentExecutionID != "" { if err := s.agentManager.StopAgent(ctx, currentSession.AgentExecutionID, false); err != nil { @@ -531,15 +570,17 @@ func (s *Service) maybySwitchSessionForProfile( func (s *Service) processOnEnter(ctx context.Context, taskID string, session *models.TaskSession, step *wfmodels.WorkflowStep, taskDescription string) { // Switch session if this step requires a different agent profile. var ok bool + prevSessionID := session.ID if session, ok = s.maybySwitchSessionForProfile(ctx, taskID, session, step); !ok { return } + sessionSwitched := session.ID != prevSessionID sessionID := session.ID isPassthrough := s.agentManager.IsPassthroughSession(ctx, sessionID) hasPlanMode := s.resolveStepPlanMode(ctx, session, step, isPassthrough) - if len(step.Events.OnEnter) == 0 { + if len(step.Events.OnEnter) == 0 && !sessionSwitched { s.setSessionWaitingForInput(ctx, taskID, sessionID, session) s.publishSessionWaitingEvent(ctx, taskID, sessionID, step.ID, session) return @@ -602,6 +643,27 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo } default: + // When the session was just switched (agent profile change) but the step + // has no auto_start_agent, launch the agent anyway — the profile override + // implies the user wants this agent to run on this step. + if sessionSwitched && step.Prompt != "" { + effectivePrompt := s.buildWorkflowPrompt(taskDescription, step, taskID, sessionID) + planMode := hasPlanMode + s.logger.Info("auto-launching agent after profile switch (no explicit auto_start)", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("step_name", step.Name)) + err := s.autoStartStepPrompt(ctx, taskID, session, step.Name, effectivePrompt, planMode, true) + if err != nil { + s.logger.Error("failed to launch agent after profile switch", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + s.setSessionWaitingForInput(ctx, taskID, sessionID, session) + s.publishSessionWaitingEvent(ctx, taskID, sessionID, step.ID, session) + } + return + } s.setSessionWaitingForInput(ctx, taskID, sessionID, session) s.publishSessionWaitingEvent(ctx, taskID, sessionID, step.ID, session) } @@ -924,6 +986,9 @@ func (s *Service) publishSessionWaitingEvent(ctx context.Context, taskID, sessio if session.AgentProfileID != "" { eventData["agent_profile_id"] = session.AgentProfileID } + if session.TaskEnvironmentID != "" { + eventData["task_environment_id"] = session.TaskEnvironmentID + } if len(session.Metadata) > 0 { eventData["session_metadata"] = session.Metadata } @@ -1079,8 +1144,10 @@ func (s *Service) applyEngineTransition( 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 { + // Even without on_enter, load the target step — needed for profile switch check. + 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)) @@ -1116,7 +1183,14 @@ func (s *Service) applyEngineTransition( } if !triggerOnEnter { - s.setSessionWaitingForInput(ctx, taskID, session.ID, session) + // on_turn_start transitions: user is about to send a message, no on_enter needed. + // However, we still need to switch the agent profile if the target step requires + // a different one — the user's prompt should go to the correct agent. + effectiveSession, ok := s.maybySwitchSessionForProfile(ctx, taskID, session, targetStep) + if !ok { + return false + } + s.setSessionWaitingForInput(ctx, taskID, effectiveSession.ID) return true } diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 34ee2489a6..986a911bfe 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -127,6 +127,8 @@ type repoStore interface { type sessionExecutorStore interface { // Session GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) + GetActiveTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) + SetSessionPrimary(ctx context.Context, sessionID string) error UpdateTaskSession(ctx context.Context, session *models.TaskSession) error UpdateTaskSessionState(ctx context.Context, id string, state models.TaskSessionState, errorMessage string) error UpdateTaskSessionBaseCommit(ctx context.Context, id string, baseCommitSHA string) error @@ -152,9 +154,8 @@ type sessionExecutorStore interface { CreateSessionCommit(ctx context.Context, commit *models.SessionCommit) error GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) DeleteSessionCommit(ctx context.Context, id string) error - // Session listing + primary/delete + // Session listing + delete ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) - SetSessionPrimary(ctx context.Context, sessionID string) error DeleteTaskSession(ctx context.Context, id string) error // Task environment GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 9f6adcff53..2f0bf5d471 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -175,10 +175,27 @@ func (s *Service) StartTaskWithSession(ctx context.Context, taskID string, sessi zap.Bool("plan_mode", planMode)) // Process on_turn_start before launching the agent. + // If the target step has a different agent profile, executeStepTransition will switch + // the session — so we need to pick up the new session afterwards. if session, err := s.repo.GetTaskSession(ctx, sessionID); err == nil { s.processOnTurnStartViaEngine(ctx, taskID, session) } + // Re-read the session — on_turn_start may have switched it (agent profile change). + reloadedSession, reloadErr := s.repo.GetTaskSession(ctx, sessionID) + if reloadErr != nil { + return nil, fmt.Errorf("failed to reload session after on_turn_start: %w", reloadErr) + } + if reloadedSession.State == models.TaskSessionStateCompleted { + activeSession, activeErr := s.repo.GetActiveTaskSessionByTaskID(ctx, taskID) + if activeErr != nil || activeSession == nil { + return nil, fmt.Errorf("session was switched during on_turn_start but no active session found: %w", activeErr) + } + sessionID = activeSession.ID + agentProfileID = activeSession.AgentProfileID + executorID = activeSession.ExecutorID + } + 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), @@ -275,13 +292,26 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag // 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. + // If the target step has a different agent profile, executeStepTransition switches + // the session — so we need to pick up the new session afterwards. s.processOnTurnStartViaEngine(ctx, taskID, session) - // Re-read the session after on_turn_start may have changed the workflow step. + // Re-read the session after on_turn_start may have switched it (agent profile change). + // The original session may have been completed; use the active session for this task. session, err = s.repo.GetTaskSession(ctx, sessionID) if err != nil { return nil, fmt.Errorf("failed to reload session after on_turn_start: %w", err) } + if session.State == models.TaskSessionStateCompleted { + // on_turn_start switched the agent profile — find the new active session. + activeSession, activeErr := s.repo.GetActiveTaskSessionByTaskID(ctx, taskID) + if activeErr != nil || activeSession == nil { + return nil, fmt.Errorf("session was switched during on_turn_start but no active session found: %w", activeErr) + } + session = activeSession + sessionID = activeSession.ID + effectiveProfileID = activeSession.AgentProfileID + } // Apply workflow step prompt wrapping and plan mode injection. // Called unconditionally so workflow-step prompt composition (prefix/suffix) diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts index cf73dc6cdc..55e06f5bb3 100644 --- a/apps/web/components/task-create-dialog-effects.ts +++ b/apps/web/components/task-create-dialog-effects.ts @@ -23,6 +23,7 @@ import { autoSelectBranch } from "@/components/task-create-dialog-helpers"; export function useWorkflowAgentProfileEffect( fs: DialogFormState, workflows: Array<{ id: string; agent_profile_id?: string }>, + agentProfiles: AgentProfileOption[], ) { const { selectedWorkflowId, setAgentProfileId, setWorkflowAgentProfileId } = fs; useEffect(() => { @@ -32,15 +33,21 @@ export function useWorkflowAgentProfileEffect( } const workflow = workflows.find((w) => w.id === selectedWorkflowId); if (workflow?.agent_profile_id) { - setAgentProfileId(workflow.agent_profile_id); + // Always lock the selector when the workflow specifies an agent profile. + // This prevents the race condition where agentProfiles hasn't loaded yet. setWorkflowAgentProfileId(workflow.agent_profile_id); + // Only set the agentProfileId once the profile is confirmed available. + const profileExists = agentProfiles.some((p) => p.id === workflow.agent_profile_id); + if (profileExists) { + setAgentProfileId(workflow.agent_profile_id); + } } else { setWorkflowAgentProfileId(""); // Restore the user's last-used agent profile when unlocking const lastId = getLocalStorage(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, null); setAgentProfileId(lastId ?? ""); } - }, [selectedWorkflowId, workflows, setAgentProfileId, setWorkflowAgentProfileId]); + }, [selectedWorkflowId, workflows, agentProfiles, setAgentProfileId, setWorkflowAgentProfileId]); } export function useWorkflowStepsEffect(fs: DialogFormState, workflowId: string | null) { @@ -174,11 +181,13 @@ export function useDefaultSelectionsEffect( fs: DialogFormState, open: boolean, sel: StoreSelections, + workflows: Array<{ id: string; agent_profile_id?: string }>, ) { const { agentProfiles, executors, workspaceDefaults } = sel; const { agentProfileId, workflowAgentProfileId, + selectedWorkflowId, executorId, executorProfileId, setAgentProfileId, @@ -186,7 +195,20 @@ export function useDefaultSelectionsEffect( setExecutorProfileId, } = fs; useEffect(() => { - if (!open || agentProfileId || workflowAgentProfileId || agentProfiles.length === 0) return; + // Check synchronously whether the selected workflow has an agent override. + // This avoids a race condition where workflowAgentProfileId state hasn't + // been committed yet by the workflow effect running in the same cycle. + const workflowHasAgent = selectedWorkflowId + ? workflows.some((w) => w.id === selectedWorkflowId && w.agent_profile_id) + : false; + if ( + !open || + agentProfileId || + workflowAgentProfileId || + workflowHasAgent || + agentProfiles.length === 0 + ) + return; const lastId = getLocalStorage(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, null); if (lastId && agentProfiles.some((p: AgentProfileOption) => p.id === lastId)) { void Promise.resolve().then(() => setAgentProfileId(lastId)); @@ -202,6 +224,8 @@ export function useDefaultSelectionsEffect( open, agentProfileId, workflowAgentProfileId, + selectedWorkflowId, + workflows, agentProfiles, workspaceDefaults, setAgentProfileId, @@ -384,11 +408,11 @@ export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreate const { open, workspaceId, workflowId, repositories, repositoriesLoading, branches } = args; const { agentProfiles, executors, workspaceDefaults, toast, workflows } = args; useWorkflowStepsEffect(fs, workflowId); - useWorkflowAgentProfileEffect(fs, workflows); + useWorkflowAgentProfileEffect(fs, workflows, agentProfiles); useRepositoryAutoSelectEffect(fs, open, workspaceId, repositories); useDiscoverReposEffect(fs, open, workspaceId, repositoriesLoading, toast); useBranchAutoSelectEffect(fs, branches); useLocalBranchesEffect(fs, open, workspaceId, toast); - useDefaultSelectionsEffect(fs, open, { agentProfiles, executors, workspaceDefaults }); + useDefaultSelectionsEffect(fs, open, { agentProfiles, executors, workspaceDefaults }, workflows); useGitHubUrlBranchesEffect(fs, open); } diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index 0f3529069e..94755e18ee 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import type { AgentProfileOption } from "@/lib/state/slices"; import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; import { WorkflowSelectorRow } from "@/components/workflow-selector-row"; +import { AgentLogo } from "@/components/agent-logo"; import type { DialogFormState } from "@/components/task-create-dialog-types"; import type { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut"; import { TaskFormInputs } from "@/components/task-create-dialog-selectors"; @@ -64,7 +65,7 @@ type CreateEditSelectorsProps = { }>; isLocalExecutor: boolean; useGitHubUrl: boolean; - workflowAgentProfileId?: string; + workflowAgentLocked: boolean; }; export const CreateEditSelectors = memo(function CreateEditSelectors({ @@ -87,14 +88,14 @@ export const CreateEditSelectors = memo(function CreateEditSelectors({ executorsLoading, isLocalExecutor, useGitHubUrl, - workflowAgentProfileId, + workflowAgentLocked, BranchSelectorComponent, AgentSelectorComponent, ExecutorProfileSelectorComponent, }: CreateEditSelectorsProps) { if (isTaskStarted) return null; - const agentLockedByWorkflow = Boolean(workflowAgentProfileId); + const agentLockedByWorkflow = workflowAgentLocked; const isLocalWithoutGitHubUrl = isLocalExecutor && !useGitHubUrl; @@ -235,10 +236,11 @@ export const SessionSelectors = memo(function SessionSelectors({ type WorkflowSectionProps = { isCreateMode: boolean; isTaskStarted: boolean; - workflows: Array<{ id: string; name: string; [key: string]: unknown }>; + workflows: Array<{ id: string; name: string; agent_profile_id?: string; [key: string]: unknown }>; snapshots: Record; effectiveWorkflowId: string | null; onWorkflowChange: (value: string) => void; + agentProfiles: AgentProfileOption[]; }; export const WorkflowSection = memo(function WorkflowSection({ @@ -248,6 +250,7 @@ export const WorkflowSection = memo(function WorkflowSection({ snapshots, effectiveWorkflowId, onWorkflowChange, + agentProfiles, }: WorkflowSectionProps) { const [lastUsedWorkflowId, setLastUsedWorkflowId] = useState(null); @@ -259,7 +262,46 @@ export const WorkflowSection = memo(function WorkflowSection({ [onWorkflowChange], ); - if (!isCreateMode || workflows.length <= 1 || isTaskStarted) return null; + if (!isCreateMode || isTaskStarted) return null; + + // Single workflow — show agent override info if any overrides exist + if (workflows.length <= 1) { + const singleWorkflow = workflows[0]; + if (!singleWorkflow) return null; + const snapshot = snapshots[singleWorkflow.id]; + const workflowProfile = singleWorkflow.agent_profile_id + ? agentProfiles.find((p) => p.id === singleWorkflow.agent_profile_id) + : null; + const stepsWithOverrides = (snapshot?.steps ?? []) + .filter((s) => s.agent_profile_id) + .map((s) => ({ + name: s.title, + profile: agentProfiles.find((p) => p.id === s.agent_profile_id), + })) + .filter((s) => s.profile); + if (!workflowProfile && stepsWithOverrides.length === 0) return null; + return ( +
+ {workflowProfile && ( + + + {workflowProfile.label} + + )} + {stepsWithOverrides.map((s) => ( + + {s.name}: + + {s.profile!.label} + + ))} +
+ ); + } + return ( ); }); diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index 89570cdeaf..a2086217e5 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -522,6 +522,7 @@ export function useDialogComputed({ workspaces, executors, repositories, + workflows, }: DialogComputedArgs): DialogComputedValues { const isPassthroughProfile = useMemo( () => computePassthroughProfile(fs.agentProfileId, agentProfiles), @@ -562,6 +563,14 @@ export function useDialogComputed({ const { headerRepositoryOptions } = useRepositoryOptions(repositories, fs.discoveredRepositories); const agentProfilesLoading = open && !settingsData.agentsLoaded; const executorsLoading = open && !settingsData.executorsLoaded; + // Compute workflow agent lock directly from data — avoids effect timing issues. + const workflowAgentProfileId = (() => { + const wfId = effectiveWorkflowId; + if (!wfId) return ""; + const wf = workflows.find((w) => w.id === wfId); + return wf?.agent_profile_id ?? ""; + })(); + const workflowAgentLocked = Boolean(workflowAgentProfileId); return { isPassthroughProfile, effectiveWorkflowId, @@ -576,6 +585,8 @@ export function useDialogComputed({ headerRepositoryOptions, agentProfilesLoading, executorsLoading, + workflowAgentLocked, + workflowAgentProfileId, }; } @@ -628,6 +639,7 @@ export function useTaskCreateDialogData( workspaces, executors, repositories, + workflows, }); return { workflows, diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index 0c03c76fbb..e312673826 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -52,6 +52,10 @@ export type DialogComputedValues = { headerRepositoryOptions: ReturnType["headerRepositoryOptions"]; agentProfilesLoading: boolean; executorsLoading: boolean; + /** True when the effective workflow has an agent_profile_id override */ + workflowAgentLocked: boolean; + /** The agent_profile_id from the effective workflow (empty string if none) */ + workflowAgentProfileId: string; }; export type DialogComputedArgs = { @@ -66,6 +70,7 @@ export type DialogComputedArgs = { workspaces: Workspace[]; executors: Executor[]; repositories: Repository[]; + workflows: Array<{ id: string; agent_profile_id?: string }>; }; export type TaskCreateEffectsArgs = { diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index b1bc7760d0..76a987a4db 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -263,7 +263,7 @@ type DialogFormBodyProps = { hasRepositorySelection: boolean; isLocalExecutor: boolean; enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean }; - workflowAgentProfileId?: string; + workflowAgentLocked: boolean; }; function DialogFormBody({ @@ -293,7 +293,7 @@ function DialogFormBody({ hasRepositorySelection, isLocalExecutor, enhance, - workflowAgentProfileId, + workflowAgentLocked, }: DialogFormBodyProps) { return (
@@ -328,7 +328,7 @@ function DialogFormBody({ executorsLoading={executorsLoading} isLocalExecutor={isLocalExecutor} useGitHubUrl={fs.useGitHubUrl} - workflowAgentProfileId={workflowAgentProfileId} + workflowAgentLocked={workflowAgentLocked} BranchSelectorComponent={BranchSelector} AgentSelectorComponent={AgentSelector} ExecutorProfileSelectorComponent={ExecutorProfileSelector} @@ -341,6 +341,7 @@ function DialogFormBody({ snapshots={snapshots as Parameters[0]["snapshots"]} effectiveWorkflowId={effectiveWorkflowId} onWorkflowChange={onWorkflowChange} + agentProfiles={agentProfiles} /> {isSessionMode && ( = { CREATED: { label: "", icon: null }, - STARTING: { label: "Agent is starting", icon: "spinner" }, + STARTING: { label: "Agent is starting", dynamicLabel: true, icon: "spinner" }, RUNNING: { label: "Agent is running", icon: "spinner" }, WAITING_FOR_INPUT: { label: "", icon: null }, COMPLETED: { label: "", icon: null }, @@ -247,14 +247,26 @@ function renderActiveStatus( } } +function useAgentLabel(sessionId: string | null, dynamicLabel?: boolean): string | null { + const agentProfileId = useAppStore((state) => + sessionId ? state.taskSessions.items[sessionId]?.agent_profile_id : undefined, + ) as string | undefined; + const agentProfiles = useAppStore((state) => state.agentProfiles.items); + if (!dynamicLabel || !agentProfileId) return null; + const profile = agentProfiles.find((p) => p.id === agentProfileId); + return profile ? profile.label.split(" \u2022 ")[0] : null; +} + export function AgentStatus({ sessionState, sessionId, messages = [] }: AgentStatusProps) { const config = sessionState ? STATE_CONFIG[sessionState] : null; const isRunning = config?.icon === "spinner"; + const agentLabel = useAgentLabel(sessionId, config?.dynamicLabel); const runningData = useAgentStatusData(sessionId, messages, isRunning); if (config?.icon) { - return renderActiveStatus({ label: config.label, icon: config.icon }, sessionId, runningData); + const label = agentLabel ? `Starting ${agentLabel}` : config.label; + return renderActiveStatus({ label, icon: config.icon }, sessionId, runningData); } const displayDuration = runningData.displayDuration; diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index 6627e97a7f..6106fb9b19 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -383,6 +383,8 @@ const VALID_COMPONENTS = new Set(Object.keys(components)); function useSessionSwitchCleanup(effectiveSessionId: string | null) { const prevSessionRef = useRef(undefined); + const prevTaskRef = useRef(null); + const appStore = useAppStoreApi(); useEffect(() => { if (prevSessionRef.current === undefined) { prevSessionRef.current = effectiveSessionId; @@ -398,9 +400,29 @@ function useSessionSwitchCleanup(effectiveSessionId: string | null) { // serves as a backup for external session changes (e.g. WS-driven) // that don't go through the sidebar/dropdown switch helpers. if (effectiveSessionId) { + // Skip full layout rebuild for same-task session switches (workflow step + // transitions with different agent profiles). useAutoSessionTab already + // handles creating the new panel — a full fromJSON rebuild would destroy + // the old session tab and cause a visible layout flash. + const state = appStore.getState(); + const oldTask = oldSessionId ? state.taskSessions.items[oldSessionId]?.task_id : null; + const newTask = state.taskSessions.items[effectiveSessionId]?.task_id; + // Use activeTaskId as fallback when the old session has been cleaned from + // the store (e.g., after a COMPLETED state change removed it). + const effectiveOldTask = oldTask ?? prevTaskRef.current; + if (effectiveOldTask && newTask && effectiveOldTask === newTask) { + console.log("[session-switch] same-task switch, skipping layout rebuild", { + oldSessionId: oldSessionId?.slice(0, 8), + newSessionId: effectiveSessionId.slice(0, 8), + taskId: newTask.slice(0, 8), + }); + prevTaskRef.current = newTask; + return; + } + prevTaskRef.current = newTask ?? null; performLayoutSwitch(oldSessionId, effectiveSessionId); } - }, [effectiveSessionId]); + }, [effectiveSessionId, appStore]); } // --------------------------------------------------------------------------- diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts index ed1628a189..bf1502fad6 100644 --- a/apps/web/components/task/dockview-session-tabs.ts +++ b/apps/web/components/task/dockview-session-tabs.ts @@ -4,7 +4,7 @@ import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { focusOrAddPanel } from "@/lib/state/dockview-layout-builders"; -import { useAppStore } from "@/components/state-provider"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { wasPRPanelOffered, markPRPanelOffered } from "@/lib/local-storage"; /** @@ -163,12 +163,38 @@ export function useAutoPRPanel() { }, [taskId, hasPR, hasApi, sessionId]); } +/** Remove panels for terminal sessions that are no longer active. */ +function cleanupTerminalSessionPanels( + api: DockviewReadyEvent["api"], + createdSet: Set, + activeSessionId: string, + sessions: Record, +): void { + for (const panelId of [...createdSet]) { + if (panelId === activeSessionId) continue; + const sess = sessions[panelId]; + if (!sess) continue; + if (sess.state === "COMPLETED" || sess.state === "CANCELLED" || sess.state === "FAILED") { + const stalePanel = api.getPanel(`session:${panelId}`); + if (stalePanel) { + try { + stalePanel.api.close(); + } catch { + /* already gone */ + } + } + createdSet.delete(panelId); + } + } +} + /** * Auto-create a session tab when a session becomes active. * Replaces the generic "chat" panel with a per-session tab on first use. */ export function useAutoSessionTab(effectiveSessionId: string | null) { const sessionTabCreatedRef = useRef>(new Set()); + const appStore = useAppStoreApi(); useEffect(() => { if (!effectiveSessionId) return; const api = useDockviewStore.getState().api; @@ -185,6 +211,9 @@ export function useAutoSessionTab(effectiveSessionId: string | null) { } if (api.getPanel(`session:${effectiveSessionId}`)) { sessionTabCreatedRef.current.add(effectiveSessionId); + // Activate the existing panel so it comes to focus + const existingPanel = api.getPanel(`session:${effectiveSessionId}`); + if (existingPanel) existingPanel.api.setActive(); return; } // In maximized state the session panel is intentionally absent from the layout; @@ -221,5 +250,14 @@ export function useAutoSessionTab(effectiveSessionId: string | null) { useDockviewStore.setState({ centerGroupId: panel.group.id }); } sessionTabCreatedRef.current.add(effectiveSessionId); - }, [effectiveSessionId]); + + // Clean up panels for completed intermediate sessions (e.g., sessions + // created during on_turn_start profile switch that were immediately completed). + cleanupTerminalSessionPanels( + api, + sessionTabCreatedRef.current, + effectiveSessionId, + appStore.getState().taskSessions.items, + ); + }, [effectiveSessionId, appStore]); } diff --git a/apps/web/components/workflow-selector-row.tsx b/apps/web/components/workflow-selector-row.tsx index f351adfc1c..194c01c7b0 100644 --- a/apps/web/components/workflow-selector-row.tsx +++ b/apps/web/components/workflow-selector-row.tsx @@ -1,30 +1,76 @@ "use client"; import { Fragment, memo, useMemo, useState } from "react"; -import { IconCheck, IconChevronDown, IconLogicBuffer, IconUserCog } from "@tabler/icons-react"; +import { IconCheck, IconChevronDown, IconLogicBuffer } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Button } from "@kandev/ui/button"; import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; +import type { AgentProfileOption } from "@/lib/state/slices"; +import { AgentLogo } from "@/components/agent-logo"; -type StepItem = { id: string; title: string; color: string; position: number }; +type StepItem = { + id: string; + title: string; + color: string; + position: number; + agent_profile_id?: string; + is_start_step?: boolean; +}; -function InlineSteps({ steps }: { steps: StepItem[] }) { +function InlineSteps({ + steps, + agentProfiles, +}: { + steps: StepItem[]; + agentProfiles: AgentProfileOption[]; +}) { if (steps.length === 0) return null; return (
- {steps.map((s, i) => ( - - {i > 0 && →} - - - {s.title} - - - ))} + {steps.map((s, i) => { + const stepProfile = s.agent_profile_id + ? agentProfiles.find((p) => p.id === s.agent_profile_id) + : null; + return ( + + {i > 0 && {"\u2192"}} + + + {s.title} + {s.is_start_step && ( + + + + * + + Start step + + + )} + {stepProfile && ( + + + + + + + + {stepProfile.label} + + + )} + + + ); + })}
); } @@ -40,6 +86,7 @@ type WorkflowSelectorRowProps = { selectedWorkflowId: string | null; onWorkflowChange: (workflowId: string) => void; lastUsedWorkflowId?: string | null; + agentProfiles: AgentProfileOption[]; }; export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({ @@ -48,6 +95,7 @@ export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({ selectedWorkflowId, onWorkflowChange, lastUsedWorkflowId, + agentProfiles, }: WorkflowSelectorRowProps) { const [open, setOpen] = useState(false); @@ -94,20 +142,30 @@ export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({
{wf.name} - {wf.agent_profile_id && ( - - - - - - Custom agent profile - - - )} + {wf.agent_profile_id && + (() => { + const wfProfile = agentProfiles.find((p) => p.id === wf.agent_profile_id); + return wfProfile ? ( + + + + + + + + {wfProfile.label} + + + ) : null; + })()}
{steps.length > 0 && (
- +
)} {isSelected && ( diff --git a/apps/web/e2e/tests/workflow/workflow-agent-profile.spec.ts b/apps/web/e2e/tests/workflow/workflow-agent-profile.spec.ts index 636dc4058b..d9a48fb736 100644 --- a/apps/web/e2e/tests/workflow/workflow-agent-profile.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-agent-profile.spec.ts @@ -143,4 +143,154 @@ test.describe("Workflow agent profile", () => { }); } }); + + test("single workflow with agent override enables Start task button and shows profile", async ({ + testPage, + seedData, + apiClient, + }) => { + // Set an agent profile on the only workflow + const { agents } = await apiClient.listAgents(); + const agentProfile = agents.flatMap((a) => a.profiles ?? [])[0]; + expect(agentProfile).toBeDefined(); + await apiClient.updateWorkflow(seedData.workflowId, { + agent_profile_id: agentProfile.id, + }); + + try { + // Verify the API actually has the agent_profile_id set + const { workflows: wfList } = await apiClient.listWorkflows(seedData.workspaceId); + const updatedWf = wfList.find((w) => w.id === seedData.workflowId); + expect(updatedWf?.agent_profile_id).toBe(agentProfile.id); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + // Reload to pick up the updated workflow; wait for network to settle + await testPage.reload({ waitUntil: "networkidle" }); + + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + await testPage.getByTestId("task-title-input").fill("Single Workflow Test"); + await testPage.getByTestId("task-description-input").fill("testing single workflow"); + + // The selector should be disabled with "Agent set by workflow" text + await expect(testPage.getByText("Agent set by workflow")).toBeVisible({ timeout: 10_000 }); + + // The agent selector should be disabled (locked by workflow) + const agentSelector = testPage.getByTestId("agent-profile-selector"); + await expect(agentSelector).toBeDisabled({ timeout: 10_000 }); + } finally { + await apiClient.updateWorkflow(seedData.workflowId, { + agent_profile_id: "", + }); + } + }); + + test("workflow selector shows agent icon for workflow-level override", async ({ + testPage, + seedData, + apiClient, + }) => { + const { agents } = await apiClient.listAgents(); + const agentProfile = agents.flatMap((a) => a.profiles ?? [])[0]; + expect(agentProfile).toBeDefined(); + + // Set agent profile on seeded workflow + await apiClient.updateWorkflow(seedData.workflowId, { + agent_profile_id: agentProfile.id, + }); + + let noProfileWorkflowId: string | undefined; + try { + // Create second workflow without agent profile so selector is visible + const noProfileWorkflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Plain Workflow", + "simple", + ); + noProfileWorkflowId = noProfileWorkflow.id; + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await testPage.reload({ waitUntil: "networkidle" }); + + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + await testPage.getByTestId("task-title-input").fill("Icon Test"); + + // Open workflow selector + const workflowButton = dialog.locator("button", { hasText: "E2E Workflow" }); + await expect(workflowButton).toBeVisible({ timeout: 10_000 }); + await workflowButton.click(); + + // The workflow with agent override should have an agent logo + const agentLogo = testPage.getByTestId("workflow-agent-logo"); + await expect(agentLogo.first()).toBeVisible(); + } finally { + if (noProfileWorkflowId) { + await apiClient.deleteWorkflow(noProfileWorkflowId).catch(() => {}); + } + await apiClient.updateWorkflow(seedData.workflowId, { + agent_profile_id: "", + }); + } + }); + + test("step-level agent override shown in workflow selector", async ({ + testPage, + seedData, + apiClient, + }) => { + const { agents } = await apiClient.listAgents(); + const agentProfile = agents.flatMap((a) => a.profiles ?? [])[0]; + expect(agentProfile).toBeDefined(); + + // Set agent profile on the first step + const firstStep = seedData.steps[0]; + expect(firstStep).toBeDefined(); + await apiClient.updateWorkflowStep(firstStep.id, { + agent_profile_id: agentProfile.id, + }); + + let extraWorkflowId: string | undefined; + try { + // Create second workflow so selector is visible + const extraWorkflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Extra Workflow", + "simple", + ); + extraWorkflowId = extraWorkflow.id; + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await testPage.reload({ waitUntil: "networkidle" }); + + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + await testPage.getByTestId("task-title-input").fill("Step Override Test"); + + // Open workflow selector + const workflowButton = dialog.locator("button", { hasText: "E2E Workflow" }); + await expect(workflowButton).toBeVisible({ timeout: 10_000 }); + await workflowButton.click(); + + // The step with agent override should show an agent logo + const stepAgentLogo = testPage.getByTestId("step-agent-logo"); + await expect(stepAgentLogo.first()).toBeVisible({ timeout: 10_000 }); + } finally { + if (extraWorkflowId) { + await apiClient.deleteWorkflow(extraWorkflowId).catch(() => {}); + } + await apiClient.updateWorkflowStep(firstStep.id, { + agent_profile_id: "", + }); + } + }); }); diff --git a/apps/web/e2e/tests/workflow/workflow-agent-switch.spec.ts b/apps/web/e2e/tests/workflow/workflow-agent-switch.spec.ts index 71c3bb0b77..4e1fc384e9 100644 --- a/apps/web/e2e/tests/workflow/workflow-agent-switch.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-agent-switch.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; import { WorkflowSettingsPage } from "../../pages/workflow-settings-page"; async function createProfiles( @@ -138,6 +139,224 @@ test.describe("Workflow agent profile switching", () => { expect(finalSessions[1].agent_profile_id).toBe(profileB.id); }); + test("manual step move updates chat UI to show new agent profile", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(60_000); + const { profileA, profileB } = await createProfiles(apiClient); + + // Create workflow: Step1 (profileA, auto_start, is_start) → Step2 (profileB, auto_start) + const workflow = await apiClient.createWorkflow(seedData.workspaceId, "UI Switch Test"); + const step1 = await apiClient.createWorkflowStep(workflow.id, "Step1", 0, { + is_start_step: true, + }); + const step2 = await apiClient.createWorkflowStep(workflow.id, "Step2", 1); + + await apiClient.updateWorkflowStep(step1.id, { + agent_profile_id: profileA.id, + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + await apiClient.updateWorkflowStep(step2.id, { + agent_profile_id: profileB.id, + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + + // Create task in Step1 with profileA + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "UI Switch Task", + profileA.id, + { + workflow_id: workflow.id, + workflow_step_id: step1.id, + repository_ids: [seedData.repositoryId], + }, + ); + + // Navigate to task and wait for the chat to load + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await expect(session.chat).toBeVisible({ timeout: 15_000 }); + + // Wait for the first session tab to appear with the correct profile name + const sessionTabs = testPage.locator('[data-testid^="session-tab-"]'); + await expect(sessionTabs.first()).toBeVisible({ timeout: 30_000 }); + await expect(sessionTabs.first()).toContainText("Profile A", { timeout: 10_000 }); + + // Wait for the agent to be ready (WAITING_FOR_INPUT) before moving + for (let i = 0; i < 20; i++) { + const { sessions } = await apiClient.listTaskSessions(task.id); + if (sessions.some((s) => s.state === "WAITING_FOR_INPUT")) break; + await new Promise((r) => setTimeout(r, 500)); + } + + // Move task to Step2 — should create new session with profileB + await apiClient.moveTask(task.id, workflow.id, step2.id); + + // The UI should create a new session. Verify via API that the backend created it. + const finalSessions = await pollSessions(apiClient, task.id, 2, 30_000); + const profileBSession = finalSessions.find((s) => s.agent_profile_id === profileB.id); + expect(profileBSession).toBeDefined(); + }); + + test("on_turn_start transition to step with agent override uses correct profile", async ({ + apiClient, + seedData, + }) => { + test.setTimeout(60_000); + const { profileA, profileB } = await createProfiles(apiClient); + + // Backlog (on_turn_start: move_to_next) → Step1 (profileB, auto_start) + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Agent Switch OnTurnStart", + ); + const backlog = await apiClient.createWorkflowStep(workflow.id, "Backlog", 0); + const step1 = await apiClient.createWorkflowStep(workflow.id, "Step1", 1); + await apiClient.createWorkflowStep(workflow.id, "Done", 2); + + await apiClient.updateWorkflowStep(backlog.id, { + events: { on_turn_start: [{ type: "move_to_next" }] }, + }); + await apiClient.updateWorkflowStep(step1.id, { + agent_profile_id: profileB.id, + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + + // Create task with profileA — on_turn_start should move to Step1 and switch to profileB + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "OnTurnStart Switch", + profileA.id, + { + workflow_id: workflow.id, + workflow_step_id: backlog.id, + repository_ids: [seedData.repositoryId], + }, + ); + + // Poll for sessions — on_turn_start creates an intermediate session then switches + const sessions = await pollSessions(apiClient, task.id, 2, 45_000); + const profileBSession = sessions.find((s) => s.agent_profile_id === profileB.id); + expect(profileBSession).toBeDefined(); + }); + + test("auto-launches agent when step has profile override and prompt", async ({ + apiClient, + seedData, + }) => { + test.setTimeout(60_000); + const { profileA, profileB } = await createProfiles(apiClient); + + // Step1 (profileA, auto_start, is_start) → Step2 (profileB, prompt but NO auto_start) + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Agent Switch AutoLaunch", + ); + const step1 = await apiClient.createWorkflowStep(workflow.id, "Step1", 0, { + is_start_step: true, + }); + const step2 = await apiClient.createWorkflowStep(workflow.id, "Step2", 1); + await apiClient.createWorkflowStep(workflow.id, "Done", 2); + + await apiClient.updateWorkflowStep(step1.id, { + agent_profile_id: profileA.id, + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + // Step2 has profile + prompt but NO auto_start_agent — should still auto-launch + await apiClient.updateWorkflowStep(step2.id, { + agent_profile_id: profileB.id, + prompt: "hello from step2", + }); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "AutoLaunch Task", + profileA.id, + { + workflow_id: workflow.id, + workflow_step_id: step1.id, + repository_ids: [seedData.repositoryId], + }, + ); + + // Wait for Step1 agent to finish + for (let i = 0; i < 20; i++) { + const { sessions } = await apiClient.listTaskSessions(task.id); + if (sessions.some((s) => s.state === "WAITING_FOR_INPUT")) break; + await new Promise((r) => setTimeout(r, 500)); + } + + // Move to Step2 — should auto-launch agent despite no auto_start_agent + await apiClient.moveTask(task.id, workflow.id, step2.id); + + // Poll for a session with profileB that has completed at least one turn + const finalSessions = await pollSessions(apiClient, task.id, 2, 30_000); + const step2Session = finalSessions.find((s) => s.agent_profile_id === profileB.id); + expect(step2Session).toBeDefined(); + // The session should have progressed past CREATED (agent was launched) + expect(step2Session!.state).not.toBe("CREATED"); + }); + + test("new session inherits task_environment_id from old session", async ({ + apiClient, + seedData, + }) => { + test.setTimeout(60_000); + const { profileA, profileB } = await createProfiles(apiClient); + + // Step1 (profileA, auto_start, is_start) → Step2 (profileB, auto_start) + const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Env Inherit Test"); + const step1 = await apiClient.createWorkflowStep(workflow.id, "Step1", 0, { + is_start_step: true, + }); + const step2 = await apiClient.createWorkflowStep(workflow.id, "Step2", 1); + await apiClient.createWorkflowStep(workflow.id, "Done", 2); + + await apiClient.updateWorkflowStep(step1.id, { + agent_profile_id: profileA.id, + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + await apiClient.updateWorkflowStep(step2.id, { + agent_profile_id: profileB.id, + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Env Inherit Task", + profileA.id, + { + workflow_id: workflow.id, + workflow_step_id: step1.id, + repository_ids: [seedData.repositoryId], + }, + ); + + // Wait for Step1 agent to finish + for (let i = 0; i < 20; i++) { + const { sessions } = await apiClient.listTaskSessions(task.id); + if (sessions.some((s) => s.state === "WAITING_FOR_INPUT")) break; + await new Promise((r) => setTimeout(r, 500)); + } + + // Move to Step2 + await apiClient.moveTask(task.id, workflow.id, step2.id); + + // Wait for second session + const finalSessions = await pollSessions(apiClient, task.id, 2, 30_000); + const step2Session = finalSessions.find((s) => s.agent_profile_id === profileB.id); + expect(step2Session).toBeDefined(); + + // The new session should inherit task_environment_id from the old session + const step1Session = finalSessions.find((s) => s.agent_profile_id === profileA.id); + if (step1Session?.task_environment_id) { + expect(step2Session!.task_environment_id).toBe(step1Session.task_environment_id); + } + }); + test("reset context checkbox is disabled when step has agent profile override", async ({ testPage, apiClient, diff --git a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts index 9672503865..ae34cef90a 100644 --- a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts +++ b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts @@ -28,6 +28,7 @@ async function fetchAndWriteSnapshot( allow_manual_move: step.allow_manual_move, prompt: step.prompt, is_start_step: step.is_start_step, + agent_profile_id: step.agent_profile_id, })); const stepIds = new Set(steps.map((s) => s.id)); diff --git a/apps/web/hooks/domains/kanban/use-plan-actions.ts b/apps/web/hooks/domains/kanban/use-plan-actions.ts index 3bea15efa5..64130200e2 100644 --- a/apps/web/hooks/domains/kanban/use-plan-actions.ts +++ b/apps/web/hooks/domains/kanban/use-plan-actions.ts @@ -23,11 +23,11 @@ function useNextWorkflowStep(taskId: string | null) { return task?.workflowStepId ?? null; }); - // Track which step the user was on when they clicked proceed. isMoving is true - // while taskStepId still matches the step we're moving FROM. Once the WS event - // updates taskStepId, isMoving becomes false and the button is clickable again. - const [moveFromStepId, setMoveFromStepId] = useState(null); - const isMoving = moveFromStepId != null && taskStepId === moveFromStepId; + // Track agent switching: isMoving stays true from "proceed" click until the + // new session is adopted (activeSessionId changes from the original). + const [moveFromSessionId, setMoveFromSessionId] = useState(null); + const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); + const isMoving = moveFromSessionId != null && activeSessionId === moveFromSessionId; const sortedSteps = useMemo(() => [...steps].sort((a, b) => a.position - b.position), [steps]); @@ -60,19 +60,26 @@ function useNextWorkflowStep(taskId: string | null) { const proceed = useCallback(async () => { if (!taskId || !workflowId || !nextStep) return; - setMoveFromStepId(taskStepId); + const capturedSessionId = activeSessionId; + setMoveFromSessionId(capturedSessionId); try { await moveTask(taskId, { workflow_id: workflowId, workflow_step_id: nextStep.id, position: 0, }); + // Safety: if the next step reuses the same session (no agent-profile + // override), activeSessionId never changes and isMoving would be stuck. + // Clear after 10 s if no session handoff occurred. + setTimeout(() => { + setMoveFromSessionId((prev) => (prev === capturedSessionId ? null : prev)); + }, 10_000); } catch (err) { console.error("Failed to proceed to next step:", err); toast({ description: "Failed to proceed to next step", variant: "error" }); - setMoveFromStepId(null); + setMoveFromSessionId(null); } - }, [taskId, workflowId, nextStep, taskStepId, toast]); + }, [taskId, workflowId, nextStep, activeSessionId, toast]); const proceedStepName = nextStep && !currentStepAutoTransitions ? nextStep.title : null; diff --git a/apps/web/hooks/domains/session/use-session-state.test.ts b/apps/web/hooks/domains/session/use-session-state.test.ts index 7348a50785..cb236f3102 100644 --- a/apps/web/hooks/domains/session/use-session-state.test.ts +++ b/apps/web/hooks/domains/session/use-session-state.test.ts @@ -116,6 +116,25 @@ describe("useSessionState", () => { expect(result.current.isWorking).toBe(true); }); + it("does not set isStarting when session state is CREATED", () => { + mockSession = createMockSession("session-1", "task-1", "CREATED"); + + const { result } = renderHook(() => useSessionState("session-1")); + + // CREATED is not isStarting — the input should be enabled so tests + // that create sessions and immediately fill() the input work correctly. + expect(result.current.isStarting).toBe(false); + }); + + it("does not set isStarting when WAITING_FOR_INPUT", () => { + mockSession = createMockSession("session-1", "task-1", "WAITING_FOR_INPUT"); + + const { result } = renderHook(() => useSessionState("session-1")); + + expect(result.current.isStarting).toBe(false); + expect(result.current.isWorking).toBe(false); + }); + it("sets isAgentBusy when session state is RUNNING", () => { mockSession = createMockSession("session-1", "task-1", "RUNNING"); diff --git a/apps/web/lib/state/dockview-session-switch.ts b/apps/web/lib/state/dockview-session-switch.ts index cfe7ea0209..0baa3965a8 100644 --- a/apps/web/lib/state/dockview-session-switch.ts +++ b/apps/web/lib/state/dockview-session-switch.ts @@ -144,7 +144,9 @@ export function performSessionSwitch(params: SessionSwitchParams): LayoutGroupId // Try fast path: skip fromJSON when layout structure hasn't changed const fastResult = tryFastSessionSwitch(params); - if (fastResult) return fastResult; + if (fastResult) { + return fastResult; + } // Slow path: full layout rebuild via fromJSON const saved = getSessionLayout(newSessionId); diff --git a/apps/web/lib/ws/handlers/agent-session.test.ts b/apps/web/lib/ws/handlers/agent-session.test.ts index f7012732f1..ec689b9e51 100644 --- a/apps/web/lib/ws/handlers/agent-session.test.ts +++ b/apps/web/lib/ws/handlers/agent-session.test.ts @@ -1,14 +1,22 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { registerTaskSessionHandlers } from "./agent-session"; +import { + isTerminalSessionState, + pickReplacementSessionId, + registerTaskSessionHandlers, + shouldAdoptNewSession, +} from "./agent-session"; import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; +import type { TaskSessionState } from "@/lib/types/http"; function makeStore(overrides: Record = {}) { const state: Record = { + tasks: { activeTaskId: null, activeSessionId: null }, taskSessions: { items: {} }, taskSessionsByTask: { itemsByTaskId: {} }, setTaskSession: vi.fn(), setTaskSessionsForTask: vi.fn(), + setActiveSession: vi.fn(), setSessionFailureNotification: vi.fn(), setContextWindow: vi.fn(), ...overrides, @@ -22,8 +30,10 @@ function makeStore(overrides: Record = {}) { } as unknown as StoreApi; } +const STATE_CHANGED_EVENT = "session.state_changed"; + function makeMessage(payload: Record) { - return { id: "msg-1", type: "notification", action: "session.state_changed", payload }; + return { id: "msg-1", type: "notification", action: STATE_CHANGED_EVENT, payload }; } describe("session.state_changed handler", () => { @@ -41,7 +51,7 @@ describe("session.state_changed handler", () => { items: { "s-1": { id: "s-1", task_id: "t-1", state: "STARTING" } }, }, }); - handler = registerTaskSessionHandlers(store)["session.state_changed"]!; + handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; handler( makeMessage({ @@ -65,7 +75,7 @@ describe("session.state_changed handler", () => { items: { "s-1": { id: "s-1", task_id: "t-1", state: "FAILED" } }, }, }); - handler = registerTaskSessionHandlers(store)["session.state_changed"]!; + handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; handler( makeMessage({ @@ -81,7 +91,7 @@ describe("session.state_changed handler", () => { it("sets failure notification for unknown session (first event)", () => { store = makeStore(); - handler = registerTaskSessionHandlers(store)["session.state_changed"]!; + handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; handler( makeMessage({ @@ -105,7 +115,7 @@ describe("session.state_changed handler", () => { items: { "s-1": { id: "s-1", task_id: "t-1", state: "STARTING" } }, }, }); - handler = registerTaskSessionHandlers(store)["session.state_changed"]!; + handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; handler( makeMessage({ @@ -120,3 +130,262 @@ describe("session.state_changed handler", () => { expect(store.getState().setSessionFailureNotification).not.toHaveBeenCalled(); }); }); + +function makeAppState(partial: Partial): AppState { + return { + tasks: { activeTaskId: null, activeSessionId: null }, + taskSessions: { items: {} }, + taskSessionsByTask: { itemsByTaskId: {} }, + ...partial, + } as unknown as AppState; +} + +describe("isTerminalSessionState", () => { + it.each<[TaskSessionState | undefined, boolean]>([ + ["COMPLETED", true], + ["FAILED", true], + ["CANCELLED", true], + ["RUNNING", false], + ["STARTING", false], + ["CREATED", false], + ["WAITING_FOR_INPUT", false], + [undefined, false], + ])("returns %o → %s", (input, expected) => { + expect(isTerminalSessionState(input)).toBe(expected); + }); +}); + +describe("shouldAdoptNewSession", () => { + it("adopts when there is no active session for the task", () => { + const state = makeAppState({ + tasks: { activeTaskId: "t-1", activeSessionId: null }, + }); + expect(shouldAdoptNewSession(state, "t-1", "STARTING")).toBe(true); + }); + + it("adopts when active session belongs to a different task", () => { + const state = makeAppState({ + tasks: { activeTaskId: "t-1", activeSessionId: "s-other" }, + taskSessions: { + items: { "s-other": { id: "s-other", task_id: "t-2", state: "RUNNING" } }, + } as unknown as AppState["taskSessions"], + }); + expect(shouldAdoptNewSession(state, "t-1", "STARTING")).toBe(true); + }); + + it("adopts when active session is already terminal", () => { + const state = makeAppState({ + tasks: { activeTaskId: "t-1", activeSessionId: "s-old" }, + taskSessions: { + items: { "s-old": { id: "s-old", task_id: "t-1", state: "COMPLETED" } }, + } as unknown as AppState["taskSessions"], + }); + expect(shouldAdoptNewSession(state, "t-1", "STARTING")).toBe(true); + }); + + it("does NOT adopt while the current active session is still running", () => { + const state = makeAppState({ + tasks: { activeTaskId: "t-1", activeSessionId: "s-old" }, + taskSessions: { + items: { "s-old": { id: "s-old", task_id: "t-1", state: "RUNNING" } }, + } as unknown as AppState["taskSessions"], + }); + expect(shouldAdoptNewSession(state, "t-1", "STARTING")).toBe(false); + }); + + it("does NOT adopt when the event is for a non-active task", () => { + const state = makeAppState({ + tasks: { activeTaskId: "t-1", activeSessionId: null }, + }); + expect(shouldAdoptNewSession(state, "t-2", "STARTING")).toBe(false); + }); + + it("does NOT adopt terminal state events", () => { + const state = makeAppState({ + tasks: { activeTaskId: "t-1", activeSessionId: null }, + }); + expect(shouldAdoptNewSession(state, "t-1", "COMPLETED")).toBe(false); + }); +}); + +describe("pickReplacementSessionId", () => { + it("returns the newest non-terminal session in the per-task list", () => { + const state = makeAppState({ + taskSessionsByTask: { + itemsByTaskId: { + "t-1": [ + { id: "s-1", task_id: "t-1", state: "COMPLETED", started_at: "", updated_at: "" }, + { id: "s-2", task_id: "t-1", state: "RUNNING", started_at: "", updated_at: "" }, + { id: "s-3", task_id: "t-1", state: "CANCELLED", started_at: "", updated_at: "" }, + ], + }, + } as unknown as AppState["taskSessionsByTask"], + }); + expect(pickReplacementSessionId(state, "t-1")).toBe("s-2"); + }); + + it("returns null when all sessions are terminal", () => { + const state = makeAppState({ + taskSessionsByTask: { + itemsByTaskId: { + "t-1": [ + { id: "s-1", task_id: "t-1", state: "COMPLETED", started_at: "", updated_at: "" }, + { id: "s-2", task_id: "t-1", state: "FAILED", started_at: "", updated_at: "" }, + ], + }, + } as unknown as AppState["taskSessionsByTask"], + }); + expect(pickReplacementSessionId(state, "t-1")).toBeNull(); + }); + + it("returns null when the task has no sessions tracked", () => { + expect(pickReplacementSessionId(makeAppState({}), "t-missing")).toBeNull(); + }); +}); + +describe("session.state_changed → active session switching", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("adopts a newly-created session for the active task", () => { + const store = makeStore({ + tasks: { activeTaskId: "t-1", activeSessionId: null }, + }); + const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; + + handler({ + id: "m", + type: "notification", + action: STATE_CHANGED_EVENT, + payload: { task_id: "t-1", session_id: "s-new", new_state: "STARTING" }, + }); + + expect(store.getState().setActiveSession).toHaveBeenCalledWith("t-1", "s-new"); + }); + + it("does not adopt a new session for a task that is not active", () => { + const store = makeStore({ + tasks: { activeTaskId: "other-task", activeSessionId: null }, + }); + const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; + + handler({ + id: "m", + type: "notification", + action: STATE_CHANGED_EVENT, + payload: { task_id: "t-1", session_id: "s-new", new_state: "STARTING" }, + }); + + expect(store.getState().setActiveSession).not.toHaveBeenCalled(); + }); + + it("does not adopt while the current active session is still running", () => { + const store = makeStore({ + tasks: { activeTaskId: "t-1", activeSessionId: "s-old" }, + taskSessions: { + items: { "s-old": { id: "s-old", task_id: "t-1", state: "RUNNING" } }, + }, + }); + const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; + + handler({ + id: "m", + type: "notification", + action: STATE_CHANGED_EVENT, + payload: { task_id: "t-1", session_id: "s-new", new_state: "STARTING" }, + }); + + expect(store.getState().setActiveSession).not.toHaveBeenCalled(); + }); +}); + +describe("session.state_changed → active session handoff on terminal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("hands off when the current active session transitions to terminal", () => { + const store = makeStore({ + tasks: { activeTaskId: "t-1", activeSessionId: "s-old" }, + taskSessions: { + items: { "s-old": { id: "s-old", task_id: "t-1", state: "RUNNING" } }, + }, + taskSessionsByTask: { + itemsByTaskId: { + "t-1": [ + { id: "s-old", task_id: "t-1", state: "RUNNING", started_at: "", updated_at: "" }, + { id: "s-new", task_id: "t-1", state: "STARTING", started_at: "", updated_at: "" }, + ], + }, + }, + }); + const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; + + handler({ + id: "m", + type: "notification", + action: STATE_CHANGED_EVENT, + payload: { task_id: "t-1", session_id: "s-old", new_state: "COMPLETED" }, + }); + + expect(store.getState().setActiveSession).toHaveBeenCalledWith("t-1", "s-new"); + }); + + // The per-task list here still shows s-old as RUNNING (pre-event state), so + // pickReplacementSessionId returns s-old itself. This exercises the + // `replacement !== sessionId` guard — without it, we'd set activeSessionId + // to the same session that just became terminal. + it("does not hand off when the only candidate is the terminating session itself", () => { + const store = makeStore({ + tasks: { activeTaskId: "t-1", activeSessionId: "s-old" }, + taskSessions: { + items: { "s-old": { id: "s-old", task_id: "t-1", state: "RUNNING" } }, + }, + taskSessionsByTask: { + itemsByTaskId: { + "t-1": [ + { id: "s-old", task_id: "t-1", state: "RUNNING", started_at: "", updated_at: "" }, + ], + }, + }, + }); + const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; + + handler({ + id: "m", + type: "notification", + action: STATE_CHANGED_EVENT, + payload: { task_id: "t-1", session_id: "s-old", new_state: "COMPLETED" }, + }); + + expect(store.getState().setActiveSession).not.toHaveBeenCalled(); + }); + + it("does not hand off when all other sessions for the task are terminal", () => { + const store = makeStore({ + tasks: { activeTaskId: "t-1", activeSessionId: "s-old" }, + taskSessions: { + items: { "s-old": { id: "s-old", task_id: "t-1", state: "RUNNING" } }, + }, + taskSessionsByTask: { + itemsByTaskId: { + "t-1": [ + { id: "s-done", task_id: "t-1", state: "COMPLETED", started_at: "", updated_at: "" }, + { id: "s-old", task_id: "t-1", state: "RUNNING", started_at: "", updated_at: "" }, + ], + }, + }, + }); + const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; + + handler({ + id: "m", + type: "notification", + action: STATE_CHANGED_EVENT, + payload: { task_id: "t-1", session_id: "s-old", new_state: "COMPLETED" }, + }); + + expect(store.getState().setActiveSession).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index 5d7462da93..5b40b56524 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -4,6 +4,59 @@ import type { WsHandlers } from "@/lib/ws/handlers/types"; import type { TaskSessionState } from "@/lib/types/http"; import type { QueuedMessage } from "@/lib/state/slices/session/types"; +const TERMINAL_SESSION_STATES: ReadonlySet = new Set([ + "COMPLETED", + "CANCELLED", + "FAILED", +]); + +export function isTerminalSessionState(state: TaskSessionState | undefined): boolean { + return !!state && TERMINAL_SESSION_STATES.has(state); +} + +/** + * When the backend creates a new session for the active task (e.g., due to a + * workflow step transition with a different agent profile), the chat UI should + * follow the switch. Returns true when the caller should adopt the new session + * as the task's active session. + * + * Adopts only when the current active session is missing, cross-task, or + * already terminal — not while a live session for the same task is still + * running (the backend only creates sessions during workflow step transitions + * after stopping the previous one, but WS events may arrive out of order). + */ +export function shouldAdoptNewSession( + state: AppState, + taskId: string, + newState: TaskSessionState | undefined, +): boolean { + if (!newState || isTerminalSessionState(newState)) return false; + if (state.tasks.activeTaskId !== taskId) return false; + const activeSessionId = state.tasks.activeSessionId; + if (activeSessionId) { + const activeSession = state.taskSessions.items[activeSessionId]; + if (activeSession?.task_id === taskId && !isTerminalSessionState(activeSession.state)) { + return false; + } + } + return true; +} + +/** + * Pick the newest non-terminal session for a task. Used when the currently + * active session just reached a terminal state — we want to hand focus to the + * session that replaced it (typically created by a workflow step transition). + */ +export function pickReplacementSessionId(state: AppState, taskId: string): string | null { + const sessions = state.taskSessionsByTask.itemsByTaskId[taskId]; + if (!sessions) return null; + for (let i = sessions.length - 1; i >= 0; i -= 1) { + const candidate = sessions[i]; + if (!isTerminalSessionState(candidate.state)) return candidate.id; + } + return null; +} + /** Build a session update object from the state_changed payload. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function buildSessionUpdate(payload: any): Record { @@ -91,6 +144,48 @@ function extractContextWindow(store: StoreApi, sessionId: string, payl }); } +/** Copy agentctl "ready" status from one session to another (same-task switch). */ +function inheritAgentctlStatus(state: AppState, fromSessionId: string, toSessionId: string): void { + const oldAgentctl = state.sessionAgentctl?.itemsBySessionId?.[fromSessionId]; + if (oldAgentctl?.status === "ready") { + state.setSessionAgentctlStatus(toSessionId, oldAgentctl); + } +} + +/** + * After a `session.state_changed` event, decide whether the chat UI should + * follow a workflow-driven session switch. Covers both event orderings: + * 1. New non-terminal session appears for the active task before the old + * one is torn down — adopt immediately. + * 2. The current active session transitions to a terminal state — hand off + * to the newest non-terminal session for the same task, if any. + */ +function maybeAdoptSessionOnTransition( + store: StoreApi, + taskId: string, + sessionId: string, + newState: TaskSessionState | undefined, + wasKnownToStore: boolean, +): void { + const state = store.getState(); + + if (!wasKnownToStore && shouldAdoptNewSession(state, taskId, newState)) { + const oldSessionId = state.tasks.activeSessionId; + if (oldSessionId) inheritAgentctlStatus(state, oldSessionId, sessionId); + state.setActiveSession(taskId, sessionId); + return; + } + + const isActive = state.tasks.activeSessionId === sessionId; + if (isActive && newState && isTerminalSessionState(newState)) { + const replacement = pickReplacementSessionId(state, taskId); + if (replacement && replacement !== sessionId) { + inheritAgentctlStatus(state, sessionId, replacement); + state.setActiveSession(taskId, replacement); + } + } +} + /** Handle the agentctl_ready event: update session worktree info. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function handleAgentctlReady(store: StoreApi, payload: any): void { @@ -138,7 +233,8 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle "session.state_changed": (message) => { const payload = message.payload; if (!payload?.task_id) return; - const { task_id: taskId, session_id: sessionId, new_state: newState } = payload; + const { task_id: taskId, session_id: sessionId } = payload; + const newState = payload.new_state as TaskSessionState | undefined; if (!sessionId) return; @@ -162,6 +258,8 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle upsertTaskSessionList(store, taskId, sessionId, payload, sessionUpdate); extractContextWindow(store, sessionId, payload); + maybeAdoptSessionOnTransition(store, taskId, sessionId, newState, !!existingSession); + if ( newState === "FAILED" && payload.suppress_toast !== true && diff --git a/apps/web/lib/ws/handlers/notifications.ts b/apps/web/lib/ws/handlers/notifications.ts index 029824d694..db44dea60a 100644 --- a/apps/web/lib/ws/handlers/notifications.ts +++ b/apps/web/lib/ws/handlers/notifications.ts @@ -3,16 +3,36 @@ import { NOTIFICATION_EVENT_TASK_SESSION_WAITING_FOR_INPUT } from "@/lib/notific import type { AppState } from "@/lib/state/store"; import type { WsHandlers } from "@/lib/ws/handlers/types"; -export function registerNotificationsHandlers(_store: StoreApi): WsHandlers { - void _store; +/** Check whether the notification should be suppressed. */ +function shouldSuppressNotification( + state: AppState, + taskId: string | undefined, + sessionId: string | undefined, +): string | null { + // Suppress during initial preparation — session has no completed turns yet. + if (sessionId) { + const turns = state.turns.bySession[sessionId]; + if (!turns || turns.length === 0) return "session has no completed turns"; + } + // Suppress when user is actively viewing this task. + if (document.visibilityState === "visible" && taskId && state.tasks.activeTaskId === taskId) { + return "user is viewing this task"; + } + return null; +} + +export function registerNotificationsHandlers(store: StoreApi): WsHandlers { return { [NOTIFICATION_EVENT_TASK_SESSION_WAITING_FOR_INPUT]: (message) => { - if (typeof Notification === "undefined") { - return; - } - if (Notification.permission !== "granted") { - return; - } + if (typeof Notification === "undefined" || Notification.permission !== "granted") return; + + const sessionId = message.payload?.session_id as string | undefined; + const taskId = message.payload?.task_id as string | undefined; + const state = store.getState(); + + const reason = shouldSuppressNotification(state, taskId, sessionId); + if (reason) return; + const title = message.payload.title || "Task needs your input"; const body = message.payload.body || "An agent is waiting for your input."; new Notification(title, { body });