fix(web): follow workflow step session switches in chat UI (#625)
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user