fix(web): follow workflow step session switches in chat UI (#625)

This commit is contained in:
Carlos Florêncio
2026-04-20 09:26:53 +01:00
committed by GitHub
parent 375472cc02
commit 31140c8b9f
23 changed files with 1196 additions and 91 deletions
+2 -3
View File
@@ -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
@@ -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
}
@@ -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)
@@ -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)
@@ -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<string | null>(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<string | null>(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);
}
@@ -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<string, WorkflowSnapshotData>;
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<string | null>(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 (
<div
className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"
data-testid="workflow-override-info"
>
{workflowProfile && (
<span className="flex items-center gap-1">
<AgentLogo agentName={workflowProfile.agent_name} size={14} className="shrink-0" />
<span>{workflowProfile.label}</span>
</span>
)}
{stepsWithOverrides.map((s) => (
<span key={s.name} className="flex items-center gap-1">
<span className="text-muted-foreground/50">{s.name}:</span>
<AgentLogo agentName={s.profile!.agent_name} size={14} className="shrink-0" />
<span>{s.profile!.label}</span>
</span>
))}
</div>
);
}
return (
<WorkflowSelectorRow
workflows={workflows}
@@ -267,6 +309,7 @@ export const WorkflowSection = memo(function WorkflowSection({
selectedWorkflowId={effectiveWorkflowId ?? null}
onWorkflowChange={handleWorkflowChange}
lastUsedWorkflowId={lastUsedWorkflowId}
agentProfiles={agentProfiles}
/>
);
});
@@ -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,
@@ -52,6 +52,10 @@ export type DialogComputedValues = {
headerRepositoryOptions: ReturnType<typeof useRepositoryOptions>["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 = {
+5 -4
View File
@@ -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 (
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
@@ -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<typeof WorkflowSection>[0]["snapshots"]}
effectiveWorkflowId={effectiveWorkflowId}
onWorkflowChange={onWorkflowChange}
agentProfiles={agentProfiles}
/>
{isSessionMode && (
<SessionSelectors
@@ -540,7 +541,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
hasRepositorySelection={computed.hasRepositorySelection}
isLocalExecutor={computed.isLocalExecutor}
enhance={setup.enhance}
workflowAgentProfileId={fs.workflowAgentProfileId}
workflowAgentLocked={computed.workflowAgentLocked}
/>
<DialogFooter className="border-t border-border pt-3 flex-col gap-3 sm:flex-row sm:gap-2">
<TaskCreateDialogFooter
@@ -357,15 +357,16 @@ export function ChatInputArea({
chatInputRef,
});
const hasClarification = !!panelState.pendingClarification;
const placeholder =
placeholderOverride ??
resolveInputPlaceholder(
isAgentBusy,
activeDocument?.type,
planModeEnabled,
hasClarification,
needsRecovery,
);
const placeholder = isMoving
? "Switching agent..."
: (placeholderOverride ??
resolveInputPlaceholder(
isAgentBusy,
activeDocument?.type,
planModeEnabled,
hasClarification,
needsRecovery,
));
return (
<div className="bg-card flex-shrink-0 px-2 pb-2 pt-1">
<ChatStatusBar
@@ -389,7 +390,7 @@ export function ChatInputArea({
mcpServers={panelState.mcpServers}
onPlanModeChange={handlePlanModeChange}
isAgentBusy={isAgentBusy}
isStarting={panelState.isStarting}
isStarting={panelState.isStarting || isMoving}
isSending={isSending}
onCancel={handleCancelTurn}
placeholder={placeholder}
@@ -15,10 +15,10 @@ type AgentStatusProps = {
const STATE_CONFIG: Record<
TaskSessionState,
{ label: string; icon: "spinner" | "error" | "warning" | null }
{ label: string; dynamicLabel?: boolean; icon: "spinner" | "error" | "warning" | null }
> = {
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;
@@ -383,6 +383,8 @@ const VALID_COMPONENTS = new Set(Object.keys(components));
function useSessionSwitchCleanup(effectiveSessionId: string | null) {
const prevSessionRef = useRef<string | null | undefined>(undefined);
const prevTaskRef = useRef<string | null>(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]);
}
// ---------------------------------------------------------------------------
@@ -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<string>,
activeSessionId: string,
sessions: Record<string, { state?: string }>,
): 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<Set<string>>(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]);
}
+84 -26
View File
@@ -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 (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground whitespace-nowrap">
{steps.map((s, i) => (
<Fragment key={s.id}>
{i > 0 && <span className="text-muted-foreground/40">→</span>}
<span className="flex items-center gap-1">
<span
className="h-1.5 w-1.5 rounded-full shrink-0"
style={{ backgroundColor: s.color || "hsl(var(--muted-foreground))" }}
/>
{s.title}
</span>
</Fragment>
))}
{steps.map((s, i) => {
const stepProfile = s.agent_profile_id
? agentProfiles.find((p) => p.id === s.agent_profile_id)
: null;
return (
<Fragment key={s.id}>
{i > 0 && <span className="text-muted-foreground/40">{"\u2192"}</span>}
<span className="flex items-center gap-1">
<span
className="h-1.5 w-1.5 rounded-full shrink-0"
style={{ backgroundColor: s.color || "hsl(var(--muted-foreground))" }}
/>
{s.title}
{s.is_start_step && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-[10px] text-muted-foreground/60 leading-none">*</span>
</TooltipTrigger>
<TooltipContent>Start step</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{stepProfile && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span data-testid="step-agent-logo">
<AgentLogo
agentName={stepProfile.agent_name}
size={12}
className="shrink-0"
/>
</span>
</TooltipTrigger>
<TooltipContent>{stepProfile.label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>
</Fragment>
);
})}
</div>
);
}
@@ -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({
<div className="flex items-center gap-2">
<IconLogicBuffer className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-sm">{wf.name}</span>
{wf.agent_profile_id && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<IconUserCog className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>Custom agent profile</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{wf.agent_profile_id &&
(() => {
const wfProfile = agentProfiles.find((p) => p.id === wf.agent_profile_id);
return wfProfile ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span data-testid="workflow-agent-logo">
<AgentLogo
agentName={wfProfile.agent_name}
size={14}
className="shrink-0"
/>
</span>
</TooltipTrigger>
<TooltipContent>{wfProfile.label}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null;
})()}
</div>
{steps.length > 0 && (
<div className="pl-[calc(0.875rem+0.5rem)]">
<InlineSteps steps={steps} />
<InlineSteps steps={steps} agentProfiles={agentProfiles} />
</div>
)}
{isSelected && (
@@ -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: "",
});
}
});
});
@@ -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,
@@ -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));
@@ -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<string | null>(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<string | null>(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;
@@ -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");
@@ -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);
+275 -6
View File
@@ -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<string, unknown> = {}) {
const state: Record<string, unknown> = {
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<string, unknown> = {}) {
} as unknown as StoreApi<AppState>;
}
const STATE_CHANGED_EVENT = "session.state_changed";
function makeMessage(payload: Record<string, unknown>) {
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>): 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();
});
});
+99 -1
View File
@@ -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<TaskSessionState> = 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<string, unknown> {
@@ -91,6 +144,48 @@ function extractContextWindow(store: StoreApi<AppState>, 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<AppState>,
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<AppState>, payload: any): void {
@@ -138,7 +233,8 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): 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<AppState>): WsHandle
upsertTaskSessionList(store, taskId, sessionId, payload, sessionUpdate);
extractContextWindow(store, sessionId, payload);
maybeAdoptSessionOnTransition(store, taskId, sessionId, newState, !!existingSession);
if (
newState === "FAILED" &&
payload.suppress_toast !== true &&
+28 -8
View File
@@ -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<AppState>): 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<AppState>): 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 });