feat: focus committed workflow recipients (#3826)

This commit is contained in:
Carlos Florêncio
2026-09-19 21:24:01 +01:00
committed by GitHub
parent 390ca6adc8
commit 05a6eec64a
43 changed files with 2838 additions and 93 deletions
@@ -253,10 +253,11 @@ func (h *Handlers) applyMoveTaskImmediate(
return ws.NewError(msg.ID, msg.Action, classifyMoveTaskError(err), moveTaskErrorMessage(err), nil)
}
response := dto.MoveTaskResponse{
Task: dto.FromTask(result.Task),
MoveID: result.MoveID,
EntryOptions: result.EntryOptions,
Disposition: moveDispositionApplied,
Task: dto.FromTask(result.Task),
WorkflowEntryIdentity: result.WorkflowEntryIdentity,
MoveID: result.MoveID,
EntryOptions: result.EntryOptions,
Disposition: moveDispositionApplied,
}
if result.WorkflowStep != nil {
response.WorkflowStep = dto.FromWorkflowStep(result.WorkflowStep)
@@ -3046,6 +3046,45 @@ func (s *Service) switchSessionForStepWithPoliciesAndCandidate(
startPolicy models.WorkflowProfileSessionStartPolicy,
endPolicy models.WorkflowProfileSessionEndPolicy,
validatedExisting *models.TaskSession,
) (*models.TaskSession, error) {
return s.switchSessionForStepWithPoliciesAndCandidateAndRoute(
ctx, taskID, currentSession, newAgentProfileID, startPolicy, endPolicy, validatedExisting, nil,
)
}
func (s *Service) resolveWorkflowSessionSwitchExisting(
ctx context.Context,
taskID, newAgentProfileID string,
currentSession *models.TaskSession,
startPolicy models.WorkflowProfileSessionStartPolicy,
validatedExisting *models.TaskSession,
) *models.TaskSession {
if startPolicy != models.WorkflowProfileSessionStartPolicyReuse {
return nil
}
if validatedExisting != nil {
return validatedExisting
}
existing, err := s.findReusableSessionForProfile(ctx, taskID, newAgentProfileID, currentSession.ID)
if err != nil {
s.logger.Warn("failed to look up reusable session, falling through to create new",
zap.String("task_id", taskID),
zap.String("agent_profile_id", newAgentProfileID),
zap.Error(err))
return nil
}
return existing
}
func (s *Service) switchSessionForStepWithPoliciesAndCandidateAndRoute(
ctx context.Context,
taskID string,
currentSession *models.TaskSession,
newAgentProfileID string,
startPolicy models.WorkflowProfileSessionStartPolicy,
endPolicy models.WorkflowProfileSessionEndPolicy,
validatedExisting *models.TaskSession,
workflowRoute *models.WorkflowSessionRoute,
) (*models.TaskSession, error) {
startPolicy = models.NormalizeWorkflowProfileSessionStartPolicy(string(startPolicy))
endPolicy = models.NormalizeWorkflowProfileSessionEndPolicy(string(endPolicy))
@@ -3056,21 +3095,9 @@ func (s *Service) switchSessionForStepWithPoliciesAndCandidate(
zap.String("new_profile", newAgentProfileID),
zap.String("profile_session_start_policy", string(startPolicy)),
zap.String("profile_session_end_policy", string(endPolicy)))
var existing *models.TaskSession
if startPolicy == models.WorkflowProfileSessionStartPolicyReuse {
if validatedExisting != nil {
existing = validatedExisting
} else {
var lookupErr error
existing, lookupErr = s.findReusableSessionForProfile(ctx, taskID, newAgentProfileID, currentSession.ID)
if lookupErr != nil {
s.logger.Warn("failed to look up reusable session, falling through to create new",
zap.String("task_id", taskID),
zap.String("agent_profile_id", newAgentProfileID),
zap.Error(lookupErr))
}
}
}
existing := s.resolveWorkflowSessionSwitchExisting(
ctx, taskID, newAgentProfileID, currentSession, startPolicy, validatedExisting,
)
targetSession := currentSession
if existing != nil {
targetSession = existing
@@ -3101,7 +3128,19 @@ func (s *Service) switchSessionForStepWithPoliciesAndCandidate(
}
if existing != nil {
reused, err := s.reuseSessionForStepWithEndPolicy(ctx, taskID, currentSession, existing, endPolicy)
var reused *models.TaskSession
var err error
if workflowRoute != nil {
route := *workflowRoute
route.DestinationID = existing.ID
route.Phase = workflowSessionRoutePrepared
if err := s.persistWorkflowSessionRoute(ctx, taskID, route); err != nil {
return nil, err
}
reused, err = s.reuseSessionForStepWithEndPolicy(ctx, taskID, currentSession, existing, endPolicy, &route)
} else {
reused, err = s.reuseSessionForStepWithEndPolicy(ctx, taskID, currentSession, existing, endPolicy)
}
if err == nil {
return reused, nil
}
@@ -3114,7 +3153,9 @@ func (s *Service) switchSessionForStepWithPoliciesAndCandidate(
zap.String("agent_profile_id", newAgentProfileID))
}
return s.createNewSessionForStepWithEndPolicy(ctx, taskID, currentSession, newAgentProfileID, endPolicy)
return s.createNewSessionForStepWithEndPolicyAndRoute(
ctx, taskID, currentSession, newAgentProfileID, endPolicy, workflowRoute,
)
}
// findReusableSessionForProfile returns the most-recently-updated
@@ -3807,6 +3848,14 @@ func (s *Service) prepareWorkflowStepSession(
}
effectiveProfile := s.resolveStepAgentProfile(ctx, step)
startPolicy := s.resolveStepProfileSessionStartPolicy(step)
profileRoute := workflowProfileSessionRoute(
taskID,
session,
step,
effectiveProfile,
s.workflowEntryIdentity(ctx, taskID, entryIDs...),
startPolicy,
)
if shouldKeepCurrentWorkflowStepSession(effectiveProfile, session.AgentProfileID, startPolicy) {
requiresFreshSession, err := s.workflowEntryRequiresFreshExactModelSession(ctx, session, step, sourceStep, effectiveProfile)
if err != nil {
@@ -3817,9 +3866,9 @@ func (s *Service) prepareWorkflowStepSession(
return nil, false, fmt.Errorf("workflow profile switch source step is unavailable")
}
endPolicy := s.resolveStepProfileSessionEndPolicy(sourceStep)
return s.replaceExactModelWorkflowStepSession(ctx, taskID, session, step, effectiveProfile, endPolicy, entryIDs...)
return s.replaceExactModelWorkflowStepSession(ctx, taskID, session, step, effectiveProfile, endPolicy, profileRoute, entryIDs...)
}
return s.keepCurrentWorkflowStepSession(ctx, taskID, session, step, entryIDs...)
return s.keepCurrentWorkflowStepSession(ctx, taskID, session, step, profileRoute, entryIDs...)
}
if sourceStep == nil {
return nil, false, fmt.Errorf("workflow profile switch source step is unavailable")
@@ -3829,7 +3878,7 @@ func (s *Service) prepareWorkflowStepSession(
return nil, false, err
}
endPolicy := s.resolveStepProfileSessionEndPolicy(sourceStep)
newSession, err := s.switchSessionForStepWithPoliciesAndCandidate(ctx, taskID, session, effectiveProfile, startPolicy, endPolicy, validatedExisting)
newSession, err := s.switchSessionForStepWithPoliciesAndCandidateAndRoute(ctx, taskID, session, effectiveProfile, startPolicy, endPolicy, validatedExisting, profileRoute)
if err != nil {
return nil, false, err
}
@@ -3850,9 +3899,10 @@ func (s *Service) replaceExactModelWorkflowStepSession(
step *wfmodels.WorkflowStep,
profileID string,
endPolicy models.WorkflowProfileSessionEndPolicy,
workflowRoute *models.WorkflowSessionRoute,
entryIDs ...int64,
) (*models.TaskSession, bool, error) {
newSession, err := s.createNewSessionForStepWithEndPolicy(ctx, taskID, session, profileID, endPolicy)
newSession, err := s.createNewSessionForStepWithEndPolicyAndRoute(ctx, taskID, session, profileID, endPolicy, workflowRoute)
if err != nil {
return nil, false, err
}
@@ -3960,10 +4010,15 @@ func (s *Service) keepCurrentWorkflowStepSession(
taskID string,
session *models.TaskSession,
step *wfmodels.WorkflowStep,
workflowRoute *models.WorkflowSessionRoute,
entryIDs ...int64,
) (*models.TaskSession, bool, error) {
s.tagSessionAsWorkflowSwitchedForSnapshot(ctx, session)
if !session.IsPrimary {
if workflowRoute != nil {
if err := s.promoteKeptWorkflowStepSession(ctx, taskID, session, workflowRoute); err != nil {
return nil, false, err
}
} else if !session.IsPrimary {
if err := s.SetPrimarySession(ctx, session.ID); err != nil {
s.logger.Warn("failed to preserve session as primary for workflow step",
zap.String("task_id", taskID),
@@ -3980,6 +4035,28 @@ func (s *Service) keepCurrentWorkflowStepSession(
return session, false, nil
}
func (s *Service) promoteKeptWorkflowStepSession(
ctx context.Context,
taskID string,
session *models.TaskSession,
workflowRoute *models.WorkflowSessionRoute,
) error {
preparedRoute := *workflowRoute
preparedRoute.DestinationID = session.ID
preparedRoute.Phase = workflowSessionRoutePrepared
if err := s.persistWorkflowSessionRoute(ctx, taskID, preparedRoute); err != nil {
return err
}
promoted, err := s.promoteWorkflowSessionRoute(ctx, taskID, session, &preparedRoute)
if err != nil {
return err
}
if !promoted {
return errReusableSessionNoLongerActive
}
return nil
}
func (s *Service) preflightWorkflowStepCredentials(
ctx context.Context,
taskID string,
@@ -4360,8 +4437,10 @@ func (s *Service) launchAfterOnEnterDispatch(
}
s.logger.Info("implicit profile switch: reused session terminalized before dispatch, creating replacement",
zap.String("task_id", taskID), zap.String("session_id", sessionID))
replacement, replacementErr := s.createNewSessionForStep(
replacementRoute := s.workflowReplacementRoute(asyncCtx, taskID, step.ID, sessionID)
replacement, replacementErr := s.createNewSessionForStepWithEndPolicyAndRoute(
asyncCtx, taskID, session, session.AgentProfileID,
models.WorkflowProfileSessionEndPolicyComplete, replacementRoute,
)
if replacementErr != nil {
s.logger.Error("failed to create replacement after terminalized profile switch",
@@ -4399,8 +4478,10 @@ func (s *Service) launchAfterOnEnterDispatch(
}
s.logger.Info("implicit profile switch: reused session terminalized during dispatch, creating replacement",
zap.String("task_id", taskID), zap.String("session_id", sessionID))
replacement, replacementErr := s.createNewSessionForStep(
replacementRoute := s.workflowReplacementRoute(asyncCtx, taskID, step.ID, sessionID)
replacement, replacementErr := s.createNewSessionForStepWithEndPolicyAndRoute(
asyncCtx, taskID, fresh, fresh.AgentProfileID,
models.WorkflowProfileSessionEndPolicyComplete, replacementRoute,
)
if replacementErr != nil {
s.logger.Error("failed to create replacement after terminalized dispatch",
@@ -4473,8 +4554,10 @@ func (s *Service) replaceTerminalizedAutoStartSession(
}
s.logger.Info("creating fresh workflow session after reused session terminalized",
zap.String("task_id", taskID), zap.String("session_id", sessionID))
replacement, replacementErr := s.createNewSessionForStep(
replacementRoute := s.workflowReplacementRoute(ctx, taskID, step.ID, sessionID)
replacement, replacementErr := s.createNewSessionForStepWithEndPolicyAndRoute(
ctx, taskID, session, session.AgentProfileID,
models.WorkflowProfileSessionEndPolicyComplete, replacementRoute,
)
if replacementErr != nil {
s.logger.Error("failed to create replacement after reused session terminalized",
@@ -43,6 +43,10 @@ func (r failProfileSwitchPromotionRepo) SetSessionPrimary(context.Context, strin
return r.err
}
func (r failProfileSwitchPromotionRepo) SetSessionPrimaryIfNonterminal(context.Context, string) (bool, error) {
return false, r.err
}
type profileSwitchFixture struct {
repo *sqliterepo.Repository
svc *Service
@@ -526,6 +526,17 @@ func TestPrepareWorkflowStepSession_PreservesMatchingProfileSession(t *testing.T
if !updated.IsPrimary {
t.Fatal("matching profile session must remain primary")
}
task, err := repo.GetTask(ctx, "t1")
if err != nil {
t.Fatalf("reload task: %v", err)
}
route, ok := models.LoadWorkflowSessionRoute(task.Metadata)
if !ok {
t.Fatal("profile-only keep-current path must record a workflow session route")
}
if route.Phase != "committed" || route.DestinationID != session.ID || route.TargetKind != workflowSessionRouteTargetProfile {
t.Fatalf("profile-only keep-current route = %+v", route)
}
}
func TestPrepareWorkflowStepSession_ClearsCompletionFollowUpWithoutProfile(t *testing.T) {
@@ -556,6 +567,14 @@ func TestPrepareWorkflowStepSession_ClearsCompletionFollowUpWithoutProfile(t *te
if models.IsCompletionFollowUpSession(updated.Metadata) {
t.Fatal("explicit workflow step entry retained completion follow-up ownership")
}
task, err := repo.GetTask(ctx, "t1")
if err != nil {
t.Fatalf("reload task: %v", err)
}
route, ok := models.LoadWorkflowSessionRoute(task.Metadata)
if !ok || route.Phase != "committed" || route.DestinationID != session.ID {
t.Fatalf("profile-only default route = %+v, present=%t", route, ok)
}
}
func TestPrepareWorkflowStepSession_PreservesNewerCompletionFollowUpWithoutProfile(t *testing.T) {
@@ -590,6 +609,48 @@ func TestPrepareWorkflowStepSession_PreservesNewerCompletionFollowUpWithoutProfi
}
}
func TestPrepareWorkflowStepSession_ProfileOnlyReuseCommitsEntryCorrelatedRecipient(t *testing.T) {
ctx := context.Background()
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
existing := &models.TaskSession{
ID: "session-b-existing", TaskID: "t1", AgentProfileID: "profile-b",
ExecutorID: "exec-local", ExecutorProfileID: "ep1", TaskEnvironmentID: "env-1",
State: models.TaskSessionStateWaitingForInput,
StartedAt: time.Now().UTC().Add(-time.Minute), UpdatedAt: time.Now().UTC().Add(-time.Minute),
}
if err := fixture.repo.CreateTaskSession(ctx, existing); err != nil {
t.Fatalf("create reusable destination: %v", err)
}
target := &wfmodels.WorkflowStep{
ID: "step-b", WorkflowID: "wf1", Position: 1, AgentProfileID: "profile-b",
ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyReuse,
}
source := &wfmodels.WorkflowStep{
ID: "step-a", WorkflowID: "wf1", Position: 0, AgentProfileID: "profile-a",
ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyPark,
}
selected, switched, err := fixture.svc.prepareWorkflowStepSession(ctx, "t1", fixture.current, target, source, 42)
if err != nil {
t.Fatalf("prepare profile-only reuse: %v", err)
}
if !switched || selected.ID != existing.ID {
t.Fatalf("selected profile-only reuse = %v, switched=%t", selected.ID, switched)
}
task, err := fixture.repo.GetTask(ctx, "t1")
if err != nil {
t.Fatalf("reload task: %v", err)
}
route, ok := models.LoadWorkflowSessionRoute(task.Metadata)
if !ok {
t.Fatal("profile-only reuse must record a workflow session route")
}
if route.Phase != workflowSessionRouteCommitted || route.DestinationID != existing.ID ||
route.EntryIdentity != "entry:00000000000000000042" || route.DestinationStepID != target.ID {
t.Fatalf("profile-only reuse route = %+v", route)
}
}
func TestSwitchWorkflowDispatcherRoutesOnEnterToDestinationProfileSession(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
@@ -15,6 +15,10 @@ import (
const (
workflowSessionRoutePrepared = "prepared"
workflowSessionRouteCommitted = "committed"
// workflowSessionRouteTargetProfile identifies the ordinary profile-only
// workflow path. It has no target step because the recipient is selected
// by the destination step's effective profile policy.
workflowSessionRouteTargetProfile = "profile"
)
type workflowSessionBindingStore interface {
@@ -254,14 +258,22 @@ func (s *Service) promoteWorkflowSessionRoute(
promoteErr = err
return
}
destinationParkingStamp = s.captureWorkflowParkingStamp(admissionCtx, destination.ID)
if promoter, ok := s.repo.(workflowSessionRoutePromoter); ok && route != nil {
promoter, hasAtomicRoutePromotion := s.repo.(workflowSessionRoutePromoter)
if hasAtomicRoutePromotion {
destinationParkingStamp = s.captureWorkflowParkingStamp(admissionCtx, destination.ID)
} else {
destinationParkingStamp = workflowParkingStamp(destination)
}
if hasAtomicRoutePromotion && route != nil {
committed := *route
committed.DestinationID = destination.ID
committed.Phase = workflowSessionRouteCommitted
promoted, promoteErr = promoter.SetSessionPrimaryWithWorkflowSessionRouteIfNonterminal(admissionCtx, destination.ID, committed)
return
}
// Legacy repository adapters do not expose the atomic route promoter.
// Keep the nonterminal promotion guard and persist a route only after
// the selected destination has been promoted.
promoted, promoteErr = s.setNonterminalSessionPrimary(admissionCtx, destination.ID)
if promoteErr != nil || !promoted || route == nil {
return
@@ -282,10 +294,26 @@ func (s *Service) promoteWorkflowSessionRoute(
// session. Clear the selected destination's current parking projection;
// the stamped stop-intent tombstone remains for delayed callbacks.
s.clearWorkflowParkingForExplicitExecution(ctx, destination.ID, destinationParkingStamp)
s.publishPrimarySessionUpdate(ctx, taskID, destination.ID)
if task, err := s.repo.GetTask(ctx, taskID); err == nil && task != nil {
s.publishTaskUpdated(ctx, task)
} else if err != nil {
s.logger.Warn("failed to fetch task after workflow session route promotion",
zap.String("task_id", taskID), zap.Error(err))
}
return true, nil
}
func workflowParkingStamp(session *models.TaskSession) string {
if session == nil {
return ""
}
parking, ok := models.LoadWorkflowParking(session.Metadata)
if !ok {
return ""
}
return parking.Stamp
}
func (s *Service) clearWorkflowParkingForExplicitExecution(
ctx context.Context,
destinationID string,
@@ -310,9 +338,15 @@ func (s *Service) clearWorkflowParkingForExplicitExecution(
}
func workflowSessionRouteMatchesStep(route *models.WorkflowSessionRoute, step *wfmodels.WorkflowStep, profileID string) bool {
if route == nil || step == nil || step.SessionTarget == nil {
if route == nil || step == nil {
return false
}
if step.SessionTarget == nil {
return route.DestinationStepID == step.ID &&
route.TargetKind == workflowSessionRouteTargetProfile &&
route.TargetStepID == "" &&
route.AgentProfileID == profileID
}
return route.DestinationStepID == step.ID &&
route.TargetKind == string(step.SessionTarget.Kind) &&
route.TargetStepID == step.SessionTarget.StepID &&
@@ -373,7 +407,7 @@ func (s *Service) reuseRecordedWorkflowSession(
func workflowSessionRouteID(taskID, stepID, entryIdentity string, target *wfmodels.WorkflowSessionTarget, startPolicy models.WorkflowProfileSessionStartPolicy) string {
targetID := ""
targetKind := ""
targetKind := workflowSessionRouteTargetProfile
if target != nil {
targetKind = string(target.Kind)
targetID = target.StepID
@@ -381,6 +415,47 @@ func workflowSessionRouteID(taskID, stepID, entryIdentity string, target *wfmode
return fmt.Sprintf("workflow-session:%s:%s:%s:%s:%s:%s", taskID, stepID, entryIdentity, targetKind, targetID, startPolicy)
}
func workflowProfileSessionRoute(
taskID string,
currentSession *models.TaskSession,
step *wfmodels.WorkflowStep,
profileID string,
entryIdentity string,
startPolicy models.WorkflowProfileSessionStartPolicy,
) *models.WorkflowSessionRoute {
if currentSession == nil || step == nil || step.SessionTarget != nil {
return nil
}
return &models.WorkflowSessionRoute{
OperationID: workflowSessionRouteID(taskID, step.ID, entryIdentity, nil, startPolicy),
DestinationStepID: step.ID,
EntryIdentity: entryIdentity,
TargetKind: workflowSessionRouteTargetProfile,
AgentProfileID: profileID,
SourceSessionID: currentSession.ID,
Phase: workflowSessionRoutePrepared,
}
}
func (s *Service) workflowReplacementRoute(
ctx context.Context,
taskID, stepID, terminalSessionID string,
) *models.WorkflowSessionRoute {
if s == nil || s.repo == nil || taskID == "" || stepID == "" || terminalSessionID == "" {
return nil
}
task, err := s.repo.GetTask(ctx, taskID)
if err != nil || task == nil {
return nil
}
route, ok := models.LoadWorkflowSessionRoute(task.Metadata)
if !ok || route.DestinationStepID != stepID || route.DestinationID != terminalSessionID {
return nil
}
route.Phase = workflowSessionRoutePrepared
return &route
}
// workflowEntryIdentity is the durable identity of one workflow-step entry.
// Transition deliveries carry the immutable ledger id directly. Direct/manual
// starts use the task's latest transition, while creation entries fall back to
@@ -422,6 +497,18 @@ func (s *Service) reuseResolvedWorkflowSession(
return nil, false, nil
}
if targetSession.ID == currentSession.ID {
baseRoute.DestinationID = currentSession.ID
baseRoute.Phase = workflowSessionRoutePrepared
if err := s.persistWorkflowSessionRoute(ctx, taskID, *baseRoute); err != nil {
return nil, false, err
}
promoted, err := s.promoteWorkflowSessionRoute(ctx, taskID, currentSession, baseRoute)
if err != nil {
return nil, false, err
}
if !promoted {
return nil, false, nil
}
return currentSession, false, nil
}
@@ -432,6 +432,42 @@ func TestWorkflowRouteRetryRecoversAfterLegacyCommitMetadataFailure(t *testing.T
require.Equal(t, workflowSessionRouteCommitted, committed.Phase)
}
func TestPromoteWorkflowSessionRouteUsesNonterminalGuardOnLegacyRepositories(t *testing.T) {
ctx := context.Background()
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
destination := &models.TaskSession{
ID: "workflow-terminal-destination",
TaskID: "t1",
AgentProfileID: "profile-a",
State: models.TaskSessionStateCompleted,
}
require.NoError(t, fixture.repo.CreateTaskSession(ctx, destination))
route := models.WorkflowSessionRoute{
OperationID: "workflow-route-terminal-destination",
DestinationStepID: "step-review",
EntryIdentity: "entry:00000000000000000031",
TargetKind: string(wfmodels.WorkflowSessionTargetInitial),
AgentProfileID: "profile-a",
DestinationID: destination.ID,
Phase: workflowSessionRoutePrepared,
}
require.NoError(t, fixture.repo.SetTaskMetadataKey(ctx, "t1", models.MetaKeyWorkflowSessionRoute, route))
fixture.svc.repo = &failWorkflowRouteMetadataRepo{repoStore: fixture.repo}
promoted, err := fixture.svc.promoteWorkflowSessionRoute(ctx, "t1", destination, &route)
require.NoError(t, err)
require.False(t, promoted)
updatedDestination, err := fixture.repo.GetTaskSession(ctx, destination.ID)
require.NoError(t, err)
require.False(t, updatedDestination.IsPrimary)
updatedTask, err := fixture.repo.GetTask(ctx, "t1")
require.NoError(t, err)
updatedRoute, ok := models.LoadWorkflowSessionRoute(updatedTask.Metadata)
require.True(t, ok)
require.Equal(t, workflowSessionRoutePrepared, updatedRoute.Phase)
}
func TestPromoteWorkflowSessionRouteClearsSelectedDestinationParkingOnly(t *testing.T) {
ctx := context.Background()
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
@@ -492,6 +528,37 @@ func TestPromoteWorkflowSessionRouteClearsSelectedDestinationParkingOnly(t *test
require.Equal(t, sourceParking.Stamp, parking.Stamp, "promotion cleared the source marker instead of destination")
}
func TestReuseResolvedWorkflowSessionCommitsRouteForCurrentSession(t *testing.T) {
ctx := context.Background()
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
route := &models.WorkflowSessionRoute{
OperationID: "workflow-route-current-session",
DestinationStepID: "step-review",
EntryIdentity: "entry:00000000000000000022",
TargetKind: string(wfmodels.WorkflowSessionTargetInitial),
AgentProfileID: fixture.current.AgentProfileID,
}
reused, switched, err := fixture.svc.reuseResolvedWorkflowSession(
ctx,
"t1",
fixture.current,
fixture.current,
route,
models.WorkflowProfileSessionEndPolicyPark,
)
require.NoError(t, err)
require.False(t, switched)
require.Equal(t, fixture.current.ID, reused.ID)
task, err := fixture.repo.GetTask(ctx, "t1")
require.NoError(t, err)
committed, ok := models.LoadWorkflowSessionRoute(task.Metadata)
require.True(t, ok)
require.Equal(t, workflowSessionRouteCommitted, committed.Phase)
require.Equal(t, fixture.current.ID, committed.DestinationID)
}
func fixtureSessionState(t *testing.T, repo interface {
GetTaskSession(context.Context, string) (*models.TaskSession, error)
}, sessionID string) models.TaskSessionState {
+5 -4
View File
@@ -1247,10 +1247,11 @@ type StepActionDTO struct {
// MoveTaskResponse includes the task and the target workflow step info
type MoveTaskResponse struct {
Task TaskDTO `json:"task"`
WorkflowStep WorkflowStepDTO `json:"workflow_step"`
MoveID string `json:"move_id,omitempty"`
EntryOptions *workflowmove.EntryOptions `json:"entry_options,omitempty"`
Task TaskDTO `json:"task"`
WorkflowStep WorkflowStepDTO `json:"workflow_step"`
WorkflowEntryIdentity string `json:"workflow_entry_identity,omitempty"`
MoveID string `json:"move_id,omitempty"`
EntryOptions *workflowmove.EntryOptions `json:"entry_options,omitempty"`
// Disposition reports how an MCP move_task call was resolved: "applied" when
// the move committed immediately, or "deferred" when it was recorded to run
// at the source session's turn-end. It lets an agent distinguish deferred
@@ -1838,9 +1838,10 @@ func (h *TaskHandlers) httpMoveTask(c *gin.Context) {
}
response := dto.MoveTaskResponse{
Task: dto.FromTask(result.Task),
MoveID: result.MoveID,
EntryOptions: result.EntryOptions,
Task: dto.FromTask(result.Task),
WorkflowEntryIdentity: result.WorkflowEntryIdentity,
MoveID: result.MoveID,
EntryOptions: result.EntryOptions,
}
if result.WorkflowStep != nil {
response.WorkflowStep = dto.FromWorkflowStep(result.WorkflowStep)
@@ -499,9 +499,10 @@ func (h *TaskHandlers) wsMoveTask(ctx context.Context, msg *ws.Message) (*ws.Mes
}
response := dto.MoveTaskResponse{
Task: dto.FromTask(result.Task),
MoveID: result.MoveID,
EntryOptions: result.EntryOptions,
Task: dto.FromTask(result.Task),
WorkflowEntryIdentity: result.WorkflowEntryIdentity,
MoveID: result.MoveID,
EntryOptions: result.EntryOptions,
}
if result.WorkflowStep != nil {
response.WorkflowStep = dto.FromWorkflowStep(result.WorkflowStep)
@@ -420,6 +420,9 @@ type MoveTaskResult struct {
// this call's earlier pre-move snapshot — see Task.FromStepID's doc.
FromStepID string
Transitioned bool
// WorkflowEntryIdentity identifies the committed workflow-step entry that
// accepted this move. Empty when the write did not transition the task.
WorkflowEntryIdentity string
// MoveID correlates the one-shot entry options carried on the transient
// move marker with the target-step entry. Empty for an option-less move.
MoveID string
@@ -732,6 +735,10 @@ func (s *Service) MoveTaskWithOptions(
resultFromWorkflowID := task.FromWorkflowID
resultFromStepID := task.FromStepID
resultTransitioned := task.WorkflowStepTransitionID != 0
workflowEntryIdentity := ""
if resultTransitioned && task.WorkflowStepTransitionID > 0 {
workflowEntryIdentity = fmt.Sprintf("entry:%020d", task.WorkflowStepTransitionID)
}
if resultTransitioned && resultFromWorkflowID == "" {
// Keep compatibility with repository implementations that predate the
// transient source-workflow field. SQLite populates it from the write
@@ -797,7 +804,14 @@ func (s *Service) MoveTaskWithOptions(
zap.String("workflow_step_id", workflowStepID),
zap.Int("position", position))
result := &MoveTaskResult{Task: task, FromStepID: resultFromStepID, Transitioned: resultTransitioned, MoveID: moveID, EntryOptions: entryOptions}
result := &MoveTaskResult{
Task: task,
FromStepID: resultFromStepID,
Transitioned: resultTransitioned,
WorkflowEntryIdentity: workflowEntryIdentity,
MoveID: moveID,
EntryOptions: entryOptions,
}
// Fetch the workflow step info if getter is available
if s.workflowStepGetter != nil {
@@ -0,0 +1,47 @@
package service
import (
"context"
"regexp"
"testing"
)
var workflowEntryIdentityPattern = regexp.MustCompile(`^entry:[0-9]{20}$`)
func TestService_MoveTaskWorkflowFocusReturnsCommittedEntryIdentity(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
seedMoveWorkflows(t, ctx, repo)
seedMoveSteps(svc)
createMoveTask(t, ctx, repo, "task-focus", "wf-source", "step-source", nil)
result, err := svc.MoveTask(ctx, "task-focus", "wf-source", "step-review-target", 0)
if err != nil {
t.Fatalf("MoveTask: %v", err)
}
if !workflowEntryIdentityPattern.MatchString(result.WorkflowEntryIdentity) {
t.Fatalf("workflow entry identity = %q, want entry:<20-digit transition id>", result.WorkflowEntryIdentity)
}
if result.MoveID != "" {
t.Fatalf("ordinary move unexpectedly returned move id %q", result.MoveID)
}
}
func TestService_MoveTaskWorkflowFocusOmitsIdentityForNoOpMove(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
seedMoveWorkflows(t, ctx, repo)
seedMoveSteps(svc)
createMoveTask(t, ctx, repo, "task-focus-noop", "wf-source", "step-source", nil)
result, err := svc.MoveTask(ctx, "task-focus-noop", "wf-source", "step-source", 0)
if err != nil {
t.Fatalf("MoveTask: %v", err)
}
if result.Transitioned {
t.Fatal("same-step move reported a transition")
}
if result.WorkflowEntryIdentity != "" {
t.Fatalf("same-step move returned workflow entry identity %q", result.WorkflowEntryIdentity)
}
}
+104 -16
View File
@@ -1,5 +1,7 @@
"use client";
/* eslint-disable max-lines -- board preview owns the responsive board and desktop preview surface. */
import {
useCallback,
useEffect,
@@ -311,18 +313,109 @@ function useSyncSelectedTaskActivity(params: {
selectedTaskId: string | null | undefined;
activeSessionId: string | null;
setActiveSession: (taskId: string, sessionId: string) => void;
setActiveSessionAuto: (taskId: string, sessionId: string) => void;
workflowFocusRequestActive: boolean;
setActiveTask: (taskId: string) => void;
}) {
const { isOpen, selectedTaskId, activeSessionId, setActiveSession, setActiveTask } = params;
const {
isOpen,
selectedTaskId,
activeSessionId,
setActiveSession,
setActiveSessionAuto,
workflowFocusRequestActive,
setActiveTask,
} = params;
useEffect(() => {
if (!isOpen || !selectedTaskId) return;
if (activeSessionId) {
setActiveSession(selectedTaskId, activeSessionId);
if (workflowFocusRequestActive) {
setActiveSessionAuto(selectedTaskId, activeSessionId);
} else {
setActiveSession(selectedTaskId, activeSessionId);
}
} else {
setActiveTask(selectedTaskId);
}
}, [activeSessionId, isOpen, selectedTaskId, setActiveSession, setActiveTask]);
}, [
activeSessionId,
isOpen,
selectedTaskId,
setActiveSession,
setActiveSessionAuto,
workflowFocusRequestActive,
setActiveTask,
]);
}
function usePreviewSessionFocus({
previewIsOpen,
previewTaskId,
selectedTask,
selectedTaskSessionId,
userSelectedSessionId,
setUserSelectedSessionId,
}: {
previewIsOpen: boolean;
previewTaskId: string | null | undefined;
selectedTask: Task | null;
selectedTaskSessionId: string | null;
userSelectedSessionId: string | null;
setUserSelectedSessionId: (sessionId: string | null) => void;
}) {
const setActiveTask = useAppStore((state) => state.setActiveTask);
const setActiveSession = useAppStore((state) => state.setActiveSession);
const setActiveSessionAuto = useAppStore((state) => state.setActiveSessionAuto);
const workflowFocusRequest = useAppStore((state) => state.workflowSessionFocus.request);
const acknowledgeWorkflowSessionFocus = useAppStore(
(state) => state.acknowledgeWorkflowSessionFocus,
);
let activeSessionId: string | null = null;
if (previewTaskId) {
if (workflowFocusRequest?.taskId === previewTaskId) {
activeSessionId = workflowFocusRequest.sessionId;
} else {
activeSessionId =
userSelectedSessionId ?? selectedTask?.primarySessionId ?? selectedTaskSessionId;
}
}
const handleSessionChange = useCallback(
(sessionId: string | null) => {
setUserSelectedSessionId(sessionId);
if (previewTaskId && sessionId) setActiveSession(previewTaskId, sessionId);
},
[previewTaskId, setActiveSession, setUserSelectedSessionId],
);
useEffect(() => {
if (!previewIsOpen || !previewTaskId || workflowFocusRequest?.taskId !== previewTaskId) {
return;
}
setUserSelectedSessionId(null);
setActiveSessionAuto(previewTaskId, workflowFocusRequest.sessionId);
acknowledgeWorkflowSessionFocus(workflowFocusRequest.requestId);
}, [
acknowledgeWorkflowSessionFocus,
previewIsOpen,
previewTaskId,
setActiveSessionAuto,
setUserSelectedSessionId,
workflowFocusRequest,
]);
useSyncSelectedTaskActivity({
isOpen: previewIsOpen,
selectedTaskId: previewTaskId,
activeSessionId,
setActiveSession,
setActiveSessionAuto,
workflowFocusRequestActive: workflowFocusRequest?.taskId === previewTaskId,
setActiveTask,
});
return { activeSessionId, handleSessionChange };
}
export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWithPreviewProps) {
@@ -334,8 +427,6 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
const kanbanWorkflowId = useAppStore((state) => state.kanban.workflowId);
const kanbanIsLoading = useAppStore((state) => state.kanban.isLoading ?? false);
const kanbanMultiSnapshots = useAppStore((state) => state.kanbanMulti.snapshots);
const setActiveTask = useAppStore((state) => state.setActiveTask);
const setActiveSession = useAppStore((state) => state.setActiveSession);
const setKanbanPreviewedTaskId = useAppStore((state) => state.setKanbanPreviewedTaskId);
const hasLoadedTaskSources = hasLoadedKanbanTaskSources({
activeWorkflowId: kanbanWorkflowId,
@@ -403,16 +494,13 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
[router],
);
const activeSessionId = previewTaskId
? (userSelectedSessionId ?? selectedTask?.primarySessionId ?? selectedTaskSessionId)
: null;
useSyncSelectedTaskActivity({
isOpen: previewIsOpen,
selectedTaskId: previewTaskId,
activeSessionId,
setActiveSession,
setActiveTask,
const { activeSessionId, handleSessionChange } = usePreviewSessionFocus({
previewIsOpen,
previewTaskId,
selectedTask,
selectedTaskSessionId,
userSelectedSessionId,
setUserSelectedSessionId,
});
useUrlSync(previewTaskId ?? null, previewIsOpen ? (activeSessionId ?? null) : null);
@@ -446,7 +534,7 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
onPreviewTask={handlePreviewTaskWithData}
onNavigateToTask={handleNavigateToTask}
onClose={close}
onSessionChange={setUserSelectedSessionId}
onSessionChange={handleSessionChange}
onResizeMouseDown={handleResizeMouseDown}
onActionsMenuOpenChange={setActionsMenuOpen}
/>
@@ -2,6 +2,20 @@ import { describe, expect, it } from "vitest";
import { shouldActivateSessionPanel } from "./dockview-session-tab-activation";
describe("shouldActivateSessionPanel", () => {
it("activates a committed manual workflow recipient even when another panel is selected", () => {
expect(
shouldActivateSessionPanel({
sessionPanelExistedBefore: true,
prevTaskId: "task-1",
prevSessionId: "session-astra",
currentTaskId: "task-1",
currentSessionId: "session-luna",
currentActivePanelId: "plan",
workflowFocusRequested: true,
}),
).toBe(true);
});
const sessionId = "s-current";
const baseArgs = {
prevTaskId: null,
@@ -7,6 +7,7 @@ export type SessionPanelActivationArgs = {
currentTaskId: string | null;
currentSessionId: string;
currentActivePanelId: string | null;
workflowFocusRequested?: boolean;
};
export function shouldPreserveActivePanel(
@@ -47,8 +48,10 @@ export function shouldActivateSessionPanel(args: SessionPanelActivationArgs): bo
currentTaskId,
currentSessionId,
currentActivePanelId,
workflowFocusRequested = false,
} = args;
const sessionPanelId = `session:${currentSessionId}`;
if (workflowFocusRequested) return true;
if (!sessionPanelExistedBefore) {
return (
!currentActivePanelId ||
@@ -1,8 +1,10 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import type { DockviewApi } from "dockview-react";
import { StateProvider } from "@/components/state-provider";
import type { StoreApi } from "zustand";
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import { defaultState } from "@/lib/state/default-state";
import type { AppState } from "@/lib/state/store";
import { useDockviewStore } from "@/lib/state/dockview-store";
import type { TaskSession, TaskId } from "@/lib/types/http";
import { makeReorderingAutoSessionApi } from "./dockview-session-tabs.test-utils";
@@ -17,6 +19,14 @@ function Harness() {
return null;
}
let capturedStore: StoreApi<AppState> | null = null;
function FocusHarness() {
capturedStore = useAppStoreApi();
useAutoSessionTab(ACTIVE_SESSION_ID);
return null;
}
function renderHookWithHydratedSessions() {
const sessions = [ACTIVE_SESSION_ID, SIBLING_SESSION_ID].map((id) => ({ id }) as TaskSession);
@@ -60,4 +70,90 @@ describe("useAutoSessionTab", () => {
expect.arrayContaining([`session:${ACTIVE_SESSION_ID}`, `session:${SIBLING_SESSION_ID}`]),
);
});
it("subscribes to a same-session focus request and acknowledges after activation", () => {
const { api, activationSequence, centerActivePanelId } = makeReorderingAutoSessionApi("files");
const route = {
operation_id: "workflow-session:task-1:step-b:entry:00000000000000000042:profile::reuse",
destination_step_id: "step-b",
entry_identity: "entry:00000000000000000042",
destination_session_id: ACTIVE_SESSION_ID,
phase: "committed",
};
capturedStore = null;
render(
<StateProvider
initialState={{
...defaultState,
tasks: {
...defaultState.tasks,
activeTaskId: TASK_ID,
activeSessionId: ACTIVE_SESSION_ID,
},
kanban: {
...defaultState.kanban,
tasks: [
{
id: TASK_ID,
workflowId: "workflow-1",
workflowStepId: "step-b",
title: "Task",
position: 0,
updatedAt: "2026-09-19T10:00:01Z",
metadata: { workflow_session_route: route },
},
],
},
taskSessionsByTask: {
...defaultState.taskSessionsByTask,
itemsByTaskId: {
[TASK_ID]: [
{ id: ACTIVE_SESSION_ID } as TaskSession,
{ id: SIBLING_SESSION_ID } as TaskSession,
],
},
},
}}
>
<FocusHarness />
</StateProvider>,
);
expect(capturedStore).not.toBeNull();
const store = capturedStore as unknown as StoreApi<AppState>;
act(() => {
useDockviewStore.setState({ api: api as DockviewApi });
});
activationSequence.length = 0;
const acknowledge = store.getState().acknowledgeWorkflowSessionFocus;
const acknowledgeSpy = vi.fn((requestId: number) => acknowledge(requestId));
store.setState({ acknowledgeWorkflowSessionFocus: acknowledgeSpy });
act(() => {
const requestId = store.getState().beginWorkflowSessionFocus({
taskId: TASK_ID,
workflowId: "workflow-1",
destinationStepId: "step-b",
presentationToken: 1,
navigationRevision: 0,
});
expect(requestId).toBe(1);
store.getState().bindWorkflowSessionFocus({
requestId: requestId!,
presentationToken: 1,
entryIdentity: route.entry_identity,
});
store.getState().reconcileWorkflowSessionFocus(TASK_ID);
});
expect(centerActivePanelId()).toBe(`session:${ACTIVE_SESSION_ID}`);
expect(acknowledgeSpy).toHaveBeenCalledTimes(1);
expect(acknowledgeSpy).toHaveBeenCalledWith(1);
expect(activationSequence.filter((id) => id === `session:${ACTIVE_SESSION_ID}`)).toHaveLength(
1,
);
expect(store.getState().workflowSessionFocus.request).toBeNull();
});
});
@@ -297,16 +297,26 @@ type AutoSessionTabRefs = {
* Activate the newly-ensured session panel and update the center-group store
* entry. Returns the resolved active panel for sibling anchoring.
*/
function activateSessionPanel(
api: DockviewApi,
effectiveSessionId: string,
function activateSessionPanel({
api,
effectiveSessionId,
activation,
refs,
tid,
appStore,
workflowFocusRequestId,
}: {
api: DockviewApi;
effectiveSessionId: string;
activation: {
sessionPanelExistedBefore: boolean;
activePanelIdBeforeEnsure: string | null;
},
refs: AutoSessionTabRefs,
tid: string | null,
): ReturnType<DockviewApi["getPanel"]> {
};
refs: AutoSessionTabRefs;
tid: string | null;
appStore: ReturnType<typeof useAppStoreApi>;
workflowFocusRequestId: number | null;
}): ReturnType<DockviewApi["getPanel"]> {
const activePanel = api.getPanel(`session:${effectiveSessionId}`);
if (!activePanel) return activePanel;
@@ -318,6 +328,7 @@ function activateSessionPanel(
currentTaskId: tid,
currentSessionId: effectiveSessionId,
currentActivePanelId: activation.activePanelIdBeforeEnsure,
workflowFocusRequested: workflowFocusRequestId !== null,
});
if (isDebug()) {
debug("useAutoSessionTab: activation decision", {
@@ -332,7 +343,12 @@ function activateSessionPanel(
activeGroupId: activePanel.group.id,
});
}
if (shouldActivate) activePanel.api.setActive();
if (shouldActivate) {
activePanel.api.setActive();
if (workflowFocusRequestId !== null) {
appStore.getState().acknowledgeWorkflowSessionFocus(workflowFocusRequestId);
}
}
useDockviewStore.setState({
centerGroupId: isCenterCandidateGroupId(activePanel.group.id)
? activePanel.group.id
@@ -484,6 +500,15 @@ function updateAutoSessionTabRefs(
refs.prevSessionIdRef.current = effectiveSessionId;
}
function workflowFocusRequestIdForSession(
appStore: ReturnType<typeof useAppStoreApi>,
taskId: string | null,
sessionId: string,
): number | null {
const request = appStore.getState().workflowSessionFocus?.request;
return request?.taskId === taskId && request.sessionId === sessionId ? request.requestId : null;
}
/**
* Core effect body for useAutoSessionTab — extracted to reduce complexity of
* the hook itself.
@@ -533,16 +558,20 @@ export function runAutoSessionTabEffect(
}
const chatWasSelected = isChatPlaceholderSelected(api);
const workflowFocusRequestId = workflowFocusRequestIdForSession(
appStore,
tid,
effectiveSessionId,
);
const initialPosition = resolveInitialPosition(api, chatWasSelected);
const sessionPanelExistedBefore = !!api.getPanel(`session:${effectiveSessionId}`);
// Preserve the restored/user-selected panel before adding and reordering the
// session tab. Dockview's same-group move briefly activates a sibling even
// with skipSetActive, so reading api.activePanel afterward loses this intent.
const activePanelIdBeforeEnsure = api.activePanel?.id ?? null;
const preserveActivePanel = shouldPreserveActivePanel(
sessionPanelExistedBefore,
activePanelIdBeforeEnsure,
);
const preserveActivePanel =
workflowFocusRequestId === null &&
shouldPreserveActivePanel(sessionPanelExistedBefore, activePanelIdBeforeEnsure);
logSessionPanelEnsure(effectiveSessionId, sessionPanelExistedBefore, initialPosition);
@@ -567,13 +596,15 @@ export function runAutoSessionTabEffect(
removeChatPlaceholder(api);
ensureSessionTabPrecedesNonSessionTabs(api, effectiveSessionId);
const activePanel = activateSessionPanel(
const activePanel = activateSessionPanel({
api,
effectiveSessionId,
{ sessionPanelExistedBefore, activePanelIdBeforeEnsure },
activation: { sessionPanelExistedBefore, activePanelIdBeforeEnsure },
refs,
tid,
);
appStore,
workflowFocusRequestId,
});
restorePreservedActivePanel(api, activePanelIdBeforeEnsure, preserveActivePanel);
@@ -623,6 +654,12 @@ export function useAutoSessionTab(effectiveSessionId: string | null) {
if (!list || list.length === 0) return EMPTY_SESSION_IDS_KEY;
return list.map((ss) => ss.id).join(",");
});
const workflowFocusRequestId = useAppStore((s) => {
const request = s.workflowSessionFocus.request;
return request?.taskId === s.tasks.activeTaskId && request.sessionId === effectiveSessionId
? request.requestId
: null;
});
useEffect(() => {
runAutoSessionTabEffect(effectiveSessionId, appStore, {
@@ -630,5 +667,5 @@ export function useAutoSessionTab(effectiveSessionId: string | null) {
prevTaskIdRef,
prevSessionIdRef,
});
}, [appStore, dockviewApi, effectiveSessionId, sessionIdsKey]);
}, [appStore, dockviewApi, effectiveSessionId, sessionIdsKey, workflowFocusRequestId]);
}
@@ -1,13 +1,19 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconArrowLeft } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
import { SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { useAppStore } from "@/components/state-provider";
import { usePresentationToken } from "@/hooks/domains/kanban/use-workflow-step-move";
import { useWorkflowMove } from "@/hooks/domains/kanban/use-workflow-move";
import type { WorkflowMoveResponse } from "@/lib/api/domains/kanban-api";
import { useTaskMoveOptions, type TaskMoveStep } from "../task-move-context-menu";
import { WorkflowMoveOptionsForm, type WorkflowMoveOptionsSubmit } from "../workflow-move-options";
import { useToast } from "@/components/toast-provider";
import { getTaskMoveErrorMessage } from "../task-move-error-message";
export type MobileMoveOptionsRequest = {
taskId: string;
@@ -15,18 +21,172 @@ export type MobileMoveOptionsRequest = {
targetStep: TaskMoveStep;
};
type MobileFocusRequest = {
requestId: number;
taskId: string;
presentationToken: number;
};
function useMobileWorkflowSessionFocus(activeTaskId: string | null) {
const presentationToken = usePresentationToken(activeTaskId);
const navigationRevision = useAppStore((state) => state.taskRemoval.navigationRevision);
const beginWorkflowSessionFocus = useAppStore((state) => state.beginWorkflowSessionFocus);
const bindWorkflowSessionFocus = useAppStore((state) => state.bindWorkflowSessionFocus);
const reconcileWorkflowSessionFocus = useAppStore((state) => state.reconcileWorkflowSessionFocus);
const cancelWorkflowSessionFocus = useAppStore((state) => state.cancelWorkflowSessionFocus);
const focusRequestRef = useRef<MobileFocusRequest | null>(null);
const beginFocus = useCallback(
(taskId: string, workflowId: string, destinationStepId: string) => {
if (taskId !== activeTaskId) return null;
const requestId = beginWorkflowSessionFocus({
taskId,
workflowId,
destinationStepId,
presentationToken,
navigationRevision,
});
if (requestId === null) return null;
focusRequestRef.current = { requestId, taskId, presentationToken };
return requestId;
},
[activeTaskId, beginWorkflowSessionFocus, navigationRevision, presentationToken],
);
const cancelFocus = useCallback(
(requestId: number) => {
const focusRequest = focusRequestRef.current;
if (!focusRequest || focusRequest.requestId !== requestId) return;
cancelWorkflowSessionFocus({
requestId: focusRequest.requestId,
presentationToken: focusRequest.presentationToken,
});
focusRequestRef.current = null;
},
[cancelWorkflowSessionFocus],
);
const clearFocus = useCallback((requestId: number) => {
if (focusRequestRef.current?.requestId === requestId) focusRequestRef.current = null;
}, []);
const cancelCurrentFocus = useCallback(() => {
const focusRequest = focusRequestRef.current;
if (!focusRequest) return;
cancelWorkflowSessionFocus({
requestId: focusRequest.requestId,
presentationToken: focusRequest.presentationToken,
});
focusRequestRef.current = null;
}, [cancelWorkflowSessionFocus]);
const handleMoveCommitted = useCallback(
(response: WorkflowMoveResponse, expectedRequestId?: number) => {
const focusRequest = focusRequestRef.current;
if (!focusRequest) return;
if (expectedRequestId !== undefined && focusRequest.requestId !== expectedRequestId) return;
if (!response.workflow_entry_identity) {
cancelFocus(focusRequest.requestId);
return;
}
bindWorkflowSessionFocus({
requestId: focusRequest.requestId,
presentationToken: focusRequest.presentationToken,
entryIdentity: response.workflow_entry_identity,
});
reconcileWorkflowSessionFocus(focusRequest.taskId, {
metadata: response.task?.metadata,
updatedAt: response.task?.updated_at,
workflowStepId: response.task?.workflow_step_id,
entryIdentity: response.workflow_entry_identity,
});
},
[bindWorkflowSessionFocus, cancelFocus, reconcileWorkflowSessionFocus],
);
useEffect(
() => () => {
const focusRequest = focusRequestRef.current;
if (!focusRequest) return;
cancelWorkflowSessionFocus({
requestId: focusRequest.requestId,
presentationToken: focusRequest.presentationToken,
});
},
[cancelWorkflowSessionFocus],
);
return {
beginFocus,
cancelFocus,
cancelCurrentFocus,
clearFocus,
handleMoveCommitted,
navigationRevision,
presentationToken,
};
}
function useMobileDirectTaskMove({
stepsByWorkflowId,
focus,
}: {
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
focus: ReturnType<typeof useMobileWorkflowSessionFocus>;
}) {
const { move, isMoving } = useWorkflowMove();
const { toast } = useToast();
const { t } = useTranslation("task");
const handleMove = useCallback(
(taskId: string, workflowId: string, targetStepId: string) => {
const targetStep = stepsByWorkflowId[workflowId]?.find((step) => step.id === targetStepId);
if (!targetStep) return;
const focusRequestId = focus.beginFocus(taskId, workflowId, targetStepId);
void move(taskId, {
workflow_id: workflowId,
workflow_step_id: targetStepId,
position: 0,
}).then((result) => {
if (result.disposition === "failed") {
if (focusRequestId !== null) focus.cancelFocus(focusRequestId);
const fallback = t("taskMoveErrorGeneric");
toast({
title: t("failedToMoveTask"),
description: getTaskMoveErrorMessage(result.error, fallback, t),
variant: "error",
});
return;
}
if (focusRequestId !== null) {
focus.handleMoveCommitted(result.response, focusRequestId);
focus.clearFocus(focusRequestId);
}
toast({
title: t("movedTasksToStep", { count: 1 }),
description: t("movedTasksStepDescription", { count: 1 }),
variant: "success",
});
});
},
[focus, move, stepsByWorkflowId, t, toast],
);
return { handleMove, isMoving };
}
export function useMobileTaskMoveOptions({
open,
activeTaskId,
stepsByWorkflowId,
}: {
open: boolean;
activeTaskId: string | null;
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
}) {
const [request, setRequest] = useState<MobileMoveOptionsRequest | null>(null);
const focus = useMobileWorkflowSessionFocus(activeTaskId);
const directMove = useMobileDirectTaskMove({ stepsByWorkflowId, focus });
const moveOptions = useTaskMoveOptions({
taskId: request?.taskId ?? "",
workflowId: request?.workflowId,
steps: request ? [request.targetStep] : undefined,
onMoveCommitted: focus.handleMoveCommitted,
});
const handleRequest = useCallback(
(taskId: string, workflowId: string, targetStepId: string) => {
@@ -44,24 +204,31 @@ export function useMobileTaskMoveOptions({
}, [moveOptions.closeMoveOptions]);
const handleSubmit = useCallback(
async (entryOptions: Parameters<WorkflowMoveOptionsSubmit>[0]) => {
const focusRequestId = request?.workflowId
? focus.beginFocus(request.taskId, request.workflowId, request.targetStep.id)
: null;
const moved = await moveOptions.submitMoveOptions(entryOptions);
if (!moved && focusRequestId !== null) focus.cancelFocus(focusRequestId);
if (moved && focusRequestId !== null) focus.clearFocus(focusRequestId);
if (moved) setRequest(null);
return moved;
},
[moveOptions.submitMoveOptions],
[focus, moveOptions.submitMoveOptions, request],
);
useEffect(() => {
if (open || !request) return;
moveOptions.closeMoveOptions();
setRequest(null);
}, [moveOptions.closeMoveOptions, open, request]);
focus.cancelCurrentFocus();
}, [focus.cancelCurrentFocus, moveOptions.closeMoveOptions, open, request]);
return {
request,
moveOptionsStep: moveOptions.moveOptionsStep,
isMoving: moveOptions.isMoving,
isMoving: moveOptions.isMoving || directMove.isMoving,
handleRequest,
handleMove: directMove.handleMove,
handleClose,
handleSubmit,
};
@@ -664,6 +664,20 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
handleOpenFile,
handlePanelChangeAndClearSheet,
} = useMobilePanelHandlers({ effectiveSessionId, handlePanelChange });
const workflowFocusRequest = useAppStore((state) => {
const request = state.workflowSessionFocus.request;
return request && request.taskId === activeTaskId && request.sessionId === effectiveSessionId
? request
: null;
});
const acknowledgeWorkflowSessionFocus = useAppStore(
(state) => state.acknowledgeWorkflowSessionFocus,
);
useEffect(() => {
if (!workflowFocusRequest) return;
handlePanelChangeAndClearSheet("chat");
acknowledgeWorkflowSessionFocus(workflowFocusRequest.requestId);
}, [acknowledgeWorkflowSessionFocus, handlePanelChangeAndClearSheet, workflowFocusRequest]);
const [mobileScrollTarget, setMobileScrollTarget] = useState<PendingMessageScrollTarget | null>(
null,
);
@@ -25,6 +25,7 @@ export function buildMobileTaskSwitcherProps(
stepsByWorkflowId: props.stepsByWorkflowId,
activeTaskId: props.activeTaskId,
selectedTaskId: props.selectedTaskId,
onMoveToStep: props.onMoveToStep,
onRequestMoveOptions: props.onRequestMoveOptions,
onBeforeMoveOptionsOpen: props.onBeforeMoveOptionsOpen,
collapsedGroupKeys: helpers.collapsedGroupKeys,
@@ -97,6 +97,7 @@ export type MobileTaskListProps = {
activeTaskId: string | null;
selectedTaskId: string | null;
onSelectTask: (taskId: string) => void;
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
onRequestMoveOptions?: (taskId: string, workflowId: string, targetStepId: string) => void;
onBeforeMoveOptionsOpen?: () => void;
onEditTask?: (task: TaskSwitcherItem) => void;
@@ -341,6 +342,7 @@ function TaskSwitcherSurfaceContent({
: data.retryArchivedTasks;
const moveOptions = useMobileTaskMoveOptions({
open,
activeTaskId: data.activeTaskId,
stepsByWorkflowId: data.stepsByWorkflowId,
});
if (moveOptions.moveOptionsStep && moveOptions.request) {
@@ -392,6 +394,7 @@ function TaskSwitcherSurfaceContent({
activeTaskId={data.activeTaskId}
selectedTaskId={data.selectedTaskId}
onSelectTask={actions.handleSelectTask}
onMoveToStep={presentation === "drawer" ? moveOptions.handleMove : undefined}
onRequestMoveOptions={presentation === "drawer" ? moveOptions.handleRequest : undefined}
onBeforeMoveOptionsOpen={
presentation === "drawer" ? () => onOpenChange(false) : undefined
@@ -20,7 +20,7 @@ import {
type WorkflowMoveOptionsSubmit,
} from "./workflow-move-options";
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api";
import type { WorkflowMoveEntryOptions, WorkflowMoveResponse } from "@/lib/api/domains/kanban-api";
import type { WorkflowStepProgress } from "@/hooks/domains/kanban/use-workflow-step-progress";
import { StepProgressDetails } from "./workflow-step-progress-details";
@@ -45,11 +45,13 @@ export function useTaskMoveOptions({
workflowId,
steps,
closeMenu,
onMoveCommitted,
}: {
taskId: string;
workflowId?: string | null;
steps?: TaskMoveStep[];
closeMenu?: () => void;
onMoveCommitted?: (response: WorkflowMoveResponse) => void;
}) {
const [moveOptionsStep, setMoveOptionsStep] = useState<TaskMoveStep | null>(null);
const { move, isMoving } = useWorkflowMove();
@@ -87,6 +89,7 @@ export function useTaskMoveOptions({
});
return false;
}
onMoveCommitted?.(result.response);
closeMenu?.();
return true;
};
@@ -13,6 +13,11 @@ const { appStoreState, moveTaskMock, previewWorkflowMoveMock } = vi.hoisted(() =
workspaceContextGeneration: 1,
workflows: { items: [], activeId: null },
tasks: { activeSessionId: null },
taskRemoval: { navigationRevision: 0 },
beginWorkflowSessionFocus: vi.fn(() => 1),
bindWorkflowSessionFocus: vi.fn(),
reconcileWorkflowSessionFocus: vi.fn(),
cancelWorkflowSessionFocus: vi.fn(),
chatInput: { planModeBySessionId: {} },
kanban: {
tasks: [
@@ -12,6 +12,11 @@ const { moveTaskMock, previewWorkflowMoveMock, appStoreState } = vi.hoisted(() =
workspaceContextGeneration: 1,
workflows: { items: [], activeId: null },
tasks: { activeSessionId: null },
taskRemoval: { navigationRevision: 0 },
beginWorkflowSessionFocus: vi.fn(() => 1),
bindWorkflowSessionFocus: vi.fn(),
reconcileWorkflowSessionFocus: vi.fn(),
cancelWorkflowSessionFocus: vi.fn(),
chatInput: { planModeBySessionId: {} },
kanban: { tasks: [] },
kanbanMulti: { snapshots: {} },
@@ -600,7 +605,7 @@ describe("WorkflowStepper fallback states", () => {
// request must not paint a banner describing a move nobody is waiting on.
let rejectFirst!: (error: unknown) => void;
moveTaskMock.mockReturnValueOnce(new Promise((_res, rej) => (rejectFirst = rej)));
moveTaskMock.mockResolvedValueOnce(undefined);
moveTaskMock.mockResolvedValueOnce({});
const onMoveError = vi.fn();
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const props = {
@@ -626,7 +631,7 @@ describe("WorkflowStepper fallback states", () => {
it("notifies the owning surface when a move starts", () => {
const onMoveStart = vi.fn();
moveTaskMock.mockResolvedValueOnce(undefined);
moveTaskMock.mockResolvedValueOnce({});
const props = {
steps: STEPS,
currentStepId: "b",
@@ -0,0 +1,56 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import {
createWorkflowSessionFocusScenario,
waitForNewWorkflowSession,
} from "./workflow-session-focus-helpers";
test.describe("Mobile workflow session focus", () => {
test("shows the new committed recipient in chat without opening the keyboard", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const scenario = await createWorkflowSessionFocusScenario(apiClient, seedData, "new");
await testPage.goto(`/t/${scenario.task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const moveResponse = testPage.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === `/api/v1/tasks/${scenario.task.id}/move`,
);
await testPage.getByTestId("mobile-session-menu").tap();
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
const taskRow = sheet.locator(`[data-task-row-id="${scenario.task.id}"]`);
await expect(taskRow).toBeVisible();
await taskRow.getByRole("button", { name: "Task actions" }).tap();
await testPage.getByTestId("task-context-move-to").tap();
await testPage.getByTestId(`task-context-step-${scenario.destination.id}`).tap();
const response = await moveResponse;
expect(response.ok()).toBe(true);
expect((await response.json()).workflow_entry_identity).toMatch(/^entry:/);
const destinationSessionId = await waitForNewWorkflowSession(apiClient, scenario.task.id, [
scenario.sourceSessionId,
scenario.secondarySessionId,
]);
await expect(session.activeChat()).toHaveAttribute("data-session-id", destinationSessionId);
await expect(testPage.getByTestId("mobile-sessions-pill")).toBeVisible();
await expect(testPage.getByTestId("workflow-step-disclosure")).not.toBeVisible();
const focusedEditable = await testPage.evaluate(() => {
const active = document.activeElement;
return active instanceof HTMLElement && active.matches("input, textarea, [contenteditable]");
});
expect(focusedEditable).toBe(false);
const documentWidth = await testPage.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(documentWidth.scrollWidth).toBeLessThanOrEqual(documentWidth.clientWidth + 1);
});
});
@@ -0,0 +1,114 @@
import { expect, type SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import {
createWorkflowAgentProfiles,
waitForWorkflowProfileSession,
} from "./workflow-agent-switch-helpers";
export type WorkflowSessionFocusScenario = {
workflow: { id: string };
source: { id: string; name: string };
destination: { id: string; name: string };
task: { id: string };
sourceSessionId: string;
secondarySessionId: string;
};
function destinationProfileOptions(target: "new" | "initial" | "profile", profileBId: string) {
if (target === "new") {
return {
session_target: { kind: "initial" as const },
profile_session_start_policy: "new" as const,
};
}
if (target === "initial") return { session_target: { kind: "initial" as const } };
return { agent_profile_id: profileBId, profile_session_start_policy: "new" as const };
}
export async function createWorkflowSessionFocusScenario(
apiClient: ApiClient,
seedData: SeedData,
target: "new" | "initial" | "profile",
): Promise<WorkflowSessionFocusScenario> {
const { profileA, profileB } = await createWorkflowAgentProfiles(apiClient);
const workflow = await apiClient.createWorkflow(
seedData.workspaceId,
`Workflow session focus ${target}`,
);
const source = await apiClient.createWorkflowStep(workflow.id, "Source", 0, {
is_start_step: true,
});
const destination = await apiClient.createWorkflowStep(workflow.id, "Destination", 1);
await apiClient.updateWorkflowStep(source.id, {
agent_profile_id: profileA.id,
profile_session_end_policy: "park",
events: { on_enter: [{ type: "auto_start_agent" }] },
});
await apiClient.updateWorkflowStep(destination.id, {
...destinationProfileOptions(target, profileB.id),
prompt: `e2e:message("workflow-focus-${target}")`,
profile_session_end_policy: "park",
events: { on_enter: [{ type: "auto_start_agent" }] },
});
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
`Workflow session focus ${target} task`,
profileA.id,
{
workflow_id: workflow.id,
workflow_step_id: source.id,
repository_ids: [seedData.repositoryId],
},
);
const sourceSessionId = await waitForWorkflowProfileSession(apiClient, task.id, profileA.id);
const secondary = await apiClient.launchSession({
task_id: task.id,
agent_profile_id: profileA.id,
executor_profile_id: seedData.worktreeExecutorProfileId,
workflow_step_id: source.id,
prompt: 'e2e:message("workflow-focus-secondary")',
});
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return sessions.find((session) => session.id === secondary.session_id)?.state;
},
{ timeout: 30_000, message: "secondary focus session never became visible" },
)
.not.toBe("CREATED");
await apiClient.setPrimarySession(sourceSessionId);
return {
workflow,
source: { ...source, name: "Source" },
destination: { ...destination, name: "Destination" },
task,
sourceSessionId,
secondarySessionId: secondary.session_id,
};
}
export async function waitForNewWorkflowSession(
apiClient: ApiClient,
taskId: string,
excludedSessionIds: readonly string[],
): Promise<string> {
let sessionId = "";
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(taskId);
sessionId =
sessions.find((session) => !excludedSessionIds.includes(session.id) && session.is_primary)
?.id ?? "";
return sessionId;
},
{ timeout: 30_000, message: "workflow move did not create its destination session" },
)
.not.toBe("");
return sessionId;
}
@@ -0,0 +1,213 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import { KanbanPage } from "../../pages/kanban-page";
import { waitForHttp } from "../../helpers/causal-waits";
import { waitForFiniteAnimations } from "../../helpers/animations";
import {
createWorkflowSessionFocusScenario,
waitForNewWorkflowSession,
} from "./workflow-session-focus-helpers";
test.describe("Workflow session focus", () => {
test("focuses a new committed recipient on the task detail page", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const scenario = await createWorkflowSessionFocusScenario(apiClient, seedData, "new");
await testPage.goto(`/t/${scenario.task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.sessionTabBySessionId(scenario.secondarySessionId).click();
await expect(session.activeChat()).toHaveAttribute(
"data-session-id",
scenario.secondarySessionId,
);
const moveResponse = waitForHttp(
testPage,
"POST",
new RegExp(`/api/v1/tasks/${scenario.task.id}/move$`),
);
await session.moveToWorkflowStep(scenario.destination);
const response = await moveResponse;
expect(response.ok()).toBe(true);
expect((await response.json()).workflow_entry_identity).toMatch(/^entry:/);
const destinationSessionId = await waitForNewWorkflowSession(apiClient, scenario.task.id, [
scenario.sourceSessionId,
scenario.secondarySessionId,
]);
await expect(session.sessionTabBySessionId(destinationSessionId)).toBeVisible({
timeout: 15_000,
});
await expect(session.activeChat()).toHaveAttribute("data-session-id", destinationSessionId);
await expect(session.sessionTabBySessionId(destinationSessionId)).toBeVisible();
});
test("focuses a reused recipient in the desktop task preview", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const scenario = await createWorkflowSessionFocusScenario(apiClient, seedData, "initial");
await apiClient.saveUserSettings({
enable_preview_on_click: true,
workflow_filter_id: scenario.workflow.id,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
const card = kanban.taskCardByTitle(`Workflow session focus initial task`);
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
const preview = testPage.getByTestId("task-preview-panel");
await expect(preview).toBeVisible({ timeout: 15_000 });
const secondaryTab = preview.getByTestId(`preview-session-tab-${scenario.secondarySessionId}`);
await expect(secondaryTab).toBeVisible({ timeout: 15_000 });
await secondaryTab.click();
await expect(secondaryTab).toHaveAttribute("data-state", "active");
const moveResponse = waitForHttp(
testPage,
"POST",
new RegExp(`/api/v1/tasks/${scenario.task.id}/move$`),
);
await preview.getByTestId("workflow-stepper-minimal").hover();
const disclosure = testPage.getByTestId("workflow-step-disclosure");
await expect(disclosure).toBeVisible();
await waitForFiniteAnimations(testPage.locator('[data-slot="popover-content"]:visible'));
const destinationRow = disclosure.getByTestId(
`workflow-step-disclosure-row-${scenario.destination.id}`,
);
await expect(destinationRow).toBeVisible();
const moveButton = destinationRow.getByTestId(
`workflow-step-disclosure-move-${scenario.destination.id}`,
);
await expect(moveButton).toBeVisible();
await moveButton.click();
const response = await moveResponse;
const movePayload = await response.json();
expect(response.ok()).toBe(true);
expect(movePayload.workflow_entry_identity).toMatch(/^entry:/);
await expect
.poll(async () => (await apiClient.getTask(scenario.task.id)).workflow_step_id)
.toBe(scenario.destination.id);
await expect
.poll(
async () =>
(await apiClient.getTask(scenario.task.id)).metadata?.workflow_session_route?.phase,
)
.toBe("committed");
const sourceTab = preview.getByTestId(`preview-session-tab-${scenario.sourceSessionId}`);
await expect(sourceTab).toHaveAttribute("data-state", "active");
await expect(secondaryTab).toHaveAttribute("data-state", "inactive");
});
test("keeps a later explicit session choice during a delayed move response", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const scenario = await createWorkflowSessionFocusScenario(apiClient, seedData, "new");
await testPage.goto(`/t/${scenario.task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.activeChat()).toHaveAttribute("data-session-id", scenario.sourceSessionId);
let releaseResponse!: () => void;
let responseFetched!: () => void;
const responseReleased = new Promise<void>((resolve) => {
releaseResponse = resolve;
});
const backendMoveCommitted = new Promise<void>((resolve) => {
responseFetched = resolve;
});
await testPage.route(`**/api/v1/tasks/${scenario.task.id}/move`, async (route) => {
const response = await route.fetch();
responseFetched();
await responseReleased;
await route.fulfill({ response });
});
try {
const moveResponse = waitForHttp(
testPage,
"POST",
new RegExp(`/api/v1/tasks/${scenario.task.id}/move$`),
);
await session.moveToWorkflowStep(scenario.destination);
await backendMoveCommitted;
// The destination may be arriving over WS while the HTTP response is
// still held. This click is the user's later, explicit choice.
await session.sessionTabBySessionId(scenario.secondarySessionId).click();
releaseResponse();
const response = await moveResponse;
expect(response.ok()).toBe(true);
const destinationSessionId = await waitForNewWorkflowSession(apiClient, scenario.task.id, [
scenario.sourceSessionId,
scenario.secondarySessionId,
]);
await expect(session.sessionTabBySessionId(destinationSessionId)).toBeVisible({
timeout: 15_000,
});
await expect(session.activeChat()).toHaveAttribute(
"data-session-id",
scenario.secondarySessionId,
);
} finally {
releaseResponse();
await testPage.unroute(`**/api/v1/tasks/${scenario.task.id}/move`);
}
});
test("focuses a profile-only recipient instead of the pinned source session", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const scenario = await createWorkflowSessionFocusScenario(apiClient, seedData, "profile");
await testPage.goto(`/t/${scenario.task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.sessionTabBySessionId(scenario.secondarySessionId).click();
await expect(session.activeChat()).toHaveAttribute(
"data-session-id",
scenario.secondarySessionId,
);
const moveResponse = waitForHttp(
testPage,
"POST",
new RegExp(`/api/v1/tasks/${scenario.task.id}/move$`),
);
await session.moveToWorkflowStep(scenario.destination);
const response = await moveResponse;
const payload = await response.json();
expect(response.ok()).toBe(true);
expect(payload.workflow_entry_identity).toMatch(/^entry:/);
const destinationSessionId = await waitForNewWorkflowSession(apiClient, scenario.task.id, [
scenario.sourceSessionId,
scenario.secondarySessionId,
]);
await expect(session.activeChat()).toHaveAttribute("data-session-id", destinationSessionId);
await expect(
apiClient.getTask(scenario.task.id).then((task) => task.metadata?.workflow_session_route),
).resolves.toMatchObject({
phase: "committed",
destination_step_id: scenario.destination.id,
destination_session_id: destinationSessionId,
});
});
});
@@ -6,9 +6,14 @@ import { usePresentationToken, useWorkflowStepMove } from "./use-workflow-step-m
const mockAppState = vi.hoisted(() => ({
value: {
tasks: { activeSessionId: null as string | null },
taskRemoval: { navigationRevision: 4 },
chatInput: { planModeBySessionId: {} as Record<string, boolean> },
setPlanMode: vi.fn(),
setActiveDocument: vi.fn(),
beginWorkflowSessionFocus: vi.fn(() => 1),
bindWorkflowSessionFocus: vi.fn(),
reconcileWorkflowSessionFocus: vi.fn(),
cancelWorkflowSessionFocus: vi.fn(),
},
}));
const mockLayoutStore = vi.hoisted(() => ({ closeDocument: vi.fn() }));
@@ -37,11 +42,13 @@ vi.mock("@/lib/api", () => ({
const TASK_ID = "task-1";
const WORKFLOW_ID = "workflow-1";
const ENTRY_IDENTITY = "entry:00000000000000000042";
afterEach(() => {
vi.clearAllMocks();
mockAppState.value.tasks.activeSessionId = null;
mockAppState.value.chatInput.planModeBySessionId = {};
mockAppState.value.taskRemoval.navigationRevision = 4;
});
describe("usePresentationToken", () => {
@@ -70,6 +77,7 @@ describe("usePresentationToken", () => {
});
});
// eslint-disable-next-line max-lines-per-function -- move races share one hook fixture
describe("useWorkflowStepMove", () => {
it("issues the move request with the task's workflow id, target step, and position 0", async () => {
vi.mocked(moveTask).mockResolvedValue({} as Awaited<ReturnType<typeof moveTask>>);
@@ -88,6 +96,102 @@ describe("useWorkflowStepMove", () => {
});
});
it("binds the committed response identity and reconciles its task route", async () => {
const task = {
metadata: {
workflow_session_route: {
operation_id: "operation-1",
destination_step_id: "step-b",
entry_identity: ENTRY_IDENTITY,
destination_session_id: "session-b",
phase: "committed",
},
},
};
vi.mocked(moveTask).mockResolvedValue({
workflow_entry_identity: ENTRY_IDENTITY,
task,
} as unknown as Awaited<ReturnType<typeof moveTask>>);
const { result } = renderHook(() =>
useWorkflowStepMove({
taskId: TASK_ID,
workflowId: WORKFLOW_ID,
presentationToken: 0,
}),
);
await act(async () => {
await result.current.handleMove("step-b");
});
expect(mockAppState.value.beginWorkflowSessionFocus).toHaveBeenCalledWith({
taskId: TASK_ID,
workflowId: WORKFLOW_ID,
destinationStepId: "step-b",
presentationToken: 0,
navigationRevision: 4,
});
expect(mockAppState.value.bindWorkflowSessionFocus).toHaveBeenCalledWith({
requestId: 1,
presentationToken: 0,
entryIdentity: ENTRY_IDENTITY,
});
expect(mockAppState.value.reconcileWorkflowSessionFocus).toHaveBeenCalledWith(TASK_ID, {
metadata: task.metadata,
updatedAt: undefined,
workflowStepId: undefined,
entryIdentity: ENTRY_IDENTITY,
});
});
it("cancels the local focus intent when the move response has no committed identity", async () => {
vi.mocked(moveTask).mockResolvedValue({} as Awaited<ReturnType<typeof moveTask>>);
const { result, unmount } = renderHook(() =>
useWorkflowStepMove({
taskId: TASK_ID,
workflowId: WORKFLOW_ID,
presentationToken: 0,
}),
);
await act(async () => {
await result.current.handleMove("step-b");
});
expect(mockAppState.value.cancelWorkflowSessionFocus).toHaveBeenCalledWith({
requestId: 1,
presentationToken: 0,
});
vi.clearAllMocks();
unmount();
expect(mockAppState.value.cancelWorkflowSessionFocus).not.toHaveBeenCalled();
});
it("cancels only the active request when the move hook unmounts", async () => {
let resolveMove!: () => void;
vi.mocked(moveTask).mockReturnValueOnce(
new Promise<Awaited<ReturnType<typeof moveTask>>>((resolve) => {
resolveMove = () => resolve({} as Awaited<ReturnType<typeof moveTask>>);
}),
);
const { result, unmount } = renderHook(() =>
useWorkflowStepMove({
taskId: TASK_ID,
workflowId: WORKFLOW_ID,
presentationToken: 0,
}),
);
act(() => {
void result.current.handleMove("step-b");
});
unmount();
expect(mockAppState.value.cancelWorkflowSessionFocus).toHaveBeenCalledWith({ requestId: 1 });
resolveMove();
await act(async () => {});
});
it("notifies onMoveStart and disables plan mode before the request resolves", async () => {
mockAppState.value.tasks.activeSessionId = "session-1";
mockAppState.value.chatInput.planModeBySessionId = { "session-1": true };
@@ -1,8 +1,8 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { moveTask } from "@/lib/api";
import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api";
import type { WorkflowMoveEntryOptions, WorkflowMoveResponse } from "@/lib/api/domains/kanban-api";
import { useAppStore } from "@/components/state-provider";
import { useContextFilesStore } from "@/lib/state/context-files-store";
import { useLayoutStore } from "@/lib/state/layout-store";
@@ -101,6 +101,89 @@ function shouldClearProgressMove(
);
}
function useWorkflowStepMoveFocus({
taskId,
workflowId,
presentationToken,
}: Pick<UseWorkflowStepMoveParams, "taskId" | "workflowId" | "presentationToken">) {
const navigationRevision = useAppStore((s) => s.taskRemoval.navigationRevision);
const beginWorkflowSessionFocus = useAppStore((s) => s.beginWorkflowSessionFocus);
const bindWorkflowSessionFocus = useAppStore((s) => s.bindWorkflowSessionFocus);
const reconcileWorkflowSessionFocus = useAppStore((s) => s.reconcileWorkflowSessionFocus);
const cancelWorkflowSessionFocus = useAppStore((s) => s.cancelWorkflowSessionFocus);
const activeFocusRequestIdRef = useRef<number | null>(null);
const beginFocus = useCallback(
(destinationStepId: string) => {
if (!taskId || !workflowId) return null;
const requestId = beginWorkflowSessionFocus({
taskId,
workflowId,
destinationStepId,
presentationToken,
navigationRevision,
});
activeFocusRequestIdRef.current = requestId;
return requestId;
},
[beginWorkflowSessionFocus, navigationRevision, presentationToken, taskId, workflowId],
);
const commitFocus = useCallback(
(requestId: number | null, response: WorkflowMoveResponse) => {
if (requestId === null) return;
try {
if (!response.workflow_entry_identity) {
cancelWorkflowSessionFocus({ requestId, presentationToken });
return;
}
bindWorkflowSessionFocus({
requestId,
presentationToken,
entryIdentity: response.workflow_entry_identity,
});
if (!taskId) return;
reconcileWorkflowSessionFocus(taskId, {
metadata: response.task?.metadata,
updatedAt: response.task?.updated_at,
workflowStepId: response.task?.workflow_step_id,
entryIdentity: response.workflow_entry_identity,
});
} finally {
if (activeFocusRequestIdRef.current === requestId) {
activeFocusRequestIdRef.current = null;
}
}
},
[
bindWorkflowSessionFocus,
cancelWorkflowSessionFocus,
presentationToken,
reconcileWorkflowSessionFocus,
taskId,
],
);
const cancelFocus = useCallback(
(requestId: number | null) => {
if (requestId === null) return;
cancelWorkflowSessionFocus({ requestId, presentationToken });
if (activeFocusRequestIdRef.current === requestId) {
activeFocusRequestIdRef.current = null;
}
},
[cancelWorkflowSessionFocus, presentationToken],
);
useEffect(
() => () => {
const requestId = activeFocusRequestIdRef.current;
if (requestId !== null) cancelWorkflowSessionFocus({ requestId });
},
[cancelWorkflowSessionFocus],
);
return { beginFocus, cancelFocus, commitFocus };
}
/**
* The single implementation of the compact stepper's move request. Extracted
* so the task top bar and the kanban preview header share one plan-mode
@@ -119,6 +202,7 @@ export function useWorkflowStepMove({
onMoveError,
}: UseWorkflowStepMoveParams): UseWorkflowStepMoveResult {
const disablePlanMode = useDisablePlanMode();
const focus = useWorkflowStepMoveFocus({ taskId, workflowId, presentationToken });
const [movingToStepId, setMovingToStepId] = useState<string | null>(null);
const [progressingToStepId, setProgressingToStepId] = useState<string | null>(null);
// Only the in-flight step's own button is disabled, so every other step stays
@@ -162,6 +246,7 @@ export function useWorkflowStepMove({
const handleMove = useCallback(
async (stepId: string, entryOptions?: WorkflowMoveEntryOptions): Promise<boolean> => {
if (!taskId || !workflowId) return false;
const focusRequestId = focus.beginFocus(stepId);
onMoveStart?.();
disablePlanMode();
const requestId = ++moveRequestRef.current;
@@ -179,15 +264,17 @@ export function useWorkflowStepMove({
setMovingToStepId(stepId);
setProgressingToStepId(stepId);
try {
await moveTask(taskId, {
const response = await moveTask(taskId, {
workflow_id: workflowId,
workflow_step_id: stepId,
position: 0,
entry_options: entryOptions,
});
if (requestId === moveRequestRef.current) focus.commitFocus(focusRequestId, response);
return true;
} catch (err) {
console.error("[useWorkflowStepMove] Failed to move task:", err);
focus.cancelFocus(focusRequestId);
if (requestId === moveRequestRef.current) {
progressMoveRef.current = null;
priorMoveTargetsRef.current.clear();
@@ -201,7 +288,7 @@ export function useWorkflowStepMove({
if (requestId === moveRequestRef.current) setMovingToStepId(null);
}
},
[taskId, workflowId, currentStepId, disablePlanMode, onMoveStart, onMoveError],
[taskId, workflowId, currentStepId, focus, disablePlanMode, onMoveStart, onMoveError],
);
return { movingToStepId, progressingToStepId, handleMove };
+2
View File
@@ -49,6 +49,7 @@ async function hydrateTaskSessions({
[...fetchedSessions, ...sessionsAddedDuringLoad],
activityEpochsAtRequestStart,
);
getStoreState().reconcileWorkflowSessionFocus?.(taskId);
return sessionsAddedDuringLoad.length > 0;
} catch (error) {
console.error("Failed to load task sessions:", error);
@@ -58,6 +59,7 @@ async function hydrateTaskSessions({
const currentSessions = storedTaskSessions(getStoreState, taskId);
if (!force && currentSessions.length > 0) {
setTaskSessionsForTask(taskId, currentSessions, activityEpochsAtRequestStart);
getStoreState().reconcileWorkflowSessionFocus?.(taskId);
}
setTaskSessionsError(taskId, error instanceof Error ? error.message : String(error));
return false;
+2
View File
@@ -49,6 +49,7 @@ export const defaultState = {
workspaceContextGeneration: defaultKanbanState.workspaceContextGeneration,
workspaceContextRead: defaultKanbanState.workspaceContextRead,
tasks: defaultKanbanState.tasks,
workflowSessionFocus: defaultKanbanState.workflowSessionFocus,
taskRemoval: defaultKanbanState.taskRemoval,
workspaces: defaultWorkspaceState.workspaces,
repositories: defaultWorkspaceState.repositories,
@@ -431,6 +432,7 @@ function mergeTaskSessionState(initialState: HydrationState) {
export function mergeInitialState(initialState?: HydrationState): DefaultState {
if (!initialState) return defaultState;
const hydration = { ...initialState };
delete hydration.workflowSessionFocus;
delete hydration.taskRemoval;
return {
...defaultState,
@@ -10,6 +10,17 @@ import {
releaseTaskRemoval,
} from "@/lib/state/task-removal";
import { mergeStepOrderRevisions } from "@/lib/kanban/workflow-step-order";
import {
acknowledgeWorkflowSessionFocus,
beginWorkflowSessionFocus,
bindWorkflowSessionFocus,
cancelWorkflowSessionFocus,
createWorkflowSessionFocusState,
reconcileWorkflowSessionFocus,
type WorkflowSessionFocusCancelScope,
type WorkflowSessionFocusStart,
} from "@/lib/state/workflow-session-focus";
import { parseTurnTimestamp } from "@/lib/state/slices/session/turn-actions";
import {
WORKSPACE_CONTEXT_COLLECTIONS,
type WorkspaceContextCollection,
@@ -73,12 +84,16 @@ export const defaultKanbanState: KanbanSliceState = {
lastSessionByTaskId: {},
resumeSkippedSessionIds: {},
},
workflowSessionFocus: createWorkflowSessionFocusState(),
taskRemoval: createTaskRemovalState(),
};
type KanbanSliceSet = Parameters<
StateCreator<KanbanSlice, [["zustand/immer", never]], [], KanbanSlice>
>[0];
type KanbanSliceGet = Parameters<
StateCreator<KanbanSlice, [["zustand/immer", never]], [], KanbanSlice>
>[1];
type SidebarArchivedTaskActions = Pick<
KanbanSliceActions,
@@ -153,6 +168,11 @@ type TaskActions = Pick<
| "setActiveSession"
| "setActiveSessionAuto"
| "clearActiveSession"
| "beginWorkflowSessionFocus"
| "bindWorkflowSessionFocus"
| "reconcileWorkflowSessionFocus"
| "cancelWorkflowSessionFocus"
| "acknowledgeWorkflowSessionFocus"
| "setResumeSkipped"
| "beginTaskRemoval"
| "recordTaskRemovalResult"
@@ -292,10 +312,118 @@ function createWorkspaceContextActions(set: KanbanSliceSet): WorkspaceContextAct
};
}
function createTaskActions(set: KanbanSliceSet): TaskActions {
type FocusLookupState = {
taskSessionsByTask?: {
itemsByTaskId?: Record<string, Array<{ id: string }>>;
};
taskSessions?: {
items?: Record<string, { id: string; task_id?: string }>;
};
};
type WorkflowSessionFocusActions = Pick<
TaskActions,
| "beginWorkflowSessionFocus"
| "bindWorkflowSessionFocus"
| "reconcileWorkflowSessionFocus"
| "cancelWorkflowSessionFocus"
| "acknowledgeWorkflowSessionFocus"
>;
function createWorkflowSessionFocusActions(
set: KanbanSliceSet,
get: KanbanSliceGet,
): WorkflowSessionFocusActions {
const knownSessionIdsForTask = (taskId: string): string[] => {
const state = get() as KanbanSlice & FocusLookupState;
const ids = new Set(
state.taskSessionsByTask?.itemsByTaskId?.[taskId]?.map((session) => session.id) ?? [],
);
for (const session of Object.values(state.taskSessions?.items ?? {})) {
if (session.task_id === taskId) ids.add(session.id);
}
return [...ids];
};
const taskProjectionForTask = (
taskId: string,
): { metadata: unknown; updatedAt?: string; workflowStepId?: string } => {
const state = get();
const tasks = [
...state.kanban.tasks,
...Object.values(state.kanbanMulti.snapshots).flatMap((snapshot) => snapshot.tasks),
].filter((candidate) => candidate.id === taskId);
let task = tasks[0];
for (const candidate of tasks.slice(1)) {
const selectedTime = parseTurnTimestamp(task?.updatedAt);
const candidateTime = parseTurnTimestamp(candidate.updatedAt);
if (
task === undefined ||
(selectedTime === null && candidateTime !== null) ||
(candidateTime !== null && selectedTime !== null && candidateTime > selectedTime)
) {
task = candidate;
}
}
return {
metadata: task?.metadata,
updatedAt: task?.updatedAt,
workflowStepId: task?.workflowStepId,
};
};
return {
beginWorkflowSessionFocus: (input: WorkflowSessionFocusStart) => {
let requestId: number | null = null;
set((draft) => {
const result = beginWorkflowSessionFocus(draft.workflowSessionFocus, input);
draft.workflowSessionFocus = result.state;
requestId = result.requestId;
});
return requestId;
},
bindWorkflowSessionFocus: (input) =>
set((draft) => {
draft.workflowSessionFocus = bindWorkflowSessionFocus(draft.workflowSessionFocus, input);
}),
reconcileWorkflowSessionFocus: (taskId, responseProjection) =>
set((draft) => {
const liveProjection = taskProjectionForTask(taskId);
const result = reconcileWorkflowSessionFocus(draft.workflowSessionFocus, {
activeTaskId: draft.tasks.activeTaskId,
navigationRevision: draft.taskRemoval.navigationRevision,
routeMetadata: liveProjection.metadata,
routeUpdatedAt: liveProjection.updatedAt,
workflowStepId: liveProjection.workflowStepId,
responseProjection,
knownSessionIds: knownSessionIdsForTask(taskId),
});
draft.workflowSessionFocus = result.state;
if (!result.sessionId) return;
draft.tasks.activeSessionId = result.sessionId;
draft.tasks.pinnedSessionId = null;
draft.tasks.lastSessionByTaskId[taskId] = result.sessionId;
}),
cancelWorkflowSessionFocus: (scope?: WorkflowSessionFocusCancelScope) =>
set((draft) => {
draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus, scope);
}),
acknowledgeWorkflowSessionFocus: (requestId) =>
set((draft) => {
draft.workflowSessionFocus = acknowledgeWorkflowSessionFocus(
draft.workflowSessionFocus,
requestId,
);
}),
};
}
function createTaskActions(set: KanbanSliceSet, get: KanbanSliceGet): TaskActions {
return {
...createWorkflowSessionFocusActions(set, get),
setActiveTask: (taskId) =>
set((draft) => {
draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus);
draft.taskRemoval = advanceTaskNavigationRevision(draft.taskRemoval);
draft.tasks.activeTaskId = taskId;
draft.tasks.activeSessionId = null;
@@ -304,12 +432,16 @@ function createTaskActions(set: KanbanSliceSet): TaskActions {
}),
setActiveTaskAuto: (taskId) =>
set((draft) => {
if (draft.tasks.activeTaskId !== taskId) {
draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus);
}
draft.tasks.activeTaskId = taskId;
draft.tasks.activeSessionId = null;
draft.tasks.pinnedSessionId = null;
}),
setActiveSession: (taskId, sessionId) =>
set((draft) => {
draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus);
draft.taskRemoval = advanceTaskNavigationRevision(draft.taskRemoval);
draft.tasks.activeTaskId = taskId;
draft.tasks.activeSessionId = sessionId;
@@ -320,6 +452,7 @@ function createTaskActions(set: KanbanSliceSet): TaskActions {
setActiveSessionAuto: (taskId, sessionId) =>
set((draft) => {
if (draft.tasks.activeTaskId !== taskId) {
draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus);
draft.tasks.pinnedSessionId = null;
}
draft.tasks.activeTaskId = taskId;
@@ -347,10 +480,12 @@ function createTaskActions(set: KanbanSliceSet): TaskActions {
}),
advanceTaskNavigationRevision: () =>
set((draft) => {
draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus);
draft.taskRemoval = advanceTaskNavigationRevision(draft.taskRemoval);
}),
clearActiveSession: () =>
set((draft) => {
draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus);
draft.tasks.activeSessionId = null;
draft.tasks.pinnedSessionId = null;
}),
@@ -374,10 +509,10 @@ export const createKanbanSlice: StateCreator<
[["zustand/immer", never]],
[],
KanbanSlice
> = (set) => ({
> = (set, get) => ({
...defaultKanbanState,
...createSidebarArchivedTaskActions(set),
...createTaskActions(set),
...createTaskActions(set, get),
...createWorkspaceContextActions(set),
resetKanbanWorkspaceContext: () =>
set((draft) => {
+20
View File
@@ -12,6 +12,12 @@ import type {
} from "@/lib/types/http";
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
import type { BeginTaskRemovalInput, TaskRemovalState } from "@/lib/state/task-removal";
import type {
WorkflowSessionFocusCancelScope,
WorkflowSessionFocusState,
WorkflowSessionFocusStart,
WorkflowSessionFocusTaskProjection,
} from "@/lib/state/workflow-session-focus";
export type KanbanStepEvents = {
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
@@ -326,6 +332,8 @@ export type KanbanSliceState = {
workspaceContextGeneration: number;
workspaceContextRead: WorkspaceContextReadState;
tasks: TaskState;
/** Browser-local one-shot focus handoff for an explicitly moved task. */
workflowSessionFocus: WorkflowSessionFocusState;
/** Browser-local removal intent. It is deliberately excluded from hydration. */
taskRemoval: TaskRemovalState;
};
@@ -361,6 +369,18 @@ export type KanbanSliceActions = {
// it explicitly after checking no non-terminal manual pin should be preserved.
setActiveSessionAuto: (taskId: string, sessionId: string) => void;
clearActiveSession: () => void;
beginWorkflowSessionFocus: (input: WorkflowSessionFocusStart) => number | null;
bindWorkflowSessionFocus: (input: {
requestId: number;
presentationToken: number;
entryIdentity: string;
}) => void;
reconcileWorkflowSessionFocus: (
taskId: string,
responseProjection?: WorkflowSessionFocusTaskProjection,
) => void;
cancelWorkflowSessionFocus: (scope?: WorkflowSessionFocusCancelScope) => void;
acknowledgeWorkflowSessionFocus: (requestId: number) => void;
// setResumeSkipped records/clears the resume-skipped marker for a session.
// Recording is guarded at the call site (the session-resumption hook reads
// the live session row with typed store access), so a stale status response
+1
View File
@@ -13,6 +13,7 @@ export function buildStateOverrides(m: DefaultState) {
workspaceContextGeneration: m.workspaceContextGeneration,
workspaceContextRead: m.workspaceContextRead,
tasks: m.tasks,
workflowSessionFocus: m.workflowSessionFocus,
taskRemoval: m.taskRemoval,
workspaces: m.workspaces,
repositories: m.repositories,
@@ -0,0 +1,490 @@
import { describe, expect, it } from "vitest";
import { createAppStore } from "./store";
import {
acknowledgeWorkflowSessionFocus,
bindWorkflowSessionFocus,
cancelWorkflowSessionFocus,
createWorkflowSessionFocusState,
beginWorkflowSessionFocus,
reconcileWorkflowSessionFocus,
} from "./workflow-session-focus";
const START = {
taskId: "task-1",
workflowId: "workflow-1",
destinationStepId: "step-implement",
presentationToken: 7,
navigationRevision: 3,
};
const SOURCE_SESSION_ID = "session-astra";
const DESTINATION_SESSION_ID = "session-luna";
const NEWER_SESSION_ID = "session-newer";
const LATER_STEP_ID = "step-review";
const LIVE_UPDATED_AT = "2026-09-19T10:00:02Z";
const RESPONSE_UPDATED_AT = "2026-09-19T10:00:01Z";
const FOCUS_INTENT_ERROR = "test focus intent did not start";
const COMMITTED_ROUTE = {
operation_id: "operation-1",
destination_step_id: "step-implement",
entry_identity: "entry:00000000000000000042",
destination_session_id: DESTINATION_SESSION_ID,
phase: "committed",
};
function started(): {
state: ReturnType<typeof createWorkflowSessionFocusState>;
requestId: number;
} {
const result = beginWorkflowSessionFocus(createWorkflowSessionFocusState(), START);
if (result.requestId === null) throw new Error(FOCUS_INTENT_ERROR);
return { state: result.state, requestId: result.requestId };
}
// eslint-disable-next-line max-lines-per-function -- state-machine scenarios share one transition fixture
describe("workflow session focus state", () => {
it("applies the resolved recipient atomically in the app store and clears the old pin", () => {
const store = createAppStore();
store.setState((state) => {
state.tasks.activeTaskId = START.taskId;
state.tasks.activeSessionId = SOURCE_SESSION_ID;
state.tasks.pinnedSessionId = SOURCE_SESSION_ID;
state.taskRemoval.navigationRevision = START.navigationRevision;
state.kanban.tasks = [
{
id: START.taskId,
workflowStepId: START.destinationStepId,
metadata: { workflow_session_route: COMMITTED_ROUTE },
},
] as never;
state.taskSessionsByTask.itemsByTaskId[START.taskId] = [
{ id: SOURCE_SESSION_ID },
{ id: DESTINATION_SESSION_ID },
] as never;
state.taskSessions.items[DESTINATION_SESSION_ID] = {
id: DESTINATION_SESSION_ID,
task_id: START.taskId,
} as never;
});
const requestId = store.getState().beginWorkflowSessionFocus(START);
expect(requestId).toBe(1);
if (requestId === null) throw new Error(FOCUS_INTENT_ERROR);
store.getState().bindWorkflowSessionFocus({
requestId,
presentationToken: START.presentationToken,
entryIdentity: COMMITTED_ROUTE.entry_identity,
});
store.getState().reconcileWorkflowSessionFocus(START.taskId);
expect(store.getState().tasks.activeSessionId).toBe(DESTINATION_SESSION_ID);
expect(store.getState().tasks.pinnedSessionId).toBeNull();
expect(store.getState().tasks.lastSessionByTaskId[START.taskId]).toBe(DESTINATION_SESSION_ID);
expect(store.getState().workflowSessionFocus.request?.sessionId).toBe(DESTINATION_SESSION_ID);
store.getState().setActiveSession(START.taskId, SOURCE_SESSION_ID);
expect(store.getState().workflowSessionFocus.request).toBeNull();
});
it("reconsiders the cached live route when a move response is delayed", () => {
const store = createAppStore();
store.setState((state) => {
state.tasks.activeTaskId = START.taskId;
state.tasks.activeSessionId = SOURCE_SESSION_ID;
state.taskRemoval.navigationRevision = START.navigationRevision;
state.kanban.tasks = [
{
id: START.taskId,
workflowStepId: START.destinationStepId,
updatedAt: LIVE_UPDATED_AT,
metadata: { workflow_session_route: COMMITTED_ROUTE },
},
] as never;
state.taskSessionsByTask.itemsByTaskId[START.taskId] = [
{ id: SOURCE_SESSION_ID },
{ id: DESTINATION_SESSION_ID },
] as never;
});
const requestId = store.getState().beginWorkflowSessionFocus(START);
if (requestId === null) throw new Error(FOCUS_INTENT_ERROR);
store.getState().bindWorkflowSessionFocus({
requestId,
presentationToken: START.presentationToken,
entryIdentity: COMMITTED_ROUTE.entry_identity,
});
store.getState().reconcileWorkflowSessionFocus(START.taskId, {
metadata: { workflow_session_route: { ...COMMITTED_ROUTE, phase: "prepared" } },
updatedAt: RESPONSE_UPDATED_AT,
entryIdentity: COMMITTED_ROUTE.entry_identity,
});
expect(store.getState().tasks.activeSessionId).toBe(DESTINATION_SESSION_ID);
expect(store.getState().workflowSessionFocus.request?.sessionId).toBe(DESTINATION_SESSION_ID);
});
it("accepts the response projection when the live task is still on the source step", () => {
const startedState = started();
const bound = bindWorkflowSessionFocus(startedState.state, {
requestId: startedState.requestId,
presentationToken: START.presentationToken,
entryIdentity: COMMITTED_ROUTE.entry_identity,
});
const result = reconcileWorkflowSessionFocus(bound, {
activeTaskId: START.taskId,
navigationRevision: START.navigationRevision,
workflowStepId: "step-plan",
routeMetadata: undefined,
routeUpdatedAt: "2026-09-19T10:00:00Z",
responseProjection: {
metadata: { workflow_session_route: COMMITTED_ROUTE },
updatedAt: RESPONSE_UPDATED_AT,
workflowStepId: START.destinationStepId,
entryIdentity: COMMITTED_ROUTE.entry_identity,
},
knownSessionIds: [DESTINATION_SESSION_ID],
});
expect(result.sessionId).toBe(DESTINATION_SESSION_ID);
});
it("keeps an intent pending when its response route is older than a newer live entry", () => {
const store = createAppStore();
const newerLiveRoute = {
...COMMITTED_ROUTE,
entry_identity: "entry:00000000000000000043",
destination_session_id: NEWER_SESSION_ID,
};
store.setState((state) => {
state.tasks.activeTaskId = START.taskId;
state.tasks.activeSessionId = SOURCE_SESSION_ID;
state.taskRemoval.navigationRevision = START.navigationRevision;
state.kanban.tasks = [
{
id: START.taskId,
workflowStepId: START.destinationStepId,
updatedAt: LIVE_UPDATED_AT,
metadata: { workflow_session_route: newerLiveRoute },
},
] as never;
state.taskSessionsByTask.itemsByTaskId[START.taskId] = [
{ id: SOURCE_SESSION_ID },
{ id: DESTINATION_SESSION_ID },
{ id: NEWER_SESSION_ID },
] as never;
});
const requestId = store.getState().beginWorkflowSessionFocus(START);
if (requestId === null) throw new Error(FOCUS_INTENT_ERROR);
store.getState().bindWorkflowSessionFocus({
requestId,
presentationToken: START.presentationToken,
entryIdentity: COMMITTED_ROUTE.entry_identity,
});
store.getState().reconcileWorkflowSessionFocus(START.taskId, {
metadata: { workflow_session_route: COMMITTED_ROUTE },
updatedAt: RESPONSE_UPDATED_AT,
entryIdentity: COMMITTED_ROUTE.entry_identity,
});
expect(store.getState().tasks.activeSessionId).toBe(SOURCE_SESSION_ID);
expect(store.getState().workflowSessionFocus.intent).not.toBeNull();
expect(store.getState().workflowSessionFocus.request).toBeNull();
});
it("binds a response entry and resolves it only for the committed task-owned recipient", () => {
const startedState = started();
expect(startedState.requestId).toBe(1);
const bound = bindWorkflowSessionFocus(startedState.state, {
requestId: startedState.requestId,
presentationToken: START.presentationToken,
entryIdentity: COMMITTED_ROUTE.entry_identity,
});
const unresolved = reconcileWorkflowSessionFocus(bound, {
activeTaskId: START.taskId,
navigationRevision: START.navigationRevision,
workflowStepId: START.destinationStepId,
routeMetadata: { workflow_session_route: COMMITTED_ROUTE },
knownSessionIds: [SOURCE_SESSION_ID],
});
expect(unresolved.sessionId).toBeNull();
expect(unresolved.state.intent).not.toBeNull();
const resolved = reconcileWorkflowSessionFocus(bound, {
activeTaskId: START.taskId,
navigationRevision: START.navigationRevision,
workflowStepId: START.destinationStepId,
routeMetadata: { workflow_session_route: COMMITTED_ROUTE },
knownSessionIds: [SOURCE_SESSION_ID, DESTINATION_SESSION_ID],
});
expect(resolved.sessionId).toBe(DESTINATION_SESSION_ID);
expect(resolved.state.intent).toBeNull();
expect(resolved.state.request).toEqual({
requestId: startedState.requestId,
presentationToken: START.presentationToken,
taskId: START.taskId,
sessionId: DESTINATION_SESSION_ID,
});
});
it("rejects prepared, foreign, stale, and mismatched routes", () => {
const startedState = started();
const bound = bindWorkflowSessionFocus(startedState.state, {
requestId: startedState.requestId,
presentationToken: START.presentationToken,
entryIdentity: COMMITTED_ROUTE.entry_identity,
});
for (const routeMetadata of [
{ workflow_session_route: { ...COMMITTED_ROUTE, phase: "prepared" } },
{ workflow_session_route: { ...COMMITTED_ROUTE, entry_identity: "entry:other" } },
{ workflow_session_route: { ...COMMITTED_ROUTE, destination_step_id: "step-review" } },
{ workflow_session_route: { ...COMMITTED_ROUTE, destination_session_id: "session-other" } },
{ workflow_session_route: { ...COMMITTED_ROUTE, operation_id: "" } },
]) {
const result = reconcileWorkflowSessionFocus(bound, {
activeTaskId: START.taskId,
navigationRevision: START.navigationRevision,
workflowStepId: START.destinationStepId,
routeMetadata,
knownSessionIds: [DESTINATION_SESSION_ID],
});
expect(result.sessionId).toBeNull();
expect(result.state.intent).not.toBeNull();
}
});
it("cancels on a later navigation, newer request, and a missing response identity", () => {
const first = started();
const canceled = cancelWorkflowSessionFocus(first.state, {
taskId: START.taskId,
presentationToken: START.presentationToken,
});
expect(canceled.intent).toBeNull();
const second = beginWorkflowSessionFocus(canceled, { ...START, presentationToken: 8 });
const newer = beginWorkflowSessionFocus(second.state, {
...START,
destinationStepId: LATER_STEP_ID,
});
expect(newer.requestId).toBe(3);
expect(newer.state.intent?.destinationStepId).toBe(LATER_STEP_ID);
if (newer.requestId === null) throw new Error("test focus intent did not restart");
const missingIdentity = bindWorkflowSessionFocus(newer.state, {
requestId: newer.requestId,
presentationToken: START.presentationToken,
entryIdentity: "",
});
expect(missingIdentity.intent).toBeNull();
expect(missingIdentity.request).toBeNull();
});
it("keeps a focus request until its desktop or phone consumer acknowledges it", () => {
const startedState = started();
const bound = bindWorkflowSessionFocus(startedState.state, {
requestId: startedState.requestId,
presentationToken: START.presentationToken,
entryIdentity: COMMITTED_ROUTE.entry_identity,
});
const resolved = reconcileWorkflowSessionFocus(bound, {
activeTaskId: START.taskId,
navigationRevision: START.navigationRevision,
workflowStepId: START.destinationStepId,
routeMetadata: { workflow_session_route: COMMITTED_ROUTE },
knownSessionIds: [DESTINATION_SESSION_ID],
});
const acknowledged = acknowledgeWorkflowSessionFocus(resolved.state, startedState.requestId);
expect(acknowledged.request).toBeNull();
});
it("uses the live committed route when the response arrives after its task event", () => {
const startedState = started();
const bound = bindWorkflowSessionFocus(startedState.state, {
requestId: startedState.requestId,
presentationToken: START.presentationToken,
entryIdentity: COMMITTED_ROUTE.entry_identity,
});
const resolved = reconcileWorkflowSessionFocus(bound, {
activeTaskId: START.taskId,
navigationRevision: START.navigationRevision,
workflowStepId: START.destinationStepId,
routeMetadata: { workflow_session_route: COMMITTED_ROUTE },
routeUpdatedAt: LIVE_UPDATED_AT,
responseProjection: {
metadata: {
workflow_session_route: { ...COMMITTED_ROUTE, phase: "prepared" },
},
updatedAt: RESPONSE_UPDATED_AT,
entryIdentity: COMMITTED_ROUTE.entry_identity,
},
knownSessionIds: [DESTINATION_SESSION_ID],
});
expect(resolved.sessionId).toBe(DESTINATION_SESSION_ID);
});
it("rejects an obsolete response route after a newer live entry", () => {
const startedState = started();
const bound = bindWorkflowSessionFocus(startedState.state, {
requestId: startedState.requestId,
presentationToken: START.presentationToken,
entryIdentity: COMMITTED_ROUTE.entry_identity,
});
const newerLiveRoute = {
...COMMITTED_ROUTE,
entry_identity: "entry:00000000000000000043",
destination_session_id: NEWER_SESSION_ID,
};
const result = reconcileWorkflowSessionFocus(bound, {
activeTaskId: START.taskId,
navigationRevision: START.navigationRevision,
workflowStepId: START.destinationStepId,
routeMetadata: { workflow_session_route: newerLiveRoute },
routeUpdatedAt: LIVE_UPDATED_AT,
responseProjection: {
metadata: { workflow_session_route: COMMITTED_ROUTE },
updatedAt: RESPONSE_UPDATED_AT,
entryIdentity: COMMITTED_ROUTE.entry_identity,
},
knownSessionIds: [DESTINATION_SESSION_ID, NEWER_SESSION_ID],
});
expect(result.sessionId).toBeNull();
expect(result.state.intent).not.toBeNull();
});
it("accepts a committed response only when its entry identity matches the bound move", () => {
const startedState = started();
const bound = bindWorkflowSessionFocus(startedState.state, {
requestId: startedState.requestId,
presentationToken: START.presentationToken,
entryIdentity: COMMITTED_ROUTE.entry_identity,
});
const result = reconcileWorkflowSessionFocus(bound, {
activeTaskId: START.taskId,
navigationRevision: START.navigationRevision,
workflowStepId: START.destinationStepId,
routeMetadata: undefined,
responseProjection: {
metadata: { workflow_session_route: COMMITTED_ROUTE },
updatedAt: RESPONSE_UPDATED_AT,
entryIdentity: "entry:other",
},
knownSessionIds: [DESTINATION_SESSION_ID],
});
expect(result.sessionId).toBeNull();
expect(result.state.intent).not.toBeNull();
});
it("rejects a committed route after the task reaches a later workflow step", () => {
const startedState = started();
const bound = bindWorkflowSessionFocus(startedState.state, {
requestId: startedState.requestId,
presentationToken: START.presentationToken,
entryIdentity: COMMITTED_ROUTE.entry_identity,
});
const result = reconcileWorkflowSessionFocus(bound, {
activeTaskId: START.taskId,
navigationRevision: START.navigationRevision,
workflowStepId: LATER_STEP_ID,
routeMetadata: { workflow_session_route: COMMITTED_ROUTE },
knownSessionIds: [DESTINATION_SESSION_ID],
});
expect(result.sessionId).toBeNull();
expect(result.state.intent).toBeNull();
});
it.each(["0", "2026-02-30T10:00:00Z"])(
"ignores malformed response timestamp %s when choosing a live route",
(updatedAt) => {
const startedState = started();
const bound = bindWorkflowSessionFocus(startedState.state, {
requestId: startedState.requestId,
presentationToken: START.presentationToken,
entryIdentity: COMMITTED_ROUTE.entry_identity,
});
const result = reconcileWorkflowSessionFocus(bound, {
activeTaskId: START.taskId,
navigationRevision: START.navigationRevision,
workflowStepId: START.destinationStepId,
routeMetadata: { workflow_session_route: COMMITTED_ROUTE },
routeUpdatedAt: LIVE_UPDATED_AT,
responseProjection: {
metadata: {
workflow_session_route: { ...COMMITTED_ROUTE, phase: "prepared" },
},
updatedAt,
entryIdentity: COMMITTED_ROUTE.entry_identity,
},
knownSessionIds: [DESTINATION_SESSION_ID],
});
expect(result.sessionId).toBe(DESTINATION_SESSION_ID);
},
);
it("selects a valid snapshot over a normalized-invalid task timestamp", () => {
const store = createAppStore();
const wrongRoute = {
...COMMITTED_ROUTE,
destination_session_id: NEWER_SESSION_ID,
};
store.setState((state) => {
state.tasks.activeTaskId = START.taskId;
state.tasks.activeSessionId = SOURCE_SESSION_ID;
state.taskRemoval.navigationRevision = START.navigationRevision;
state.kanban.tasks = [
{
id: START.taskId,
workflowStepId: START.destinationStepId,
updatedAt: "2026-02-30T10:00:00Z",
metadata: { workflow_session_route: wrongRoute },
},
] as never;
state.kanbanMulti.snapshots = {
[START.workflowId]: {
workflowId: START.workflowId,
workflowName: "Workflow",
steps: [],
tasks: [
{
id: START.taskId,
workflowStepId: START.destinationStepId,
updatedAt: "2026-03-01T10:00:00Z",
metadata: { workflow_session_route: COMMITTED_ROUTE },
},
],
},
} as never;
state.taskSessionsByTask.itemsByTaskId[START.taskId] = [
{ id: SOURCE_SESSION_ID },
{ id: DESTINATION_SESSION_ID },
{ id: NEWER_SESSION_ID },
] as never;
});
const requestId = store.getState().beginWorkflowSessionFocus(START);
if (requestId === null) throw new Error(FOCUS_INTENT_ERROR);
store.getState().bindWorkflowSessionFocus({
requestId,
presentationToken: START.presentationToken,
entryIdentity: COMMITTED_ROUTE.entry_identity,
});
store.getState().reconcileWorkflowSessionFocus(START.taskId);
expect(store.getState().tasks.activeSessionId).toBe(DESTINATION_SESSION_ID);
});
});
@@ -0,0 +1,281 @@
import { parseTurnTimestamp } from "@/lib/state/slices/session/turn-actions";
export type WorkflowSessionFocusStart = {
taskId: string;
workflowId: string;
destinationStepId: string;
presentationToken: number;
navigationRevision: number;
};
export type WorkflowSessionFocusIntent = WorkflowSessionFocusStart & {
requestId: number;
entryIdentity: string | null;
};
export type WorkflowSessionFocusRequest = {
requestId: number;
presentationToken: number;
taskId: string;
sessionId: string;
};
export type WorkflowSessionFocusState = {
nextRequestId: number;
intent: WorkflowSessionFocusIntent | null;
request: WorkflowSessionFocusRequest | null;
};
export type WorkflowSessionFocusCancelScope = {
requestId?: number;
presentationToken?: number;
taskId?: string;
};
export type WorkflowSessionFocusRoute = {
operationId: string;
destinationStepId: string;
entryIdentity: string;
destinationSessionId: string;
phase: "committed";
};
/**
* The task projection returned by the move request. It is a candidate only;
* the live task projection remains authoritative when it is newer.
*/
export type WorkflowSessionFocusTaskProjection = {
metadata: unknown;
updatedAt?: string | null;
entryIdentity?: string | null;
workflowStepId?: string | null;
};
export type WorkflowSessionFocusReconcileInput = {
activeTaskId: string | null;
navigationRevision: number;
routeMetadata: unknown;
routeUpdatedAt?: string | null;
workflowStepId?: string | null;
responseProjection?: WorkflowSessionFocusTaskProjection;
knownSessionIds: readonly string[];
};
export type WorkflowSessionFocusReconcileResult = {
state: WorkflowSessionFocusState;
sessionId: string | null;
};
export function createWorkflowSessionFocusState(): WorkflowSessionFocusState {
return { nextRequestId: 0, intent: null, request: null };
}
function clearWorkflowSessionFocusState(
state: WorkflowSessionFocusState,
): WorkflowSessionFocusState {
return { ...state, intent: null, request: null };
}
function nonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();
return normalized === "" ? null : normalized;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function timestampValue(value: string | null | undefined): bigint | null {
return parseTurnTimestamp(value ?? undefined);
}
function acceptedTaskProjection(
intent: WorkflowSessionFocusIntent,
input: WorkflowSessionFocusReconcileInput,
): WorkflowSessionFocusTaskProjection {
const liveProjection: WorkflowSessionFocusTaskProjection = {
metadata: input.routeMetadata,
updatedAt: input.routeUpdatedAt,
workflowStepId: input.workflowStepId,
};
const liveRoute = readCommittedWorkflowSessionRoute(input.routeMetadata);
if (liveRoute?.entryIdentity === intent.entryIdentity) return liveProjection;
const response = input.responseProjection;
if (!response) return liveProjection;
const responseRoute = readCommittedWorkflowSessionRoute(response.metadata);
const responseEntryIdentity = nonEmptyString(response.entryIdentity);
const liveUpdatedAt = timestampValue(input.routeUpdatedAt);
const responseUpdatedAt = timestampValue(response.updatedAt);
const responseIsEntryCorrelated =
responseEntryIdentity === intent.entryIdentity &&
responseRoute?.entryIdentity === intent.entryIdentity;
const responseIsFreshEnough =
responseIsEntryCorrelated &&
(liveUpdatedAt === null || (responseUpdatedAt !== null && responseUpdatedAt >= liveUpdatedAt));
if (!responseIsFreshEnough) return liveProjection;
// When timestamps tie, retain a different live committed entry. Equal
// timestamps are common for grouped task writes, and the live entry is
// the projection that won the store's ordering guard.
const liveHasDifferentCommittedEntry =
liveRoute !== null && liveRoute.entryIdentity !== intent.entryIdentity;
return liveHasDifferentCommittedEntry && responseUpdatedAt === liveUpdatedAt
? liveProjection
: response;
}
export function readCommittedWorkflowSessionRoute(
routeMetadata: unknown,
): WorkflowSessionFocusRoute | null {
const metadata = asRecord(routeMetadata);
const route = asRecord(metadata?.workflow_session_route ?? routeMetadata);
if (!route) return null;
const operationId = nonEmptyString(route.operation_id);
const destinationStepId = nonEmptyString(route.destination_step_id);
const entryIdentity = nonEmptyString(route.entry_identity);
const destinationSessionId = nonEmptyString(route.destination_session_id);
if (
!operationId ||
!destinationStepId ||
!entryIdentity ||
!destinationSessionId ||
route.phase !== "committed"
) {
return null;
}
return {
operationId,
destinationStepId,
entryIdentity,
destinationSessionId,
phase: "committed",
};
}
export function beginWorkflowSessionFocus(
state: WorkflowSessionFocusState,
input: WorkflowSessionFocusStart,
): { state: WorkflowSessionFocusState; requestId: number | null } {
if (!input.taskId || !input.workflowId || !input.destinationStepId) {
return { state, requestId: null };
}
const requestId = state.nextRequestId + 1;
return {
requestId,
state: {
nextRequestId: requestId,
intent: { ...input, requestId, entryIdentity: null },
request: null,
},
};
}
export function bindWorkflowSessionFocus(
state: WorkflowSessionFocusState,
input: {
requestId: number;
presentationToken: number;
entryIdentity: string;
},
): WorkflowSessionFocusState {
const intent = state.intent;
if (
!intent ||
intent.requestId !== input.requestId ||
intent.presentationToken !== input.presentationToken
) {
return state;
}
const entryIdentity = nonEmptyString(input.entryIdentity);
if (!entryIdentity) return clearWorkflowSessionFocusState(state);
return { ...state, intent: { ...intent, entryIdentity } };
}
function scopeMatches(
value: WorkflowSessionFocusIntent | WorkflowSessionFocusRequest,
scope: WorkflowSessionFocusCancelScope,
): boolean {
if (scope.requestId !== undefined && value.requestId !== scope.requestId) return false;
if (
scope.presentationToken !== undefined &&
value.presentationToken !== scope.presentationToken
) {
return false;
}
if (scope.taskId !== undefined && value.taskId !== scope.taskId) return false;
return true;
}
export function cancelWorkflowSessionFocus(
state: WorkflowSessionFocusState,
scope: WorkflowSessionFocusCancelScope = {},
): WorkflowSessionFocusState {
const activeValues = [state.intent, state.request].filter(
(value): value is WorkflowSessionFocusIntent | WorkflowSessionFocusRequest => value !== null,
);
if (activeValues.length > 0 && !activeValues.some((value) => scopeMatches(value, scope))) {
return state;
}
return clearWorkflowSessionFocusState(state);
}
export function reconcileWorkflowSessionFocus(
state: WorkflowSessionFocusState,
input: WorkflowSessionFocusReconcileInput,
): WorkflowSessionFocusReconcileResult {
const intent = state.intent;
if (!intent || !intent.entryIdentity) return { state, sessionId: null };
if (
input.activeTaskId !== intent.taskId ||
input.navigationRevision !== intent.navigationRevision
) {
return { state: clearWorkflowSessionFocusState(state), sessionId: null };
}
const projection = acceptedTaskProjection(intent, input);
const route = readCommittedWorkflowSessionRoute(projection.metadata);
if (
!route ||
route.destinationStepId !== intent.destinationStepId ||
route.entryIdentity !== intent.entryIdentity ||
!input.knownSessionIds.includes(route.destinationSessionId)
) {
return { state, sessionId: null };
}
if (projection.workflowStepId !== intent.destinationStepId) {
return { state: clearWorkflowSessionFocusState(state), sessionId: null };
}
return {
state: {
...state,
intent: null,
request: {
requestId: intent.requestId,
presentationToken: intent.presentationToken,
taskId: intent.taskId,
sessionId: route.destinationSessionId,
},
},
sessionId: route.destinationSessionId,
};
}
export function acknowledgeWorkflowSessionFocus(
state: WorkflowSessionFocusState,
requestId: number,
): WorkflowSessionFocusState {
if (state.request?.requestId !== requestId) return state;
return { ...state, request: null };
}
+1
View File
@@ -567,6 +567,7 @@ export type WorkflowStepDTO = {
export type MoveTaskResponse = {
task: Task;
workflow_step: WorkflowStepDTO;
workflow_entry_identity?: string;
move_id?: string;
entry_options?: {
reset_context?: boolean;
@@ -458,6 +458,7 @@ function syncEnvFromAgentctlPayload(
...getAgentctlWorktreeFields(payload, isSibling),
workspace_path: payload.workspace_path ?? payload.task_workspace_path ?? payload.worktree_path,
});
store.getState().reconcileWorkflowSessionFocus?.(taskId);
}
/** Builds the partial-session patch applied for an agentctl_ready event.
@@ -858,6 +859,7 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
return {
"message.queue.status_changed": (message) =>
handleQueueStatusChangedMessage(store, message.payload),
// eslint-disable-next-line complexity -- ordered session reconciliation keeps stale-event guards together
"session.state_changed": (message) => {
const payload = message.payload;
if (!payload?.task_id) return;
@@ -900,6 +902,7 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
updatedAt: payload.updated_at,
});
upsertTaskSessionList(store, taskId, sessionId, payload, sessionUpdate);
store.getState().reconcileWorkflowSessionFocus?.(taskId);
syncKanbanPrimarySessionState(store, taskId, sessionId, newState);
extractContextWindow(store, sessionId, payload);
maybePromoteAgentctlReady(store, sessionId, newState, message.timestamp);
+3
View File
@@ -368,6 +368,8 @@ function handleTaskUpdated(store: StoreApi<AppState>, message: TaskUpdatedMessag
}),
);
store.getState().reconcileWorkflowSessionFocus?.(taskId);
if (archivedAt) {
if (!localRemovalOwnsDeparture) redirectAwayFromRemovedTask(taskId);
return;
@@ -403,6 +405,7 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
"task.deleted": (message) => {
const deletedId = message.payload.task_id;
const currentState = store.getState();
currentState.cancelWorkflowSessionFocus?.({ taskId: deletedId });
removeRecentTask(deletedId);
// A quick chat closed on another device must not linger here as a tab
// pointing at a task the backend already deleted.