From 044a82367b53475d7fecde60870290d452b75066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 16 Sep 2026 11:16:47 +0100 Subject: [PATCH] feat: preview workflow moves before execution (#3677) --- .../orchestrator/event_handlers_workflow.go | 46 +- .../event_handlers_workflow_profile_test.go | 32 + .../internal/orchestrator/task_operations.go | 4 +- .../orchestrator/workflow_move_preview.go | 654 ++++++++++++++++ .../workflow_move_preview_settings.go | 348 +++++++++ .../workflow_move_preview_test.go | 734 ++++++++++++++++++ .../orchestrator/workflow_session_config.go | 56 +- .../internal/task/handlers/task_handlers.go | 12 + .../handlers/task_move_preview_handlers.go | 73 ++ .../task_move_preview_handlers_test.go | 135 ++++ .../backend/internal/workflow/move/overlay.go | 11 + .../internal/workflow/move/overlay_test.go | 19 + .../integrations/use-hover-popover.ts | 21 +- .../components/task/chat/chat-input-area.tsx | 1 + .../components/task/chat/chat-status-bar.tsx | 4 + .../components/task/passthrough-toolbar.tsx | 5 + .../components/task/workflow-move-options.tsx | 19 + .../task/workflow-move-preview-footer.tsx | 30 + .../task/workflow-move-preview.test.tsx | 231 ++++++ .../components/task/workflow-move-preview.tsx | 381 +++++++++ .../workflow-move-proceed-button.test.tsx | 59 ++ .../task/workflow-move-proceed-button.tsx | 11 + .../task/workflow-step-disclosure-actions.tsx | 4 +- ...workflow-step-disclosure-move-controls.tsx | 82 ++ .../task/workflow-step-disclosure.tsx | 64 +- .../task/workflow-stepper-keyboard.test.tsx | 123 ++- .../components/task/workflow-stepper.test.tsx | 38 +- apps/web/components/task/workflow-stepper.tsx | 136 +++- .../mobile-workflow-move-preview.spec.ts | 82 ++ ...obile-workflow-step-move-overrides.spec.ts | 6 + .../workflow-move-preview-assertions.ts | 22 + .../workflow/workflow-move-preview.spec.ts | 144 ++++ .../workflow-step-move-overrides.spec.ts | 5 + .../domains/kanban/use-plan-actions.test.ts | 5 + .../hooks/domains/kanban/use-plan-actions.ts | 9 +- ...use-workflow-move-preview-revision.test.ts | 187 +++++ .../use-workflow-move-preview-revision.ts | 126 +++ .../kanban/use-workflow-move-preview.test.ts | 319 ++++++++ .../kanban/use-workflow-move-preview.ts | 271 +++++++ apps/web/lib/api/domains/kanban-api.test.ts | 54 ++ apps/web/lib/api/domains/kanban-api.ts | 74 ++ apps/web/lib/ssr/mapper.ts | 43 +- apps/web/lib/state/slices/kanban/types.ts | 8 + apps/web/src/locales/en/task.json | 63 ++ apps/web/src/locales/pseudo/task.json | 63 ++ apps/web/src/locales/pt-pt/task.json | 63 ++ apps/web/src/locales/zh-cn/task.json | 63 ++ apps/web/src/locales/zh-hk/task.json | 63 ++ apps/web/src/locales/zh-tw/task.json | 63 ++ docs/plans/workflow-move-preview/plan.md | 263 +++++++ .../task-01-move-decision-api.md | 141 ++++ .../task-02-compact-preview.md | 194 +++++ docs/public/tasks-and-workflows.md | 14 + .../requirements/workflow-move-preview.md | 110 +++ .../system-design/workflow-move-preview.md | 213 +++++ 55 files changed, 5868 insertions(+), 133 deletions(-) create mode 100644 apps/backend/internal/orchestrator/workflow_move_preview.go create mode 100644 apps/backend/internal/orchestrator/workflow_move_preview_settings.go create mode 100644 apps/backend/internal/orchestrator/workflow_move_preview_test.go create mode 100644 apps/backend/internal/task/handlers/task_move_preview_handlers.go create mode 100644 apps/backend/internal/task/handlers/task_move_preview_handlers_test.go create mode 100644 apps/web/components/task/workflow-move-preview-footer.tsx create mode 100644 apps/web/components/task/workflow-move-preview.test.tsx create mode 100644 apps/web/components/task/workflow-move-preview.tsx create mode 100644 apps/web/components/task/workflow-step-disclosure-move-controls.tsx create mode 100644 apps/web/e2e/tests/workflow/mobile-workflow-move-preview.spec.ts create mode 100644 apps/web/e2e/tests/workflow/workflow-move-preview-assertions.ts create mode 100644 apps/web/e2e/tests/workflow/workflow-move-preview.spec.ts create mode 100644 apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.test.ts create mode 100644 apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.ts create mode 100644 apps/web/hooks/domains/kanban/use-workflow-move-preview.test.ts create mode 100644 apps/web/hooks/domains/kanban/use-workflow-move-preview.ts create mode 100644 docs/plans/workflow-move-preview/plan.md create mode 100644 docs/plans/workflow-move-preview/task-01-move-decision-api.md create mode 100644 docs/plans/workflow-move-preview/task-02-compact-preview.md create mode 100644 docs/specs/tasks/requirements/workflow-move-preview.md create mode 100644 docs/specs/tasks/system-design/workflow-move-preview.md diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index a59bbf4c26..f130eeb67d 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -1957,7 +1957,7 @@ func (s *Service) autoStartTaskForLoadedStep(ctx context.Context, task *models.T if s.launchDeferredTask(ctx, task, eventName, restoreQueuePromotion, autoStartOnCreateClaimed) { return } - if step == nil || !step.HasOnEnterAction(wfmodels.OnEnterAutoStartAgent) { + if !workflowmove.ShouldAutoStartAgent(step, nil) { s.logger.Debug(eventName+": target step has no auto-start", zap.String("task_id", task.ID), zap.String("to_step_id", step.ID)) @@ -1986,7 +1986,7 @@ func (s *Service) autoStartTaskForLoadedStep(ctx context.Context, task *models.T if moveOptions != nil { s.clearWorkflowMovePending(ctx, task.ID) } - if moveOptions != nil && moveOptions.SkipStepPrompt && moveOptions.Instructions == "" { + if !workflowmove.ShouldAutoStartAgent(step, moveOptions) { // skip_step_prompt with no instructions suppresses the turn entirely. // For a task with no session that means preparing nothing: leave it idle // exactly as a step without auto_start_agent would, so the user starts @@ -2973,10 +2973,28 @@ func (s *Service) resolveStepProfileSessionEndPolicy(step *wfmodels.WorkflowStep // workflow step override rather than direct user selection. Uses the atomic // SetSessionMetadataKey (json_set) so other metadata keys are preserved. func (s *Service) tagSessionAsWorkflowSwitched(ctx context.Context, sessionID string) { + s.persistWorkflowSwitchTag(ctx, sessionID, true) +} + +// tagSessionAsWorkflowSwitchedForSnapshot records workflow ownership using the +// metadata observed by the caller. A workflow entry can run asynchronously +// with a stale session snapshot, so it must not clear a conversational +// follow-up marker written after that snapshot was loaded. +func (s *Service) tagSessionAsWorkflowSwitchedForSnapshot(ctx context.Context, session *models.TaskSession) { + if session == nil { + return + } + s.persistWorkflowSwitchTag(ctx, session.ID, models.IsCompletionFollowUpSession(session.Metadata)) +} + +func (s *Service) persistWorkflowSwitchTag(ctx context.Context, sessionID string, clearCompletionFollowUp bool) { if err := s.repo.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyCreatedBy, models.SessionCreatedByWorkflowSwitch); err != nil { s.logger.Warn("failed to persist workflow-switch tag", zap.String("session_id", sessionID), zap.Error(err)) } + if !clearCompletionFollowUp { + return + } // A workflow step explicitly taking ownership of this session is the only // path that clears conversational-only follow-up ownership. Ordinary sends, // page activation, and automatic profile lookup must leave the marker set. @@ -3120,25 +3138,7 @@ func (s *Service) findReusableSessionForProfile(ctx context.Context, taskID, pro if err != nil { return nil, err } - var best *models.TaskSession - for _, sess := range sessions { - if sess.ID == excludeSessionID { - continue - } - if sess.AgentProfileID != profileID { - continue - } - if models.IsCompletionFollowUpSession(sess.Metadata) { - continue - } - if isTerminalSessionState(sess.State) { - continue - } - if best == nil || sess.UpdatedAt.After(best.UpdatedAt) { - best = sess - } - } - return best, nil + return selectReusableWorkflowSession(sessions, profileID, excludeSessionID), nil } // transferQueuedSessionState keeps queue rows and their claimed attachment @@ -3409,7 +3409,7 @@ func (s *Service) reuseSessionForStepWithEndPolicy( } return existing, nil } - s.tagSessionAsWorkflowSwitched(ctx, existing.ID) + s.tagSessionAsWorkflowSwitchedForSnapshot(ctx, existing) if err := s.transferWorkflowProfileSwitchQueue(ctx, currentSession.ID, existing.ID); err != nil { transferErr := fmt.Errorf("transfer queued state to reused session: %w", err) @@ -3957,7 +3957,7 @@ func (s *Service) keepCurrentWorkflowStepSession( step *wfmodels.WorkflowStep, entryIDs ...int64, ) (*models.TaskSession, bool, error) { - s.tagSessionAsWorkflowSwitched(ctx, session.ID) + s.tagSessionAsWorkflowSwitchedForSnapshot(ctx, session) if !session.IsPrimary { if err := s.SetPrimarySession(ctx, session.ID); err != nil { s.logger.Warn("failed to preserve session as primary for workflow step", diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go index 93fd957934..f21b116db2 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go @@ -558,6 +558,38 @@ func TestPrepareWorkflowStepSession_ClearsCompletionFollowUpWithoutProfile(t *te } } +func TestPrepareWorkflowStepSession_PreservesNewerCompletionFollowUpWithoutProfile(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + // Model the asynchronous workflow-entry path: it loads its session before + // the conversational turn records the completion follow-up marker. + staleSession, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("get stale session: %v", err) + } + if err := repo.SetSessionMetadataKey(ctx, "s1", models.SessionMetaKeyCompletionFollowUp, true); err != nil { + t.Fatalf("mark completion follow-up: %v", err) + } + + step := &wfmodels.WorkflowStep{ID: "step1", WorkflowID: "wf1"} + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + if _, switched, err := svc.prepareWorkflowStepSession(ctx, "t1", staleSession, step, nil); err != nil { + t.Fatalf("prepareWorkflowStepSession returned error: %v", err) + } else if switched { + t.Fatal("step without a profile must not switch sessions") + } + + updated, err := repo.GetTaskSession(ctx, staleSession.ID) + if err != nil { + t.Fatalf("reload session: %v", err) + } + if !models.IsCompletionFollowUpSession(updated.Metadata) { + t.Fatal("stale workflow entry cleared a newer completion follow-up marker") + } +} + func TestSwitchWorkflowDispatcherRoutesOnEnterToDestinationProfileSession(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index a12f13e391..eedd5cc091 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -736,7 +736,7 @@ func (s *Service) startCreatedSession( } // Tag as workflow-spawned provenance only after the guarded profile // write succeeds; a concurrent stop owns a rejected session. - s.tagSessionAsWorkflowSwitched(ctx, sessionID) + s.tagSessionAsWorkflowSwitchedForSnapshot(ctx, session) s.promoteSessionIfTaskHasNoPrimary(ctx, taskID, session) } @@ -1273,7 +1273,7 @@ func (s *Service) promoteSelectedExplicitWorkflowSession( if !promoted { return "", false, nil } - s.tagSessionAsWorkflowSwitched(ctx, session.ID) + s.tagSessionAsWorkflowSwitchedForSnapshot(ctx, session) return session.ID, true, nil } diff --git a/apps/backend/internal/orchestrator/workflow_move_preview.go b/apps/backend/internal/orchestrator/workflow_move_preview.go new file mode 100644 index 0000000000..857d88b345 --- /dev/null +++ b/apps/backend/internal/orchestrator/workflow_move_preview.go @@ -0,0 +1,654 @@ +package orchestrator + +import ( + "context" + "fmt" + "maps" + "strings" + "time" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + workflowmove "github.com/kandev/kandev/internal/workflow/move" +) + +// WorkflowMovePreviewRequest identifies the same destination and one-shot +// options that a manual workflow move will use. It intentionally has no +// source session field: the server resolves the current session exactly as +// the move path does. +type WorkflowMovePreviewRequest struct { + TaskID string + WorkflowID string + WorkflowStepID string + EntryOptions *workflowmove.EntryOptions +} + +type WorkflowMovePreviewOutcome string + +const ( + WorkflowMovePreviewOutcomeReuseCurrent WorkflowMovePreviewOutcome = "reuse_current" + WorkflowMovePreviewOutcomeReuseOther WorkflowMovePreviewOutcome = "reuse_other" + WorkflowMovePreviewOutcomeCreateNew WorkflowMovePreviewOutcome = "create_new" + WorkflowMovePreviewOutcomeNoSession WorkflowMovePreviewOutcome = "no_session" + WorkflowMovePreviewOutcomeUnknown WorkflowMovePreviewOutcome = "unknown" +) + +const ( + previewSettingMode = "mode" + previewSettingStepPrompt = "step_prompt" + previewSettingReasoningEffort = "reasoning_effort" + previewSettingVerbosity = "verbosity" + previewSourceOverride = "override" +) + +type WorkflowMovePreviewSourceDisposition string + +const ( + WorkflowMovePreviewSourceDispositionKeep WorkflowMovePreviewSourceDisposition = "keep" + WorkflowMovePreviewSourceDispositionPark WorkflowMovePreviewSourceDisposition = "park" + WorkflowMovePreviewSourceDispositionComplete WorkflowMovePreviewSourceDisposition = "complete" + WorkflowMovePreviewSourceDispositionUnknown WorkflowMovePreviewSourceDisposition = "unknown" +) + +type WorkflowMovePreviewApplicability string + +const ( + WorkflowMovePreviewPlanned WorkflowMovePreviewApplicability = "planned" + WorkflowMovePreviewUnchanged WorkflowMovePreviewApplicability = "unchanged" + WorkflowMovePreviewSkipped WorkflowMovePreviewApplicability = "skipped" + WorkflowMovePreviewUnknown WorkflowMovePreviewApplicability = "unknown" +) + +type WorkflowMovePreviewDispatch string + +const ( + WorkflowMovePreviewDispatchPrompt WorkflowMovePreviewDispatch = "prompt" + WorkflowMovePreviewDispatchNoPrompt WorkflowMovePreviewDispatch = "no_prompt" + WorkflowMovePreviewDispatchNoSession WorkflowMovePreviewDispatch = "no_session" + WorkflowMovePreviewDispatchDeferred WorkflowMovePreviewDispatch = "deferred" + WorkflowMovePreviewDispatchUnknown WorkflowMovePreviewDispatch = "unknown" +) + +// WorkflowMovePreview is a read-only prediction of the recipient and +// destination settings. It contains display-safe values only; prompts and +// one-shot instruction text never leave the request boundary. +type WorkflowMovePreview struct { + TaskID string `json:"task_id"` + WorkflowStepID string `json:"workflow_step_id"` + SourceSessionID string `json:"source_session_id,omitempty"` + EvaluatedAt time.Time `json:"evaluated_at"` + Outcome WorkflowMovePreviewOutcome `json:"outcome"` + Recipient *WorkflowMovePreviewRecipient `json:"recipient,omitempty"` + Model WorkflowMovePreviewModel `json:"model"` + Changes []WorkflowMovePreviewChange `json:"changes,omitempty"` + ContextReset bool `json:"context_reset"` + ContextResetState WorkflowMovePreviewApplicability `json:"context_reset_state"` + SourceDisposition WorkflowMovePreviewSourceDisposition `json:"source_disposition"` + Dispatch WorkflowMovePreviewDispatch `json:"dispatch"` + Notices []WorkflowMovePreviewNotice `json:"notices,omitempty"` +} + +type WorkflowMovePreviewRecipient struct { + SessionID string `json:"session_id,omitempty"` + SessionName string `json:"session_name,omitempty"` + ProfileID string `json:"profile_id,omitempty"` + ProfileName string `json:"profile_name,omitempty"` + AgentFamily string `json:"agent_family,omitempty"` +} + +type WorkflowMovePreviewModel struct { + Before WorkflowMovePreviewModelValue `json:"before"` + After WorkflowMovePreviewModelValue `json:"after"` + BeforeSource string `json:"before_source,omitempty"` + AfterSource string `json:"after_source,omitempty"` +} + +type WorkflowMovePreviewModelValue struct { + ID string `json:"id,omitempty"` + Label string `json:"label,omitempty"` + Known bool `json:"known"` + Mode string `json:"mode,omitempty"` + ConfigOptions map[string]string `json:"config_options,omitempty"` +} + +type WorkflowMovePreviewChange struct { + Key string `json:"key"` + Label string `json:"label"` + Before string `json:"before,omitempty"` + After string `json:"after,omitempty"` + Applicability WorkflowMovePreviewApplicability `json:"applicability"` +} + +type WorkflowMovePreviewNotice struct { + Code string `json:"code"` + Params map[string]string `json:"params,omitempty"` +} + +type workflowMovePreviewInput struct { + TaskID string + WorkflowStepID string + SourceSession *models.TaskSession + Sessions []*models.TaskSession + Destination *wfmodels.WorkflowStep + Source *wfmodels.WorkflowStep + TargetSession *models.TaskSession + TargetProfileID string + ProfileName string + ProfileInfo *executor.AgentProfileInfo + DynamicProfile bool + TargetPassthrough bool + ExplicitTarget bool + NoStepChange bool + OriginalSession *models.TaskSession + StartPolicy models.WorkflowProfileSessionStartPolicy + SourceEndPolicy models.WorkflowProfileSessionEndPolicy + SessionlessLaunchAllowed bool + AgentResolver AgentFamilyResolver + EntryOptions *workflowmove.EntryOptions + Notices []WorkflowMovePreviewNotice +} + +// PreviewWorkflowMove resolves a manual move without reserving a route, +// launching a provider, changing a session, or writing metadata. The actual +// move still repeats its atomic validation after this advisory response. +func (s *Service) PreviewWorkflowMove(ctx context.Context, request WorkflowMovePreviewRequest) (*WorkflowMovePreview, error) { + if strings.TrimSpace(request.TaskID) == "" { + return nil, fmt.Errorf("task id is required") + } + if strings.TrimSpace(request.WorkflowID) == "" || strings.TrimSpace(request.WorkflowStepID) == "" { + return nil, fmt.Errorf("workflow id and workflow step id are required") + } + if err := s.authorizeTask(ctx, request.TaskID); err != nil { + return nil, err + } + if s.repo == nil || s.workflowStepGetter == nil { + return nil, fmt.Errorf("workflow move preview is unavailable") + } + + task, err := s.repo.GetTask(ctx, request.TaskID) + if err != nil { + return nil, err + } + if task == nil { + return nil, fmt.Errorf("task %s not found", request.TaskID) + } + if task.ArchivedAt != nil { + return nil, fmt.Errorf("archived tasks cannot be moved") + } + + destination, err := s.workflowStepGetter.GetStep(ctx, request.WorkflowStepID) + if err != nil { + return nil, fmt.Errorf("failed to get target workflow step: %w", err) + } + if destination == nil { + return nil, fmt.Errorf("target workflow step %s not found", request.WorkflowStepID) + } + if destination.WorkflowID != request.WorkflowID { + return nil, fmt.Errorf("target workflow step does not belong to target workflow") + } + + input, err := s.resolveWorkflowMovePreviewInput(ctx, request, task, destination) + if err != nil { + return nil, err + } + return buildWorkflowMovePreview(input), nil +} + +func (s *Service) resolveWorkflowMovePreviewInput( + ctx context.Context, + request WorkflowMovePreviewRequest, + task *models.Task, + destination *wfmodels.WorkflowStep, +) (workflowMovePreviewInput, error) { + sessions, err := s.repo.ListTaskSessions(ctx, request.TaskID) + if err != nil { + return workflowMovePreviewInput{}, fmt.Errorf("failed to list task sessions: %w", err) + } + current := previewCurrentSession(sessions) + var source *wfmodels.WorkflowStep + if task.WorkflowStepID != "" { + source, err = s.workflowStepGetter.GetStep(ctx, task.WorkflowStepID) + if err != nil { + return workflowMovePreviewInput{}, fmt.Errorf("failed to get current workflow step: %w", err) + } + } + + input := workflowMovePreviewInput{ + TaskID: request.TaskID, + WorkflowStepID: request.WorkflowStepID, + NoStepChange: task.WorkflowID == request.WorkflowID && task.WorkflowStepID == request.WorkflowStepID, + SourceSession: current, + Sessions: sessions, + Destination: destination, + Source: source, + StartPolicy: models.NormalizeWorkflowProfileSessionStartPolicy(string(destination.ProfileSessionStartPolicy)), + SourceEndPolicy: models.WorkflowProfileSessionEndPolicyPark, + SessionlessLaunchAllowed: workflowmove.ShouldAutoStartAgent(destination, request.EntryOptions), + AgentResolver: s.agentFamilyResolver, + EntryOptions: request.EntryOptions, + } + if source != nil { + input.SourceEndPolicy = s.resolveStepProfileSessionEndPolicy(source) + } + if _, ok := configureSessionAction(destination); ok { + input.OriginalSession = previewOriginalTaskSessionFromTask(task, sessions) + } + + s.resolveWorkflowMovePreviewRecipient(ctx, request.TaskID, task, destination, &input) + return input, nil +} + +func (s *Service) resolveWorkflowMovePreviewRecipient( + ctx context.Context, + taskID string, + task *models.Task, + destination *wfmodels.WorkflowStep, + input *workflowMovePreviewInput, +) { + if destination.SessionTarget != nil { + input.ExplicitTarget = true + target, profileID, err := s.resolvePreviewWorkflowSessionTarget(ctx, taskID, destination) + if err != nil { + input.Notices = append(input.Notices, workflowMovePreviewNotice("target_unavailable", nil)) + } else { + input.TargetSession = target + input.TargetProfileID = profileID + } + } else { + profileID, err := s.previewStepAgentProfile(ctx, destination, task, input.SourceSession == nil) + input.TargetProfileID = profileID + if err != nil { + input.Notices = append(input.Notices, workflowMovePreviewNotice("profile_unavailable", nil)) + } + } + if input.TargetProfileID == "" { + return + } + if s.agentManager == nil { + input.Notices = append(input.Notices, workflowMovePreviewNotice("profile_unavailable", nil)) + return + } + profile, err := s.agentManager.ResolveAgentProfile(ctx, input.TargetProfileID) + if err != nil || profile == nil { + input.Notices = append(input.Notices, workflowMovePreviewNotice("profile_unavailable", nil)) + return + } + input.ProfileInfo = profile + input.ProfileName = profile.ProfileName + input.TargetPassthrough = profile.CLIPassthrough + input.DynamicProfile = profile.AgentID == agents.DynamicAgentID || + strings.EqualFold(profile.AgentName, agents.DynamicAgentID) +} + +func (s *Service) previewStepAgentProfile(ctx context.Context, step *wfmodels.WorkflowStep, task *models.Task, sessionless bool) (string, error) { + if step == nil || step.SessionTarget != nil { + return "", nil + } + if profileID := strings.TrimSpace(step.AgentProfileID); profileID != "" { + return profileID, nil + } + if step.WorkflowID == "" || s.workflowStepGetter == nil { + if task != nil && sessionless { + return strings.TrimSpace(models.StringFromAny(task.Metadata[models.MetaKeyAgentProfileID])), nil + } + return "", nil + } + meta, err := s.getWorkflowMeta(ctx, step.WorkflowID) + profileID := strings.TrimSpace(meta.AgentProfileID) + if profileID == "" && task != nil && sessionless { + profileID = strings.TrimSpace(models.StringFromAny(task.Metadata[models.MetaKeyAgentProfileID])) + } + if profileID != "" { + return profileID, nil + } + return "", err +} + +func (s *Service) resolvePreviewWorkflowSessionTarget( + ctx context.Context, + taskID string, + step *wfmodels.WorkflowStep, +) (*models.TaskSession, string, error) { + if step == nil || step.SessionTarget == nil { + return nil, "", fmt.Errorf("workflow session target is required") + } + if err := wfmodels.ValidateWorkflowSessionTarget(step.SessionTarget); err != nil { + return nil, "", err + } + if step.SessionTarget.Kind == wfmodels.WorkflowSessionTargetInitial { + return s.resolvePreviewInitialWorkflowSession(ctx, taskID) + } + sourceStep, err := s.loadWorkflowStepForLifecycle(ctx, step.SessionTarget.StepID, "session target source") + if err != nil { + return nil, "", err + } + if err := validateWorkflowSessionTargetSource(step, sourceStep); err != nil { + return nil, "", err + } + session, err := s.resolveBoundSourceWorkflowSession(ctx, taskID, step.WorkflowID, sourceStep) + if err != nil { + return nil, "", err + } + return session, sourceStep.AgentProfileID, nil +} + +// resolvePreviewInitialWorkflowSession is the read-only sibling of +// resolveInitialWorkflowSession. In particular, it never backfills the +// legacy initial-session marker while calculating a disclosure. +func (s *Service) resolvePreviewInitialWorkflowSession( + ctx context.Context, + taskID string, +) (*models.TaskSession, string, error) { + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + return nil, "", fmt.Errorf("load task for initial session target: %w", err) + } + if task == nil { + return nil, "", fmt.Errorf("task %s not found for initial session target", taskID) + } + sessions, err := s.repo.ListTaskSessions(ctx, taskID) + if err != nil { + return nil, "", fmt.Errorf("list sessions for initial session target: %w", err) + } + initial := previewOriginalTaskSessionFromTask(task, sessions) + profileID := "" + if snapshot, ok := models.LoadWorkflowInitialSessionSnapshot(task.Metadata); ok { + profileID = strings.TrimSpace(snapshot.AgentProfileID) + } + if profileID == "" && initial != nil { + profileID = strings.TrimSpace(initial.AgentProfileID) + } + if profileID == "" && len(sessions) == 0 { + profileID = models.StringFromAny(task.Metadata[models.MetaKeyAgentProfileID]) + } + if profileID == "" { + return nil, "", fmt.Errorf("initial session provenance is unavailable for task %s", taskID) + } + return initial, profileID, nil +} + +func previewOriginalTaskSessionFromTask(task *models.Task, sessions []*models.TaskSession) *models.TaskSession { + if task != nil { + if snapshot, ok := models.LoadWorkflowInitialSessionSnapshot(task.Metadata); ok { + for _, candidate := range sessions { + if candidate != nil && candidate.ID == snapshot.SessionID { + return candidate + } + } + return nil + } + } + if original, resolved := markedOriginalTaskSession(sessions); resolved { + return original + } + return legacyOriginalTaskSession(sessions) +} + +func buildWorkflowMovePreview(input workflowMovePreviewInput) *WorkflowMovePreview { + preview := &WorkflowMovePreview{ + TaskID: input.TaskID, + WorkflowStepID: input.WorkflowStepID, + EvaluatedAt: time.Now().UTC(), + Outcome: WorkflowMovePreviewOutcomeUnknown, + SourceDisposition: WorkflowMovePreviewSourceDispositionUnknown, + Dispatch: WorkflowMovePreviewDispatchUnknown, + } + if input.SourceSession != nil { + preview.SourceSessionID = input.SourceSession.ID + } + if input.NoStepChange { + preview.Outcome = WorkflowMovePreviewOutcomeUnknown + if input.SourceSession != nil { + preview.Outcome = WorkflowMovePreviewOutcomeReuseCurrent + preview.Recipient = previewRecipientDTO(input.SourceSession, workflowMovePreviewInput{}) + before, known := previewSessionRuntimeConfig(input.SourceSession) + preview.Model = WorkflowMovePreviewModel{ + Before: previewModelValue(before, known), + After: previewModelValue(before, known), + BeforeSource: previewSessionModelSource(input.SourceSession), + AfterSource: previewSessionModelSource(input.SourceSession), + } + preview.SourceDisposition = WorkflowMovePreviewSourceDispositionKeep + } else { + preview.Model = WorkflowMovePreviewModel{ + Before: previewModelValue(models.SessionRuntimeConfig{}, false), + After: previewModelValue(models.SessionRuntimeConfig{}, false), + } + } + preview.ContextResetState = WorkflowMovePreviewUnchanged + preview.Dispatch = WorkflowMovePreviewDispatchNoPrompt + preview.Notices = append([]WorkflowMovePreviewNotice(nil), input.Notices...) + return preview + } + + target, outcome := previewRecipient(input) + input.TargetSession = target + preview.Outcome = outcome + if outcome != WorkflowMovePreviewOutcomeUnknown && outcome != WorkflowMovePreviewOutcomeNoSession { + preview.Recipient = previewRecipientDTO(target, input) + } + + beforeSession := target + if beforeSession == nil && outcome == WorkflowMovePreviewOutcomeReuseCurrent { + beforeSession = input.SourceSession + } + before, beforeKnown := previewSessionRuntimeConfig(beforeSession) + beforeSource := previewSessionModelSource(beforeSession) + after, afterKnown, afterSource := previewDestinationRuntimeConfig(target, outcome, input) + if beforeSession == nil && outcome == WorkflowMovePreviewOutcomeCreateNew { + before, beforeKnown, beforeSource = models.SessionRuntimeConfig{}, false, "unknown" + } + if outcome == WorkflowMovePreviewOutcomeUnknown { + after, afterKnown, afterSource = models.SessionRuntimeConfig{}, false, "unknown" + } + if outcome == WorkflowMovePreviewOutcomeNoSession { + after, afterKnown, afterSource = models.SessionRuntimeConfig{}, false, "unknown" + } + + applyPreviewConfigureSession(&after, &afterKnown, &afterSource, target, outcome, &input) + applyPreviewSessionMode(&after, &afterKnown, target, input) + preview.Model = WorkflowMovePreviewModel{ + Before: previewModelValue(before, beforeKnown), + After: previewModelValue(after, afterKnown), + BeforeSource: beforeSource, + AfterSource: afterSource, + } + if beforeSource == previewSourceOverride && afterSource == previewSourceOverride { + input.Notices = appendPreviewNoticeOnce(input.Notices, workflowMovePreviewNotice("retained_model_override", nil)) + } + preview.Notices = append([]WorkflowMovePreviewNotice(nil), input.Notices...) + preview.Changes = previewSettingChanges(before, beforeKnown, after, afterKnown) + preview.ContextReset, preview.ContextResetState = previewContextReset(input, outcome) + preview.Changes = append(preview.Changes, previewPromptChange(input, outcome)...) + preview.SourceDisposition = previewSourceDisposition(input, outcome) + preview.Dispatch = previewDispatch(input, outcome) + return preview +} + +func previewRecipient(input workflowMovePreviewInput) (*models.TaskSession, WorkflowMovePreviewOutcome) { + if input.ExplicitTarget { + return previewExplicitTargetRecipient(input) + } + return previewProfileRecipient(input) +} + +func previewExplicitTargetRecipient(input workflowMovePreviewInput) (*models.TaskSession, WorkflowMovePreviewOutcome) { + if input.StartPolicy == models.WorkflowProfileSessionStartPolicyReuse && + input.TargetSession != nil && !isTerminalSessionState(input.TargetSession.State) { + if input.SourceSession != nil && input.TargetSession.ID == input.SourceSession.ID { + return input.TargetSession, WorkflowMovePreviewOutcomeReuseCurrent + } + return input.TargetSession, WorkflowMovePreviewOutcomeReuseOther + } + if input.TargetProfileID == "" { + return nil, WorkflowMovePreviewOutcomeUnknown + } + return previewSessionlessRecipient(input) +} + +func previewProfileRecipient(input workflowMovePreviewInput) (*models.TaskSession, WorkflowMovePreviewOutcome) { + if input.TargetProfileID == "" { + if input.SourceSession != nil { + return input.SourceSession, WorkflowMovePreviewOutcomeReuseCurrent + } + return nil, WorkflowMovePreviewOutcomeUnknown + } + if input.SourceSession != nil && shouldKeepCurrentWorkflowStepSession( + input.TargetProfileID, + input.SourceSession.AgentProfileID, + input.StartPolicy, + ) { + return input.SourceSession, WorkflowMovePreviewOutcomeReuseCurrent + } + if input.StartPolicy == models.WorkflowProfileSessionStartPolicyReuse { + if reusable := previewReusableSession(input.Sessions, input.TargetProfileID, sessionID(input.SourceSession)); reusable != nil { + return reusable, WorkflowMovePreviewOutcomeReuseOther + } + } + return previewSessionlessRecipient(input) +} + +func previewSessionlessRecipient(input workflowMovePreviewInput) (*models.TaskSession, WorkflowMovePreviewOutcome) { + if input.SourceSession == nil && !input.SessionlessLaunchAllowed { + return nil, WorkflowMovePreviewOutcomeNoSession + } + return nil, WorkflowMovePreviewOutcomeCreateNew +} + +func previewReusableSession(sessions []*models.TaskSession, profileID, excludeID string) *models.TaskSession { + return selectReusableWorkflowSession(sessions, profileID, excludeID) +} + +// selectReusableWorkflowSession is the pure candidate selector shared by the +// preview and the side-effecting workflow switch path. Both paths must reject +// terminal and completion-follow-up conversations in the same way. +func selectReusableWorkflowSession(sessions []*models.TaskSession, profileID, excludeID string) *models.TaskSession { + if profileID == "" { + return nil + } + var best *models.TaskSession + for _, session := range sessions { + if session == nil || session.ID == excludeID || session.AgentProfileID != profileID || + models.IsCompletionFollowUpSession(session.Metadata) || isTerminalSessionState(session.State) { + continue + } + if best == nil || session.UpdatedAt.After(best.UpdatedAt) { + best = session + } + } + return best +} + +func previewCurrentSession(sessions []*models.TaskSession) *models.TaskSession { + for _, session := range sessions { + if session != nil && session.IsPrimary && isPreviewActiveSession(session.State) { + return session + } + } + var latest *models.TaskSession + for _, session := range sessions { + if session == nil || !isPreviewActiveSession(session.State) { + continue + } + if latest == nil || session.StartedAt.After(latest.StartedAt) || + (session.StartedAt.Equal(latest.StartedAt) && session.ID > latest.ID) { + latest = session + } + } + return latest +} + +func isPreviewActiveSession(state models.TaskSessionState) bool { + switch state { + case models.TaskSessionStateCreated, models.TaskSessionStateStarting, + models.TaskSessionStateRunning, models.TaskSessionStateWaitingForInput: + return true + default: + return false + } +} + +func sessionID(session *models.TaskSession) string { + if session == nil { + return "" + } + return session.ID +} + +func previewRecipientDTO(session *models.TaskSession, input workflowMovePreviewInput) *WorkflowMovePreviewRecipient { + recipient := &WorkflowMovePreviewRecipient{ + ProfileID: input.TargetProfileID, + ProfileName: input.ProfileName, + } + if session == nil { + return recipient + } + recipient.SessionID = session.ID + recipient.SessionName = session.Name + if recipient.ProfileID == "" { + recipient.ProfileID = session.AgentProfileID + } + if recipient.ProfileName == "" { + recipient.ProfileName = previewSnapshotString(session, "name") + } + recipient.AgentFamily = previewSnapshotString(session, "agent_name") + return recipient +} + +func previewSessionRuntimeConfig(session *models.TaskSession) (models.SessionRuntimeConfig, bool) { + if session == nil { + return models.SessionRuntimeConfig{}, false + } + return models.LoadEffectiveSessionRuntimeConfig(session) +} + +func previewDestinationRuntimeConfig( + target *models.TaskSession, + outcome WorkflowMovePreviewOutcome, + input workflowMovePreviewInput, +) (models.SessionRuntimeConfig, bool, string) { + if target != nil && (outcome == WorkflowMovePreviewOutcomeReuseCurrent || outcome == WorkflowMovePreviewOutcomeReuseOther) { + config, known := previewSessionRuntimeConfig(target) + return config, known, previewSessionModelSource(target) + } + if input.DynamicProfile { + return models.SessionRuntimeConfig{}, false, "unknown" + } + if input.ProfileInfo == nil { + return models.SessionRuntimeConfig{}, false, "unknown" + } + config := models.SessionRuntimeConfig{ + Model: strings.TrimSpace(input.ProfileInfo.Model), + Mode: strings.TrimSpace(input.ProfileInfo.Mode), + ConfigOptions: maps.Clone(input.ProfileInfo.ConfigOptions), + } + return config, !config.IsZero(), "profile" +} + +func previewSessionModelSource(session *models.TaskSession) string { + if session == nil { + return "unknown" + } + if overrides, ok := models.LoadSessionRuntimeConfigOverrides(session.Metadata); ok && overrides.Model != "" { + return previewSourceOverride + } + if runtime, ok := models.LoadSessionRuntimeConfig(session.Metadata); ok && runtime.Model != "" { + return "runtime" + } + if previewSnapshotString(session, "model") != "" { + return "profile" + } + return "unknown" +} + +func previewModelValue(config models.SessionRuntimeConfig, known bool) WorkflowMovePreviewModelValue { + return WorkflowMovePreviewModelValue{ + ID: config.Model, + Label: config.Model, + Known: known && config.Model != "", + Mode: config.Mode, + ConfigOptions: maps.Clone(config.ConfigOptions), + } +} diff --git a/apps/backend/internal/orchestrator/workflow_move_preview_settings.go b/apps/backend/internal/orchestrator/workflow_move_preview_settings.go new file mode 100644 index 0000000000..9d790c012f --- /dev/null +++ b/apps/backend/internal/orchestrator/workflow_move_preview_settings.go @@ -0,0 +1,348 @@ +package orchestrator + +import ( + "sort" + "strings" + "unicode" + + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" +) + +func applyPreviewConfigureSession( + after *models.SessionRuntimeConfig, + afterKnown *bool, + afterSource *string, + target *models.TaskSession, + outcome WorkflowMovePreviewOutcome, + input *workflowMovePreviewInput, +) { + if input == nil || input.Destination == nil { + return + } + action, ok := configureSessionAction(input.Destination) + if !ok { + return + } + rules, err := wfmodels.ParseConfigureSessionRules(action) + if err != nil { + input.Notices = append(input.Notices, workflowMovePreviewNotice("invalid_session_configuration", nil)) + return + } + var rule *wfmodels.ConfigureSessionRule + if target == nil { + if outcome != WorkflowMovePreviewOutcomeCreateNew || input.SourceSession != nil { + return + } + rule = previewFreshConfigureSessionRule(input, rules) + } else { + var ok bool + rule, ok = previewConfigureSessionRule(target, input, rules) + if !ok { + return + } + } + if rule == nil || rule.Operation == wfmodels.ConfigureSessionKeep { + return + } + targetConfig, ok := sessionConfigurationTargetForRule(target, *rule) + if !ok { + input.Notices = appendPreviewNoticeOnce(input.Notices, workflowMovePreviewNotice("session_configuration_unavailable", nil)) + return + } + applyPreviewConfigurationTarget(after, afterKnown, afterSource, targetConfig, rule.Operation) +} + +func previewFreshConfigureSessionRule( + input *workflowMovePreviewInput, + rules []wfmodels.ConfigureSessionRule, +) *wfmodels.ConfigureSessionRule { + if input.ProfileInfo == nil { + return nil + } + selection := selectConfigureSessionRuleWithResolver(rules, input.ProfileInfo.AgentName, input.AgentResolver) + if selection.rule == nil { + if selection.warning != "" { + input.Notices = appendPreviewNoticeOnce(input.Notices, previewConfigureSelectionNotice(selection)) + } + return nil + } + if selection.rule.Operation == wfmodels.ConfigureSessionRestoreOriginal { + input.Notices = appendPreviewNoticeOnce(input.Notices, workflowMovePreviewNotice("session_configuration_unavailable", nil)) + return nil + } + return selection.rule +} + +func applyPreviewConfigurationTarget( + after *models.SessionRuntimeConfig, + afterKnown *bool, + afterSource *string, + targetConfig sessionConfigurationTarget, + operation wfmodels.ConfigureSessionOperation, +) { + if targetConfig.model != "" { + after.Model = targetConfig.model + } + if len(targetConfig.configOptions) > 0 { + if after.ConfigOptions == nil { + after.ConfigOptions = make(map[string]string) + } + for key, value := range targetConfig.configOptions { + after.ConfigOptions[key] = value + } + } + *afterKnown = *afterKnown || targetConfig.model != "" || len(targetConfig.configOptions) > 0 + if operation == wfmodels.ConfigureSessionRestoreOriginal { + *afterSource = "original" + } else { + *afterSource = "step_rule" + } +} + +func previewConfigureSessionRule( + target *models.TaskSession, + input *workflowMovePreviewInput, + rules []wfmodels.ConfigureSessionRule, +) (*wfmodels.ConfigureSessionRule, bool) { + original := input.OriginalSession + if original == nil { + original = previewOriginalFromSessions(input.Sessions) + } + if original == nil { + input.Notices = appendPreviewNoticeOnce(input.Notices, workflowMovePreviewNotice("missing_original_snapshot", nil)) + return nil, false + } + if original.ID != target.ID || target.IsPassthrough || input.TargetPassthrough { + input.Notices = appendPreviewNoticeOnce(input.Notices, workflowMovePreviewNotice("session_configuration_skipped", nil)) + return nil, false + } + agentName := sessionAgentFamily(target) + if agentName == "" { + input.Notices = appendPreviewNoticeOnce(input.Notices, workflowMovePreviewNotice("session_configuration_unavailable", nil)) + return nil, false + } + selection := selectConfigureSessionRuleWithResolver(rules, agentName, input.AgentResolver) + if selection.rule == nil { + if selection.warning != "" { + input.Notices = appendPreviewNoticeOnce(input.Notices, previewConfigureSelectionNotice(selection)) + } + return nil, false + } + return selection.rule, true +} + +func previewOriginalFromSessions(sessions []*models.TaskSession) *models.TaskSession { + if original, resolved := markedOriginalTaskSession(sessions); resolved { + return original + } + return legacyOriginalTaskSession(sessions) +} + +func applyPreviewSessionMode( + after *models.SessionRuntimeConfig, + afterKnown *bool, + target *models.TaskSession, + input workflowMovePreviewInput, +) { + if input.Destination == nil { + return + } + for _, action := range input.Destination.Events.OnEnter { + if action.Type != wfmodels.OnEnterSetSessionMode { + continue + } + mode := strings.TrimSpace(models.StringFromAny(action.Config[previewSettingMode])) + if mode == "" { + continue + } + if (target != nil && target.IsPassthrough) || input.TargetPassthrough { + return + } + after.Mode = mode + *afterKnown = true + } +} + +func previewSettingChanges( + before models.SessionRuntimeConfig, + beforeKnown bool, + after models.SessionRuntimeConfig, + afterKnown bool, +) []WorkflowMovePreviewChange { + changes := make([]WorkflowMovePreviewChange, 0) + if before.Mode != after.Mode { + changes = append(changes, WorkflowMovePreviewChange{ + Key: previewSettingMode, Label: previewSettingLabel(previewSettingMode), Before: before.Mode, After: after.Mode, + Applicability: previewApplicability(beforeKnown, afterKnown), + }) + } + keys := make(map[string]struct{}, len(before.ConfigOptions)+len(after.ConfigOptions)) + for key := range before.ConfigOptions { + keys[key] = struct{}{} + } + for key := range after.ConfigOptions { + keys[key] = struct{}{} + } + ordered := make([]string, 0, len(keys)) + for key := range keys { + ordered = append(ordered, key) + } + sort.Strings(ordered) + for _, key := range ordered { + beforeValue, afterValue := before.ConfigOptions[key], after.ConfigOptions[key] + if beforeValue == afterValue { + continue + } + changes = append(changes, WorkflowMovePreviewChange{ + Key: key, Label: previewSettingLabel(key), Before: beforeValue, After: afterValue, + Applicability: previewApplicability(beforeKnown, afterKnown), + }) + } + return changes +} + +// previewSettingLabel keeps provider-owned option labels displayable without +// exposing raw option identifiers as UI copy. Kandev-owned fields keep their +// stable keys so the web client can localize them; provider fields use a +// bounded, readable label because their catalog is provider-defined. +func previewSettingLabel(key string) string { + key = strings.TrimSpace(key) + if key == previewSettingMode || key == previewSettingStepPrompt || key == previewSettingReasoningEffort || key == previewSettingVerbosity { + return key + } + + var label strings.Builder + capitalize := true + runes := 0 + for _, r := range key { + if runes >= 64 { + break + } + if unicode.IsLetter(r) || unicode.IsDigit(r) { + if capitalize { + r = unicode.ToUpper(r) + capitalize = false + } + label.WriteRune(r) + runes++ + continue + } + if label.Len() > 0 && !capitalize { + label.WriteByte(' ') + capitalize = true + } + } + if value := strings.TrimSpace(label.String()); value != "" { + return value + } + return "Provider option" +} + +func previewApplicability(beforeKnown, afterKnown bool) WorkflowMovePreviewApplicability { + if beforeKnown && afterKnown { + return WorkflowMovePreviewPlanned + } + return WorkflowMovePreviewUnknown +} + +func previewContextReset(input workflowMovePreviewInput, outcome WorkflowMovePreviewOutcome) (bool, WorkflowMovePreviewApplicability) { + requested := input.EntryOptions != nil && input.EntryOptions.ResetContext + declared := input.Destination != nil && input.Destination.HasOnEnterAction(wfmodels.OnEnterResetAgentContext) + if !requested && !declared { + return false, WorkflowMovePreviewUnchanged + } + if outcome == WorkflowMovePreviewOutcomeUnknown { + return requested || declared, WorkflowMovePreviewUnknown + } + if outcome == WorkflowMovePreviewOutcomeCreateNew || outcome == WorkflowMovePreviewOutcomeNoSession { + return requested || declared, WorkflowMovePreviewSkipped + } + return requested || declared, WorkflowMovePreviewPlanned +} + +func previewPromptChange(input workflowMovePreviewInput, outcome WorkflowMovePreviewOutcome) []WorkflowMovePreviewChange { + if input.EntryOptions == nil || !input.EntryOptions.SkipStepPrompt { + return nil + } + applicability := WorkflowMovePreviewPlanned + if outcome == WorkflowMovePreviewOutcomeUnknown { + applicability = WorkflowMovePreviewUnknown + } + if outcome == WorkflowMovePreviewOutcomeNoSession { + applicability = WorkflowMovePreviewSkipped + } + return []WorkflowMovePreviewChange{{ + Key: previewSettingStepPrompt, Label: previewSettingLabel(previewSettingStepPrompt), Before: "configured", After: "skipped", Applicability: applicability, + }} +} + +func previewSourceDisposition(input workflowMovePreviewInput, outcome WorkflowMovePreviewOutcome) WorkflowMovePreviewSourceDisposition { + if input.SourceSession == nil { + return WorkflowMovePreviewSourceDispositionUnknown + } + if outcome == WorkflowMovePreviewOutcomeReuseCurrent || input.Source == nil { + return WorkflowMovePreviewSourceDispositionKeep + } + switch models.NormalizeWorkflowProfileSessionEndPolicy(string(input.SourceEndPolicy)) { + case models.WorkflowProfileSessionEndPolicyComplete: + return WorkflowMovePreviewSourceDispositionComplete + case models.WorkflowProfileSessionEndPolicyPark: + return WorkflowMovePreviewSourceDispositionPark + default: + return WorkflowMovePreviewSourceDispositionUnknown + } +} + +func previewDispatch(input workflowMovePreviewInput, outcome WorkflowMovePreviewOutcome) WorkflowMovePreviewDispatch { + if outcome == WorkflowMovePreviewOutcomeUnknown || input.Destination == nil { + return WorkflowMovePreviewDispatchUnknown + } + if outcome == WorkflowMovePreviewOutcomeNoSession { + return WorkflowMovePreviewDispatchNoSession + } + if !input.Destination.HasOnEnterAction(wfmodels.OnEnterAutoStartAgent) { + return WorkflowMovePreviewDispatchNoPrompt + } + if input.EntryOptions != nil && input.EntryOptions.SkipStepPrompt && strings.TrimSpace(input.EntryOptions.Instructions) == "" { + return WorkflowMovePreviewDispatchNoPrompt + } + if previewSessionDispatchDeferred(input.SourceSession) || previewSessionDispatchDeferred(input.TargetSession) { + return WorkflowMovePreviewDispatchDeferred + } + return WorkflowMovePreviewDispatchPrompt +} + +func previewSessionDispatchDeferred(session *models.TaskSession) bool { + if session == nil { + return false + } + return session.State == models.TaskSessionStateStarting || session.State == models.TaskSessionStateRunning +} + +func previewSnapshotString(session *models.TaskSession, key string) string { + if session == nil { + return "" + } + return strings.TrimSpace(models.StringFromAny(session.AgentProfileSnapshot[key])) +} + +func workflowMovePreviewNotice(code string, params map[string]string) WorkflowMovePreviewNotice { + return WorkflowMovePreviewNotice{Code: code, Params: params} +} + +func appendPreviewNoticeOnce(notices []WorkflowMovePreviewNotice, notice WorkflowMovePreviewNotice) []WorkflowMovePreviewNotice { + for _, existing := range notices { + if existing.Code == notice.Code { + return notices + } + } + return append(notices, notice) +} + +func previewConfigureSelectionNotice(selection configureSessionRuleSelection) WorkflowMovePreviewNotice { + if selection.noticeCode == "" { + return workflowMovePreviewNotice("session_configuration_unavailable", nil) + } + return workflowMovePreviewNotice(selection.noticeCode, nil) +} diff --git a/apps/backend/internal/orchestrator/workflow_move_preview_test.go b/apps/backend/internal/orchestrator/workflow_move_preview_test.go new file mode 100644 index 0000000000..25a2b5356a --- /dev/null +++ b/apps/backend/internal/orchestrator/workflow_move_preview_test.go @@ -0,0 +1,734 @@ +package orchestrator + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/orchestrator/watcher" + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + workflowmove "github.com/kandev/kandev/internal/workflow/move" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +func TestBuildWorkflowMovePreview_SelectsTheSameReusableSessionAsMove(t *testing.T) { + now := time.Now().UTC() + current := &models.TaskSession{ + ID: "session-current", + TaskID: "task-1", + AgentProfileID: "profile-analysis", + State: models.TaskSessionStateWaitingForInput, + IsPrimary: true, + UpdatedAt: now, + } + older := &models.TaskSession{ + ID: "session-older", + TaskID: "task-1", + AgentProfileID: "profile-implement", + State: models.TaskSessionStateIdle, + UpdatedAt: now.Add(-time.Minute), + } + newer := &models.TaskSession{ + ID: "session-newer", + TaskID: "task-1", + AgentProfileID: "profile-implement", + State: models.TaskSessionStateWaitingForInput, + UpdatedAt: now.Add(time.Minute), + } + terminal := &models.TaskSession{ + ID: "session-terminal", + TaskID: "task-1", + AgentProfileID: "profile-implement", + State: models.TaskSessionStateCompleted, + UpdatedAt: now.Add(2 * time.Minute), + } + + preview := buildWorkflowMovePreview(workflowMovePreviewInput{ + TaskID: "task-1", + SourceSession: current, + Sessions: []*models.TaskSession{current, older, newer, terminal}, + Destination: &wfmodels.WorkflowStep{ID: "step-implement", Name: "Implement"}, + Source: &wfmodels.WorkflowStep{ID: "step-analysis", Name: "Analysis"}, + TargetProfileID: "profile-implement", + StartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + SourceEndPolicy: models.WorkflowProfileSessionEndPolicyPark, + ProfileName: "Implementation", + }) + + if preview.Outcome != WorkflowMovePreviewOutcomeReuseOther { + t.Fatalf("outcome = %q, want reuse_other", preview.Outcome) + } + if preview.Recipient == nil || preview.Recipient.SessionID != newer.ID { + t.Fatalf("recipient = %#v, want the newest nonterminal target session", preview.Recipient) + } + if preview.SourceDisposition != WorkflowMovePreviewSourceDispositionPark { + t.Fatalf("source disposition = %q, want park", preview.SourceDisposition) + } +} + +func TestBuildWorkflowMovePreview_NewPolicyDoesNotReuseAnExistingSession(t *testing.T) { + current := &models.TaskSession{ + ID: "session-current", + TaskID: "task-1", + AgentProfileID: "profile-analysis", + State: models.TaskSessionStateWaitingForInput, + IsPrimary: true, + } + existing := &models.TaskSession{ + ID: "session-existing", + TaskID: "task-1", + AgentProfileID: "profile-implement", + State: models.TaskSessionStateIdle, + } + + preview := buildWorkflowMovePreview(workflowMovePreviewInput{ + TaskID: "task-1", + SourceSession: current, + Sessions: []*models.TaskSession{current, existing}, + Destination: &wfmodels.WorkflowStep{ID: "step-implement", Name: "Implement"}, + TargetProfileID: "profile-implement", + StartPolicy: models.WorkflowProfileSessionStartPolicyNew, + SourceEndPolicy: models.WorkflowProfileSessionEndPolicyComplete, + SessionlessLaunchAllowed: true, + }) + + if preview.Outcome != WorkflowMovePreviewOutcomeCreateNew { + t.Fatalf("outcome = %q, want create_new", preview.Outcome) + } + if preview.Recipient == nil || preview.Recipient.SessionID != "" { + t.Fatalf("recipient = %#v, want a sessionless new recipient", preview.Recipient) + } +} + +func TestBuildWorkflowMovePreview_ReportsDeferredDispatchForActiveSessions(t *testing.T) { + current := &models.TaskSession{ + ID: "session-current", + TaskID: "task-1", + AgentProfileID: "profile-analysis", + State: models.TaskSessionStateRunning, + IsPrimary: true, + } + preview := buildWorkflowMovePreview(workflowMovePreviewInput{ + TaskID: "task-1", + SourceSession: current, + Sessions: []*models.TaskSession{current}, + Destination: &wfmodels.WorkflowStep{ + ID: "step-review", + Events: wfmodels.StepEvents{OnEnter: []wfmodels.OnEnterAction{{Type: wfmodels.OnEnterAutoStartAgent}}}, + }, + TargetProfileID: "profile-analysis", + StartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + }) + if preview.Dispatch != WorkflowMovePreviewDispatchDeferred { + t.Fatalf("dispatch = %q, want deferred", preview.Dispatch) + } +} + +func TestBuildWorkflowMovePreview_ReportsDeferredDispatchForReusableTarget(t *testing.T) { + now := time.Now().UTC() + current := &models.TaskSession{ + ID: "session-current", + TaskID: "task-1", + AgentProfileID: "profile-analysis", + State: models.TaskSessionStateWaitingForInput, + IsPrimary: true, + UpdatedAt: now, + } + target := &models.TaskSession{ + ID: "session-target", + TaskID: "task-1", + AgentProfileID: "profile-review", + State: models.TaskSessionStateRunning, + UpdatedAt: now.Add(time.Minute), + } + + preview := buildWorkflowMovePreview(workflowMovePreviewInput{ + TaskID: "task-1", + SourceSession: current, + Sessions: []*models.TaskSession{current, target}, + Destination: &wfmodels.WorkflowStep{ID: "step-review", Events: wfmodels.StepEvents{OnEnter: []wfmodels.OnEnterAction{{Type: wfmodels.OnEnterAutoStartAgent}}}}, + TargetProfileID: target.AgentProfileID, + StartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + SessionlessLaunchAllowed: true, + }) + + if preview.Outcome != WorkflowMovePreviewOutcomeReuseOther { + t.Fatalf("outcome = %q, want reuse_other", preview.Outcome) + } + if preview.Dispatch != WorkflowMovePreviewDispatchDeferred { + t.Fatalf("dispatch = %q, want deferred for the active reusable target", preview.Dispatch) + } +} + +func TestBuildWorkflowMovePreview_ProfileSwitchWithSourceCreatesSessionWithoutAutoStart(t *testing.T) { + current := &models.TaskSession{ + ID: "session-current", + TaskID: "task-1", + AgentProfileID: "profile-analysis", + State: models.TaskSessionStateWaitingForInput, + IsPrimary: true, + } + + preview := buildWorkflowMovePreview(workflowMovePreviewInput{ + TaskID: "task-1", + SourceSession: current, + Sessions: []*models.TaskSession{current}, + Destination: &wfmodels.WorkflowStep{ID: "step-review"}, + TargetProfileID: "profile-review", + StartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + SessionlessLaunchAllowed: false, + }) + + if preview.Outcome != WorkflowMovePreviewOutcomeCreateNew { + t.Fatalf("outcome = %q, want create_new for an existing-source profile switch", preview.Outcome) + } +} + +func TestBuildWorkflowMovePreview_SkipsFreshSessionContextReset(t *testing.T) { + preview := buildWorkflowMovePreview(workflowMovePreviewInput{ + TaskID: "task-1", + Destination: &wfmodels.WorkflowStep{ID: "step-review"}, + TargetProfileID: "profile-review", + SessionlessLaunchAllowed: true, + EntryOptions: &workflowmove.EntryOptions{ResetContext: true}, + }) + + if preview.ContextResetState != WorkflowMovePreviewSkipped { + t.Fatalf("context reset state = %q, want skipped for a fresh session", preview.ContextResetState) + } +} + +func TestBuildWorkflowMovePreview_SkipsModeForFreshPassthroughProfile(t *testing.T) { + preview := buildWorkflowMovePreview(workflowMovePreviewInput{ + TaskID: "task-1", + Destination: &wfmodels.WorkflowStep{ID: "step-review", Events: wfmodels.StepEvents{OnEnter: []wfmodels.OnEnterAction{{Type: wfmodels.OnEnterSetSessionMode, Config: map[string]interface{}{previewSettingMode: "plan"}}}}}, + TargetProfileID: "profile-review", + TargetPassthrough: true, + ProfileInfo: &executor.AgentProfileInfo{Model: "gpt-5.6-luna"}, + SessionlessLaunchAllowed: true, + }) + + if preview.Model.After.Mode != "" { + t.Fatalf("after mode = %q, want no mode change for a fresh passthrough profile", preview.Model.After.Mode) + } +} + +func TestPreviewConfigureSelectionNoticeUsesTypedReason(t *testing.T) { + resolver := previewAgentFamilyResolver{ + "ambiguous": {"codex-acp", "codex-cli"}, + "codex-acp": {"codex-acp"}, + "codex-cli": {"codex-cli"}, + } + rules := []wfmodels.ConfigureSessionRule{ + {AgentName: "ambiguous", Operation: wfmodels.ConfigureSessionSet, Model: "gpt-5.6-luna"}, + } + selection := selectConfigureSessionRuleWithResolver(rules, "codex-acp", resolver) + if selection.rule != nil { + t.Fatalf("selection = %#v, want no rule for ambiguous reference", selection) + } + if got := previewConfigureSelectionNotice(selection).Code; got != "ambiguous_session_configuration" { + t.Fatalf("notice code = %q, want ambiguous_session_configuration", got) + } + + conflictResolver := previewAgentFamilyResolver{ + "codex-acp": {"codex-acp"}, + "codex-cli": {"codex-acp"}, + } + selection = selectConfigureSessionRuleWithResolver([]wfmodels.ConfigureSessionRule{ + {AgentName: "codex-acp", Operation: wfmodels.ConfigureSessionSet, Model: "gpt-5.6-luna"}, + {AgentName: "codex-cli", Operation: wfmodels.ConfigureSessionSet, Model: "gpt-5.6-astra"}, + }, "codex-acp", conflictResolver) + if selection.rule != nil { + t.Fatalf("selection = %#v, want no rule for conflicting references", selection) + } + if got := previewConfigureSelectionNotice(selection).Code; got != "conflicting_session_configuration" { + t.Fatalf("notice code = %q, want conflicting_session_configuration", got) + } +} + +func TestPreviewWorkflowMove_ProjectsConfigureSessionForFreshLaunch(t *testing.T) { + ctx := t.Context() + repo := setupTestRepo(t) + seedTaskWithoutSession(t, repo, "task-fresh-config", "step-source") + + steps := newMockStepGetter() + steps.steps["step-source"] = &wfmodels.WorkflowStep{ID: "step-source", WorkflowID: "wf1", Position: 0} + target := configureSessionStep("step-target", "codex", "set", "gpt-5.6-astra", map[string]string{"reasoning_effort": "max"}) + target.WorkflowID = "wf1" + target.Position = 1 + target.Events.OnEnter = append(target.Events.OnEnter, wfmodels.OnEnterAction{Type: wfmodels.OnEnterAutoStartAgent}) + steps.steps[target.ID] = target + svc := createTestServiceWithAgent(repo, steps, newMockTaskRepo(), &mockAgentManager{ + resolveProfileInfo: &executor.AgentProfileInfo{ + ProfileID: "profile-task", ProfileName: "Task profile", AgentName: "codex", Model: "gpt-5.6-luna", + }, + }) + task, err := repo.GetTask(ctx, "task-fresh-config") + if err != nil { + t.Fatalf("load task: %v", err) + } + task.Metadata[models.MetaKeyAgentProfileID] = "profile-task" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("update task: %v", err) + } + + preview, err := svc.PreviewWorkflowMove(ctx, WorkflowMovePreviewRequest{ + TaskID: "task-fresh-config", WorkflowID: "wf1", WorkflowStepID: target.ID, + }) + if err != nil { + t.Fatalf("PreviewWorkflowMove: %v", err) + } + if preview.Outcome != WorkflowMovePreviewOutcomeCreateNew { + t.Fatalf("outcome = %q, want create_new", preview.Outcome) + } + if preview.Model.After.ID != "gpt-5.6-astra" || preview.Model.After.ConfigOptions["reasoning_effort"] != "max" { + t.Fatalf("after model/config = %#v, want rule-projected fresh-session settings", preview.Model.After) + } +} + +func TestPreviewWorkflowMove_KeepsCurrentSessionWhenOnlyTaskProfileFallbackExists(t *testing.T) { + ctx := t.Context() + repo := setupTestRepo(t) + seedSession(t, repo, "task-current-routing", "session-current-routing", "step-source") + session, err := repo.GetTaskSession(ctx, "session-current-routing") + if err != nil { + t.Fatalf("load session: %v", err) + } + session.AgentProfileID = "profile-current" + session.State = models.TaskSessionStateWaitingForInput + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("update session: %v", err) + } + if err := repo.SetSessionPrimary(ctx, session.ID); err != nil { + t.Fatalf("set primary session: %v", err) + } + task, err := repo.GetTask(ctx, "task-current-routing") + if err != nil { + t.Fatalf("load task: %v", err) + } + task.Metadata[models.MetaKeyAgentProfileID] = "profile-task-fallback" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("update task: %v", err) + } + + steps := newMockStepGetter() + steps.steps["step-source"] = &wfmodels.WorkflowStep{ID: "step-source", WorkflowID: "wf1", Position: 0} + steps.steps["step-target"] = &wfmodels.WorkflowStep{ID: "step-target", WorkflowID: "wf1", Position: 1} + svc := createTestServiceWithAgent(repo, steps, newMockTaskRepo(), &mockAgentManager{}) + preview, err := svc.PreviewWorkflowMove(ctx, WorkflowMovePreviewRequest{ + TaskID: "task-current-routing", WorkflowID: "wf1", WorkflowStepID: "step-target", + }) + if err != nil { + t.Fatalf("PreviewWorkflowMove: %v", err) + } + if preview.Outcome != WorkflowMovePreviewOutcomeReuseCurrent || preview.Recipient == nil || preview.Recipient.SessionID != session.ID { + t.Fatalf("preview = %#v, want current-session reuse", preview) + } +} + +func TestPreviewWorkflowMoveAuthorizesTaskBeforeRepositoryAccess(t *testing.T) { + denied := errors.New("task is not visible") + svc := &Service{} + svc.SetTaskAccessChecker(func(_ context.Context, taskID string) error { + if taskID != "task-private" { + return errors.New("unexpected task") + } + return denied + }) + + _, err := svc.PreviewWorkflowMove(context.Background(), WorkflowMovePreviewRequest{ + TaskID: "task-private", WorkflowID: "workflow-1", WorkflowStepID: "step-1", + }) + if !errors.Is(err, denied) { + t.Fatalf("error = %v, want task authorization error", err) + } +} + +type previewAgentFamilyResolver map[string][]string + +func (r previewAgentFamilyResolver) ResolveFamilyIDs(name string) []string { + return append([]string(nil), r[name]...) +} + +func TestBuildWorkflowMovePreview_ConditionalSetRetainsUnnamedRuntimeOptions(t *testing.T) { + current := &models.TaskSession{ + ID: "session-current", + TaskID: "task-1", + AgentProfileID: "profile-analysis", + State: models.TaskSessionStateWaitingForInput, + IsPrimary: true, + AgentProfileSnapshot: map[string]interface{}{ + "agent_name": "codex", + "model": "gpt-5.6-luna", + "config_options": map[string]interface{}{ + "reasoning_effort": "medium", + "verbosity": "high", + }, + }, + Metadata: map[string]interface{}{ + models.SessionMetaKeyOrigin: models.SessionOriginTaskInitial, + }, + } + + preview := buildWorkflowMovePreview(workflowMovePreviewInput{ + TaskID: "task-1", + SourceSession: current, + Sessions: []*models.TaskSession{current}, + Destination: &wfmodels.WorkflowStep{ + ID: "step-review", + Events: wfmodels.StepEvents{OnEnter: []wfmodels.OnEnterAction{{ + Type: wfmodels.OnEnterConfigureSession, + Config: map[string]interface{}{"rules": []interface{}{map[string]interface{}{ + "agent_name": "codex", + "operation": "set", + "model": "gpt-5.6-astra", + "config_options": map[string]interface{}{ + "reasoning_effort": "max", + }, + }}}, + }}}, + }, + TargetProfileID: "profile-analysis", + StartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + SourceEndPolicy: models.WorkflowProfileSessionEndPolicyPark, + }) + + if preview.Model.After.ID != "gpt-5.6-astra" { + t.Fatalf("after model = %q, want gpt-5.6-astra", preview.Model.After.ID) + } + if preview.Model.After.ConfigOptions["verbosity"] != "high" { + t.Fatalf("after options = %#v, want unnamed verbosity retained", preview.Model.After.ConfigOptions) + } + if len(preview.Changes) != 1 || preview.Changes[0].Key != "reasoning_effort" || preview.Changes[0].After != "max" { + t.Fatalf("changes = %#v, want one reasoning_effort change", preview.Changes) + } +} + +func TestPreviewSettingChangesUsesStableKandevKeysAndSafeProviderLabels(t *testing.T) { + before := models.SessionRuntimeConfig{ + Mode: "default", + ConfigOptions: map[string]string{ + "provider.option-name": "off", + }, + } + after := models.SessionRuntimeConfig{ + Mode: "plan", + ConfigOptions: map[string]string{ + "provider.option-name": "on", + }, + } + + changes := previewSettingChanges(before, true, after, true) + if len(changes) != 2 { + t.Fatalf("changes = %#v, want mode and provider option", changes) + } + if changes[0].Key != "mode" || changes[0].Label != "mode" { + t.Fatalf("Kandev change = %#v, want stable mode key", changes[0]) + } + if changes[1].Key != "provider.option-name" || changes[1].Label != "Provider Option Name" { + t.Fatalf("provider change = %#v, want safe display label", changes[1]) + } +} + +func TestBuildWorkflowMovePreview_IsReadOnlyForSessionMetadata(t *testing.T) { + current := &models.TaskSession{ + ID: "session-current", + TaskID: "task-1", + AgentProfileID: "profile-analysis", + State: models.TaskSessionStateWaitingForInput, + IsPrimary: true, + Metadata: map[string]interface{}{ + models.SessionMetaKeyRuntimeConfigOverrides: models.SessionRuntimeConfig{ + Model: "gpt-5.6-astra", + }, + }, + } + preview := buildWorkflowMovePreview(workflowMovePreviewInput{ + TaskID: "task-1", + SourceSession: current, + Sessions: []*models.TaskSession{current}, + Destination: &wfmodels.WorkflowStep{ID: "step-review"}, + TargetProfileID: "profile-analysis", + StartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + SourceEndPolicy: models.WorkflowProfileSessionEndPolicyPark, + }) + + if preview == nil || preview.SourceSessionID != current.ID { + t.Fatalf("preview = %#v, want a current-session result", preview) + } + if _, ok := current.Metadata[models.MetaKeyWorkflowInitialSession]; ok { + t.Fatal("preview wrote workflow initial-session metadata") + } +} + +func TestBuildWorkflowMovePreview_PreservesDiagnosticNotices(t *testing.T) { + current := &models.TaskSession{ + ID: "session-current", + TaskID: "task-1", + AgentProfileID: "profile-analysis", + State: models.TaskSessionStateWaitingForInput, + IsPrimary: true, + AgentProfileSnapshot: map[string]interface{}{ + "agent_name": "codex", + "model": "gpt-5.6-luna", + }, + Metadata: map[string]interface{}{ + models.SessionMetaKeyRuntimeConfigOverrides: models.SessionRuntimeConfig{Model: "gpt-5.6-astra"}, + }, + } + retained := buildWorkflowMovePreview(workflowMovePreviewInput{ + TaskID: "task-1", + SourceSession: current, + Sessions: []*models.TaskSession{current}, + Destination: &wfmodels.WorkflowStep{ID: "step-review"}, + TargetProfileID: "profile-analysis", + StartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + }) + if len(retained.Notices) != 1 || retained.Notices[0].Code != "retained_model_override" { + t.Fatalf("retained notices = %#v, want retained_model_override", retained.Notices) + } + + startedAt := time.Now().UTC() + ambiguousSessions := []*models.TaskSession{ + { + ID: "session-current", + TaskID: "task-1", + AgentProfileID: "profile-analysis", + State: models.TaskSessionStateWaitingForInput, + IsPrimary: true, + StartedAt: startedAt, + AgentProfileSnapshot: map[string]interface{}{ + "agent_name": "codex", + }, + }, + { + ID: "session-other", + TaskID: "task-1", + AgentProfileID: "profile-other", + State: models.TaskSessionStateIdle, + StartedAt: startedAt, + }, + } + ambiguous := buildWorkflowMovePreview(workflowMovePreviewInput{ + TaskID: "task-1", + SourceSession: ambiguousSessions[0], + Sessions: ambiguousSessions, + Destination: &wfmodels.WorkflowStep{ + ID: "step-review", + Events: wfmodels.StepEvents{OnEnter: []wfmodels.OnEnterAction{{ + Type: wfmodels.OnEnterConfigureSession, + Config: map[string]interface{}{"rules": []interface{}{map[string]interface{}{ + "agent_name": "codex", + "operation": "set", + "model": "gpt-5.6-astra", + }}}, + }}}, + }, + TargetProfileID: "profile-analysis", + StartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + }) + if len(ambiguous.Notices) != 1 || ambiguous.Notices[0].Code != "missing_original_snapshot" { + t.Fatalf("missing-original notices = %#v, want missing_original_snapshot", ambiguous.Notices) + } +} + +func TestPreviewWorkflowMove_DoesNotBackfillLegacyInitialSessionMetadata(t *testing.T) { + ctx := t.Context() + repo := setupTestRepo(t) + seedSession(t, repo, "task-preview", "session-preview", "step-current") + + session, err := repo.GetTaskSession(ctx, "session-preview") + if err != nil { + t.Fatalf("load session: %v", err) + } + session.AgentProfileID = "profile-analysis" + session.State = models.TaskSessionStateWaitingForInput + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("update session: %v", err) + } + if err := repo.SetSessionPrimary(ctx, session.ID); err != nil { + t.Fatalf("set primary session: %v", err) + } + if _, err := repo.RemoveTaskMetadataKey(ctx, "task-preview", models.MetaKeyWorkflowInitialSession); err != nil { + t.Fatalf("remove initial-session marker: %v", err) + } + + steps := newMockStepGetter() + steps.steps["step-current"] = &wfmodels.WorkflowStep{ID: "step-current", WorkflowID: "wf1", Position: 0} + steps.steps["step-target"] = &wfmodels.WorkflowStep{ + ID: "step-target", + WorkflowID: "wf1", + Position: 1, + SessionTarget: &wfmodels.WorkflowSessionTarget{Kind: wfmodels.WorkflowSessionTargetInitial}, + ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + } + agent := &mockAgentManager{resolveProfileInfo: &executor.AgentProfileInfo{ + ProfileID: "profile-analysis", + ProfileName: "Analysis", + AgentName: "codex", + Model: "gpt-5.6-luna", + }} + svc := createTestServiceWithAgent(repo, steps, newMockTaskRepo(), agent) + + if _, err := svc.PreviewWorkflowMove(ctx, WorkflowMovePreviewRequest{ + TaskID: "task-preview", + WorkflowID: "wf1", + WorkflowStepID: "step-target", + }); err != nil { + t.Fatalf("PreviewWorkflowMove: %v", err) + } + + task, err := repo.GetTask(ctx, "task-preview") + if err != nil { + t.Fatalf("reload task: %v", err) + } + if _, ok := task.Metadata[models.MetaKeyWorkflowInitialSession]; ok { + t.Fatal("preview backfilled the legacy initial-session marker") + } +} + +func TestPreviewWorkflowMove_NoSessionLaunchGatesMatchActualMove(t *testing.T) { + t.Run("step without auto-start stays idle", func(t *testing.T) { + ctx := t.Context() + repo := setupTestRepo(t) + now := time.Now().UTC() + requireNoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now})) + requireNoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "WF", CreatedAt: now, UpdatedAt: now})) + requireNoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "task-no-auto-start", WorkspaceID: "ws1", WorkflowID: "wf1", WorkflowStepID: "step-source", + Title: "Task", State: v1.TaskStateCreated, CreatedAt: now, UpdatedAt: now, + Metadata: map[string]interface{}{models.MetaKeyAgentProfileID: "profile-task"}, + })) + + steps := newMockStepGetter() + steps.steps["step-source"] = &wfmodels.WorkflowStep{ID: "step-source", WorkflowID: "wf1"} + steps.steps["step-target"] = &wfmodels.WorkflowStep{ + ID: "step-target", WorkflowID: "wf1", AgentProfileID: "profile-task", + } + svc := createTestServiceWithAgent(repo, steps, newMockTaskRepo(), &mockAgentManager{ + resolveProfileInfo: &executor.AgentProfileInfo{ProfileID: "profile-task", ProfileName: "Task profile", Model: "gpt-5.6-luna"}, + }) + + preview, err := svc.PreviewWorkflowMove(ctx, WorkflowMovePreviewRequest{ + TaskID: "task-no-auto-start", WorkflowID: "wf1", WorkflowStepID: "step-target", + }) + if err != nil { + t.Fatalf("PreviewWorkflowMove: %v", err) + } + if preview.Outcome != WorkflowMovePreviewOutcomeNoSession || preview.Recipient != nil { + t.Fatalf("preview = %#v, want no_session without a recipient", preview) + } + + svc.handleTaskMovedNoSession(ctx, watcher.TaskMovedEventData{TaskID: "task-no-auto-start", ToStepID: "step-target"}) + sessions, err := repo.ListTaskSessions(ctx, "task-no-auto-start") + if err != nil { + t.Fatalf("ListTaskSessions: %v", err) + } + if len(sessions) != 0 { + t.Fatalf("actual move created %d sessions, want idle task", len(sessions)) + } + }) + + t.Run("skip prompt without instructions suppresses auto-start", func(t *testing.T) { + ctx := t.Context() + repo := setupTestRepo(t) + now := time.Now().UTC() + requireNoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now})) + requireNoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "WF", CreatedAt: now, UpdatedAt: now})) + options := &workflowmove.EntryOptions{SkipStepPrompt: true} + encoded, err := workflowmove.EncodeEntryOptionsJSON(options) + if err != nil { + t.Fatalf("EncodeEntryOptionsJSON: %v", err) + } + requireNoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "task-skip-prompt", WorkspaceID: "ws1", WorkflowID: "wf1", WorkflowStepID: "step-source", + Title: "Task", State: v1.TaskStateCreated, CreatedAt: now, UpdatedAt: now, + Metadata: map[string]interface{}{ + models.MetaKeyAgentProfileID: "profile-task", + models.MetaKeyWorkflowMovePending: map[string]interface{}{ + "from_step_id": "step-source", "move_id": "move-1", "options": string(encoded), + }, + }, + })) + + steps := newMockStepGetter() + steps.steps["step-source"] = &wfmodels.WorkflowStep{ID: "step-source", WorkflowID: "wf1"} + steps.steps["step-target"] = &wfmodels.WorkflowStep{ + ID: "step-target", WorkflowID: "wf1", + Events: wfmodels.StepEvents{OnEnter: []wfmodels.OnEnterAction{{Type: wfmodels.OnEnterAutoStartAgent}}}, + } + svc := createTestServiceWithAgent(repo, steps, newMockTaskRepo(), &mockAgentManager{ + resolveProfileInfo: &executor.AgentProfileInfo{ProfileID: "profile-task", ProfileName: "Task profile", Model: "gpt-5.6-luna"}, + }) + + preview, err := svc.PreviewWorkflowMove(ctx, WorkflowMovePreviewRequest{ + TaskID: "task-skip-prompt", WorkflowID: "wf1", WorkflowStepID: "step-target", EntryOptions: options, + }) + if err != nil { + t.Fatalf("PreviewWorkflowMove: %v", err) + } + if preview.Outcome != WorkflowMovePreviewOutcomeNoSession || preview.Recipient != nil { + t.Fatalf("preview = %#v, want no_session without a recipient", preview) + } + + svc.handleTaskMovedNoSession(ctx, watcher.TaskMovedEventData{TaskID: "task-skip-prompt", ToStepID: "step-target"}) + sessions, err := repo.ListTaskSessions(ctx, "task-skip-prompt") + if err != nil { + t.Fatalf("ListTaskSessions: %v", err) + } + if len(sessions) != 0 { + t.Fatalf("actual move created %d sessions, want idle task", len(sessions)) + } + task, err := repo.GetTask(ctx, "task-skip-prompt") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if _, pending := task.Metadata[models.MetaKeyWorkflowMovePending]; pending { + t.Fatal("actual move left the one-shot marker after suppressing auto-start") + } + }) + + t.Run("allowed auto-start retains the task profile fallback", func(t *testing.T) { + ctx := t.Context() + repo := setupTestRepo(t) + now := time.Now().UTC() + requireNoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now})) + requireNoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "WF", CreatedAt: now, UpdatedAt: now})) + requireNoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "task-profile-fallback", WorkspaceID: "ws1", WorkflowID: "wf1", WorkflowStepID: "step-source", + Title: "Task", State: v1.TaskStateCreated, CreatedAt: now, UpdatedAt: now, + Metadata: map[string]interface{}{models.MetaKeyAgentProfileID: "profile-task"}, + })) + + steps := newMockStepGetter() + steps.steps["step-source"] = &wfmodels.WorkflowStep{ID: "step-source", WorkflowID: "wf1"} + steps.steps["step-target"] = &wfmodels.WorkflowStep{ + ID: "step-target", WorkflowID: "wf1", + Events: wfmodels.StepEvents{OnEnter: []wfmodels.OnEnterAction{{Type: wfmodels.OnEnterAutoStartAgent}}}, + } + svc := createTestServiceWithAgent(repo, steps, newMockTaskRepo(), &mockAgentManager{ + resolveProfileInfo: &executor.AgentProfileInfo{ + ProfileID: "profile-task", ProfileName: "Task profile", Model: "gpt-5.6-luna", + }, + }) + + preview, err := svc.PreviewWorkflowMove(ctx, WorkflowMovePreviewRequest{ + TaskID: "task-profile-fallback", WorkflowID: "wf1", WorkflowStepID: "step-target", + }) + if err != nil { + t.Fatalf("PreviewWorkflowMove: %v", err) + } + if preview.Outcome != WorkflowMovePreviewOutcomeCreateNew { + t.Fatalf("preview outcome = %q, want create_new", preview.Outcome) + } + if preview.Recipient == nil || preview.Recipient.ProfileID != "profile-task" { + t.Fatalf("preview recipient = %#v, want task profile fallback", preview.Recipient) + } + if preview.Model.After.ID != "gpt-5.6-luna" { + t.Fatalf("preview model = %#v, want task profile model", preview.Model.After) + } + }) +} diff --git a/apps/backend/internal/orchestrator/workflow_session_config.go b/apps/backend/internal/orchestrator/workflow_session_config.go index 703b0ac28c..9ecfba7d51 100644 --- a/apps/backend/internal/orchestrator/workflow_session_config.go +++ b/apps/backend/internal/orchestrator/workflow_session_config.go @@ -161,8 +161,9 @@ func (s *Service) resolveWorkflowSessionConfigTarget( // a session's agent family. A nil rule with an empty warning is the one silent // outcome: the step deliberately configures some other agent. type configureSessionRuleSelection struct { - rule *wfmodels.ConfigureSessionRule - warning string + rule *wfmodels.ConfigureSessionRule + warning string + noticeCode string } // selectConfigureSessionRule returns the single rule governing the session's @@ -186,20 +187,34 @@ func (s *Service) selectConfigureSessionRule( rules []wfmodels.ConfigureSessionRule, agentName string, ) configureSessionRuleSelection { - sessionFamily, sessionAmbiguous := s.resolveSessionAgentFamily(agentName) + return selectConfigureSessionRuleWithResolver(rules, agentName, s.agentFamilyResolver) +} + +func selectConfigureSessionRuleWithResolver( + rules []wfmodels.ConfigureSessionRule, + agentName string, + resolver AgentFamilyResolver, +) configureSessionRuleSelection { + sessionFamily, sessionAmbiguous := resolveSessionAgentFamilyWithResolver(agentName, resolver) if sessionAmbiguous { - return configureSessionRuleSelection{warning: ambiguousAgentFamilyWarning(agentName)} + return configureSessionRuleSelection{ + warning: ambiguousAgentFamilyWarning(agentName), + noticeCode: "ambiguous_session_configuration", + } } matched := make([]*wfmodels.ConfigureSessionRule, 0, 1) anyKnownFamily := false for index := range rules { - switch s.classifyRuleAgentFamily(rules[index].AgentName, sessionFamily) { + switch classifyRuleAgentFamilyWithResolver(rules[index].AgentName, sessionFamily, resolver) { case ruleGovernsSession: anyKnownFamily = true matched = append(matched, &rules[index]) case ruleAmbiguousForSession: - return configureSessionRuleSelection{warning: ambiguousAgentFamilyWarning(rules[index].AgentName)} + return configureSessionRuleSelection{ + warning: ambiguousAgentFamilyWarning(rules[index].AgentName), + noticeCode: "ambiguous_session_configuration", + } case ruleNamesOtherAgents: anyKnownFamily = true case ruleNamesNothingKnown: @@ -208,11 +223,17 @@ func (s *Service) selectConfigureSessionRule( switch { case len(matched) > 1: - return configureSessionRuleSelection{warning: conflictingRulesWarning(len(matched), sessionFamily)} + return configureSessionRuleSelection{ + warning: conflictingRulesWarning(len(matched), sessionFamily), + noticeCode: "conflicting_session_configuration", + } case len(matched) == 1: return configureSessionRuleSelection{rule: matched[0]} case !anyKnownFamily: - return configureSessionRuleSelection{warning: noKnownAgentFamilyWarning} + return configureSessionRuleSelection{ + warning: noKnownAgentFamilyWarning, + noticeCode: "session_configuration_unavailable", + } default: return configureSessionRuleSelection{} } @@ -257,11 +278,15 @@ func conflictingRulesWarning(count int, family string) string { // returned so rule matching degrades to the exact comparison this used to // perform. func (s *Service) resolveSessionAgentFamily(name string) (string, bool) { + return resolveSessionAgentFamilyWithResolver(name, s.agentFamilyResolver) +} + +func resolveSessionAgentFamilyWithResolver(name string, resolver AgentFamilyResolver) (string, bool) { trimmed := strings.TrimSpace(name) - if s.agentFamilyResolver == nil { + if resolver == nil { return trimmed, false } - switch candidates := s.agentFamilyResolver.ResolveFamilyIDs(trimmed); len(candidates) { + switch candidates := resolver.ResolveFamilyIDs(trimmed); len(candidates) { case 0: return trimmed, false case 1: @@ -276,14 +301,21 @@ func (s *Service) resolveSessionAgentFamily(name string) (string, bool) { // exact comparison, so it can still match an equally unrecognized session family // the way it did before resolution existed. func (s *Service) classifyRuleAgentFamily(ruleAgentName, sessionFamily string) ruleAgentFamilyVerdict { + return classifyRuleAgentFamilyWithResolver(ruleAgentName, sessionFamily, s.agentFamilyResolver) +} + +func classifyRuleAgentFamilyWithResolver( + ruleAgentName, sessionFamily string, + resolver AgentFamilyResolver, +) ruleAgentFamilyVerdict { trimmed := strings.TrimSpace(ruleAgentName) - if s.agentFamilyResolver == nil { + if resolver == nil { // Nothing is knowable about an agent family without a resolver, so a // reference that does not match verbatim is treated as naming some other // agent rather than reported to the user as a workflow typo. return verbatimRuleVerdict(trimmed, sessionFamily) } - candidates := s.agentFamilyResolver.ResolveFamilyIDs(trimmed) + candidates := resolver.ResolveFamilyIDs(trimmed) switch { case len(candidates) == 0: if trimmed == sessionFamily { diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 63c1138f48..0a54f84ec5 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -33,6 +33,7 @@ type handlerRepo interface { type TaskHandlers struct { service *service.Service orchestrator OrchestratorStarter + movePreviewer WorkflowMovePreviewer foregroundActivity dto.ForegroundActivityProvider cancellationPending dto.CancellationPendingProvider parkedProjection dto.ParkedProvider @@ -139,6 +140,13 @@ type OrchestratorStarter interface { EnsureSession(ctx context.Context, taskID string, opts ...orchestrator.EnsureSessionOptions) (*orchestrator.EnsureSessionResponse, error) } +// WorkflowMovePreviewer is deliberately separate from OrchestratorStarter so +// existing launch/ensure fakes and plugin adapters do not need to implement a +// read-only advisory surface. +type WorkflowMovePreviewer interface { + PreviewWorkflowMove(context.Context, orchestrator.WorkflowMovePreviewRequest) (*orchestrator.WorkflowMovePreview, error) +} + func NewTaskHandlers(svc *service.Service, orchestrator OrchestratorStarter, repo handlerRepo, planService *service.PlanService, log *logger.Logger) *TaskHandlers { h := &TaskHandlers{ service: svc, @@ -147,6 +155,9 @@ func NewTaskHandlers(svc *service.Service, orchestrator OrchestratorStarter, rep planService: planService, logger: log.WithFields(zap.String("component", "task-task-handlers")), } + if previewer, ok := orchestrator.(WorkflowMovePreviewer); ok { + h.movePreviewer = previewer + } // The orchestrator also surfaces the in-memory fine-grained busy substate // (ADR-0049). Derive the narrow provider from it so the // session-fetch handlers can stamp foreground_activity onto sessions without @@ -203,6 +214,7 @@ func (h *TaskHandlers) registerHTTP(router *gin.Engine) { api.POST("/tasks/:id/workspace-sources", h.httpAttachWorkspaceSources) api.PATCH("/tasks/:id/repositories/:repo_id", h.httpUpdateTaskRepository) api.POST("/tasks/:id/move", h.httpMoveTask) + api.POST("/tasks/:id/move-preview", h.httpMoveTaskPreview) api.DELETE("/tasks/:id", h.httpDeleteTask) api.POST("/tasks/:id/archive", h.httpArchiveTask) api.POST("/tasks/:id/unarchive", h.httpUnarchiveTask) diff --git a/apps/backend/internal/task/handlers/task_move_preview_handlers.go b/apps/backend/internal/task/handlers/task_move_preview_handlers.go new file mode 100644 index 0000000000..ead1733753 --- /dev/null +++ b/apps/backend/internal/task/handlers/task_move_preview_handlers.go @@ -0,0 +1,73 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/orchestrator" + workflowmove "github.com/kandev/kandev/internal/workflow/move" +) + +// httpMoveTaskPreview returns an advisory move prediction. The task and +// workflow reads here provide the same access checks as the move endpoint; +// the orchestrator then performs the read-only routing calculation. +func (h *TaskHandlers) httpMoveTaskPreview(c *gin.Context) { + c.Header("Cache-Control", "no-store") + if h.movePreviewer == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "workflow move preview is unavailable"}) + return + } + + var body httpMoveTaskRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if body.WorkflowID == "" || body.WorkflowStepID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workflow_id and workflow_step_id are required"}) + return + } + + taskID := c.Param("id") + task, err := h.service.GetTask(c.Request.Context(), taskID) + if err != nil { + handleNotFound(c, h.logger, err, "task not found") + return + } + workflow, err := h.service.GetWorkflow(c.Request.Context(), body.WorkflowID) + if err != nil { + handleNotFound(c, h.logger, err, "workflow not found") + return + } + if workflow == nil || workflow.WorkspaceID != task.WorkspaceID { + c.JSON(http.StatusBadRequest, gin.H{"error": "target workflow is in a different workspace"}) + return + } + + entryOptions, err := workflowmove.NormalizeEntryOptions(body.EntryOptions, "") + if err != nil { + handleSelectedMoveError(c, h.logger, err) + return + } + change := workflowmove.MoveChangeStep + if task.WorkflowID == body.WorkflowID && task.WorkflowStepID == body.WorkflowStepID { + change = workflowmove.MoveChangePositionOnly + } + if err := workflowmove.ValidateEntryOptions(entryOptions, change); err != nil { + handleSelectedMoveError(c, h.logger, err) + return + } + + preview, err := h.movePreviewer.PreviewWorkflowMove(c.Request.Context(), orchestrator.WorkflowMovePreviewRequest{ + TaskID: taskID, + WorkflowID: body.WorkflowID, + WorkflowStepID: body.WorkflowStepID, + EntryOptions: entryOptions, + }) + if err != nil { + handleSelectedMoveError(c, h.logger, err) + return + } + c.JSON(http.StatusOK, preview) +} diff --git a/apps/backend/internal/task/handlers/task_move_preview_handlers_test.go b/apps/backend/internal/task/handlers/task_move_preview_handlers_test.go new file mode 100644 index 0000000000..06ca7bcb4a --- /dev/null +++ b/apps/backend/internal/task/handlers/task_move_preview_handlers_test.go @@ -0,0 +1,135 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" +) + +type captureWorkflowMovePreviewer struct { + request orchestrator.WorkflowMovePreviewRequest + calls int + result *orchestrator.WorkflowMovePreview +} + +func (p *captureWorkflowMovePreviewer) PreviewWorkflowMove( + _ context.Context, + request orchestrator.WorkflowMovePreviewRequest, +) (*orchestrator.WorkflowMovePreview, error) { + p.request = request + p.calls++ + return p.result, nil +} + +func newMovePreviewHandler(t *testing.T, repo *moveTaskConflictRepo, previewer WorkflowMovePreviewer, log *logger.Logger) *TaskHandlers { + t.Helper() + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + return &TaskHandlers{service: svc, movePreviewer: previewer, logger: log} +} + +func TestHTTPMoveTaskPreviewNormalizesOptionsAndReturnsNoStore(t *testing.T) { + gin.SetMode(gin.TestMode) + log := newTestLogger(t) + task := &models.Task{ + ID: "task-preview-handler", + WorkspaceID: "workspace-1", + WorkflowID: "wf-source", + WorkflowStepID: "step-source", + } + repo := &moveTaskConflictRepo{ + task: task, + workflows: map[string]*models.Workflow{ + "wf-target": {ID: "wf-target", WorkspaceID: task.WorkspaceID}, + }, + } + previewer := &captureWorkflowMovePreviewer{ + result: &orchestrator.WorkflowMovePreview{ + TaskID: task.ID, + WorkflowStepID: "step-target", + Outcome: orchestrator.WorkflowMovePreviewOutcomeCreateNew, + }, + } + h := newMovePreviewHandler(t, repo, previewer, log) + router := gin.New() + router.POST("/api/v1/tasks/:id/move-preview", h.httpMoveTaskPreview) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+task.ID+"/move-preview", strings.NewReader(`{ + "workflow_id": "wf-target", + "workflow_step_id": "step-target", + "entry_options": { + "instructions": " inspect the implementation ", + "reset_context": true + } + }`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + assert.Equal(t, 1, previewer.calls) + assert.Equal(t, task.ID, previewer.request.TaskID) + assert.Equal(t, "wf-target", previewer.request.WorkflowID) + assert.Equal(t, "step-target", previewer.request.WorkflowStepID) + require.NotNil(t, previewer.request.EntryOptions) + assert.Equal(t, "inspect the implementation", previewer.request.EntryOptions.Instructions) + assert.True(t, previewer.request.EntryOptions.ResetContext) + assert.Equal(t, "wf-source", repo.task.WorkflowID) + assert.Equal(t, "step-source", repo.task.WorkflowStepID) + + var body orchestrator.WorkflowMovePreview + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, orchestrator.WorkflowMovePreviewOutcomeCreateNew, body.Outcome) +} + +func TestHTTPMoveTaskPreviewRejectsOptionsForPositionOnlyMove(t *testing.T) { + gin.SetMode(gin.TestMode) + log := newTestLogger(t) + task := &models.Task{ + ID: "task-position-preview", + WorkspaceID: "workspace-1", + WorkflowID: "wf-target", + WorkflowStepID: "step-target", + } + repo := &moveTaskConflictRepo{ + task: task, + workflows: map[string]*models.Workflow{ + "wf-target": {ID: "wf-target", WorkspaceID: task.WorkspaceID}, + }, + } + previewer := &captureWorkflowMovePreviewer{} + h := newMovePreviewHandler(t, repo, previewer, log) + router := gin.New() + router.POST("/api/v1/tasks/:id/move-preview", h.httpMoveTaskPreview) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+task.ID+"/move-preview", strings.NewReader(`{ + "workflow_id": "wf-target", + "workflow_step_id": "step-target", + "entry_options": {"reset_context": true} + }`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + assert.Zero(t, previewer.calls) + assert.Contains(t, rec.Body.String(), "entry options") +} diff --git a/apps/backend/internal/workflow/move/overlay.go b/apps/backend/internal/workflow/move/overlay.go index 6688a3391f..9dd63d6ea9 100644 --- a/apps/backend/internal/workflow/move/overlay.go +++ b/apps/backend/internal/workflow/move/overlay.go @@ -11,6 +11,17 @@ const ( InstructionsEnd = "" ) +// ShouldAutoStartAgent reports whether entering a step can start an agent for +// this move. A missing auto_start_agent action leaves a task without a +// session, and skip_step_prompt without instructions removes the only turn +// that could be started for a new session. +func ShouldAutoStartAgent(step *models.WorkflowStep, opts *EntryOptions) bool { + if step == nil || !step.HasOnEnterAction(models.OnEnterAutoStartAgent) { + return false + } + return opts == nil || !opts.SkipStepPrompt || strings.TrimSpace(opts.Instructions) != "" +} + // OverlayStep returns a copy of step with one-shot move-entry options applied. // The original step is never mutated. Reset is OR-ed onto the copy and never // disables an existing reset_agent_context action. diff --git a/apps/backend/internal/workflow/move/overlay_test.go b/apps/backend/internal/workflow/move/overlay_test.go index 14088b774d..263b319259 100644 --- a/apps/backend/internal/workflow/move/overlay_test.go +++ b/apps/backend/internal/workflow/move/overlay_test.go @@ -6,6 +6,25 @@ import ( "github.com/kandev/kandev/internal/workflow/models" ) +func TestShouldAutoStartAgentMatchesMoveEntryGates(t *testing.T) { + step := &models.WorkflowStep{ + Events: models.StepEvents{OnEnter: []models.OnEnterAction{{Type: models.OnEnterAutoStartAgent}}}, + } + + if !ShouldAutoStartAgent(step, nil) { + t.Fatal("an auto-start step should launch without move options") + } + if ShouldAutoStartAgent(&models.WorkflowStep{}, nil) { + t.Fatal("a step without auto_start_agent should not launch") + } + if ShouldAutoStartAgent(step, &EntryOptions{SkipStepPrompt: true}) { + t.Fatal("skip_step_prompt without instructions should not launch") + } + if !ShouldAutoStartAgent(step, &EntryOptions{SkipStepPrompt: true, Instructions: " handoff "}) { + t.Fatal("skip_step_prompt with instructions should launch") + } +} + func TestOverlayStep_DoesNotMutateOriginal(t *testing.T) { step := &models.WorkflowStep{ ID: "step-qa", diff --git a/apps/web/components/integrations/use-hover-popover.ts b/apps/web/components/integrations/use-hover-popover.ts index 3cbbc5a919..03cfe97dd2 100644 --- a/apps/web/components/integrations/use-hover-popover.ts +++ b/apps/web/components/integrations/use-hover-popover.ts @@ -144,12 +144,18 @@ export function useHoverPopover({ openDelayMs, closeDelayMs, disabled = false, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, }: { openDelayMs: number; closeDelayMs: number; disabled?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { - const [open, setOpen] = useState(false); + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const open = controlledOpen ?? uncontrolledOpen; + const setOpen = controlledOnOpenChange ?? setUncontrolledOpen; const openTimer = useRef | null>(null); const closeTimer = useRef | null>(null); const overTrigger = useRef(false); @@ -180,7 +186,7 @@ export function useHoverPopover({ setOpen(false); } }, closeDelayMs); - }, [disabled, clearOpen, clearClose, closeDelayMs]); + }, [disabled, clearOpen, clearClose, closeDelayMs, setOpen]); const scheduleOpen = useCallback(() => { if (disabled || open || openTimer.current) return; @@ -188,7 +194,7 @@ export function useHoverPopover({ openTimer.current = null; setOpen(true); }, openDelayMs); - }, [disabled, open, openDelayMs]); + }, [disabled, open, openDelayMs, setOpen]); const { onTriggerEnter, onTriggerLeave, onContentEnter, onContentLeave } = useHoverRegionHandlers( { @@ -214,9 +220,16 @@ export function useHoverPopover({ clearClose(); setOpen(false); }, - [clearOpen, clearClose], + [clearOpen, clearClose, setOpen], ); + useEffect(() => { + if (controlledOpen !== false) return; + clearRegionPresence(overTrigger, triggerFocused, overContent, contentFocused); + clearOpen(); + clearClose(); + }, [controlledOpen, clearOpen, clearClose]); + useEffect( () => () => { clearOpen(); diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index 15fa092c8e..2f2c310e51 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -528,6 +528,7 @@ export function ChatInputArea(props: ChatInputAreaProps) { taskId={statusRowTaskId} sessionId={resolvedSessionId} sessionState={sessionState} + previewTarget={planActions.proceedPreviewTarget} nextStepName={proceedStepName} onProceed={proceed} isAgentBusy={isAgentBusy} diff --git a/apps/web/components/task/chat/chat-status-bar.tsx b/apps/web/components/task/chat/chat-status-bar.tsx index 0a083c08d1..148067b72f 100644 --- a/apps/web/components/task/chat/chat-status-bar.tsx +++ b/apps/web/components/task/chat/chat-status-bar.tsx @@ -10,6 +10,7 @@ * indicators, so nothing here participates in composing or sending a message. */ +import type { WorkflowMovePreviewTarget } from "../workflow-move-preview-footer"; import { useMemo, type ReactNode } from "react"; import { useAppStore } from "@/components/state-provider"; import { WorkflowMoveProceedButton } from "@/components/task/workflow-move-proceed-button"; @@ -117,6 +118,7 @@ export type ChatStatusBarProps = { taskId: string | null; sessionId: string | null; sessionState: string | null; + previewTarget?: WorkflowMovePreviewTarget; nextStepName: string | null; onProceed: (options?: WorkflowMoveEntryOptions) => boolean | void | Promise; isAgentBusy: boolean; @@ -156,6 +158,7 @@ export function ChatStatusBar({ taskId, sessionId, sessionState, + previewTarget, nextStepName, onProceed, isAgentBusy, @@ -241,6 +244,7 @@ export function ChatStatusBar({ )} {showProceed && nextStepName && ( boolean | void | Promise; isMoving: boolean; @@ -544,6 +547,7 @@ type StatusRowProps = { function PassthroughStatusRow({ taskId, sessionId, + previewTarget, nextStepName, onProceed, isMoving, @@ -583,6 +587,7 @@ function PassthroughStatusRow({ {taskId && } {showProceed && nextStepName && ( boolean | void | Promise; type WorkflowMoveOptionsFormProps = { + previewTarget?: WorkflowMovePreviewTarget; isMoving: boolean; isTouchSurface: boolean; instructionsRows?: number; @@ -231,6 +237,7 @@ function WorkflowMoveOptionsActions({ * form open so nothing the user typed is lost. */ export function WorkflowMoveOptionsForm({ + previewTarget, isMoving, isTouchSurface, instructionsRows, @@ -255,11 +262,19 @@ export function WorkflowMoveOptionsForm({ onCancel={onCancel} onSubmit={() => void submit()} /> + {previewTarget && ( + + )} ); } type WorkflowMoveOptionsProps = { + previewTarget?: WorkflowMovePreviewTarget; open: boolean; onOpenChange: (open: boolean) => void; targetStepName: string; @@ -269,6 +284,7 @@ type WorkflowMoveOptionsProps = { /** Fine-pointer Dialog wrapper around the shared form. */ export function WorkflowMoveDialog({ + previewTarget, open, onOpenChange, targetStepName, @@ -284,6 +300,7 @@ export function WorkflowMoveDialog({ {t("task:workflowMoveOptionsDescription")} ; +} diff --git a/apps/web/components/task/workflow-move-preview.test.tsx b/apps/web/components/task/workflow-move-preview.test.tsx new file mode 100644 index 0000000000..a66efc8310 --- /dev/null +++ b/apps/web/components/task/workflow-move-preview.test.tsx @@ -0,0 +1,231 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "@/lib/i18n"; +import type { WorkflowMovePreviewResponse } from "@/lib/api"; +import { + WorkflowMovePreviewDisclosure, + workflowMovePreviewChangeCount, +} from "./workflow-move-preview"; + +afterEach(async () => { + cleanup(); + await i18n.changeLanguage("en"); +}); + +function makePreview( + overrides: Partial = {}, +): WorkflowMovePreviewResponse { + return { + task_id: "task-1", + workflow_step_id: "step-review", + source_session_id: "session-source", + evaluated_at: "2026-09-14T00:00:00Z", + outcome: "reuse_current", + recipient: { + session_id: "session-source", + session_name: "Analysis conversation", + profile_id: "profile-luna", + profile_name: "Luna", + agent_family: "codex", + }, + model: { + before: { known: true, label: "gpt-5.6-astra" }, + after: { known: true, label: "gpt-5.6-luna" }, + before_source: "override", + after_source: "step_rule", + }, + changes: [ + { + key: "reasoning_effort", + label: "reasoning_effort", + before: "medium", + after: "max", + applicability: "planned", + }, + ], + context_reset: true, + context_reset_state: "planned", + source_disposition: "park", + dispatch: "prompt", + ...overrides, + }; +} + +function makeState(preview: WorkflowMovePreviewResponse | null) { + return { + status: preview ? ("success" as const) : ("loading" as const), + preview, + error: null, + retry: vi.fn(), + }; +} + +describe("WorkflowMovePreviewDisclosure", () => { + it("keeps the compact summary to the recipient and model lines", () => { + render( + , + ); + + expect(screen.getByTestId("workflow-move-preview")).toBeTruthy(); + expect(screen.getByText("Reuse current session")).toBeTruthy(); + expect(screen.getByText("gpt-5.6-astra to gpt-5.6-luna")).toBeTruthy(); + expect(screen.getByText("+2 changes")).toBeTruthy(); + expect(screen.queryByTestId("workflow-move-preview-details")).toBeNull(); + }); + + it("shows the full planned change and recipient details on demand", () => { + render( + , + ); + + fireEvent.click(screen.getByTestId("workflow-move-preview-details-toggle")); + + expect(screen.getByTestId("workflow-move-preview-details")).toBeTruthy(); + expect(screen.getByText("Analysis conversation")).toBeTruthy(); + expect(screen.getByText("Luna")).toBeTruthy(); + expect(screen.getByText(/Reasoning effort/)).toBeTruthy(); + expect(screen.getByText(/Medium/)).toBeTruthy(); + expect(screen.getByText(/Maximum/)).toBeTruthy(); + expect(screen.getByText(/Reset/)).toBeTruthy(); + expect(screen.getByText(/Park/)).toBeTruthy(); + expect(screen.getByText(/Send step prompt/)).toBeTruthy(); + }); + + it("labels a no-session dispatch separately from a suppressed prompt", () => { + render( + , + ); + + fireEvent.click(screen.getByTestId("workflow-move-preview-details-toggle")); + + expect(screen.getByText("Agent not started")).toBeTruthy(); + }); + + it("prioritizes unknown models and identifies retained overrides", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("Model not known")).toBeTruthy(); + expect(screen.queryByText(/gpt-5.6-luna to/)).toBeNull(); + + rerender( + , + ); + expect(screen.getByText(/override retained/)).toBeTruthy(); + }); + + it("keeps move available while loading or retrying an unavailable preview", () => { + const retry = vi.fn(); + const { rerender } = render( + , + ); + expect(screen.getByTestId("workflow-move-preview-loading")).toBeTruthy(); + + rerender( + , + ); + fireEvent.click(screen.getByTestId("workflow-move-preview-retry")); + expect(retry).toHaveBeenCalledOnce(); + expect(screen.getByTestId("workflow-move-preview-retry").className).toContain("min-h-11"); + }); +}); + +describe("WorkflowMovePreviewDisclosure localization", () => { + it("localizes backend field, value, and diagnostic codes", async () => { + await i18n.changeLanguage("pt-pt"); + render( + , + ); + + fireEvent.click(screen.getByTestId("workflow-move-preview-details-toggle")); + + expect(screen.getByText(/Esforço de raciocínio/)).toBeTruthy(); + expect(screen.getByText(/Médio/)).toBeTruthy(); + expect(screen.getByText(/Máximo/)).toBeTruthy(); + expect(screen.getByText("As definições da sessão original estão indisponíveis.")).toBeTruthy(); + expect( + screen.getByText("Mais do que uma regra de definições da sessão corresponde."), + ).toBeTruthy(); + expect(screen.getByText("A definição da sessão foi ignorada para esta conversa.")).toBeTruthy(); + }); +}); + +describe("workflowMovePreviewChangeCount", () => { + it("counts planned and uncertain non-model effects once", () => { + const preview = makePreview({ + changes: [ + { key: "model", label: "Model", before: "A", after: "B", applicability: "planned" }, + { + key: "reasoning", + label: "Reasoning", + before: "low", + after: "high", + applicability: "planned", + }, + { + key: "verbosity", + label: "Verbosity", + before: "low", + after: "high", + applicability: "unknown", + }, + { key: "same", label: "Same", before: "x", after: "x", applicability: "unchanged" }, + { key: "skipped", label: "Skipped", before: "x", after: "y", applicability: "skipped" }, + ], + context_reset: true, + context_reset_state: "planned", + }); + expect(workflowMovePreviewChangeCount(preview)).toBe(3); + }); +}); diff --git a/apps/web/components/task/workflow-move-preview.tsx b/apps/web/components/task/workflow-move-preview.tsx new file mode 100644 index 0000000000..55025e3d26 --- /dev/null +++ b/apps/web/components/task/workflow-move-preview.tsx @@ -0,0 +1,381 @@ +"use client"; + +import { Button } from "@kandev/ui/button"; +import { cn } from "@kandev/ui/lib/utils"; +import { IconInfoCircle, IconLoader2, IconRefresh } from "@tabler/icons-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { WorkflowMovePreviewApplicability, WorkflowMovePreviewResponse } from "@/lib/api"; +import type { WorkflowMovePreviewState } from "@/hooks/domains/kanban/use-workflow-move-preview"; + +type Translation = ReturnType["t"]; + +const UNKNOWN_PREVIEW_LABEL_KEY = "task:workflowMovePreviewUnknown"; + +// i18n-exempt: these are closed backend field and value codes used to select +// localized preview copy, never user-facing text. +const PREVIEW_FIELD_TRANSLATION_KEYS: Record = { + mode: "workflowMovePreviewFieldMode", + step_prompt: "workflowMovePreviewFieldStepPrompt", + reasoning_effort: "workflowMovePreviewFieldReasoningEffort", + verbosity: "workflowMovePreviewFieldVerbosity", +}; + +// i18n-exempt: these are closed backend setting values used to select +// localized preview copy, never user-facing text. +const PREVIEW_VALUE_TRANSLATION_KEYS: Record> = { + step_prompt: { + configured: "workflowMovePreviewValueConfigured", + skipped: "workflowMovePreviewValueSkipped", + }, + mode: { + default: "workflowMovePreviewValueModeDefault", + plan: "workflowMovePreviewValueModePlan", + }, + reasoning_effort: { + low: "workflowMovePreviewValueLow", + medium: "workflowMovePreviewValueMedium", + high: "workflowMovePreviewValueHigh", + max: "workflowMovePreviewValueMax", + }, + verbosity: { + low: "workflowMovePreviewValueLow", + medium: "workflowMovePreviewValueMedium", + high: "workflowMovePreviewValueHigh", + }, +}; + +// i18n-exempt: these are closed backend diagnostic codes used to select +// localized preview copy, never user-facing text. +const PREVIEW_NOTICE_TRANSLATION_KEYS: Record = { + retained_model_override: "workflowMovePreviewNoticeRetainedOverride", + missing_original_snapshot: "workflowMovePreviewNoticeMissingOriginal", + ambiguous_session_configuration: "workflowMovePreviewNoticeAmbiguous", + conflicting_session_configuration: "workflowMovePreviewNoticeConflicting", + session_configuration_skipped: "workflowMovePreviewNoticeInapplicable", + session_configuration_unavailable: "workflowMovePreviewNoticeConfigurationUnavailable", + invalid_session_configuration: "workflowMovePreviewNoticeInvalidConfiguration", + target_unavailable: "workflowMovePreviewNoticeTargetUnavailable", + profile_unavailable: "workflowMovePreviewNoticeProfileUnavailable", +}; + +export type WorkflowMovePreviewDisclosureProps = { + state: WorkflowMovePreviewState; + isTouchSurface: boolean; + className?: string; +}; + +export function workflowMovePreviewChangeCount(preview: WorkflowMovePreviewResponse): number { + const fieldChanges = (preview.changes ?? []).filter( + (change) => + change.key !== "model" && + (change.applicability === "planned" || change.applicability === "unknown"), + ).length; + const contextReset = + preview.context_reset && + (preview.context_reset_state === "planned" || preview.context_reset_state === "unknown"); + return fieldChanges + (contextReset ? 1 : 0); +} + +function outcomeLabel(t: Translation, preview: WorkflowMovePreviewResponse): string { + switch (preview.outcome) { + case "reuse_current": + return t("task:workflowMovePreviewReuseCurrent"); + case "reuse_other": + return preview.recipient?.session_name + ? t("task:workflowMovePreviewReuseOtherNamed", { + session: preview.recipient.session_name, + }) + : t("task:workflowMovePreviewReuseOther"); + case "create_new": + return t("task:workflowMovePreviewNewSession"); + case "no_session": + return t("task:workflowMovePreviewNoSession"); + default: + return t("task:workflowMovePreviewUnknownSession"); + } +} + +function modelSummaryParts(t: Translation, preview: WorkflowMovePreviewResponse) { + const after = preview.model.after; + const before = preview.model.before; + const modelUnknown = !after.known || !after.label; + const changeCount = workflowMovePreviewChangeCount(preview); + if (modelUnknown) { + return { + label: t("task:workflowMovePreviewModelUnknown"), + detail: undefined, + changeCount, + }; + } + + let label: string; + if (before.known && before.label && before.label !== after.label) { + label = t("task:workflowMovePreviewModelChange", { + before: before.label, + after: after.label, + }); + } else if (preview.model.after_source === "override") { + label = t("task:workflowMovePreviewModelRetainedOverride", { model: after.label }); + } else { + label = t("task:workflowMovePreviewModel", { model: after.label }); + } + return { label, changeCount }; +} + +function applicabilityLabel(t: Translation, applicability: WorkflowMovePreviewApplicability) { + switch (applicability) { + case "planned": + return t("task:workflowMovePreviewPlanned"); + case "skipped": + return t("task:workflowMovePreviewSkipped"); + case "unchanged": + return t("task:workflowMovePreviewUnchanged"); + default: + return t(UNKNOWN_PREVIEW_LABEL_KEY); + } +} + +function sourceDispositionLabel( + t: Translation, + value: WorkflowMovePreviewResponse["source_disposition"], +): string { + switch (value) { + case "keep": + return t("task:workflowMovePreviewSourceKeep"); + case "park": + return t("task:workflowMovePreviewSourcePark"); + case "complete": + return t("task:workflowMovePreviewSourceComplete"); + default: + return t(UNKNOWN_PREVIEW_LABEL_KEY); + } +} + +function dispatchLabel(t: Translation, value: WorkflowMovePreviewResponse["dispatch"]): string { + switch (value) { + case "prompt": + return t("task:workflowMovePreviewDispatchPrompt"); + case "no_prompt": + return t("task:workflowMovePreviewDispatchNoPrompt"); + case "deferred": + return t("task:workflowMovePreviewDispatchDeferred"); + case "no_session": + return t("task:workflowMovePreviewDispatchNoSession"); + default: + return t(UNKNOWN_PREVIEW_LABEL_KEY); + } +} + +function noticeLabel(t: Translation, code: string): string { + const translationKey = PREVIEW_NOTICE_TRANSLATION_KEYS[code]; + return t(`task:${translationKey ?? "workflowMovePreviewNoticeUnavailable"}`); +} + +function changeFieldLabel(t: Translation, key: string, providerLabel: string): string { + const translationKey = PREVIEW_FIELD_TRANSLATION_KEYS[key]; + return translationKey + ? t(`task:${translationKey}`) + : providerLabel || t("task:workflowMovePreviewFieldOther"); +} + +function changeValueLabel(t: Translation, key: string, value: string | undefined): string { + if (!value) return t(UNKNOWN_PREVIEW_LABEL_KEY); + const translationKey = PREVIEW_VALUE_TRANSLATION_KEYS[key]?.[value]; + return translationKey ? t(`task:${translationKey}`) : value; +} + +function displayValue(t: Translation, value: string | undefined, known = true): string { + return known && value ? value : t(UNKNOWN_PREVIEW_LABEL_KEY); +} + +function WorkflowMovePreviewDetails({ + preview, + t, +}: { + preview: WorkflowMovePreviewResponse; + t: Translation; +}) { + const recipient = preview.recipient; + return ( +
+
+ {t("task:workflowMovePreviewSession")} + + {displayValue(t, recipient?.session_name)} + +
+
+ {t("task:workflowMovePreviewProfile")} + + {displayValue(t, recipient?.profile_name)} + +
+
+ + {t("task:workflowMovePreviewModelDetails")} + + + {displayValue(t, preview.model.before.label, preview.model.before.known)}{" "} + {t("task:workflowMovePreviewTo")}{" "} + {displayValue(t, preview.model.after.label, preview.model.after.known)} + +
+ {(preview.changes ?? []).length > 0 && ( +
+ + {t("task:workflowMovePreviewChanges")} + + {preview.changes?.map((change) => { + const label = changeFieldLabel(t, change.key, change.label); + return ( +
+ + {label} + + + {changeValueLabel(t, change.key, change.before)} {t("task:workflowMovePreviewTo")}{" "} + {changeValueLabel(t, change.key, change.after)} + ({applicabilityLabel(t, change.applicability)}) + +
+ ); + })} +
+ )} + {preview.context_reset && ( +
+ + {t("task:workflowMovePreviewContext")}:{" "} + + {t("task:workflowMovePreviewContextReset")} ( + {applicabilityLabel(t, preview.context_reset_state)}) +
+ )} +
+ {t("task:workflowMovePreviewSource")}: + {sourceDispositionLabel(t, preview.source_disposition)} +
+
+ + {t("task:workflowMovePreviewDispatch")}:{" "} + + {dispatchLabel(t, preview.dispatch)} +
+ {(preview.notices ?? []).length > 0 && ( +
+ {t("task:workflowMovePreviewNotes")} + {preview.notices?.map((notice, index) => ( + {noticeLabel(t, notice.code)} + ))} +
+ )} + {t("task:workflowMovePreviewExecutionCheck")} +
+ ); +} + +export function WorkflowMovePreviewDisclosure({ + state, + isTouchSurface, + className, +}: WorkflowMovePreviewDisclosureProps) { + const { t } = useTranslation(); + const [detailsOpen, setDetailsOpen] = useState(false); + const retryButtonClass = isTouchSurface ? "min-h-11" : "h-7"; + const detailsButtonClass = isTouchSurface ? "min-h-11 min-w-11 p-0" : "h-7 w-7 p-0"; + + if (state.status === "idle") return null; + if (state.status === "loading") { + return ( +
+ + +
+ ); + } + if (state.status === "error" || !state.preview) { + return ( +
+ {t("task:workflowMovePreviewUnavailable")} + +
+ ); + } + + const preview = state.preview; + const model = modelSummaryParts(t, preview); + const firstLine = outcomeLabel(t, preview); + return ( +
+
+ {firstLine} +
+
+ + {model.label} + {model.changeCount > 0 && ( + + {t("task:workflowMovePreviewAdditionalChanges", { count: model.changeCount })} + + )} + + +
+ {detailsOpen && } +
+ ); +} diff --git a/apps/web/components/task/workflow-move-proceed-button.test.tsx b/apps/web/components/task/workflow-move-proceed-button.test.tsx index ba27293d8f..4c092ffe6d 100644 --- a/apps/web/components/task/workflow-move-proceed-button.test.tsx +++ b/apps/web/components/task/workflow-move-proceed-button.test.tsx @@ -7,6 +7,27 @@ import { WorkflowMoveProceedButton, } from "./workflow-move-proceed-button"; +const previewMock = vi.hoisted(() => + vi.fn().mockResolvedValue({ + outcome: "reuse_current", + model: { + before: { known: true, label: "mock-fast" }, + after: { known: true, label: "mock-fast" }, + }, + changes: [], + context_reset: false, + source_disposition: "keep", + dispatch: "prompt", + }), +); +vi.mock("@/lib/api", async (importOriginal) => ({ + ...(await importOriginal()), + previewWorkflowMove: previewMock, +})); +vi.mock("@/hooks/domains/kanban/use-workflow-move-preview-revision", () => ({ + useWorkflowMovePreviewRevision: () => "revision-1", +})); + const touchMocks = vi.hoisted(() => ({ enabled: false })); vi.mock("@/hooks/use-compact-task-chrome", () => ({ @@ -381,3 +402,41 @@ describe("useWorkflowMoveLongPress", () => { expect(result.current.consumePendingClick()).toBe(false); }); }); + +describe("proceed preview footer", () => { + it.each([false, true])("previews the current options on touch=%s", async (touch) => { + touchMocks.enabled = touch; + previewMock.mockClear(); + render( + + + , + ); + expect(previewMock).not.toHaveBeenCalled(); + if (touch) { + fireEvent.pointerDown(screen.getByTestId(PROCEED_TEST_ID), touchPointer()); + act(() => vi.advanceTimersByTime(WORKFLOW_MOVE_LONG_PRESS_MS)); + fireEvent.pointerUp(screen.getByTestId(PROCEED_TEST_ID), touchPointer()); + fireEvent.click(screen.getByTestId(PROCEED_TEST_ID)); + } else openHoverForm(); + await act(() => vi.advanceTimersByTimeAsync(200)); + expect(screen.getByTestId("workflow-move-preview").textContent).toContain("mock-fast"); + fireEvent.click(screen.getByTestId("workflow-move-skip-step-prompt")); + await act(() => vi.advanceTimersByTimeAsync(200)); + expect(previewMock).toHaveBeenLastCalledWith( + "task-1", + { + workflow_id: "workflow-1", + workflow_step_id: "review", + entry_options: { skip_step_prompt: true }, + }, + expect.anything(), + ); + }); +}); diff --git a/apps/web/components/task/workflow-move-proceed-button.tsx b/apps/web/components/task/workflow-move-proceed-button.tsx index abece23674..359021888b 100644 --- a/apps/web/components/task/workflow-move-proceed-button.tsx +++ b/apps/web/components/task/workflow-move-proceed-button.tsx @@ -21,6 +21,8 @@ import { useHoverPopover } from "@/components/integrations/use-hover-popover"; import { WorkflowMoveOptions, WorkflowMoveOptionsForm } from "./workflow-move-options"; import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api"; +import type { WorkflowMovePreviewTarget } from "./workflow-move-preview-footer"; + export const WORKFLOW_MOVE_LONG_PRESS_MS = 450; export const WORKFLOW_MOVE_LONG_PRESS_SLOP_PX = 10; @@ -121,6 +123,7 @@ export function useWorkflowMoveLongPress(onLongPress: () => void): { } type ProceedSurfaceCommon = { + previewTarget?: WorkflowMovePreviewTarget; nextStepName: string; isMoving: boolean; className?: string; @@ -137,6 +140,7 @@ type ProceedSurfaceCommon = { * can retarget onto the Drawer submit button. */ function CoarseProceedSurface({ + previewTarget, nextStepName, isMoving, className, @@ -171,6 +175,7 @@ function CoarseProceedSurface({ boolean | void | Promise; isMoving: boolean; @@ -316,6 +324,7 @@ type WorkflowMoveProceedButtonProps = { * Drawer. A failed move keeps the form and its draft open. */ export function WorkflowMoveProceedButton({ + previewTarget, nextStepName, onProceed, isMoving, @@ -382,6 +391,7 @@ export function WorkflowMoveProceedButton({ if (usesTouchDrawer) { return ( void; onMove: (stepId: string, entryOptions?: WorkflowMoveEntryOptions) => Promise; }; @@ -25,6 +26,7 @@ export function StepDisclosureRowActions({ showOptions, buttonSizeClass, draft, + entryOptions, onToggleOptions, onMove, }: StepDisclosureRowActionsProps) { @@ -55,7 +57,7 @@ export function StepDisclosureRowActions({ variant="default" className={cn("shrink-0 cursor-pointer rounded-sm px-2.5 text-xs", buttonSizeClass)} disabled={movePending} - onClick={() => void onMove(stepId, workflowMoveOptionsPayload(draft))} + onClick={() => void onMove(stepId, entryOptions ?? workflowMoveOptionsPayload(draft))} > {isMoving ? t("task:moving") : t("task:moveHere")} diff --git a/apps/web/components/task/workflow-step-disclosure-move-controls.tsx b/apps/web/components/task/workflow-step-disclosure-move-controls.tsx new file mode 100644 index 0000000000..d9ee5acf6b --- /dev/null +++ b/apps/web/components/task/workflow-step-disclosure-move-controls.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useState } from "react"; +import { StepDisclosureRowActions } from "./workflow-step-disclosure-actions"; +import { + WorkflowMoveOptionsFields, + useWorkflowMoveOptionsForm, + workflowMoveOptionsPayload, +} from "./workflow-move-options"; +import { WorkflowMovePreviewDisclosure } from "./workflow-move-preview"; +import { useWorkflowMovePreview } from "@/hooks/domains/kanban/use-workflow-move-preview"; +import { useWorkflowMovePreviewRevision } from "@/hooks/domains/kanban/use-workflow-move-preview-revision"; +import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api"; + +type StepDisclosureMoveControlsProps = { + stepId: string; + taskId: string; + workflowId: string; + isMoving: boolean; + movePending: boolean; + isTouchSurface: boolean; + previewEnabled: boolean; + onMove: (stepId: string, entryOptions?: WorkflowMoveEntryOptions) => Promise; +}; + +export function StepDisclosureMoveControls({ + stepId, + taskId, + workflowId, + isMoving, + movePending, + isTouchSurface, + previewEnabled, + onMove, +}: StepDisclosureMoveControlsProps) { + const [showOptions, setShowOptions] = useState(false); + const { draft, patchDraft } = useWorkflowMoveOptionsForm(); + const entryOptions = workflowMoveOptionsPayload(draft); + const invalidationKey = useWorkflowMovePreviewRevision(taskId, workflowId, stepId); + const previewState = useWorkflowMovePreview({ + taskId, + workflowId, + workflowStepId: stepId, + entryOptions, + enabled: previewEnabled, + invalidationKey, + }); + const buttonSizeClass = isTouchSurface ? "h-11" : "h-7 [@media(pointer:coarse)]:h-11"; + + return ( + <> +
+ setShowOptions((value) => !value)} + onMove={onMove} + /> +
+ {showOptions && ( +
event.stopPropagation()} + data-testid={`workflow-step-disclosure-options-panel-${stepId}`} + > + +
+ )} + + + ); +} diff --git a/apps/web/components/task/workflow-step-disclosure.tsx b/apps/web/components/task/workflow-step-disclosure.tsx index 986dacb356..5f89982f82 100644 --- a/apps/web/components/task/workflow-step-disclosure.tsx +++ b/apps/web/components/task/workflow-step-disclosure.tsx @@ -1,12 +1,6 @@ "use client"; -import { - forwardRef, - useEffect, - useState, - type ComponentPropsWithoutRef, - type ReactNode, -} from "react"; +import { forwardRef, useEffect, type ComponentPropsWithoutRef, type ReactNode } from "react"; import { cn } from "@kandev/ui/lib/utils"; import { Drawer, @@ -22,7 +16,6 @@ import { StepCapabilityIcons } from "@/components/step-capability-icons"; import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import type { KanbanStepEvents } from "@/lib/state/slices/kanban/types"; import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api"; -import { WorkflowMoveOptionsFields, useWorkflowMoveOptionsForm } from "./workflow-move-options"; import { useCompactWorkflowDisclosure, type CompactWorkflowDisclosureControls, @@ -33,8 +26,8 @@ import { workflowStepProgressTranslationKey, } from "./workflow-step-progress-details"; import { StepCircleIndicator } from "./workflow-step-marker"; -import { StepDisclosureRowActions } from "./workflow-step-disclosure-actions"; import { useTranslation } from "react-i18next"; +import { StepDisclosureMoveControls } from "./workflow-step-disclosure-move-controls"; /** Move callback shared by every compact-disclosure surface. A revealed, * filled options draft rides along as one-shot `entry_options`. */ @@ -454,6 +447,9 @@ function StepDisclosureBody({ isMoving={movingToStepId === step.id} movePending={movingToStepId !== null} isTouchSurface={isTouchSurface} + taskId={taskId} + workflowId={workflowId} + previewEnabled={canMove} progress={progressByStepId[step.id]} agentLabelsByProfileId={agentLabelsByProfileId} onMove={onMove} @@ -480,6 +476,9 @@ function StepDisclosureRow({ isMoving, movePending, isTouchSurface, + taskId, + workflowId, + previewEnabled, progress, agentLabelsByProfileId, onMove, @@ -491,14 +490,14 @@ function StepDisclosureRow({ isMoving: boolean; movePending: boolean; isTouchSurface: boolean; + taskId: string; + workflowId: string; + previewEnabled: boolean; progress?: WorkflowStepProgress; agentLabelsByProfileId: Readonly>; onMove: DisclosureMove; }) { const { t } = useTranslation(); - const [showOptions, setShowOptions] = useState(false); - const { draft, patchDraft } = useWorkflowMoveOptionsForm(); - const buttonSizeClass = isTouchSurface ? "h-11" : "h-7 [@media(pointer:coarse)]:h-11"; return (
{t("task:currentStep")} - ) : ( - canMove && ( - setShowOptions((value) => !value)} - onMove={onMove} - /> - ) - )} + ) : null}
+ {canMove && !isCurrent && ( + + )} {progress && ( )} - {canMove && !isCurrent && showOptions && ( -
event.stopPropagation()} - data-testid={`workflow-step-disclosure-options-panel-${step.id}`} - > - -
- )} ); } diff --git a/apps/web/components/task/workflow-stepper-keyboard.test.tsx b/apps/web/components/task/workflow-stepper-keyboard.test.tsx index 6a0ad87e15..d1bd814373 100644 --- a/apps/web/components/task/workflow-stepper-keyboard.test.tsx +++ b/apps/web/components/task/workflow-stepper-keyboard.test.tsx @@ -1,11 +1,15 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { moveTask } from "@/lib/api"; import { WorkflowStepper, type WorkflowStepperStep } from "./workflow-stepper"; -const { appStoreState, moveTaskMock } = vi.hoisted(() => ({ +const { appStoreState, moveTaskMock, previewWorkflowMoveMock } = vi.hoisted(() => ({ moveTaskMock: vi.fn(), + previewWorkflowMoveMock: vi.fn().mockResolvedValue(undefined), appStoreState: { + connection: { status: "connected", error: null, issueSeverity: "none" }, + workspaceContextGeneration: 1, + workflows: { items: [], activeId: null }, tasks: { activeSessionId: null }, chatInput: { planModeBySessionId: {} }, kanban: { @@ -19,14 +23,17 @@ const { appStoreState, moveTaskMock } = vi.hoisted(() => ({ }, kanbanMulti: { snapshots: {} }, taskSessions: { items: {} }, - taskSessionsByTask: { itemsByTaskId: {} }, + taskSessionsByTask: { itemsByTaskId: {}, loadedByTaskId: {} }, agentProfiles: { items: [] }, setPlanMode: vi.fn(), setActiveDocument: vi.fn(), }, })); -vi.mock("@/lib/api", () => ({ moveTask: moveTaskMock })); +vi.mock("@/lib/api", () => ({ + moveTask: moveTaskMock, + previewWorkflowMove: previewWorkflowMoveMock, +})); vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: typeof appStoreState) => unknown) => selector(appStoreState), @@ -53,6 +60,10 @@ vi.mock("./workflow-move-options", () => ({ workflowMoveOptionsPayload: () => undefined, })); +const POPOVER_TEST_ID = "workflow-step-popover"; +const REVIEW_TRIGGER_TEST_ID = "workflow-step-Review"; +const EXPANDED_ATTRIBUTE = "aria-expanded"; + const STEPS: WorkflowStepperStep[] = [ { id: "spec", name: "Spec", color: "#111", position: 0 }, { id: "work", name: "Work", color: "#222", position: 1 }, @@ -82,7 +93,7 @@ describe("WorkflowStepper full-layout keyboard disclosure", () => { await waitFor( () => { - expect(screen.getByTestId("workflow-step-popover")).toBeTruthy(); + expect(screen.getByTestId(POPOVER_TEST_ID)).toBeTruthy(); expect(screen.getByTestId("workflow-step-progress-work").textContent).toContain( "Preparing agent", ); @@ -100,7 +111,7 @@ describe("WorkflowStepper full-layout keyboard disclosure", () => { workflowId="workflow-1" />, ); - const destinationTrigger = screen.getByTestId("workflow-step-Review"); + const destinationTrigger = screen.getByTestId(REVIEW_TRIGGER_TEST_ID); destinationTrigger.focus(); await waitFor(() => { expect(destinationTrigger.getAttribute("aria-current")).toBe("step"); @@ -121,7 +132,7 @@ describe("WorkflowStepper full-layout keyboard disclosure", () => { />, ); - const destinationTrigger = screen.getByTestId("workflow-step-Review"); + const destinationTrigger = screen.getByTestId(REVIEW_TRIGGER_TEST_ID); destinationTrigger.focus(); const moveButton = await screen.findByTestId("workflow-step-move-here"); @@ -140,3 +151,101 @@ describe("WorkflowStepper full-layout keyboard disclosure", () => { ); }); }); + +describe("WorkflowStepper exclusive hover", () => { + it("replaces a focused step hover when the pointer enters another step", async () => { + vi.useFakeTimers(); + try { + render( + , + ); + const work = screen.getByTestId("workflow-step-Work"); + const review = screen.getByTestId(REVIEW_TRIGGER_TEST_ID); + act(() => work.focus()); + await act(() => vi.advanceTimersByTimeAsync(200)); + expect(work.getAttribute(EXPANDED_ATTRIBUTE)).toBe("true"); + + fireEvent.pointerEnter(review); + await act(() => vi.advanceTimersByTimeAsync(200)); + expect(review.getAttribute(EXPANDED_ATTRIBUTE)).toBe("true"); + expect(screen.getAllByTestId(POPOVER_TEST_ID)).toHaveLength(1); + expect(work.getAttribute(EXPANDED_ATTRIBUTE)).toBe("false"); + + // The old close timer and focus restoration must not reclaim the disclosure. + fireEvent.pointerLeave(review); + fireEvent.pointerEnter(work); + await act(() => vi.advanceTimersByTimeAsync(200)); + expect(work.getAttribute(EXPANDED_ATTRIBUTE)).toBe("true"); + expect(review.getAttribute(EXPANDED_ATTRIBUTE)).toBe("false"); + expect(screen.getAllByTestId(POPOVER_TEST_ID)).toHaveLength(1); + } finally { + cleanup(); + vi.useRealTimers(); + } + }); +}); + +describe("WorkflowStepper hover dismissal", () => { + it.each([false, true])( + "stays closed after pointer exit (entered content: %s)", + async (enterContent) => { + vi.useFakeTimers(); + try { + render( + , + ); + const trigger = screen.getByTestId("workflow-step-Work"); + fireEvent.pointerEnter(trigger); + await act(() => vi.advanceTimersByTimeAsync(200)); + const popover = screen.getByTestId(POPOVER_TEST_ID); + if (enterContent) fireEvent.pointerEnter(popover); + fireEvent.pointerLeave(trigger); + if (enterContent) fireEvent.pointerLeave(popover); + await act(() => vi.advanceTimersByTimeAsync(100)); + expect(screen.queryByTestId(POPOVER_TEST_ID)).toBeNull(); + await act(() => vi.advanceTimersByTimeAsync(500)); + expect(screen.queryByTestId(POPOVER_TEST_ID)).toBeNull(); + expect(document.activeElement).not.toBe(trigger); + } finally { + cleanup(); + vi.useRealTimers(); + } + }, + ); + + it("returns keyboard focus on Escape without reopening", async () => { + vi.useFakeTimers(); + try { + render( + , + ); + const trigger = screen.getByTestId(REVIEW_TRIGGER_TEST_ID); + act(() => trigger.focus()); + await act(() => vi.advanceTimersByTimeAsync(200)); + const move = screen.getByTestId("workflow-step-move-here"); + act(() => move.focus()); + fireEvent.keyDown(move, { key: "Escape" }); + await act(() => vi.advanceTimersByTimeAsync(500)); + expect(screen.queryByTestId(POPOVER_TEST_ID)).toBeNull(); + expect(document.activeElement).toBe(trigger); + } finally { + cleanup(); + vi.useRealTimers(); + } + }); +}); diff --git a/apps/web/components/task/workflow-stepper.test.tsx b/apps/web/components/task/workflow-stepper.test.tsx index ae64f6fc6b..087a8f7d8f 100644 --- a/apps/web/components/task/workflow-stepper.test.tsx +++ b/apps/web/components/task/workflow-stepper.test.tsx @@ -4,15 +4,19 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { moveTask } from "@/lib/api"; import { WorkflowStepper, type WorkflowStepperStep } from "./workflow-stepper"; -const { moveTaskMock, appStoreState } = vi.hoisted(() => ({ +const { moveTaskMock, previewWorkflowMoveMock, appStoreState } = vi.hoisted(() => ({ moveTaskMock: vi.fn(), + previewWorkflowMoveMock: vi.fn().mockResolvedValue(undefined), appStoreState: { + connection: { status: "connected", error: null, issueSeverity: "none" }, + workspaceContextGeneration: 1, + workflows: { items: [], activeId: null }, tasks: { activeSessionId: null }, chatInput: { planModeBySessionId: {} }, kanban: { tasks: [] }, kanbanMulti: { snapshots: {} }, taskSessions: { items: {} }, - taskSessionsByTask: { itemsByTaskId: {} }, + taskSessionsByTask: { itemsByTaskId: {}, loadedByTaskId: {} }, agentProfiles: { items: [] }, setPlanMode: vi.fn(), setActiveDocument: vi.fn(), @@ -26,6 +30,7 @@ function Passthrough({ children }: { children: ReactNode }) { vi.mock("@/lib/api", () => ({ moveTask: moveTaskMock, + previewWorkflowMove: previewWorkflowMoveMock, })); vi.mock("@kandev/ui/hover-card", () => ({ @@ -147,6 +152,10 @@ const DISCLOSURE_STEPS: WorkflowStepperStep[] = [ ...STEPS, { id: "d", name: "Done", color: "#444", position: 3, allow_manual_move: false }, ]; +const LATER_MOVABLE_DISCLOSURE_STEPS: WorkflowStepperStep[] = [ + ...DISCLOSURE_STEPS, + { id: "e", name: "Later", color: "#555", position: 4, allow_manual_move: true }, +]; const TASK_ID = "task-1"; const WORKFLOW_ID = "workflow-1"; const DISCLOSURE_TEST_ID = "workflow-step-disclosure"; @@ -450,6 +459,31 @@ describe("WorkflowStepper compact disclosure options", () => { }); }); +describe("WorkflowStepper compact disclosure preview queue", () => { + it("requests a later movable row after the first two destinations", async () => { + collapsedMock.mockReturnValue(true); + render( + , + ); + + fireEvent.mouseEnter(screen.getByRole("button", { name: /Step 2 of 5/ })); + const disclosure = screen.getByTestId(DISCLOSURE_TEST_ID); + expect(screen.getByTestId("workflow-step-disclosure-row-e")).toBeTruthy(); + fireEvent.scroll(disclosure); + + await waitFor(() => { + expect( + previewWorkflowMoveMock.mock.calls.some(([, payload]) => payload?.workflow_step_id === "e"), + ).toBe(true); + }); + }); +}); + describe("WorkflowStepper fallback states", () => { it("falls back to the first step when collapsed with no current step", () => { collapsedMock.mockReturnValue(true); diff --git a/apps/web/components/task/workflow-stepper.tsx b/apps/web/components/task/workflow-stepper.tsx index e082b3acbe..13f6134ab8 100644 --- a/apps/web/components/task/workflow-stepper.tsx +++ b/apps/web/components/task/workflow-stepper.tsx @@ -1,6 +1,15 @@ "use client"; -import { memo, useMemo, useRef, useState } from "react"; +import { + useCallback, + type Dispatch, + type ReactNode, + type SetStateAction, + memo, + useMemo, + useRef, + useState, +} from "react"; import { cn } from "@kandev/ui/lib/utils"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Button } from "@kandev/ui/button"; @@ -17,6 +26,8 @@ import { usePresentationToken, useWorkflowStepMove, } from "@/hooks/domains/kanban/use-workflow-step-move"; +import { useWorkflowMovePreview } from "@/hooks/domains/kanban/use-workflow-move-preview"; +import { useWorkflowMovePreviewRevision } from "@/hooks/domains/kanban/use-workflow-move-preview-revision"; import { useWorkflowStepProgress, type WorkflowStepProgress, @@ -35,6 +46,7 @@ import { } from "./workflow-step-progress-details"; import { StepCircleIndicator } from "./workflow-step-marker"; import { useHoverPopover } from "@/components/integrations/use-hover-popover"; +import { WorkflowMovePreviewDisclosure } from "./workflow-move-preview"; type Step = WorkflowStepperStep; @@ -80,6 +92,7 @@ const WorkflowStepper = memo(function WorkflowStepper({ movingToStepId: progressingToStepId ?? movingToStepId, }); + const [openStepId, setOpenStepId] = useState(null); const sortedSteps = useMemo(() => sortWorkflowStepsByPosition(steps), [steps]); const currentIndex = useMemo( @@ -118,6 +131,8 @@ const WorkflowStepper = memo(function WorkflowStepper({ >, +) { + const onHoverOpenChange = useCallback( + (open: boolean) => { + setOpenStepId((current) => { + if (open) return stepId; + return current === stepId ? null : current; + }); + }, + [setOpenStepId, stepId], + ); + return useHoverPopover({ + openDelayMs: 200, + closeDelayMs: 100, + open: openStepId === stepId, + onOpenChange: onHoverOpenChange, + }); +} + /** Individual step in the workflow stepper */ function WorkflowStepItem({ step, + openStepId, + setOpenStepId, index, currentIndex, isArchived, @@ -164,6 +203,8 @@ function WorkflowStepItem({ onMove, }: { step: Step; + openStepId: string | null; + setOpenStepId: Dispatch>; index: number; currentIndex: number; isArchived?: boolean; @@ -175,6 +216,7 @@ function WorkflowStepItem({ agentLabelsByProfileId: Readonly>; onMove: (stepId: string, entryOptions?: WorkflowMoveEntryOptions) => Promise; }) { + const triggerRef = useRef(null); const isCompleted = !isArchived && currentIndex >= 0 && index < currentIndex; const isCurrent = !isArchived && index === currentIndex; const isAdjacent = @@ -187,7 +229,7 @@ function WorkflowStepItem({ isAdjacent, allowManualMove: step.allow_manual_move, }); - const hover = useHoverPopover({ openDelayMs: 200, closeDelayMs: 100 }); + const hover = useStepHover(step.id, openStepId, setOpenStepId); return (
@@ -201,6 +243,7 @@ function WorkflowStepItem({
@@ -258,41 +304,29 @@ function StepHoverContent({ isCurrent, canMove, isMoving, + taskId, + workflowId, progress, agentLabelsByProfileId, onMove, hover, + onReturnFocus, }: { step: Step; isCurrent: boolean; canMove: boolean; isMoving: boolean; + taskId?: string | null; + workflowId?: string | null; progress?: WorkflowStepProgress; agentLabelsByProfileId: Readonly>; onMove: (stepId: string, entryOptions?: WorkflowMoveEntryOptions) => Promise; hover: ReturnType; + onReturnFocus: () => void; }) { const { t } = useTranslation(); - return ( - event.preventDefault()} - > - {canMove && } - {isCurrent && ( -
{t("task:currentStep")}
- )} + const stepDetails = ( +
{progress && ( )} +
+ ); + return ( + event.preventDefault()} + // Restoring focus after pointer exit would trigger another hover open. + onCloseAutoFocus={(event) => event.preventDefault()} + onEscapeKeyDown={onReturnFocus} + > + {canMove && ( + + )} + {isCurrent && ( +
{t("task:currentStep")}
+ )} + {!canMove && stepDetails}
); } @@ -314,17 +384,35 @@ function StepHoverContent({ * hook subscribes lazily and resets whenever the pointer leaves the step. */ function StepMoveControls({ + children, step, + taskId, + workflowId, + previewEnabled, isMoving, onMove, }: { step: Step; + taskId?: string | null; + workflowId?: string | null; + previewEnabled: boolean; + children: ReactNode; isMoving: boolean; onMove: (stepId: string, entryOptions?: WorkflowMoveEntryOptions) => Promise; }) { const { t } = useTranslation(); const [showOptions, setShowOptions] = useState(false); const { draft, patchDraft } = useWorkflowMoveOptionsForm(); + const entryOptions = workflowMoveOptionsPayload(draft); + const invalidationKey = useWorkflowMovePreviewRevision(taskId, workflowId, step.id); + const previewState = useWorkflowMovePreview({ + taskId, + workflowId, + workflowStepId: step.id, + entryOptions, + enabled: previewEnabled, + invalidationKey, + }); return (
@@ -333,7 +421,7 @@ function StepMoveControls({ variant="default" className="cursor-pointer text-xs h-6 px-2.5 rounded-sm" disabled={isMoving} - onClick={() => void onMove(step.id, workflowMoveOptionsPayload(draft))} + onClick={() => void onMove(step.id, entryOptions)} data-testid="workflow-step-move-here" > @@ -363,6 +451,8 @@ function StepMoveControls({ {t("task:workflowMoveOptions")} )} + {children} +
); } diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-move-preview.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-move-preview.spec.ts new file mode 100644 index 0000000000..c1d0e0e82b --- /dev/null +++ b/apps/web/e2e/tests/workflow/mobile-workflow-move-preview.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { waitForLatestSessionDone } from "../../helpers/session"; +import { KanbanPage } from "../../pages/kanban-page"; + +test.describe("mobile: workflow move preview", () => { + test("shows the same prediction and details inside the touch drawer", async ({ + tabletTestPage, + apiClient, + seedData, + }) => { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile Move Preview Task", + seedData.agentProfileId, + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await waitForLatestSessionDone( + apiClient, + task.id, + 1, + "Mobile move preview task must finish before navigation", + 30_000, + ); + const sortedSteps = [...seedData.steps].sort((left, right) => left.position - right.position); + const currentIndex = sortedSteps.findIndex((step) => step.id === seedData.startStepId); + const targetStep = sortedSteps[currentIndex + 1] ?? sortedSteps[currentIndex - 1]; + if (!targetStep) throw new Error("mobile move preview requires an adjacent target"); + + const kanban = new KanbanPage(tabletTestPage); + await kanban.goto(); + await apiClient.saveUserSettings({ enable_preview_on_click: true }); + await kanban.goto(); + const card = kanban.taskCardByTitle("Mobile Move Preview Task"); + await expect(card).toBeVisible({ timeout: 10_000 }); + await card.tap(); + + const previewPanel = tabletTestPage.getByTestId("task-preview-panel"); + await expect(previewPanel).toBeVisible({ timeout: 10_000 }); + const trigger = previewPanel.getByTestId("workflow-stepper-minimal"); + await expect(trigger).toBeVisible({ timeout: 15_000 }); + await trigger.tap(); + + const drawer = tabletTestPage.locator('[data-slot="drawer-content"][data-state="open"]'); + await expect(drawer).toBeVisible(); + const row = drawer.getByTestId(`workflow-step-disclosure-row-${targetStep.id}`); + await expect(row).toBeVisible(); + const preview = row.getByTestId("workflow-move-preview"); + await expect(preview).toBeVisible({ timeout: 15_000 }); + await expect(preview).toContainText("Reuse current session"); + await expect(preview).toContainText("mock-fast"); + + const detailsToggle = preview.getByTestId("workflow-move-preview-details-toggle"); + const toggleBox = await detailsToggle.boundingBox(); + expect(toggleBox).not.toBeNull(); + if (!toggleBox) return; + expect(toggleBox.height).toBeGreaterThanOrEqual(44); + await detailsToggle.tap(); + await expect(row.getByTestId("workflow-move-preview-details")).toBeVisible(); + + for (const testId of [ + "workflow-step-disclosure-options-" + targetStep.id, + "workflow-step-disclosure-move-" + targetStep.id, + ]) { + const box = await drawer.getByTestId(testId).boundingBox(); + expect(box).not.toBeNull(); + if (!box) return; + expect(box.height).toBeGreaterThanOrEqual(44); + } + await assertNoDocumentHorizontalOverflow(tabletTestPage); + + await tabletTestPage.keyboard.press("Escape"); + await expect( + tabletTestPage.locator('[data-slot="drawer-content"][data-state="open"]'), + ).toHaveCount(0); + await expect(trigger).toBeFocused(); + }); +}); diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-step-move-overrides.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-step-move-overrides.spec.ts index edb1b472af..57c8bb810a 100644 --- a/apps/web/e2e/tests/workflow/mobile-workflow-step-move-overrides.spec.ts +++ b/apps/web/e2e/tests/workflow/mobile-workflow-step-move-overrides.spec.ts @@ -1,3 +1,4 @@ +import { expectPreviewFooter } from "./workflow-move-preview-assertions"; import { expect, test } from "../../fixtures/test-base"; import type { Locator, Page } from "@playwright/test"; import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; @@ -101,6 +102,11 @@ test("long-presses the existing mobile next-step button for move options", async await longPress(testPage, nextStepButton); await expectMoveOptionsDrawer(testPage); + await expectPreviewFooter( + testPage.getByTestId("workflow-move-options"), + "workflow-move-submit", + true, + ); await fillMoveOverrides(testPage); const moveRequest = waitForMoveRequest(testPage, fixture.taskId); await testPage.getByTestId("workflow-move-submit").tap(); diff --git a/apps/web/e2e/tests/workflow/workflow-move-preview-assertions.ts b/apps/web/e2e/tests/workflow/workflow-move-preview-assertions.ts new file mode 100644 index 0000000000..056d439507 --- /dev/null +++ b/apps/web/e2e/tests/workflow/workflow-move-preview-assertions.ts @@ -0,0 +1,22 @@ +import { expect, type Locator } from "@playwright/test"; + +export async function expectPreviewFooter(surface: Locator, moveTestId: string, touch = false) { + const preview = surface.getByTestId("workflow-move-preview"); + await expect(preview).toBeVisible(); + await expect(preview).toContainText("Reuse current session"); + const moveBox = await surface.getByTestId(moveTestId).boundingBox(); + const previewBox = await preview.boundingBox(); + expect(moveBox).not.toBeNull(); + expect(previewBox).not.toBeNull(); + expect(previewBox!.y).toBeGreaterThanOrEqual(moveBox!.y + moveBox!.height); + await expect(preview).toHaveCSS("text-align", "center"); + const info = preview.getByTestId("workflow-move-preview-details-toggle"); + await expect(info).toHaveText(""); + if (touch) { + const box = await info.boundingBox(); + expect(box!.height).toBeGreaterThanOrEqual(44); + expect(box!.width).toBeGreaterThanOrEqual(44); + await info.tap(); + } else await info.click(); + await expect(preview.getByTestId("workflow-move-preview-details")).toBeVisible(); +} diff --git a/apps/web/e2e/tests/workflow/workflow-move-preview.spec.ts b/apps/web/e2e/tests/workflow/workflow-move-preview.spec.ts new file mode 100644 index 0000000000..8770022264 --- /dev/null +++ b/apps/web/e2e/tests/workflow/workflow-move-preview.spec.ts @@ -0,0 +1,144 @@ +import { expectPreviewFooter } from "./workflow-move-preview-assertions"; +import type { Page } from "@playwright/test"; +import { expect, test } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import { + createWorkflowAgentProfiles, + waitForWorkflowProfileSession, +} from "./workflow-agent-switch-helpers"; +import { seedMoveOverrideFixture } from "./workflow-step-move-overrides-helpers"; + +function waitForPreviewResponse(page: Page, taskId: string) { + return page.waitForResponse( + (response) => + response.request().method() === "POST" && + new URL(response.url()).pathname === `/api/v1/tasks/${taskId}/move-preview` && + response.ok(), + ); +} + +async function openStepPreview(page: Page, taskId: string, stepName: string) { + const response = waitForPreviewResponse(page, taskId); + await page.getByTestId(`workflow-step-${stepName}`).hover(); + const popover = page.locator('[data-testid="workflow-step-popover"]:visible'); + await expect(popover).toBeVisible(); + await expect(popover.getByTestId("workflow-move-preview")).toBeVisible({ timeout: 15_000 }); + return { popover, preview: await response.then((value) => value.json()) }; +} + +test.describe("Workflow move preview", () => { + test("shows a retained session model override before an in-place move", async ({ + testPage, + apiClient, + seedData, + }) => { + const fixture = await seedMoveOverrideFixture( + testPage, + apiClient, + seedData, + "Desktop Move Preview Override", + ); + await apiClient.setSessionModel(fixture.primarySessionId, "mock-slow"); + await testPage.reload(); + await fixture.session.waitForLoad(); + + const { popover, preview } = await openStepPreview(testPage, fixture.taskId, "Verify"); + await expectPreviewFooter(popover, "workflow-step-move-here"); + expect(preview.outcome).toBe("reuse_current"); + expect(preview.recipient.session_id).toBe(fixture.primarySessionId); + expect(preview.model.after.label).toBe("mock-slow"); + expect(preview.model.after_source).toBe("override"); + await expect(popover.getByTestId("workflow-move-preview")).toContainText("override retained"); + + const moveRequest = testPage.waitForRequest( + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === `/api/v1/tasks/${fixture.taskId}/move`, + ); + await popover.getByTestId("workflow-step-move-here").click(); + expect((await moveRequest).postDataJSON()).toMatchObject({ + workflow_step_id: fixture.targetStepId, + }); + await expect + .poll(() => apiClient.getTask(fixture.taskId).then((task) => task.primary_session_id)) + .toBe(fixture.primarySessionId); + }); + + test("matches other-session reuse and fresh-session creation", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const { agentId, profileA, profileB } = await createWorkflowAgentProfiles(apiClient); + const profileC = await apiClient.createAgentProfile(agentId, "Profile C (new)", { + model: "mock-fast", + }); + const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Move Preview Routing"); + const source = await apiClient.createWorkflowStep(workflow.id, "Source", 0, { + is_start_step: true, + agent_profile_id: profileA.id, + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + const bridge = await apiClient.createWorkflowStep(workflow.id, "Bridge", 1, { + agent_profile_id: profileB.id, + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + await apiClient.createWorkflowStep(workflow.id, "Return A", 2, { + agent_profile_id: profileA.id, + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + await apiClient.createWorkflowStep(workflow.id, "Fresh C", 3, { + agent_profile_id: profileC.id, + profile_session_start_policy: "new", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Move Preview Routing Task", + profileA.id, + { + workflow_id: workflow.id, + workflow_step_id: source.id, + repository_ids: [seedData.repositoryId], + }, + ); + const initialSessionId = await waitForWorkflowProfileSession(apiClient, task.id, profileA.id); + await apiClient.moveTask(task.id, workflow.id, bridge.id); + await waitForWorkflowProfileSession(apiClient, task.id, profileB.id); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + const returnPreview = await openStepPreview(testPage, task.id, "Return A"); + expect(returnPreview.preview.outcome).toBe("reuse_other"); + expect(returnPreview.preview.recipient.session_id).toBe(initialSessionId); + expect(returnPreview.preview.model.after.known).toBe(true); + const returnMove = testPage.waitForRequest( + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === `/api/v1/tasks/${task.id}/move`, + ); + await returnPreview.popover.getByTestId("workflow-step-move-here").click(); + await returnMove; + await expect + .poll(() => apiClient.getTask(task.id).then((current) => current.primary_session_id)) + .toBe(initialSessionId); + + const freshPreview = await openStepPreview(testPage, task.id, "Fresh C"); + expect(freshPreview.preview.outcome).toBe("create_new"); + expect(freshPreview.preview.recipient.session_id ?? "").toBe(""); + expect(freshPreview.preview.model.after.known).toBe(true); + const freshMove = testPage.waitForRequest( + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === `/api/v1/tasks/${task.id}/move`, + ); + await freshPreview.popover.getByTestId("workflow-step-move-here").click(); + await freshMove; + const freshSessionId = await waitForWorkflowProfileSession(apiClient, task.id, profileC.id); + expect(freshSessionId).not.toBe(initialSessionId); + }); +}); diff --git a/apps/web/e2e/tests/workflow/workflow-step-move-overrides.spec.ts b/apps/web/e2e/tests/workflow/workflow-step-move-overrides.spec.ts index 049c69c5bd..226eb66428 100644 --- a/apps/web/e2e/tests/workflow/workflow-step-move-overrides.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-step-move-overrides.spec.ts @@ -1,3 +1,4 @@ +import { expectPreviewFooter } from "./workflow-move-preview-assertions"; import { expect, test } from "../../fixtures/test-base"; import { dwell } from "../../helpers/causal-waits"; import { @@ -130,6 +131,10 @@ test("uses the desktop next-step anchored form for the same one-shot move contra await expect(nextStepButton).toBeVisible(); await nextStepButton.hover(); await expect(testPage.getByTestId("proceed-next-step-options")).toBeVisible(); + await expectPreviewFooter( + testPage.getByTestId("proceed-next-step-options"), + "workflow-move-submit", + ); await fillMoveOverrides(testPage); const moveRequest = waitForMoveRequest(testPage, fixture.taskId); await testPage.getByTestId("workflow-move-submit").click(); diff --git a/apps/web/hooks/domains/kanban/use-plan-actions.test.ts b/apps/web/hooks/domains/kanban/use-plan-actions.test.ts index f1af8d9056..31998dc97f 100644 --- a/apps/web/hooks/domains/kanban/use-plan-actions.test.ts +++ b/apps/web/hooks/domains/kanban/use-plan-actions.test.ts @@ -217,6 +217,11 @@ describe("useNextWorkflowStep visibility", () => { const { result } = renderHook(() => useNextWorkflowStep(TASK_ID)); expect(result.current.proceedStepName).toBe("Work"); + expect(result.current.proceedPreviewTarget).toEqual({ + taskId: TASK_ID, + workflowId: WORKFLOW_ID, + workflowStepId: "work-step", + }); }); it("suppresses the next step for an ungated turn-complete move", () => { diff --git a/apps/web/hooks/domains/kanban/use-plan-actions.ts b/apps/web/hooks/domains/kanban/use-plan-actions.ts index 27b91795fe..138051e2bd 100644 --- a/apps/web/hooks/domains/kanban/use-plan-actions.ts +++ b/apps/web/hooks/domains/kanban/use-plan-actions.ts @@ -111,7 +111,11 @@ export function useNextWorkflowStep(taskId: string | null) { const proceedStepName = nextStep && !currentStepAutoTransitions ? nextStep.title : null; - return { proceedStepName, nextStepIsWorkStep, proceed, isMoving }; + const proceedPreviewTarget = + taskId && workflowId && nextStep + ? { taskId, workflowId, workflowStepId: nextStep.id } + : undefined; + return { proceedStepName, proceedPreviewTarget, nextStepIsWorkStep, proceed, isMoving }; } // i18n-exempt: system block sent verbatim to the agent. @@ -287,6 +291,7 @@ export function usePlanActions(opts: { }); const { proceedStepName, + proceedPreviewTarget, nextStepIsWorkStep, proceed: rawProceed, isMoving, @@ -314,7 +319,7 @@ export function usePlanActions(opts: { return implementPlan(fresh); } : undefined; - return { implementPlanHandler, proceedStepName, proceed, isMoving }; + return { implementPlanHandler, proceedStepName, proceedPreviewTarget, proceed, isMoving }; } export function useImplementPlanRunner(opts: { diff --git a/apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.test.ts b/apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.test.ts new file mode 100644 index 0000000000..6839d8402e --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.test.ts @@ -0,0 +1,187 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { AppState } from "@/lib/state/store"; +import { + getWorkflowMovePreviewRevision, + useWorkflowMovePreviewRevision, +} from "./use-workflow-move-preview-revision"; + +const TASK_ID = "task-1"; +const WORKFLOW_ID = "workflow-1"; +const SESSION_ID = "session-1"; + +type RevisionState = Pick< + AppState, + | "connection" + | "workspaceContextGeneration" + | "kanban" + | "kanbanMulti" + | "workflows" + | "taskSessions" + | "taskSessionsByTask" + | "agentProfiles" + | "sessionModels" +>; + +let state: RevisionState; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (value: RevisionState) => string) => selector(state), +})); + +function resetState() { + state = { + connection: { status: "connected", error: null, issueSeverity: "none" }, + workspaceContextGeneration: 1, + kanban: { + workflowId: WORKFLOW_ID, + steps: [ + { + id: "step-2", + title: "Implement", + color: "blue", + position: 1, + allow_manual_move: true, + profile_session_start_policy: "reuse", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }, + { + id: "step-1", + title: "Analysis", + color: "gray", + position: 0, + profile_session_end_policy: "park", + }, + ], + tasks: [ + { + id: TASK_ID, + workflowId: WORKFLOW_ID, + workflowStepId: "step-1", + title: "Task", + position: 0, + primarySessionId: SESSION_ID, + primarySessionState: "WAITING_FOR_INPUT", + updatedAt: "2026-09-15T00:00:00Z", + }, + ], + isLoading: false, + }, + kanbanMulti: { + snapshots: {}, + isLoading: false, + orderRevisionByStepId: {}, + pendingReorderBandKeys: {}, + withheldReorderByBandKey: {}, + }, + workflows: { + items: [{ id: WORKFLOW_ID, workspaceId: "workspace-1", name: "Workflow" }], + activeId: WORKFLOW_ID, + }, + taskSessions: { items: {} }, + taskSessionsByTask: { + itemsByTaskId: { + [TASK_ID]: [ + { + id: SESSION_ID, + task_id: TASK_ID, + state: "WAITING_FOR_INPUT", + started_at: "2026-09-15T00:00:00Z", + updated_at: "2026-09-15T00:00:00Z", + is_primary: true, + agent_profile_id: "profile-1", + }, + ], + }, + loadingByTaskId: {}, + loadedByTaskId: { [TASK_ID]: true }, + errorByTaskId: {}, + }, + agentProfiles: { items: [], version: 1 }, + sessionModels: { + bySessionId: { + [SESSION_ID]: { + currentModelId: "gpt-5.6-luna", + models: [], + configOptions: [], + }, + }, + }, + } as unknown as RevisionState; +} + +describe("workflow move preview revisions", () => { + it("changes for connection, session, task, step, and profile updates", () => { + resetState(); + const first = getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"); + + state.connection.status = "reconnecting"; + expect( + getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"), + ).not.toBe(first); + + state.connection.status = "connected"; + state.taskSessionsByTask.itemsByTaskId[TASK_ID]![0].state = "RUNNING"; + const afterSession = getWorkflowMovePreviewRevision( + state as AppState, + TASK_ID, + WORKFLOW_ID, + "step-2", + ); + expect(afterSession).not.toBe(first); + + state.kanban.steps[0]!.profile_session_start_policy = "new"; + const afterStep = getWorkflowMovePreviewRevision( + state as AppState, + TASK_ID, + WORKFLOW_ID, + "step-2", + ); + expect(afterStep).not.toBe(afterSession); + + state.kanban.steps[1]!.profile_session_end_policy = "complete"; + const afterSourceStep = getWorkflowMovePreviewRevision( + state as AppState, + TASK_ID, + WORKFLOW_ID, + "step-2", + ); + expect(afterSourceStep).not.toBe(afterStep); + + state.agentProfiles.items = [ + { + id: "profile-1", + label: "Luna", + agent_id: "agent-1", + agent_name: "luna", + cli_passthrough: false, + model: "gpt-5.6-luna", + }, + ]; + expect( + getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"), + ).not.toBe(afterSourceStep); + + const beforeModel = getWorkflowMovePreviewRevision( + state as AppState, + TASK_ID, + WORKFLOW_ID, + "step-2", + ); + state.sessionModels.bySessionId[SESSION_ID]!.currentModelId = "gpt-5.6-astra"; + expect( + getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"), + ).not.toBe(beforeModel); + }); + + it("exposes the authoritative revision through the hook", () => { + resetState(); + const { result, rerender } = renderHook(() => + useWorkflowMovePreviewRevision(TASK_ID, WORKFLOW_ID, "step-2"), + ); + const first = result.current; + state.connection.status = "disconnected"; + rerender(); + expect(result.current).not.toBe(first); + }); +}); diff --git a/apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.ts b/apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.ts new file mode 100644 index 0000000000..179de5aec7 --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.ts @@ -0,0 +1,126 @@ +"use client"; + +import { useCallback } from "react"; +import { useAppStore } from "@/components/state-provider"; +import type { AppState } from "@/lib/state/store"; + +type PreviewRevisionTask = AppState["kanban"]["tasks"][number]; +type PreviewRevisionStep = AppState["kanban"]["steps"][number]; +type PreviewRevisionSession = AppState["taskSessions"]["items"][string]; + +function matchingTasks(state: AppState, taskId: string | null | undefined) { + if (!taskId) return []; + const tasks: Array<{ source: string; task: PreviewRevisionTask }> = []; + for (const task of state.kanban.tasks) { + if (task.id === taskId) tasks.push({ source: "active", task }); + } + for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots).sort( + ([a], [b]) => a.localeCompare(b), + )) { + for (const task of snapshot.tasks) { + if (task.id === taskId) tasks.push({ source: `snapshot:${workflowId}`, task }); + } + } + return tasks.map(({ source, task }) => ({ source, task })); +} + +function matchingSteps(state: AppState, workflowIds: Set, workflowStepIds: Set) { + if (workflowIds.size === 0 || workflowStepIds.size === 0) return []; + const steps: Array<{ source: string; step: PreviewRevisionStep }> = []; + if (state.kanban.workflowId && workflowIds.has(state.kanban.workflowId)) { + for (const step of state.kanban.steps) { + if (workflowStepIds.has(step.id)) steps.push({ source: "active", step }); + } + } + for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots).sort( + ([a], [b]) => a.localeCompare(b), + )) { + if (!workflowIds.has(workflowId)) continue; + for (const step of snapshot.steps) { + if (workflowStepIds.has(step.id)) steps.push({ source: `snapshot:${workflowId}`, step }); + } + } + return steps.map(({ source, step }) => ({ source, step })); +} + +function matchingSessions(state: AppState, taskId: string | null | undefined) { + if (!taskId) return []; + const sessions: Array<{ source: string; session: PreviewRevisionSession }> = []; + for (const session of state.taskSessionsByTask.itemsByTaskId[taskId] ?? []) { + sessions.push({ source: "task", session }); + } + for (const session of Object.values(state.taskSessions.items)) { + if (session.task_id === taskId) sessions.push({ source: "session", session }); + } + return sessions + .sort( + (left, right) => + left.session.id.localeCompare(right.session.id) || left.source.localeCompare(right.source), + ) + .map(({ source, session }) => ({ source, session })); +} + +/** + * Returns a serializable revision of the state that can change a move + * prediction. It deliberately includes both active and background workflow + * projections because a disclosure can remain open while either one receives + * a WebSocket update. + */ +export function getWorkflowMovePreviewRevision( + state: AppState, + taskId: string | null | undefined, + workflowId: string | null | undefined, + workflowStepId: string | null | undefined, +): string { + const tasks = matchingTasks(state, taskId); + const workflowIds = new Set(); + const workflowStepIds = new Set(); + if (workflowId) workflowIds.add(workflowId); + if (workflowStepId) workflowStepIds.add(workflowStepId); + for (const { task } of tasks) { + if (task.workflowId) workflowIds.add(task.workflowId); + if (task.workflowStepId) workflowStepIds.add(task.workflowStepId); + } + const workflows = state.workflows.items + .filter((workflow) => workflow.id === workflowId) + .map((workflow) => ({ ...workflow })); + const profiles = state.agentProfiles.items + .slice() + .sort((left, right) => left.id.localeCompare(right.id)) + .map((profile) => ({ ...profile })); + const sessions = matchingSessions(state, taskId); + const sessionModels = Array.from(new Set(sessions.map(({ session }) => session.id))) + .sort() + .map((sessionId) => ({ + session_id: sessionId, + models: state.sessionModels?.bySessionId?.[sessionId] ?? null, + })); + + return JSON.stringify({ + connection: state.connection.status, + workspace_context_generation: state.workspaceContextGeneration, + active_workflow_id: state.kanban.workflowId, + workflow_id: workflowId ?? "", + workflow_step_id: workflowStepId ?? "", + workflows, + steps: matchingSteps(state, workflowIds, workflowStepIds), + tasks, + sessions_loaded: taskId ? state.taskSessionsByTask.loadedByTaskId[taskId] === true : false, + sessions, + session_models: sessionModels, + agent_profiles_version: state.agentProfiles.version, + profiles, + }); +} + +export function useWorkflowMovePreviewRevision( + taskId: string | null | undefined, + workflowId: string | null | undefined, + workflowStepId: string | null | undefined, +): string { + const selector = useCallback( + (state: AppState) => getWorkflowMovePreviewRevision(state, taskId, workflowId, workflowStepId), + [taskId, workflowId, workflowStepId], + ); + return useAppStore(selector); +} diff --git a/apps/web/hooks/domains/kanban/use-workflow-move-preview.test.ts b/apps/web/hooks/domains/kanban/use-workflow-move-preview.test.ts new file mode 100644 index 0000000000..e019b8abef --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-workflow-move-preview.test.ts @@ -0,0 +1,319 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { WorkflowMovePreviewResponse } from "@/lib/api"; +import { + MAX_PREVIEW_CONCURRENT_REQUESTS, + useWorkflowMovePreview, +} from "./use-workflow-move-preview"; + +const TASK_ID = "task-1"; +const WORKFLOW_ID = "workflow-1"; +const FIRST_STEP_ID = "step-2"; +const SECOND_STEP_ID = "step-3"; + +const { previewWorkflowMoveMock } = vi.hoisted(() => ({ + previewWorkflowMoveMock: vi.fn(), +})); + +vi.mock("@/lib/api", () => ({ + previewWorkflowMove: previewWorkflowMoveMock, +})); + +function makePreview(stepId: string): WorkflowMovePreviewResponse { + return { + task_id: "task-1", + workflow_step_id: stepId, + evaluated_at: "2026-09-14T00:00:00Z", + outcome: "create_new", + recipient: { profile_name: "Luna" }, + model: { + before: { known: false }, + after: { known: true, label: "gpt-5.6-luna" }, + }, + context_reset: false, + context_reset_state: "unchanged", + source_disposition: "park", + dispatch: "prompt", + }; +} + +function useThreePreviews() { + const first = useWorkflowMovePreview({ + taskId: TASK_ID, + workflowId: WORKFLOW_ID, + workflowStepId: "step-2", + enabled: true, + }); + const second = useWorkflowMovePreview({ + taskId: TASK_ID, + workflowId: WORKFLOW_ID, + workflowStepId: "step-3", + enabled: true, + }); + const third = useWorkflowMovePreview({ + taskId: TASK_ID, + workflowId: WORKFLOW_ID, + workflowStepId: "step-4", + enabled: true, + }); + return [first, second, third]; +} + +beforeEach(() => { + vi.useFakeTimers(); + previewWorkflowMoveMock.mockReset(); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("useWorkflowMovePreview request lifecycle", () => { + it("debounces a visible request and sends normalized move options", async () => { + previewWorkflowMoveMock.mockResolvedValueOnce(makePreview("step-2")); + const { result } = renderHook(() => + useWorkflowMovePreview({ + taskId: TASK_ID, + workflowId: WORKFLOW_ID, + workflowStepId: FIRST_STEP_ID, + entryOptions: { + reset_context: true, + instructions: " inspect the destination ", + }, + enabled: true, + }), + ); + + expect(result.current.status).toBe("loading"); + expect(previewWorkflowMoveMock).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(150); + await Promise.resolve(); + }); + + expect(previewWorkflowMoveMock).toHaveBeenCalledOnce(); + expect(previewWorkflowMoveMock).toHaveBeenCalledWith( + TASK_ID, + { + workflow_id: WORKFLOW_ID, + workflow_step_id: FIRST_STEP_ID, + entry_options: { + reset_context: true, + instructions: "inspect the destination", + }, + }, + { cache: "no-store", init: { signal: expect.any(AbortSignal) } }, + ); + expect(result.current.status).toBe("success"); + expect(result.current.preview?.workflow_step_id).toBe(FIRST_STEP_ID); + }); +}); + +describe("useWorkflowMovePreview invalidation", () => { + it("ignores a late response from an earlier destination", async () => { + let resolveFirst!: (value: WorkflowMovePreviewResponse) => void; + let resolveSecond!: (value: WorkflowMovePreviewResponse) => void; + previewWorkflowMoveMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); + + const { result, rerender } = renderHook( + ({ workflowStepId }: { workflowStepId: string }) => + useWorkflowMovePreview({ + taskId: TASK_ID, + workflowId: WORKFLOW_ID, + workflowStepId, + enabled: true, + }), + { initialProps: { workflowStepId: FIRST_STEP_ID } }, + ); + + await act(async () => { + vi.advanceTimersByTime(150); + await Promise.resolve(); + }); + expect(previewWorkflowMoveMock).toHaveBeenCalledOnce(); + + rerender({ workflowStepId: SECOND_STEP_ID }); + await act(async () => { + vi.advanceTimersByTime(150); + await Promise.resolve(); + }); + expect(previewWorkflowMoveMock).toHaveBeenCalledTimes(2); + + await act(async () => { + resolveSecond(makePreview(SECOND_STEP_ID)); + await Promise.resolve(); + }); + expect(result.current.preview?.workflow_step_id).toBe(SECOND_STEP_ID); + + await act(async () => { + resolveFirst(makePreview(FIRST_STEP_ID)); + await Promise.resolve(); + }); + expect(result.current.preview?.workflow_step_id).toBe(SECOND_STEP_ID); + }); + + it("clears a successful result and fetches again when the invalidation key changes", async () => { + previewWorkflowMoveMock + .mockResolvedValueOnce(makePreview(FIRST_STEP_ID)) + .mockResolvedValueOnce(makePreview(SECOND_STEP_ID)); + const { result, rerender } = renderHook( + ({ invalidationKey }: { invalidationKey: string }) => + useWorkflowMovePreview({ + taskId: TASK_ID, + workflowId: WORKFLOW_ID, + workflowStepId: FIRST_STEP_ID, + invalidationKey, + enabled: true, + }), + { initialProps: { invalidationKey: "connected:1" } }, + ); + + await act(async () => { + vi.advanceTimersByTime(150); + await Promise.resolve(); + }); + expect(result.current.status).toBe("success"); + + rerender({ invalidationKey: "reconnecting:2" }); + expect(result.current.status).toBe("loading"); + expect(result.current.preview).toBeNull(); + + await act(async () => { + vi.advanceTimersByTime(150); + await Promise.resolve(); + }); + expect(previewWorkflowMoveMock).toHaveBeenCalledTimes(2); + expect(result.current.status).toBe("success"); + }); +}); + +describe("useWorkflowMovePreview request queue", () => { + it("queues disclosed destinations while keeping only two requests active", async () => { + const resolvers = new Map void>(); + previewWorkflowMoveMock.mockImplementation( + (_taskId: string, payload: { workflow_step_id: string }) => + new Promise((resolve) => { + resolvers.set(payload.workflow_step_id, resolve); + }), + ); + const { result } = renderHook(() => useThreePreviews()); + + await act(async () => { + vi.advanceTimersByTime(150); + await Promise.resolve(); + }); + expect(MAX_PREVIEW_CONCURRENT_REQUESTS).toBe(2); + expect(previewWorkflowMoveMock).toHaveBeenCalledTimes(MAX_PREVIEW_CONCURRENT_REQUESTS); + expect(result.current[2].status).toBe("loading"); + expect(resolvers.has("step-4")).toBe(false); + + await act(async () => { + resolvers.get("step-2")?.(makePreview("step-2")); + await Promise.resolve(); + }); + expect(previewWorkflowMoveMock).toHaveBeenCalledTimes(3); + expect(resolvers.has("step-4")).toBe(true); + + await act(async () => { + resolvers.get("step-3")?.(makePreview("step-3")); + resolvers.get("step-4")?.(makePreview("step-4")); + await Promise.resolve(); + }); + expect(result.current.map((entry) => entry.status)).toEqual(["success", "success", "success"]); + }); +}); + +describe("useWorkflowMovePreview cleanup", () => { + it("clears the result and aborts when the disclosure closes", async () => { + let resolveRequest!: (value: WorkflowMovePreviewResponse) => void; + previewWorkflowMoveMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + useWorkflowMovePreview({ + taskId: TASK_ID, + workflowId: WORKFLOW_ID, + workflowStepId: FIRST_STEP_ID, + enabled, + }), + { initialProps: { enabled: true } }, + ); + + await act(async () => { + vi.advanceTimersByTime(150); + await Promise.resolve(); + }); + const requestOptions = previewWorkflowMoveMock.mock.calls[0][2] as { + init: { signal: AbortSignal }; + }; + rerender({ enabled: false }); + + expect(requestOptions.init.signal.aborted).toBe(true); + expect(result.current.status).toBe("idle"); + resolveRequest(makePreview(FIRST_STEP_ID)); + }); + + it("re-enables and issues a fresh request after disabling with the same key", async () => { + let resolveFirst!: (value: WorkflowMovePreviewResponse) => void; + previewWorkflowMoveMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce(makePreview(FIRST_STEP_ID)); + + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + useWorkflowMovePreview({ + taskId: TASK_ID, + workflowId: WORKFLOW_ID, + workflowStepId: FIRST_STEP_ID, + enabled, + }), + { initialProps: { enabled: true } }, + ); + + await act(async () => { + vi.advanceTimersByTime(150); + await Promise.resolve(); + }); + expect(previewWorkflowMoveMock).toHaveBeenCalledOnce(); + + rerender({ enabled: false }); + expect(result.current.status).toBe("idle"); + + rerender({ enabled: true }); + await act(async () => { + vi.advanceTimersByTime(150); + await Promise.resolve(); + }); + expect(previewWorkflowMoveMock).toHaveBeenCalledTimes(2); + expect(result.current.status).toBe("success"); + + resolveFirst(makePreview(FIRST_STEP_ID)); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.status).toBe("success"); + }); +}); diff --git a/apps/web/hooks/domains/kanban/use-workflow-move-preview.ts b/apps/web/hooks/domains/kanban/use-workflow-move-preview.ts new file mode 100644 index 0000000000..fa224efd61 --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-workflow-move-preview.ts @@ -0,0 +1,271 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + previewWorkflowMove, + type WorkflowMoveEntryOptions, + type WorkflowMovePreviewResponse, +} from "@/lib/api"; +import { normalizeWorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api"; + +const PREVIEW_DEBOUNCE_MS = 150; +export const MAX_PREVIEW_CONCURRENT_REQUESTS = 2; + +export type WorkflowMovePreviewStatus = "idle" | "loading" | "success" | "error"; + +export type WorkflowMovePreviewState = { + status: WorkflowMovePreviewStatus; + preview: WorkflowMovePreviewResponse | null; + error: unknown; + retry: () => void; +}; + +type WorkflowMovePreviewParams = { + taskId?: string | null; + workflowId?: string | null; + workflowStepId?: string | null; + entryOptions?: WorkflowMoveEntryOptions | null; + enabled?: boolean; + /** Changes when an external task/session/workflow projection is known stale. */ + invalidationKey?: string | number | null; +}; + +type PreviewRequestEntry = { + controller: AbortController; + consumers: number; + promise: Promise; + resolve: (preview: WorkflowMovePreviewResponse) => void; + reject: (error?: unknown) => void; + started: boolean; + finished: boolean; +}; + +type QueuedPreview = { + key: string; + entry: PreviewRequestEntry; + taskId: string; + workflowId: string; + workflowStepId: string; + entryOptions: WorkflowMoveEntryOptions | undefined; +}; + +const inFlightPreviews = new Map(); +const previewQueue: QueuedPreview[] = []; +let activePreviewCount = 0; + +function previewOptionKey(options: WorkflowMoveEntryOptions | null | undefined): string { + const normalized = normalizeWorkflowMoveEntryOptions(options); + return JSON.stringify({ + reset_context: normalized?.reset_context === true, + skip_step_prompt: normalized?.skip_step_prompt === true, + // The text itself does not change routing. Its presence changes whether a + // skipped prompt still dispatches a turn, so only that fact invalidates a + // pending disclosure request while the user types. + instructions_present: Boolean(normalized?.instructions), + }); +} + +function previewRequestKey( + taskId: string | null | undefined, + workflowId: string | null | undefined, + workflowStepId: string | null | undefined, + options: WorkflowMoveEntryOptions | null | undefined, + invalidationKey: string | number | null | undefined, +): string { + return JSON.stringify([ + taskId ?? "", + workflowId ?? "", + workflowStepId ?? "", + previewOptionKey(options), + invalidationKey ?? "", + ]); +} + +function isAbortError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "name" in error && + (error as { name?: unknown }).name === "AbortError" + ); +} + +function previewAbortError(): Error { + const error = new Error("Workflow move preview was cancelled"); + error.name = "AbortError"; + return error; +} + +function finishPreview(key: string, entry: PreviewRequestEntry): void { + if (entry.finished) return; + entry.finished = true; + if (entry.started) activePreviewCount -= 1; + if (inFlightPreviews.get(key) === entry) inFlightPreviews.delete(key); + pumpPreviewQueue(); +} + +function pumpPreviewQueue(): void { + while (activePreviewCount < MAX_PREVIEW_CONCURRENT_REQUESTS && previewQueue.length > 0) { + const queued = previewQueue.shift(); + if (!queued || inFlightPreviews.get(queued.key) !== queued.entry || queued.entry.finished) { + continue; + } + + const { key, entry } = queued; + entry.started = true; + activePreviewCount += 1; + let request: Promise; + try { + request = previewWorkflowMove( + queued.taskId, + { + workflow_id: queued.workflowId, + workflow_step_id: queued.workflowStepId, + entry_options: queued.entryOptions, + }, + { cache: "no-store", init: { signal: entry.controller.signal } }, + ); + } catch (error) { + entry.reject(error); + finishPreview(key, entry); + continue; + } + + request.then( + (preview) => { + entry.resolve(preview); + finishPreview(key, entry); + }, + (error: unknown) => { + entry.reject(error); + finishPreview(key, entry); + }, + ); + } +} + +function acquirePreview( + key: string, + taskId: string, + workflowId: string, + workflowStepId: string, + entryOptions: WorkflowMoveEntryOptions | undefined, +): { promise: Promise; release: () => void } { + let entry = inFlightPreviews.get(key); + if (!entry || entry.controller.signal.aborted) { + const controller = new AbortController(); + let resolvePromise!: (preview: WorkflowMovePreviewResponse) => void; + let rejectPromise!: (error?: unknown) => void; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + entry = { + controller, + consumers: 0, + promise, + resolve: resolvePromise, + reject: rejectPromise, + started: false, + finished: false, + }; + inFlightPreviews.set(key, entry); + // A queued request can be cancelled before its consumer attaches a + // rejection handler. Keep cancellation from becoming an unhandled + // rejection while the hook's generation guard handles the result. + void entry.promise.catch(() => undefined); + previewQueue.push({ key, entry, taskId, workflowId, workflowStepId, entryOptions }); + pumpPreviewQueue(); + } + + const acquiredEntry = entry; + acquiredEntry.consumers += 1; + let released = false; + return { + promise: acquiredEntry.promise, + release: () => { + if (released) return; + released = true; + acquiredEntry.consumers -= 1; + if (acquiredEntry.consumers === 0 && inFlightPreviews.get(key) === acquiredEntry) { + acquiredEntry.reject(previewAbortError()); + acquiredEntry.controller.abort(); + finishPreview(key, acquiredEntry); + } + }, + }; +} + +export function useWorkflowMovePreview({ + taskId, + workflowId, + workflowStepId, + entryOptions, + enabled = true, + invalidationKey, +}: WorkflowMovePreviewParams): WorkflowMovePreviewState { + const normalizedEntryOptions = normalizeWorkflowMoveEntryOptions(entryOptions); + const entryOptionsRef = useRef(normalizedEntryOptions); + entryOptionsRef.current = normalizedEntryOptions; + const [retrySequence, setRetrySequence] = useState(0); + const [state, setState] = useState>({ + status: "idle", + preview: null, + error: null, + }); + const generationRef = useRef(0); + const requestKey = previewRequestKey( + taskId, + workflowId, + workflowStepId, + normalizedEntryOptions, + invalidationKey, + ); + + const retry = useCallback(() => setRetrySequence((current) => current + 1), []); + + useEffect(() => { + const generation = ++generationRef.current; + let release: (() => void) | undefined; + + setState({ status: "idle", preview: null, error: null }); + if (!enabled || !taskId || !workflowId || !workflowStepId) { + return () => { + generationRef.current += 1; + }; + } + + setState({ status: "loading", preview: null, error: null }); + const timer = window.setTimeout(() => { + if (generationRef.current !== generation) return; + const request = acquirePreview( + requestKey, + taskId, + workflowId, + workflowStepId, + entryOptionsRef.current, + ); + release = request.release; + request.promise.then( + (preview) => { + if (generationRef.current !== generation) return; + setState({ status: "success", preview, error: null }); + }, + (error: unknown) => { + if (generationRef.current !== generation || isAbortError(error)) return; + setState({ status: "error", preview: null, error }); + }, + ); + }, PREVIEW_DEBOUNCE_MS); + + return () => { + generationRef.current += 1; + window.clearTimeout(timer); + release?.(); + }; + }, [enabled, requestKey, retrySequence, taskId, workflowId, workflowStepId]); + + return { ...state, retry }; +} + +export { PREVIEW_DEBOUNCE_MS }; diff --git a/apps/web/lib/api/domains/kanban-api.test.ts b/apps/web/lib/api/domains/kanban-api.test.ts index 8c3bb19024..cac0867715 100644 --- a/apps/web/lib/api/domains/kanban-api.test.ts +++ b/apps/web/lib/api/domains/kanban-api.test.ts @@ -6,6 +6,7 @@ import { getTaskDeletePreflight, listTasksByWorkspace, moveTask, + previewWorkflowMove, reorderStepTasks, updateTask, updateTaskPortForwarding, @@ -358,3 +359,56 @@ describe("moveTask", () => { ); }); }); + +describe("previewWorkflowMove", () => { + it("posts normalized options with a no-store cache policy", async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + task_id: "task-1", + workflow_step_id: "step-2", + evaluated_at: "2026-09-14T00:00:00Z", + outcome: "create_new", + model: { + before: { known: false }, + after: { known: true, label: "gpt-5.6-luna" }, + }, + context_reset: false, + context_reset_state: "unchanged", + source_disposition: "park", + dispatch: "prompt", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await previewWorkflowMove( + "task-1", + { + workflow_id: "workflow-1", + workflow_step_id: "step-2", + entry_options: { + instructions: " inspect the destination ", + reset_context: true, + }, + }, + { baseUrl: API_BASE_URL }, + ); + + expect(fetchSpy).toHaveBeenCalledOnce(); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe(`${API_BASE_URL}/api/v1/tasks/task-1/move-preview`); + expect(init).toMatchObject({ + method: "POST", + body: JSON.stringify({ + workflow_id: "workflow-1", + workflow_step_id: "step-2", + entry_options: { + reset_context: true, + instructions: "inspect the destination", + }, + }), + }); + expect((init as RequestInit).cache).toBe("no-store"); + }); +}); diff --git a/apps/web/lib/api/domains/kanban-api.ts b/apps/web/lib/api/domains/kanban-api.ts index 2e57ba34d8..3af7ee33b5 100644 --- a/apps/web/lib/api/domains/kanban-api.ts +++ b/apps/web/lib/api/domains/kanban-api.ts @@ -279,6 +279,62 @@ export type WorkflowMoveResponse = MoveTaskResponse & { entry_options?: WorkflowMoveEntryOptions; }; +export type WorkflowMovePreviewOutcome = + | "reuse_current" + | "reuse_other" + | "create_new" + | "no_session" + | "unknown"; +export type WorkflowMovePreviewApplicability = "planned" | "unchanged" | "skipped" | "unknown"; +export type WorkflowMovePreviewSourceDisposition = "keep" | "park" | "complete" | "unknown"; +export type WorkflowMovePreviewDispatch = + | "prompt" + | "no_prompt" + | "deferred" + | "no_session" + | "unknown"; + +export type WorkflowMovePreviewModelValue = { + id?: string; + label?: string; + known: boolean; + mode?: string; + config_options?: Record; +}; + +export type WorkflowMovePreviewResponse = { + task_id: string; + workflow_step_id: string; + source_session_id?: string; + evaluated_at: string; + outcome: WorkflowMovePreviewOutcome; + recipient?: { + session_id?: string; + session_name?: string; + profile_id?: string; + profile_name?: string; + agent_family?: string; + }; + model: { + before: WorkflowMovePreviewModelValue; + after: WorkflowMovePreviewModelValue; + before_source?: string; + after_source?: string; + }; + changes?: Array<{ + key: string; + label: string; + before?: string; + after?: string; + applicability: WorkflowMovePreviewApplicability; + }>; + context_reset: boolean; + context_reset_state: WorkflowMovePreviewApplicability; + source_disposition: WorkflowMovePreviewSourceDisposition; + dispatch: WorkflowMovePreviewDispatch; + notices?: Array<{ code: string; params?: Record }>; +}; + /** * Converts form values into the wire contract. Blank text has no one-shot * effect, and an absent/empty object keeps the legacy destination-only body. @@ -315,6 +371,24 @@ export async function moveTask( }); } +export async function previewWorkflowMove( + taskId: string, + payload: MoveTaskPayload, + options?: ApiRequestOptions, +): Promise { + const { entry_options, ...destination } = payload; + const normalizedEntryOptions = normalizeWorkflowMoveEntryOptions(entry_options); + const requestPayload = normalizedEntryOptions + ? { ...destination, entry_options: normalizedEntryOptions } + : destination; + + return fetchJson(`/api/v1/tasks/${taskId}/move-preview`, { + ...options, + cache: "no-store", + init: { method: "POST", body: JSON.stringify(requestPayload), ...(options?.init ?? {}) }, + }); +} + /** * Reorders one workflow step's band. The only request surface for a reorder * (REQ-TASKS-KANBAN-TASK-REORDERING-001) — a WebSocket action was cut in the diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index a19eb67a0c..d83743ee7b 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -27,6 +27,31 @@ function resolveWorkspaceOrphaned(task: WorkflowSnapshot["tasks"][number]): bool return task.workspace_orphaned ?? false; } +function snapshotWorkflowStep(step: WorkflowSnapshot["steps"][number]) { + return { + id: step.id, + title: step.name, + color: step.color ?? "bg-neutral-400", + position: step.position, + events: step.events, + allow_manual_move: step.allow_manual_move, + auto_advance_requires_signal: step.auto_advance_requires_signal, + prompt: step.prompt, + is_start_step: step.is_start_step, + show_in_command_panel: step.show_in_command_panel, + agent_profile_id: step.agent_profile_id, + session_target: step.session_target ?? null, + profile_session_start_policy: step.profile_session_start_policy, + profile_session_end_policy: step.profile_session_end_policy, + complete_task_on_enter: step.complete_task_on_enter, + cancel_triggers_turn_complete: step.cancel_triggers_turn_complete, + wip_limit: step.wip_limit, + pull_from_step_id: step.pull_from_step_id ?? null, + stage_type: step.stage_type, + order_revision: step.order_revision, + }; +} + function primaryExecutorFields(task: Task) { return { primaryExecutorId: task.primary_executor_id ?? undefined, @@ -137,23 +162,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { kanban: { workflowId: snapshot.workflow.id, isLoading: false, - steps: snapshot.steps.map((step) => ({ - id: step.id, - title: step.name, - color: step.color ?? "bg-neutral-400", - position: step.position, - events: step.events, - allow_manual_move: step.allow_manual_move, - auto_advance_requires_signal: step.auto_advance_requires_signal, - prompt: step.prompt, - is_start_step: step.is_start_step, - show_in_command_panel: step.show_in_command_panel, - agent_profile_id: step.agent_profile_id, - wip_limit: step.wip_limit, - pull_from_step_id: step.pull_from_step_id ?? null, - stage_type: step.stage_type, - order_revision: step.order_revision, - })), + steps: snapshot.steps.map(snapshotWorkflowStep), tasks, }, }; diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index 43e0b9bb47..4156e71b99 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -6,6 +6,9 @@ import type { TaskOrigin, TaskPriority, TaskState as TaskStatus, + WorkflowProfileSessionEndPolicy, + WorkflowProfileSessionStartPolicy, + WorkflowSessionTarget, } from "@/lib/types/http"; import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; import type { BeginTaskRemovalInput, TaskRemovalState } from "@/lib/state/task-removal"; @@ -53,6 +56,11 @@ export type KanbanState = { is_start_step?: boolean; show_in_command_panel?: boolean; agent_profile_id?: string; + session_target?: WorkflowSessionTarget | null; + profile_session_start_policy?: WorkflowProfileSessionStartPolicy; + profile_session_end_policy?: WorkflowProfileSessionEndPolicy; + complete_task_on_enter?: boolean; + cancel_triggers_turn_complete?: boolean; /** Maximum concurrent tasks allowed in this step. 0 or undefined means unlimited. */ wip_limit?: number; /** Optional upstream step used by automation to pull more work. */ diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 874bb24e1f..c228465ed3 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -944,6 +944,69 @@ "workflowMoveInstructions": "One-time instructions", "workflowMoveInstructionsPlaceholder": "Add instructions for the receiving agent", "workflowMoveApply": "Move", + "workflowMovePreviewChecking": "Checking session...", + "workflowMovePreviewUnavailable": "Preview unavailable", + "workflowMovePreviewRetry": "Retry", + "workflowMovePreviewReuseCurrent": "Reuse current session", + "workflowMovePreviewReuseOther": "Reuse another session", + "workflowMovePreviewReuseOtherNamed": "Reuse: {{session}}", + "workflowMovePreviewNewSession": "New session", + "workflowMovePreviewUnknownSession": "Session unavailable", + "workflowMovePreviewModel": "{{model}}", + "workflowMovePreviewModelChange": "{{before}} to {{after}}", + "workflowMovePreviewModelRetainedOverride": "{{model}} (override retained)", + "workflowMovePreviewModelUnknown": "Model not known", + "workflowMovePreviewAdditionalChanges_one": "+{{count}} change", + "workflowMovePreviewAdditionalChanges_other": "+{{count}} changes", + "workflowMovePreviewDetails": "Details", + "workflowMovePreviewShowDetails": "Show preview details", + "workflowMovePreviewHideDetails": "Hide preview details", + "workflowMovePreviewSession": "Session", + "workflowMovePreviewProfile": "Profile", + "workflowMovePreviewModelDetails": "Model", + "workflowMovePreviewChanges": "Changes", + "workflowMovePreviewContext": "Context", + "workflowMovePreviewContextReset": "Reset", + "workflowMovePreviewSource": "Source session", + "workflowMovePreviewSourceKeep": "Keep", + "workflowMovePreviewSourcePark": "Park", + "workflowMovePreviewSourceComplete": "Complete", + "workflowMovePreviewDispatch": "Dispatch", + "workflowMovePreviewDispatchPrompt": "Send step prompt", + "workflowMovePreviewDispatchNoPrompt": "Do not send a prompt", + "workflowMovePreviewDispatchDeferred": "Check again when the turn ends", + "workflowMovePreviewDispatchNoSession": "Agent not started", + "workflowMovePreviewNotes": "Notes", + "workflowMovePreviewNoticeRetainedOverride": "The session keeps its model override.", + "workflowMovePreviewNoticeUnavailable": "Some preview settings are unavailable.", + "workflowMovePreviewPlanned": "planned", + "workflowMovePreviewSkipped": "skipped", + "workflowMovePreviewUnchanged": "unchanged", + "workflowMovePreviewUnknown": "unknown", + "workflowMovePreviewTo": "to", + "workflowMovePreviewExecutionCheck": "The move checks the current state again.", + "workflowMovePreviewNoSession": "No session will be started", + "workflowMovePreviewFieldMode": "Mode", + "workflowMovePreviewFieldStepPrompt": "Step prompt", + "workflowMovePreviewFieldReasoningEffort": "Reasoning effort", + "workflowMovePreviewFieldVerbosity": "Verbosity", + "workflowMovePreviewFieldOther": "Other setting", + "workflowMovePreviewValueConfigured": "Configured", + "workflowMovePreviewValueSkipped": "Skipped", + "workflowMovePreviewValueModeDefault": "Default", + "workflowMovePreviewValueModePlan": "Plan", + "workflowMovePreviewValueLow": "Low", + "workflowMovePreviewValueMedium": "Medium", + "workflowMovePreviewValueHigh": "High", + "workflowMovePreviewValueMax": "Maximum", + "workflowMovePreviewNoticeMissingOriginal": "The original session settings are unavailable.", + "workflowMovePreviewNoticeAmbiguous": "More than one session setting rule matches.", + "workflowMovePreviewNoticeConflicting": "The session setting rules conflict.", + "workflowMovePreviewNoticeInapplicable": "The session setting was skipped for this conversation.", + "workflowMovePreviewNoticeConfigurationUnavailable": "The session settings could not be resolved.", + "workflowMovePreviewNoticeInvalidConfiguration": "The session settings contain an invalid rule.", + "workflowMovePreviewNoticeTargetUnavailable": "The selected session target is unavailable.", + "workflowMovePreviewNoticeProfileUnavailable": "The destination agent profile is unavailable.", "moveTaskToTheNextWorkflow": "Move task to the next workflow step", "moveTo": "Move to", "moving": "Moving...", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 239cb65d6d..0df499041e 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -944,6 +944,69 @@ "workflowMoveInstructions": "Ōńē-ţĩḿē ĩńśţŕũćţĩōńś", "workflowMoveInstructionsPlaceholder": "Àďď ĩńśţŕũćţĩōńś ƒōŕ ţĥē ŕēćēĩvĩńĝ àĝēńţ", "workflowMoveApply": "Ḿōvē", + "workflowMovePreviewChecking": "Ćĥēćķĩńĝ śēśśĩōń...", + "workflowMovePreviewUnavailable": "Ƥŕēvĩēŵ ũńàvàĩĺàƀĺē", + "workflowMovePreviewRetry": "Ŕēţŕŷ", + "workflowMovePreviewReuseCurrent": "Ŕēũśē ćũŕŕēńţ śēśśĩōń", + "workflowMovePreviewReuseOther": "Ŕēũśē àńōţĥēŕ śēśśĩōń", + "workflowMovePreviewReuseOtherNamed": "Ŕēũśē: {{session}}", + "workflowMovePreviewNewSession": "Ńēŵ śēśśĩōń", + "workflowMovePreviewUnknownSession": "Śēśśĩōń ũńàvàĩĺàƀĺē", + "workflowMovePreviewModel": "{{model}}", + "workflowMovePreviewModelChange": "{{before}} ţō {{after}}", + "workflowMovePreviewModelRetainedOverride": "{{model}} (ōvēŕŕĩďē ŕēţàĩńēď)", + "workflowMovePreviewModelUnknown": "Ḿōďēĺ ńōţ ķńōŵń", + "workflowMovePreviewAdditionalChanges_one": "+{{count}} ćĥàńĝē", + "workflowMovePreviewAdditionalChanges_other": "+{{count}} ćĥàńĝēś", + "workflowMovePreviewDetails": "Ďēţàĩĺś", + "workflowMovePreviewShowDetails": "Śĥōŵ ƥŕēvĩēŵ ďēţàĩĺś", + "workflowMovePreviewHideDetails": "Ĥĩďē ƥŕēvĩēŵ ďēţàĩĺś", + "workflowMovePreviewSession": "Śēśśĩōń", + "workflowMovePreviewProfile": "Ƥŕōƒĩĺē", + "workflowMovePreviewModelDetails": "Ḿōďēĺ", + "workflowMovePreviewChanges": "Ćĥàńĝēś", + "workflowMovePreviewContext": "Ćōńţēxţ", + "workflowMovePreviewContextReset": "Ŕēśēţ", + "workflowMovePreviewSource": "Śōũŕćē śēśśĩōń", + "workflowMovePreviewSourceKeep": "Ķēēƥ", + "workflowMovePreviewSourcePark": "Ƥàŕķ", + "workflowMovePreviewSourceComplete": "Ćōḿƥĺēţē", + "workflowMovePreviewDispatch": "Ďĩśƥàţćĥ", + "workflowMovePreviewDispatchPrompt": "Śēńď śţēƥ ƥŕōḿƥţ", + "workflowMovePreviewDispatchNoPrompt": "Ďō ńōţ śēńď à ƥŕōḿƥţ", + "workflowMovePreviewDispatchDeferred": "Ćĥēćķ àĝàĩń ŵĥēń ţĥē ţũŕń ēńďś", + "workflowMovePreviewDispatchNoSession": "Āĝēńţ ńōţ śţàŕţēď", + "workflowMovePreviewNotes": "Ńōţēś", + "workflowMovePreviewNoticeRetainedOverride": "Ţĥē śēśśĩōń ķēēƥś ĩţś ḿōďēĺ ōvēŕŕĩďē.", + "workflowMovePreviewNoticeUnavailable": "Śōḿē ƥŕēvĩēŵ śēţţĩńĝś àŕē ũńàvàĩĺàƀĺē.", + "workflowMovePreviewPlanned": "ƥĺàńńēď", + "workflowMovePreviewSkipped": "śķĩƥƥēď", + "workflowMovePreviewUnchanged": "ũńćĥàńĝēď", + "workflowMovePreviewUnknown": "ũńķńōŵń", + "workflowMovePreviewTo": "ţō", + "workflowMovePreviewExecutionCheck": "Ţĥē ḿōvē ćĥēćķś ţĥē ćũŕŕēńţ śţàţē àĝàĩń.", + "workflowMovePreviewNoSession": "Ńō śēśśĩōń ŵĩĺĺ ƀē śţàŕţēď", + "workflowMovePreviewFieldMode": "Ḿōďē", + "workflowMovePreviewFieldStepPrompt": "Śţēƥ ƥŕōḿƥţ", + "workflowMovePreviewFieldReasoningEffort": "Ŕēàśōńĩńĝ ēƒƒōŕţ", + "workflowMovePreviewFieldVerbosity": "Vēŕƀōśĩţŷ", + "workflowMovePreviewFieldOther": "Ōţĥēŕ śēţţĩńĝ", + "workflowMovePreviewValueConfigured": "Ćōńƒĩĝũŕēď", + "workflowMovePreviewValueSkipped": "Śķĩƥƥēď", + "workflowMovePreviewValueModeDefault": "Ďēƒàũĺţ", + "workflowMovePreviewValueModePlan": "Ƥĺàń", + "workflowMovePreviewValueLow": "Ĺōŵ", + "workflowMovePreviewValueMedium": "Ḿēďĩũḿ", + "workflowMovePreviewValueHigh": "Ĥĩĝĥ", + "workflowMovePreviewValueMax": "Ḿàxĩḿũḿ", + "workflowMovePreviewNoticeMissingOriginal": "Ţĥē ōŕĩĝĩńàĺ śēśśĩōń śēţţĩńĝś àŕē ũńàvàĩĺàƀĺē.", + "workflowMovePreviewNoticeAmbiguous": "Ḿōŕē ţĥàń ōńē śēśśĩōń śēţţĩńĝ ŕũĺē ḿàţćĥēś.", + "workflowMovePreviewNoticeConflicting": "Ţĥē śēśśĩōń śēţţĩńĝ ŕũĺēś ćōńƒĺĩćţ.", + "workflowMovePreviewNoticeInapplicable": "Ţĥē śēśśĩōń śēţţĩńĝ ŵàś śķĩƥƥēď ƒōŕ ţĥĩś ćōńvēŕśàţĩōń.", + "workflowMovePreviewNoticeConfigurationUnavailable": "Ţĥē śēśśĩōń śēţţĩńĝś ćōũĺď ńōţ ƀē ŕēśōĺvēď.", + "workflowMovePreviewNoticeInvalidConfiguration": "Ţĥē śēśśĩōń śēţţĩńĝś ćōńţàĩń àń ĩńvàĺĩď ŕũĺē.", + "workflowMovePreviewNoticeTargetUnavailable": "Ţĥē śēĺēćţēď śēśśĩōń ţàŕĝēţ ĩś ũńàvàĩĺàƀĺē.", + "workflowMovePreviewNoticeProfileUnavailable": "Ţĥē ďēśţĩńàţĩōń àĝēńţ ƥŕōƒĩĺē ĩś ũńàvàĩĺàƀĺē.", "moveTaskToTheNextWorkflow": "Ḿōvē ţàśķ ţō ţĥē ńēxţ ŵōŕķƒĺōŵ śţēƥ", "moveTo": "Ḿōvē ţō", "moving": "Ḿōvĩńĝ...", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index 1f696bdd8b..d18a8d9e5d 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -897,6 +897,69 @@ "workflowMoveInstructions": "Instruções únicas", "workflowMoveInstructionsPlaceholder": "Adicione instruções para o agente recetor", "workflowMoveApply": "Mover", + "workflowMovePreviewChecking": "A verificar a sessão...", + "workflowMovePreviewUnavailable": "Pré-visualização indisponível", + "workflowMovePreviewRetry": "Tentar novamente", + "workflowMovePreviewReuseCurrent": "Reutilizar sessão atual", + "workflowMovePreviewReuseOther": "Reutilizar outra sessão", + "workflowMovePreviewReuseOtherNamed": "Reutilizar: {{session}}", + "workflowMovePreviewNewSession": "Nova sessão", + "workflowMovePreviewUnknownSession": "Sessão indisponível", + "workflowMovePreviewModel": "{{model}}", + "workflowMovePreviewModelChange": "{{before}} para {{after}}", + "workflowMovePreviewModelRetainedOverride": "{{model}} (substituição mantida)", + "workflowMovePreviewModelUnknown": "Modelo desconhecido", + "workflowMovePreviewAdditionalChanges_one": "+{{count}} alteração", + "workflowMovePreviewAdditionalChanges_other": "+{{count}} alterações", + "workflowMovePreviewDetails": "Detalhes", + "workflowMovePreviewShowDetails": "Mostrar detalhes da pré-visualização", + "workflowMovePreviewHideDetails": "Ocultar detalhes da pré-visualização", + "workflowMovePreviewSession": "Sessão", + "workflowMovePreviewProfile": "Perfil", + "workflowMovePreviewModelDetails": "Modelo", + "workflowMovePreviewChanges": "Alterações", + "workflowMovePreviewContext": "Contexto", + "workflowMovePreviewContextReset": "Limpar", + "workflowMovePreviewSource": "Sessão de origem", + "workflowMovePreviewSourceKeep": "Manter", + "workflowMovePreviewSourcePark": "Colocar em espera", + "workflowMovePreviewSourceComplete": "Concluir", + "workflowMovePreviewDispatch": "Envio", + "workflowMovePreviewDispatchPrompt": "Enviar o prompt do passo", + "workflowMovePreviewDispatchNoPrompt": "Não enviar um prompt", + "workflowMovePreviewDispatchDeferred": "Verificar novamente quando o turno terminar", + "workflowMovePreviewDispatchNoSession": "Agente não iniciado", + "workflowMovePreviewNotes": "Notas", + "workflowMovePreviewNoticeRetainedOverride": "A sessão mantém a substituição do modelo.", + "workflowMovePreviewNoticeUnavailable": "Algumas definições da pré-visualização estão indisponíveis.", + "workflowMovePreviewPlanned": "planeado", + "workflowMovePreviewSkipped": "ignorado", + "workflowMovePreviewUnchanged": "sem alterações", + "workflowMovePreviewUnknown": "desconhecido", + "workflowMovePreviewTo": "para", + "workflowMovePreviewExecutionCheck": "A mudança verifica novamente o estado atual.", + "workflowMovePreviewNoSession": "Não será iniciada nenhuma sessão", + "workflowMovePreviewFieldMode": "Modo", + "workflowMovePreviewFieldStepPrompt": "Prompt do passo", + "workflowMovePreviewFieldReasoningEffort": "Esforço de raciocínio", + "workflowMovePreviewFieldVerbosity": "Nível de detalhe", + "workflowMovePreviewFieldOther": "Outra definição", + "workflowMovePreviewValueConfigured": "Configurado", + "workflowMovePreviewValueSkipped": "Ignorado", + "workflowMovePreviewValueModeDefault": "Predefinido", + "workflowMovePreviewValueModePlan": "Plano", + "workflowMovePreviewValueLow": "Baixo", + "workflowMovePreviewValueMedium": "Médio", + "workflowMovePreviewValueHigh": "Alto", + "workflowMovePreviewValueMax": "Máximo", + "workflowMovePreviewNoticeMissingOriginal": "As definições da sessão original estão indisponíveis.", + "workflowMovePreviewNoticeAmbiguous": "Mais do que uma regra de definições da sessão corresponde.", + "workflowMovePreviewNoticeConflicting": "As regras de definições da sessão entram em conflito.", + "workflowMovePreviewNoticeInapplicable": "A definição da sessão foi ignorada para esta conversa.", + "workflowMovePreviewNoticeConfigurationUnavailable": "Não foi possível resolver as definições da sessão.", + "workflowMovePreviewNoticeInvalidConfiguration": "As definições da sessão contêm uma regra inválida.", + "workflowMovePreviewNoticeTargetUnavailable": "O destino de sessão selecionado está indisponível.", + "workflowMovePreviewNoticeProfileUnavailable": "O perfil de agente de destino está indisponível.", "moveTaskToTheNextWorkflow": "Mover a tarefa para o passo seguinte do fluxo de trabalho", "moveTo": "Mover para", "moving": "A mover...", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index fbb5944ab8..b7cceb858c 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -896,6 +896,69 @@ "workflowMoveInstructions": "一次性指令", "workflowMoveInstructionsPlaceholder": "为接收任务的代理添加指令", "workflowMoveApply": "移动", + "workflowMovePreviewChecking": "正在检查会话...", + "workflowMovePreviewUnavailable": "预览不可用", + "workflowMovePreviewRetry": "重试", + "workflowMovePreviewReuseCurrent": "复用当前会话", + "workflowMovePreviewReuseOther": "复用其他会话", + "workflowMovePreviewReuseOtherNamed": "复用:{{session}}", + "workflowMovePreviewNewSession": "新会话", + "workflowMovePreviewUnknownSession": "会话不可用", + "workflowMovePreviewModel": "{{model}}", + "workflowMovePreviewModelChange": "{{before}} → {{after}}", + "workflowMovePreviewModelRetainedOverride": "{{model}}(保留覆盖设置)", + "workflowMovePreviewModelUnknown": "模型未知", + "workflowMovePreviewAdditionalChanges_one": "+{{count}} 项更改", + "workflowMovePreviewAdditionalChanges_other": "+{{count}} 项更改", + "workflowMovePreviewDetails": "详细信息", + "workflowMovePreviewShowDetails": "显示预览详细信息", + "workflowMovePreviewHideDetails": "隐藏预览详细信息", + "workflowMovePreviewSession": "会话", + "workflowMovePreviewProfile": "配置", + "workflowMovePreviewModelDetails": "模型", + "workflowMovePreviewChanges": "更改", + "workflowMovePreviewContext": "上下文", + "workflowMovePreviewContextReset": "重置", + "workflowMovePreviewSource": "源会话", + "workflowMovePreviewSourceKeep": "保留", + "workflowMovePreviewSourcePark": "暂停", + "workflowMovePreviewSourceComplete": "完成", + "workflowMovePreviewDispatch": "发送", + "workflowMovePreviewDispatchPrompt": "发送步骤提示", + "workflowMovePreviewDispatchNoPrompt": "不发送提示", + "workflowMovePreviewDispatchDeferred": "回合结束时重新检查", + "workflowMovePreviewDispatchNoSession": "代理未启动", + "workflowMovePreviewNotes": "备注", + "workflowMovePreviewNoticeRetainedOverride": "会话会保留模型覆盖设置。", + "workflowMovePreviewNoticeUnavailable": "部分预览设置不可用。", + "workflowMovePreviewPlanned": "计划中", + "workflowMovePreviewSkipped": "已跳过", + "workflowMovePreviewUnchanged": "未更改", + "workflowMovePreviewUnknown": "未知", + "workflowMovePreviewTo": "至", + "workflowMovePreviewExecutionCheck": "移动时会再次检查当前状态。", + "workflowMovePreviewNoSession": "不会启动会话", + "workflowMovePreviewFieldMode": "模式", + "workflowMovePreviewFieldStepPrompt": "步骤提示", + "workflowMovePreviewFieldReasoningEffort": "推理力度", + "workflowMovePreviewFieldVerbosity": "详细程度", + "workflowMovePreviewFieldOther": "其他设置", + "workflowMovePreviewValueConfigured": "已配置", + "workflowMovePreviewValueSkipped": "已跳过", + "workflowMovePreviewValueModeDefault": "默认", + "workflowMovePreviewValueModePlan": "计划", + "workflowMovePreviewValueLow": "低", + "workflowMovePreviewValueMedium": "中", + "workflowMovePreviewValueHigh": "高", + "workflowMovePreviewValueMax": "最高", + "workflowMovePreviewNoticeMissingOriginal": "原会话设置不可用。", + "workflowMovePreviewNoticeAmbiguous": "有多个会话设置规则匹配。", + "workflowMovePreviewNoticeConflicting": "会话设置规则存在冲突。", + "workflowMovePreviewNoticeInapplicable": "该会话设置不适用于此对话,已跳过。", + "workflowMovePreviewNoticeConfigurationUnavailable": "无法解析会话设置。", + "workflowMovePreviewNoticeInvalidConfiguration": "会话设置包含无效规则。", + "workflowMovePreviewNoticeTargetUnavailable": "所选会话目标不可用。", + "workflowMovePreviewNoticeProfileUnavailable": "目标代理配置不可用。", "moveTaskToTheNextWorkflow": "将任务移至下一工作流步骤", "moveTo": "移动到", "moving": "正在移动…", diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index ed92b1292a..9b72ee6177 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -896,6 +896,69 @@ "workflowMoveInstructions": "一次性指令", "workflowMoveInstructionsPlaceholder": "為接收任務的代理新增指令", "workflowMoveApply": "移動", + "workflowMovePreviewChecking": "正在檢查工作階段...", + "workflowMovePreviewUnavailable": "預覽不可用", + "workflowMovePreviewRetry": "重試", + "workflowMovePreviewReuseCurrent": "複用目前工作階段", + "workflowMovePreviewReuseOther": "複用其他工作階段", + "workflowMovePreviewReuseOtherNamed": "複用:{{session}}", + "workflowMovePreviewNewSession": "新工作階段", + "workflowMovePreviewUnknownSession": "工作階段不可用", + "workflowMovePreviewModel": "{{model}}", + "workflowMovePreviewModelChange": "{{before}} → {{after}}", + "workflowMovePreviewModelRetainedOverride": "{{model}}(保留覆蓋設定)", + "workflowMovePreviewModelUnknown": "模型未知", + "workflowMovePreviewAdditionalChanges_one": "+{{count}} 項更改", + "workflowMovePreviewAdditionalChanges_other": "+{{count}} 項更改", + "workflowMovePreviewDetails": "詳細資訊", + "workflowMovePreviewShowDetails": "顯示預覽詳細資訊", + "workflowMovePreviewHideDetails": "隱藏預覽詳細資訊", + "workflowMovePreviewSession": "工作階段", + "workflowMovePreviewProfile": "設定", + "workflowMovePreviewModelDetails": "模型", + "workflowMovePreviewChanges": "更改", + "workflowMovePreviewContext": "上下文", + "workflowMovePreviewContextReset": "重置", + "workflowMovePreviewSource": "源工作階段", + "workflowMovePreviewSourceKeep": "保留", + "workflowMovePreviewSourcePark": "暫停", + "workflowMovePreviewSourceComplete": "完成", + "workflowMovePreviewDispatch": "傳送", + "workflowMovePreviewDispatchPrompt": "傳送步驟提示", + "workflowMovePreviewDispatchNoPrompt": "不傳送提示", + "workflowMovePreviewDispatchDeferred": "回合結束時重新檢查", + "workflowMovePreviewDispatchNoSession": "代理未啓動", + "workflowMovePreviewNotes": "備註", + "workflowMovePreviewNoticeRetainedOverride": "工作階段會保留模型覆蓋設定。", + "workflowMovePreviewNoticeUnavailable": "部分預覽設定不可用。", + "workflowMovePreviewPlanned": "計劃中", + "workflowMovePreviewSkipped": "已跳過", + "workflowMovePreviewUnchanged": "未更改", + "workflowMovePreviewUnknown": "未知", + "workflowMovePreviewTo": "至", + "workflowMovePreviewExecutionCheck": "移動時會再次檢查目前狀態。", + "workflowMovePreviewNoSession": "不會啓動工作階段", + "workflowMovePreviewFieldMode": "模式", + "workflowMovePreviewFieldStepPrompt": "步驟提示", + "workflowMovePreviewFieldReasoningEffort": "推理力度", + "workflowMovePreviewFieldVerbosity": "詳細程度", + "workflowMovePreviewFieldOther": "其他設定", + "workflowMovePreviewValueConfigured": "已設定", + "workflowMovePreviewValueSkipped": "已跳過", + "workflowMovePreviewValueModeDefault": "預設", + "workflowMovePreviewValueModePlan": "計劃", + "workflowMovePreviewValueLow": "低", + "workflowMovePreviewValueMedium": "中", + "workflowMovePreviewValueHigh": "高", + "workflowMovePreviewValueMax": "最高", + "workflowMovePreviewNoticeMissingOriginal": "原工作階段設定不可用。", + "workflowMovePreviewNoticeAmbiguous": "有多個工作階段設定規則匹配。", + "workflowMovePreviewNoticeConflicting": "工作階段設定規則存在衝突。", + "workflowMovePreviewNoticeInapplicable": "該工作階段設定不適用於此對話,已跳過。", + "workflowMovePreviewNoticeConfigurationUnavailable": "無法解析工作階段設定。", + "workflowMovePreviewNoticeInvalidConfiguration": "工作階段設定包含無效規則。", + "workflowMovePreviewNoticeTargetUnavailable": "所選工作階段目標不可用。", + "workflowMovePreviewNoticeProfileUnavailable": "目標代理設定不可用。", "moveTaskToTheNextWorkflow": "將任務移至下一工作流程步驟", "moveTo": "移動到", "moving": "正在移動…", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 441e1f191f..301c005010 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -896,6 +896,69 @@ "workflowMoveInstructions": "一次性指令", "workflowMoveInstructionsPlaceholder": "為接收任務的代理新增指令", "workflowMoveApply": "移動", + "workflowMovePreviewChecking": "正在檢查工作階段...", + "workflowMovePreviewUnavailable": "預覽不可用", + "workflowMovePreviewRetry": "重試", + "workflowMovePreviewReuseCurrent": "複用目前工作階段", + "workflowMovePreviewReuseOther": "複用其他工作階段", + "workflowMovePreviewReuseOtherNamed": "複用:{{session}}", + "workflowMovePreviewNewSession": "新工作階段", + "workflowMovePreviewUnknownSession": "工作階段不可用", + "workflowMovePreviewModel": "{{model}}", + "workflowMovePreviewModelChange": "{{before}} → {{after}}", + "workflowMovePreviewModelRetainedOverride": "{{model}}(保留覆蓋設定)", + "workflowMovePreviewModelUnknown": "模型未知", + "workflowMovePreviewAdditionalChanges_one": "+{{count}} 項更改", + "workflowMovePreviewAdditionalChanges_other": "+{{count}} 項更改", + "workflowMovePreviewDetails": "詳細資訊", + "workflowMovePreviewShowDetails": "顯示預覽詳細資訊", + "workflowMovePreviewHideDetails": "隱藏預覽詳細資訊", + "workflowMovePreviewSession": "工作階段", + "workflowMovePreviewProfile": "設定", + "workflowMovePreviewModelDetails": "模型", + "workflowMovePreviewChanges": "更改", + "workflowMovePreviewContext": "上下文", + "workflowMovePreviewContextReset": "重置", + "workflowMovePreviewSource": "源工作階段", + "workflowMovePreviewSourceKeep": "保留", + "workflowMovePreviewSourcePark": "暫停", + "workflowMovePreviewSourceComplete": "完成", + "workflowMovePreviewDispatch": "傳送", + "workflowMovePreviewDispatchPrompt": "傳送步驟提示", + "workflowMovePreviewDispatchNoPrompt": "不傳送提示", + "workflowMovePreviewDispatchDeferred": "回合結束時重新檢查", + "workflowMovePreviewDispatchNoSession": "代理未啟動", + "workflowMovePreviewNotes": "備註", + "workflowMovePreviewNoticeRetainedOverride": "工作階段會保留模型覆蓋設定。", + "workflowMovePreviewNoticeUnavailable": "部分預覽設定不可用。", + "workflowMovePreviewPlanned": "計劃中", + "workflowMovePreviewSkipped": "已跳過", + "workflowMovePreviewUnchanged": "未更改", + "workflowMovePreviewUnknown": "未知", + "workflowMovePreviewTo": "至", + "workflowMovePreviewExecutionCheck": "移動時會再次檢查目前狀態。", + "workflowMovePreviewNoSession": "不會啟動工作階段", + "workflowMovePreviewFieldMode": "模式", + "workflowMovePreviewFieldStepPrompt": "步驟提示", + "workflowMovePreviewFieldReasoningEffort": "推理力度", + "workflowMovePreviewFieldVerbosity": "詳細程度", + "workflowMovePreviewFieldOther": "其他設定", + "workflowMovePreviewValueConfigured": "已設定", + "workflowMovePreviewValueSkipped": "已跳過", + "workflowMovePreviewValueModeDefault": "預設", + "workflowMovePreviewValueModePlan": "計劃", + "workflowMovePreviewValueLow": "低", + "workflowMovePreviewValueMedium": "中", + "workflowMovePreviewValueHigh": "高", + "workflowMovePreviewValueMax": "最高", + "workflowMovePreviewNoticeMissingOriginal": "原工作階段設定不可用。", + "workflowMovePreviewNoticeAmbiguous": "有多個工作階段設定規則匹配。", + "workflowMovePreviewNoticeConflicting": "工作階段設定規則存在衝突。", + "workflowMovePreviewNoticeInapplicable": "該工作階段設定不適用於此對話,已跳過。", + "workflowMovePreviewNoticeConfigurationUnavailable": "無法解析工作階段設定。", + "workflowMovePreviewNoticeInvalidConfiguration": "工作階段設定包含無效規則。", + "workflowMovePreviewNoticeTargetUnavailable": "所選工作階段目標不可用。", + "workflowMovePreviewNoticeProfileUnavailable": "目標代理設定不可用。", "moveTaskToTheNextWorkflow": "將任務移至下一工作流程步驟", "moveTo": "移動到", "moving": "正在移動…", diff --git a/docs/plans/workflow-move-preview/plan.md b/docs/plans/workflow-move-preview/plan.md new file mode 100644 index 0000000000..557d254077 --- /dev/null +++ b/docs/plans/workflow-move-preview/plan.md @@ -0,0 +1,263 @@ +--- +created: 2026-09-14 +status: implemented +requirements: + - REQ-TASKS-WORKFLOW-MOVE-PREVIEW-001 + - REQ-TASKS-WORKFLOW-MOVE-PREVIEW-002 +system_design: + - ../../specs/tasks/system-design/workflow-move-preview.md +legacy_specs: [] +--- + +# Implementation Plan: Workflow Move Preview + +## Overview + +Add compact task-specific recipient and model feedback before a step move. +Deliver a read-only shared backend decision first, then integrate the desktop +popover and phone drawer with end-to-end move verification. The backend boundary +needs isolated side-effect and routing-parity proof before any UI depends on it. + +Confirmed user choices: two summary lines, current/other/new conversation, +unchanged or before/after model, additional settings count, optional details. +The preview includes conditional Codex model changes without profile changes. + +## Scope + +### In scope + +- Task stepper and compact step disclosure, including task preview use sites. +- Read-only move prediction using runtime overrides and entry options. +- Conditional set, keep, restore-original and no-match behavior. +- Loading, retry, uncertainty, accessibility, five locales, and phone composition. + +### Out of scope + +- Changes to session routing policy, profile defaults, provider acceptance, + native subagents, workflow editing, or mandatory move confirmation. +- Board drag, bulk moves, and unrelated move menus. +- Production instance mutation or live provider probes during development. + +## Technical approach + +Follow the [design](../../specs/tasks/system-design/workflow-move-preview.md). +Extract shared read-only decisions from orchestrator workflow routing and +conditional configuration. Add the proposed task move-preview endpoint and +DTO, then a domain API client/hook and shared renderer. Keep execution-time +atomic checks and existing move semantics authoritative. + +Nearest shipped mobile surface: `workflow-step-disclosure.tsx`. +Curated baseline: MobilePickerSheet and the mobile UI language's temporary +choice drawer. Use its fixed header, internal scroll, safe-area clearance, +focus return, and shared state logic. + +No schema or persistent preview cache. The design carries the endpoint contract; +no separate architecture decision is needed for an advisory projection of the +existing lifecycle. Public documentation changes belong to implementation. + +## ASCII UI preview + +Structural requirements: exactly two centered collapsed summary rows below actions and capabilities; +an icon-only info button expands details below the summary; Options and existing capability/progress +content stay available. Spacing and abbreviated data below are illustrative. +All drawn strings become localized copy or model/session data. + +### UI-01: Desktop step hover or keyboard disclosure + +AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.1 through .5 and -002.1, .4. + +```text ++--------------------------------+ +| -> Move here | +| Options | +| [step capabilities] | +|--------------------------------| +| Reuse current session | +| Astra -> Luna +2 (i) | ++--------------------------------+ + +Expanded details appear beneath the footer. +Other outcomes: New session; Reuse: Implementation; No session starts. +Unchanged model: Astra (override retained), or Luna without an override. +``` + +The footer layout was revised after implementation with user approval. The +next-step options form above chat uses the same footer below its Move action. + +`+2` means reasoning and context reset here, excluding the already displayed +model change. A model-only change has no count. Details disclose uncertainty, +skipped rules, full labels, profile, and source retirement as applicable. + +### UI-02: Phone step drawer + +Entry: tap the existing current-step disclosure. The drawer keeps its fixed +header and one scroll body. AC-TASKS-WORKFLOW-MOVE-PREVIEW-002.2, .4. + +```text ++--------------------------------+ +| Move to x | fixed header +|--------------------------------| +| Analysis (current) | +|--------------------------------| +| Implement | +| [Options] [Move here] | +| Reuse current session | +| Astra -> Luna +1 (i) | +|--------------------------------| +| Review | +| [Options] [Move here] | +| Reuse: Analysis | +| Astra (i) | +|________________________________| safe-area clearance +``` + +Details expand in the same scroll body, immediately after the summary. All touch +actions have at least 44px hit areas. No stacked overlays or horizontal scrolling. +Same projection and move draft serve desktop and phone. + +### UI-03: Loading, error, unknown + +AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.5, .7 and -002.3. + +```text ++-------------------------+ +-------------------------+ +| Move here | | Move here | +| Options | | Options | +|-------------------------| |-------------------------| +| Checking session... | | Preview unavailable | +| | | Retry | ++-------------------------+ +-------------------------+ + ++-------------------------+ +| Move here | +| Options | +|-------------------------| +| New session | +| Model not known (i) | ++-------------------------+ +``` + +Loading, error, or unknown alone does not disable Move here. Existing permission, +pending-move, and task gates still apply. Phone rows use these same statuses. + +## Tests + +| Criteria | Planned evidence | +| --- | --- | +| 001.1, 001.2 | workflow_move_preview_test.go: PreviewMatchesMove, SameProfileOverride, ExplicitTargets, TerminalCandidates, NewSessionDefaults | +| 001.3, 001.4, 001.5 | workflow_move_preview_test.go: ConditionalSetKeepRestore, OriginalEligibility, AmbiguousRules, UnknownCapabilities, DeduplicatedChanges | +| 001.6 | workflow_move_preview_test.go: NoSideEffects; workflow_move_preview_http_test.go: AuthorizationAndRedaction | +| 001.7, 002.3 | use-workflow-move-preview.test.ts: LateResponse, DraftChanges, ReopenReconnect, Retry; deferred-move backend fixture | +| 002.1, 002.2, 002.4 | workflow-move-preview.test.tsx and workflow-stepper.test.tsx: summary/details, keyboard, counts, localization; phone E2E | + +## E2E tests + +New `tests/workflow/workflow-move-preview.spec.ts` (chromium) covers a retained +session model override during an in-place move, reuse of another named session, +and creation of a fresh profiled session. Each scenario inspects the preview, +executes the move, and checks the resulting session identity or model against +isolated fixture API state. Unit and integration tests cover conditional +settings, reset and skipped states, no-session launch gates, authorization, +fresh-launch configuration projection, and stale-response handling. + +New `tests/workflow/mobile-workflow-move-preview.spec.ts` (mobile-chrome) covers +the coarse-pointer touch drawer, inline details, 44px controls, focus return on +dismissal, and no horizontal overflow. The scenario uses the repository's +tablet context because the full phone task route uses `SessionMobileLayout` and +does not render the desktop task stepper. Existing step-targeting and move-option +suites provide the adjacent mobile regression evidence. No production DB or task +is used. + +## Work orders + +- [x] [Task 01: Read-only move decisions and API](task-01-move-decision-api.md) +- [x] [Task 02: Compact preview and move verification](task-02-compact-preview.md) + +Order: 01 -> 02. Both are sequential. No delegation is authorized by this plan. + +## Verification results + +Design checks passed on 2026-09-14: + +- `python3 scripts/list-docs.py validate`: 267 decisions and 911 specifications validated. +- `python3 scripts/lint-spec-files.py --all`: all specification files passed. +- `python3 scripts/lint-spec-files.test.py`: 36 tests passed. +- Relative-link, acceptance-reference, and whitespace checks passed for all five files. +- `git diff --check` passed; scoped git status confirmed all five new artifacts. + +Implementation is complete and both work orders are complete. + +- Backend package tests and the targeted race suite passed. +- Frontend focused tests passed with 44 tests across five files. Typecheck, + full lint, localization checks, and the new-code localization ratchet passed. +- `make -C apps/backend build` and `pnpm run build:e2e` passed. +- Desktop workflow E2E passed all 12 tests. Mobile workflow E2E passed all 5 + tests after the final drawer exit-state assertion was stabilized. +- Specification, public-doc, and diff checks passed. + +The mobile browser scenario uses the existing coarse-pointer tablet context to +exercise `CompactWorkflowStepDisclosure`. The full Pixel 5 task route uses the +separate `SessionMobileLayout` and does not render the desktop task stepper. +This is a test-surface detail; the shared touch drawer remains the production +mobile disclosure used by the task preview and tablet task surfaces. + +### Review remediation addendum + +The implementation evidence was extended after code-only review. Backend preview +recipient selection now uses the same no-session launch predicate as execution, +including the no-auto-start and skip-without-instructions gates. The preview +returns `no_session` without a recipient when the task remains idle, and keeps +the task-level profile fallback when an allowed fresh launch can occur. + +The preview endpoint authorizes the task before reading task state. Existing +source sessions retain current-session routing when no destination profile is +configured, while profile changes can predict a fresh target session even when +the destination does not auto-start a prompt. Fresh launches project applicable +session settings, and context reset and mode changes report skipped states when +execution cannot apply them. Busy reusable targets report deferred dispatch, +and no-session dispatch has its own localized detail label. + +Open previews now invalidate from live store revisions and connection changes, +including session model and fallback updates, clear stale success while +refreshing, and retain late-response guards. Every movable disclosure row can +receive a preview through a two-request queue. Typed diagnostics and Kandev-owned +fields and values render through the active locale; provider option labels remain +bounded display data. + +Additional remediation checks passed on 2026-09-15: + +- `go test ./internal/workflow/move ./internal/orchestrator`. +- `make lint` from `apps/backend`. +- Focused Vitest: 3 files, 13 tests passed. +- `pnpm run typecheck`, `pnpm run lint`, `pnpm run i18n:check`, and + `pnpm run i18n:ratchet` from `apps/web`. + +Final post-remediation verification passed on 2026-09-15: + +- Focused Vitest passed all 50 tests across six affected files. +- `make build` from `apps/backend` and `pnpm run build:e2e` from `apps/web`. +- The focused desktop move-preview spec passed 2 tests, and the focused mobile + move-preview spec passed 1 test. +- `go test -race ./internal/orchestrator ./internal/task/handlers -run 'Preview|WorkflowStepSession|WorkflowSessionConfig|WorkflowSessionTarget|SameProfile'`. +- `go test -race ./internal/orchestrator -count=1`, including the completed-task + follow-up admission path. +- `go test ./internal/orchestrator -run '^TestCompletedTaskFollowUpAdmissionIsConversationalOnly$' -count=100 -failfast`. +- Specification, public-doc, formatting, and localization checks passed after + trimming unrelated generated catalog churn. + +### Approved footer revision (2026-09-15) + +- Centered the preview below step actions and capabilities, with an info icon. +- Added the same live-draft footer to chat and passthrough next-step options. +- Passed 79 focused unit/component tests, typecheck, and changed-file lint. +- Passed two desktop and two touch browser scenarios covering footer placement, + next-step submission, inline details, and 44px touch targets. +- Updated the requirements, design, work order, and public workflow guide. + +## Risks + +- Selection helpers can write provenance or warnings; preview must isolate pure decisions. +- Live provider rejection and deferred moves cannot be guaranteed by a hover snapshot. +- Runtime configuration can differ from mutable profile rows. +- Read-only snapshots and WS updates race; stale-generation tests are required. +- Long translations can crowd the compact surface; full details stay accessible. diff --git a/docs/plans/workflow-move-preview/task-01-move-decision-api.md b/docs/plans/workflow-move-preview/task-01-move-decision-api.md new file mode 100644 index 0000000000..858f07e480 --- /dev/null +++ b/docs/plans/workflow-move-preview/task-01-move-decision-api.md @@ -0,0 +1,141 @@ +--- +id: "01-move-decision-api" +title: "Read-only move decisions and API" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-WORKFLOW-MOVE-PREVIEW-001 + - REQ-TASKS-WORKFLOW-MOVE-PREVIEW-002 +acceptance_criteria: + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.1 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.2 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.3 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.4 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.5 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.6 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.7 +system_design: + - ../../specs/tasks/system-design/workflow-move-preview.md +--- + +# Task 01: Read-only move decisions and API + +## Summary + +Provide a task-specific preview of recipient and expected configuration without +executing routing or ACP operations. Reuse decisions in execution paths so the +endpoint and actual moves remain consistent. + +## In scope + +Shared recipient decision extraction, effective configuration and conditional +rule projection, normalized one-shot reset/dispatch effects, DTO, authorized +HTTP endpoint, dependency wiring, and focused backend regression fixtures. + +## Out of scope + +Rendered UI, schema migrations, changed lifecycle policy, provider probes, +and caching or reserving preview selections. + +## Acceptance + +1. Real fixture moves match their previews for same/other/new session cases, + explicit targets, overrides, and conditional settings; terminal exclusion holds. +2. Repeated previews leave session/task metadata, bindings, messages, and runtime + invocation counters unchanged. Authorization and safe DTO filtering hold. +3. Unknown/skipped/planned configuration is explicit; existing atomic route, + retry, deferred-move, and best-effort provider semantics remain intact. + +## Verification + +Run from repository root. Add named test files before running these commands; +use TDD to demonstrate the missing preview behavior first. + +```bash +(cd apps/backend && go test ./internal/orchestrator ./internal/task/handlers ./internal/task/service ./internal/workflow/...) +(cd apps/backend && go test -race ./internal/orchestrator ./internal/task/handlers -run 'Preview|WorkflowStepSession|WorkflowSessionConfig|WorkflowSessionTarget|SameProfile') +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +## Files likely touched + +- `apps/backend/internal/orchestrator/workflow_move_preview.go`, + `workflow_move_preview_settings.go`, and `_test.go` (new). +- `apps/backend/internal/orchestrator/event_handlers_workflow.go`. +- `apps/backend/internal/orchestrator/workflow_session_target.go`. +- `apps/backend/internal/orchestrator/workflow_session_config.go`. +- `apps/backend/internal/task/handlers/workflow_move_preview_http.go` and `_test.go` (new). +- `apps/backend/internal/task/handlers/task_handlers.go`, `task_http_handlers.go`. +- `apps/backend/internal/task/dto/` and existing backendapp dependency wiring. + +## Dependencies + +None. Read both paired specifications and current lifecycle/conditional-settings +contracts. Existing same-profile policy and session-target tests are exemplars. + +## Risks + +A helper named resolve can mutate state. Review every reachable call; extract +pure inputs/outputs and keep warning publication in execution only. + +## Parallelism + +`sequential` + +## Inputs + +[Design](../../specs/tasks/system-design/workflow-move-preview.md), especially +Resolution, API, and Freshness and failures. [Plan test matrix](plan.md#tests). + +## Results + +Implemented the read-only workflow move decision layer and the authorized +`POST /api/v1/tasks/:id/move-preview` endpoint. Recipient selection shares the +pure reusable-session selector with execution, preserves explicit targets and +terminal-session exclusion, projects runtime configuration and conditional +rules without writes, and reports unknown or deferred outcomes explicitly. + +Verification passed: + +- `cd apps/backend && go test ./internal/orchestrator ./internal/task/handlers ./internal/task/service ./internal/workflow/...` +- `cd apps/backend && go test -race ./internal/orchestrator ./internal/task/handlers -run 'Preview|WorkflowStepSession|WorkflowSessionConfig|WorkflowSessionTarget|SameProfile'` +- `python3 scripts/list-docs.py validate` +- `python3 scripts/lint-spec-files.py --all` +- `git diff --check` + +## Review remediation results + +The preview now shares `workflowmove.ShouldAutoStartAgent` with the actual +no-session launch handler. A destination without `auto_start_agent`, or a +`skip_step_prompt` move without instructions, returns `no_session` with no +recipient. A permitted sessionless launch still resolves the task-level agent +profile when the destination has no step profile. Provider option labels are +bounded display labels, while Kandev-owned setting fields keep stable keys for +localization. The endpoint authorizes the task before repository access, keeps +current-session routing when only the task profile fallback exists, predicts +profile switches from an existing source session, and projects applicable +configure-session rules for a fresh launch. Busy reusable targets report +deferred dispatch, while fresh context and passthrough mode changes report +skipped applicability. + +Regression coverage compares both idle launch gates with the actual move path, +checks task-profile fallback for an allowed launch, and verifies the setting +label DTO shape, task authorization ordering, typed configuration notices, and +busy-target dispatch. + +Remediation verification passed on 2026-09-15: + +- `cd apps/backend && go test ./internal/workflow/move ./internal/orchestrator` +- `cd apps/backend && make lint` + +Final post-remediation verification also passed for the targeted backend race +suite and the managed backend build: + +- `cd apps/backend && go test -race ./internal/orchestrator ./internal/task/handlers -run 'Preview|WorkflowStepSession|WorkflowSessionConfig|WorkflowSessionTarget|SameProfile'` +- `cd apps/backend && go test -race ./internal/orchestrator -count=1` +- `cd apps/backend && go test ./internal/orchestrator -run '^TestCompletedTaskFollowUpAdmissionIsConversationalOnly$' -count=100 -failfast` +- `cd apps/backend && make build` diff --git a/docs/plans/workflow-move-preview/task-02-compact-preview.md b/docs/plans/workflow-move-preview/task-02-compact-preview.md new file mode 100644 index 0000000000..d9f35352f6 --- /dev/null +++ b/docs/plans/workflow-move-preview/task-02-compact-preview.md @@ -0,0 +1,194 @@ +--- +id: "02-compact-preview" +title: "Compact preview and move verification" +status: completed +wave: 2 +depends_on: + - "01-move-decision-api" +plan: "plan.md" +requirements: + - REQ-TASKS-WORKFLOW-MOVE-PREVIEW-001 + - REQ-TASKS-WORKFLOW-MOVE-PREVIEW-002 +acceptance_criteria: + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.1 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.2 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.3 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.4 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.5 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.6 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.7 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-002.1 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-002.2 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-002.3 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-002.4 +system_design: + - ../../specs/tasks/system-design/workflow-move-preview.md +--- + +# Task 02: Compact preview and move verification + +## Summary + +Render the shared prediction in the step hover and mobile drawer. Verify the +visible prediction against actual moves using isolated backend fixtures. + +## In scope + +Domain client/hook, scoped invalidation, late-response guards, two-line renderer, +inline details, same draft for preview/move, all locales, focused component and +browser coverage, and public workflow guidance. + +## Out of scope + +Workflow editing, new routing rules, board drag/bulk-move previews, mandatory +confirmation, or direct provider probing. + +## Acceptance + +1. UI-01 and UI-03 remain compact, keyboard accessible, localized, and accurate + through overrides, model changes, option changes, unknowns, and transport races. +2. UI-02 exposes equivalent predictions and inline details with 44px touch actions, + one scroll owner, focus return, and viewport containment. +3. Browser tests verify preview versus actual recipient/model, including new and + reused sessions. Documentation explains predictions and execution-time checks. + +## ASCII UI preview + +UI-01 desktop and UI-02 phone excerpts; see [full previews](plan.md#ascii-ui-preview) +for UI-03 loading/error, all variants, structural notes, and criterion mappings. + +```text +Desktop Phone drawer ++---------------------------+ +------------------------------+ +| Move here | | Move to x | +| Options | |------------------------------| +| [step capabilities] | | Implement | +|---------------------------| | [Options] [Move here] | +| Reuse current session | |------------------------------| +| Astra -> Luna +1 (i) | | Reuse current session | ++---------------------------+ | Astra -> Luna +1 (i) | + +------------------------------+ +``` + +The approved footer also appears below the Move action in the next-step +options popover and drawer above chat. It uses that action's destination and draft. + +Collapsed summaries stay two centered lines. Details expand inline; phone rows share +one internal scroll region. The +1 denotes a non-model setting change here. +AC-TASKS-WORKFLOW-MOVE-PREVIEW-002.1 through .4 apply. + +## Verification + +Commands run from repository root. Bootstrap this worktree before the first +pnpm check. Managed E2E commands build fresh artifacts and run sequentially. +The new test files below are implementation outputs, not existing tests. + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm test hooks/domains/kanban/use-workflow-move-preview.test.ts components/task/workflow-move-preview.test.tsx components/task/workflow-stepper.test.tsx) +(cd apps/web && pnpm run typecheck && pnpm run lint && pnpm run i18n:check && pnpm run i18n:ratchet) +(cd apps/web && pnpm e2e:run tests/workflow/workflow-move-preview.spec.ts tests/workflow/workflow-session-targeting.spec.ts tests/workflow/workflow-step-move-overrides.spec.ts -- --project=chromium) +(cd apps/web && pnpm e2e:run tests/workflow/mobile-workflow-move-preview.spec.ts tests/workflow/mobile-workflow-session-targeting.spec.ts tests/workflow/mobile-workflow-step-move-overrides.spec.ts -- --project=mobile-chrome) +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.test.py +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +Inspect rendered desktop and phone screenshots from these tests against UI-01 +through UI-03. Record actual commands/results and any deviation. Add any other +changed unit suite to the targeted command before completion. + +## Files likely touched + +- `apps/web/lib/api/domains/kanban-api.ts`. +- `apps/web/hooks/domains/kanban/use-workflow-move-preview.ts`, its tests, and + `use-workflow-move-preview-revision.ts` (new). +- `apps/web/components/task/workflow-move-preview.tsx` and `.test.tsx` (new). +- `apps/web/components/task/workflow-stepper.tsx` and `.test.tsx`. +- `apps/web/components/task/workflow-step-disclosure.tsx` and disclosure actions. +- `apps/web/src/locales/` for en, pt-pt, zh-cn, zh-hk, zh-tw; generate Traditional Chinese with i18n:zh-hant. +- `apps/web/e2e/tests/workflow/workflow-move-preview.spec.ts` and `mobile-workflow-move-preview.spec.ts` (new). +- `docs/public/tasks-and-workflows.md` and paired feature documents/work-order results. + +## Dependencies + +Task 01 and its typed response. Reuse existing stepper, move options, session +targeting, and mobile drawer tests; do not simulate all routing in frontend mocks. + +## Risks + +Disclosure remounts, pinned viewed tabs, WS updates, and draft changes can race. +Preserve move-pending ownership and never render a response from another scope. + +## Parallelism + +`sequential` + +## Inputs + +[Design](../../specs/tasks/system-design/workflow-move-preview.md): Presentation, +Mobile, Freshness and failures. Use mobile-parity, TDD, E2E, and docs-maintainer +skills during implementation. No implementation delegation is authorized. + +## Results + +Implemented the shared preview client, debounced request hook, compact renderer, +desktop disclosure integration, and coarse-pointer drawer integration. The same +normalized options draft feeds the preview and the eventual move. All five web +locales include the new copy, and public workflow guidance describes the +advisory prediction and execution-time recheck. + +Verification passed: + +- `(cd apps && pnpm install --frozen-lockfile)` +- Focused Vitest: 5 files, 44 tests passed. +- `pnpm run typecheck`, `pnpm run lint`, `pnpm run i18n:check`, and `pnpm run i18n:ratchet`. +- `make -C apps/backend build` and `pnpm run build:e2e`. +- Desktop workflow E2E: 12 tests passed, including recipient reuse, fresh + sessions, retained model overrides, targeting, and move options. +- Mobile workflow E2E: 5 tests passed, including the preview drawer, session + targeting, and move-option regressions. The preview scenario uses the + repository's coarse-pointer tablet context because the Pixel 5 full-page task + route intentionally uses `SessionMobileLayout`, which has no desktop task + stepper. The exercised `CompactWorkflowStepDisclosure` branch is the shared + touch drawer used by the task preview and tablet task surfaces. +- `python3 scripts/list-docs.py validate` +- `python3 scripts/lint-spec-files.test.py` +- `python3 scripts/lint-spec-files.py --all` +- `node --test scripts/validate-public-docs.test.mjs` +- `node scripts/validate-public-docs.mjs` +- `git diff --check` + +The managed build reports existing Darwin signing, deprecated Vite chunk +configuration, and large-chunk warnings; all build and browser commands exit +successfully. + +## Review remediation results + +The request hook now receives a store-derived revision containing relevant task, +source and destination step, session, workflow, profile, workspace, and +connection state, including per-session model and fallback data. It clears a +successful result before refetching on live updates and retains cancellation +and generation guards. Drawer rows all enqueue their previews; the shared queue +limits active requests to two and allows later rows to resolve after earlier +requests finish. + +The renderer maps known notice, field, and built-in value codes to localized +copy, including a dedicated no-session dispatch state, while retaining a +generic fallback for unknown diagnostics and safe data labels for provider +options. A non-English DTO-shape test covers missing snapshot, ambiguous rule, +and inapplicable-session notices. + +Remediation verification passed on 2026-09-15: + +- Focused Vitest: 3 files, 13 tests passed. +- `cd apps/web && pnpm run typecheck && pnpm run lint && pnpm run i18n:check && pnpm run i18n:ratchet` + +Final post-remediation verification passed on 2026-09-15: + +- Focused Vitest: 6 files, 50 tests passed. +- `pnpm run build:e2e`, `pnpm run typecheck`, `pnpm run lint`, + `pnpm run i18n:check`, and `pnpm run i18n:ratchet`. +- Desktop workflow move-preview E2E: 2 tests passed. +- Mobile workflow move-preview E2E: 1 test passed. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 03937030ce..5f8dcdc05b 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -48,6 +48,20 @@ Workflow position and runtime state are different. Moving a card changes its wor During a move and while the destination agent is preparing or starting, the destination marker shows a spinner in the existing marker space. Open the existing step disclosure to see the lifecycle status and agent profile. On touch devices, these details appear in the existing **Move to** Drawer. A destination without auto-start settles after the move and remains available for a later agent start. +Before an allowed move, the step disclosure can show a compact preview of the +recipient and effective model. It identifies whether the move reuses the +current session, reuses another named session, creates a new session, or leaves +the task idle without a recipient when the destination has no launch turn. The +second line shows the current model or an expected model change and counts +additional settings changes. Use the info button for the session, profile, model, +context reset, source-session disposition, and prompt dispatch information. +The preview sits in a centered footer below the move controls. The next-step +button above chat shows the same footer in its options popover or touch drawer. +Changing the options refreshes that preview. +The preview is advisory. **Move here** checks routing, permissions, WIP, and +current session state again when the move runs. A preview can therefore change +while a turn is running or while another session becomes available. + ## Move a task with one-time entry options The normal **Move here** and next-step actions use the destination step's saved workflow defaults. When one transition needs an exception, open **Move with options** from the workflow stepper, Chat status bar, or passthrough toolbar. The options apply only to that entry and never rewrite the workflow step. diff --git a/docs/specs/tasks/requirements/workflow-move-preview.md b/docs/specs/tasks/requirements/workflow-move-preview.md new file mode 100644 index 0000000000..18646dd703 --- /dev/null +++ b/docs/specs/tasks/requirements/workflow-move-preview.md @@ -0,0 +1,110 @@ +--- +status: draft +system: tasks +created: 2026-09-14 +owners: + - kandev +--- + +# Workflow Move Preview Requirements + +## Overview + +Before moving a task, users can see which conversation will receive the step +and which model and settings it is expected to use. The tasks system owns this +capability because it owns workflow recipient selection and step entry behavior. + +A profile identity and a running model are different facts. A session launched +with a Luna profile can retain a later Astra model selection when reused. + +## Terminology + +- **Current session:** The conversation selected by the move's routing context, + not necessarily a separately viewed or pinned chat tab. +- **Expected configuration:** The selected session's effective settings after + applicable entry rules, subject to provider acceptance and execution-time state. +- **Preview:** A read-only prediction; it does not reserve a session or execute a move. + +## Requirements + +### REQ-TASKS-WORKFLOW-MOVE-PREVIEW-001: Session and settings prediction + +**Intent:** Expose conversation continuity and model changes before a move. + +#### Acceptance criteria + +- **AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.1:** Opening a movable step disclosure + shall show current-session reuse, other-session reuse with its name, new + session creation, or an idle task with no recipient according to task-specific + routing. Explicit initial and earlier-step targets, same-profile policies, + terminal candidates, and the destination's no-session launch gates shall + follow the existing routing contract. +- **AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.2:** An unchanged session shall show its + effective model. Reuse of a Luna-profile session running Astra shall show + Astra and disclose the retained override. A new session shall show its + expected launch model without copying the source session's overrides. +- **AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.3:** Applicable conditional settings shall + produce a before/after model summary without implying a new conversation. + Set shall retain unnamed fields; keep and no-match shall retain current + values; restore-original shall use the original effective snapshot. Rules + inapplicable to a different session or passthrough shall not predict application. +- **AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.4:** Additional effective setting changes + shall appear as a count with accessible details showing field names and + before/after values. Context reset and source-session retirement shall be + disclosed. Identical values shall not count as changes. Model changes shall + not be counted again among additional changes. +- **AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.5:** Missing original snapshots, ambiguous + rules, unavailable capability data, and provider-dependent outcomes shall + be identified with distinct reason states such as unavailable or skipped, + rather than one generic message. Unknown values shall not be presented as + confirmed models. Best-effort application remains best-effort. +- **AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.6:** Opening or refreshing the preview + shall not create sessions, start agents, change settings, write conversation + warnings, or trigger transitions. Existing authorization shall prevent + disclosure of another workspace's sessions or sensitive configuration. +- **AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.7:** Changing the task, destination, move + options, known routing/configuration state, or the connection state shall + invalidate the preview. Late responses shall not overwrite a newer selection. + Actual moves shall retain execution-time validation, including deferred moves. + +### REQ-TASKS-WORKFLOW-MOVE-PREVIEW-002: Compact accessible disclosure + +**Intent:** Provide useful feedback within the existing step move surface. + +#### Acceptance criteria + +- **AC-TASKS-WORKFLOW-MOVE-PREVIEW-002.1:** The collapsed desktop preview shall + use two centered summary lines below actions and capabilities: session outcome, + then model/settings with an icon-only details button. + Details shall remain opt-in. Long labels shall truncate with full accessible + text. Existing Options and capability/progress content shall remain usable. + The next-step options popover and touch drawer shall show the same footer + below their Move action, using the destination and draft of that action. +- **AC-TASKS-WORKFLOW-MOVE-PREVIEW-002.2:** Hover, keyboard focus, and activation + shall expose desktop feedback. Phones and coarse pointers shall expose the + same information and details in the step drawer, without requiring hover. + Touch actions shall measure at least 44 CSS pixels; content shall remain + within the viewport with one internal vertical scroll owner and focus return. +- **AC-TASKS-WORKFLOW-MOVE-PREVIEW-002.3:** Loading shall show Checking session. + Failure shall show Preview unavailable with Retry. Preview loading or failure + alone shall not disable an otherwise allowed move. Known move restrictions + shall continue to use the existing move gates. No stale prediction shall look current. +- **AC-TASKS-WORKFLOW-MOVE-PREVIEW-002.4:** All labels, details, statuses, and + accessible names shall support the five shipped locales, including field, + value, and diagnostic reason codes returned by the preview API. Model and + session names remain product data. The interface shall distinguish planned + settings from completed changes without adding a confirmation dialog. + +## Existing contracts + +- [Session lifecycle](workflow-profile-session-lifecycle.md) +- [Conditional settings](workflow-session-settings.md) +- [Design](../system-design/workflow-move-preview.md) +- [Implementation plan](../../../plans/workflow-move-preview/plan.md) + +## Out of scope + +Changing workflow defaults, editing profiles from the preview, automatically +repairing model overrides, routing native subagents, and adding mandatory +confirmation. This package targets the task stepper and its compact disclosure, +and the next-step control above chat, not every board drag, bulk move, or move menu. diff --git a/docs/specs/tasks/system-design/workflow-move-preview.md b/docs/specs/tasks/system-design/workflow-move-preview.md new file mode 100644 index 0000000000..ac6d87c1d2 --- /dev/null +++ b/docs/specs/tasks/system-design/workflow-move-preview.md @@ -0,0 +1,213 @@ +--- +status: draft +system: tasks +requirements: + - REQ-TASKS-WORKFLOW-MOVE-PREVIEW-001 + - REQ-TASKS-WORKFLOW-MOVE-PREVIEW-002 +--- + +# Workflow Move Preview System Design + +## Purpose and boundaries + +Add an advisory projection of the existing move decision. Preserve the routing +and best-effort conditional-setting contracts. The tasks system owns the +projection; the agent runtime owns actual provider acceptance. + +[Requirements](../requirements/workflow-move-preview.md) define the outcome. +[Lifecycle design](workflow-profile-session-lifecycle.md) defines recipients. +[Conditional settings](../requirements/workflow-session-settings.md) defines +set, keep, restore-original, original-session eligibility, and partial failure. + +## Requirement mapping + +| Requirement | Sections | +| --- | --- | +| REQ-TASKS-WORKFLOW-MOVE-PREVIEW-001 | Resolution, API, Freshness and failures | +| REQ-TASKS-WORKFLOW-MOVE-PREVIEW-002 | Presentation, Mobile, Verification | + +## Resolution + +Introduce a read-only decision layer in `internal/orchestrator/workflow_move_preview.go`. +Extract shared selection decisions from `prepareWorkflowStepSession`, +`shouldKeepCurrentWorkflowStepSession`, and `workflow_session_target.go`. +The existing execution paths consume those decisions but retain their atomic +selection, route-ledger, retry, credential, and terminal-session checks. +Do not call mutating prepare/ensure helpers to implement preview. + +Resolve the same authoritative move source as `httpMoveTask`; do not use the +browser's viewed session as routing authority. Read the destination, source, +sessions, explicit target bindings, profile configuration, and available runtime +configuration. Apply destination start policy and source end policy. No profile +override still follows existing workflow-default resolution. Reuse may select +a matching other session or create one if no eligible candidate exists and the +destination can actually launch a session for this move. When the destination +has no auto-start action, or when `skip_step_prompt` removes the only launch +turn because it has no instructions, report an idle `no_session` outcome +instead of claiming that a session will be created. The shared +`workflowmove.ShouldAutoStartAgent` predicate keeps this projection aligned +with the no-session execution path. + +Preserve terminal-session exclusion, explicit target identity, deterministic +candidate ordering, and same-profile/new behavior. A zero-session task is +reported through the existing launch decision; if that decision cannot be +resolved without provisioning, return unknown rather than ensuring a session. +Dynamic profile selection without a concrete recipient reports its profile and +an unknown model; preview never probes or launches a provider to choose one. + +For an existing recipient, use the runtime's effective configuration projection +and durable overrides with their existing precedence. Profile defaults alone +cannot describe a reused conversation. For new recipients, resolve launch +configuration without carrying overrides from the outgoing session. + +Factor pure rule selection and configuration projection out of +`workflow_session_config.go`. Reuse `selectConfigureSessionRule`, +`sessionConfigurationTargetForRule`, and existing option normalization. +`resolveWorkflowSessionConfigTarget` currently emits conversation warnings; +preview must instead collect structured diagnostic codes without calling that +side-effecting path. Share decisions, not warning publication. + +Evaluate original-session eligibility, canonical family matching and ambiguity, +keep/no-match, set's named fields, and restore's immutable original snapshot. +Report skipped rules accurately. Preserve restrictions on model/mode options. +Use available model-aware capability snapshots only. Provider acceptance remains +uncertain until application; do not synthesize capabilities or restore defaults. + +Project applicable context-reset entry actions and the normalized one-shot reset +option using execution order. Include whether the configured prompt will run, +is suppressed, or waits for turn end/queue admission in expanded details. +Do not duplicate reset effects or treat instruction text as a configuration change. + +## API + +Proposed read-only POST `/api/v1/tasks/:id/move-preview` accepts the destination +`workflow_id`, `workflow_step_id`, and the existing `entry_options` shape. +It shares request validation and task/workspace authorization with the move +handler. It accepts no client-selected source session. POST carries the draft +without putting instructions in a URL. Neither instructions nor raw request +bodies are logged or echoed. Return `Cache-Control: no-store`. + +Wire the handler through `task_handlers.go`, a narrow orchestrator preview +interface, and existing dependency construction. New DTO fields: + +- `task_id`, `workflow_step_id`, `source_session_id`, `evaluated_at`. +- `outcome`: `reuse_current`, `reuse_other`, `create_new`, `no_session`, or + `unknown`. +- `recipient`: nullable session ID/name, profile ID/name, agent family. +- `model`: before/after IDs and labels, known/unknown flags, and source + (`runtime`, `override`, `profile`, `step_rule`, `original`). +- `changes`: normalized field key, safe label, before/after value, and + `planned`/`unchanged`/`skipped`/`unknown` applicability. +- `context_reset`, `source_disposition`: keep/park/complete/unknown, + `dispatch`: prompt/no_prompt/deferred/unknown. +- `notices`: closed reason codes with safe parameters, including retained + model override, missing snapshot, unsupported option, and stale capability. + +Only selectable, non-sensitive model configuration enters this DTO. Never expose +credentials, environment, raw session metadata, prompts, or provider errors. +Unauthorized and missing tasks use existing API errors. Uncertain routing uses +an unknown projection; a `no_session` projection has no recipient, and no +fabricated session ID is returned for a new session. +This response is additive; no migration or durable preview ledger is needed. + +## Freshness and failures + +`use-workflow-move-preview.ts` under `hooks/domains/kanban/` owns requests through +`lib/api/domains/kanban-api.ts`. Fetch only while an applicable disclosure is open. +Use the existing hover delay. Every disclosed movable drawer row may enqueue a +request, while a shared queue keeps at most two requests in flight and cancels +rows that close or unmount. Do not request previews for every step across every +mounted task. +Deduplicate pending requests by task, destination, normalized options, and a local +invalidation generation. Abort on close/context change and reject late generations. + +Invalidate on task placement/primary-session changes, relevant session runtime +updates, profile updates, workflow-step updates, reconnect, and draft changes. +Clear on close and fetch fresh on reopen. Debounce draft edits; typing instruction +text need not refresh unless its presence changes prompt dispatch semantics. + +Keep two-line loading/error states compact. Retry is explicit and does not move +the task. Preview failure does not introduce a new move barrier. Show predictions +as planned, not guaranteed; details explain that execution rechecks current state. +Deferred moves show that settings are checked again at turn end. The actual move +continues through existing execution paths and handles provider partial failures. +No preview token is required to move and no confirmation flow is added. + +## Presentation + +Add `workflow-move-preview.tsx` as a shared projection renderer with no fetches. +`StepMoveControls` in `workflow-stepper.tsx` owns the draft and passes the same +normalized entry options to preview and move. Thread task/workflow identity down +from the stepper. Integrate the renderer in `workflow-step-disclosure.tsx` rows. + +Collapsed layout is two centered lines below actions and capabilities. Line one distinguishes current, +other named, new conversations, and an idle task with no recipient. Line two shows an unchanged effective model +or a before/after arrow, followed by an optional additional-change count. +A retained model/profile mismatch uses the compact override label from UI-01. + +Count actual non-model field changes plus one context-reset effect. Do not count +source retirement as a setting change. Deduplicate model and config-option model +representations. If only reasoning changes, show the unchanged model with +1. +Use the generic New session label for a permitted fresh launch. Use a separate +localized no-session label when the move leaves the task idle. Show the model on +line two for a fresh launch when known; profile name belongs in details when it +differs from the model label. + +The API returns stable field, value, and diagnostic reason codes for Kandev-owned +settings. The renderer localizes those codes in the active locale and displays +provider-defined labels only when they are safe display data. Model and session +names remain data. + +Center both footer lines and use an icon-only info button with a localized +accessible name. A separator divides actions from the muted footer. + +The chat and passthrough next-step controls pass the destination directly from +`usePlanActions` through `WorkflowMoveProceedButton` to the options form. The +form previews its current draft below the Move action, on desktop and touch. +Mount the preview only while the options surface is open. Use the existing +revision, debounce, cancellation, and concurrency logic. + +Use a keyboard/touch-operable details toggle with `aria-expanded`, not a nested +hover-only tooltip. Details list full session/profile identity, planned changes, +source retirement, dispatch, and applicable notices. Arrow summaries mean planned +changes; accessible text and expanded details explain this. Keep Options, existing +capability icons, progress, move errors, and move pending ownership intact. +Unknowns take priority over misleading before/after arrows. Status changes use +one localized status region without repeatedly announcing unchanged predictions. + +## Mobile + +Reuse `CompactWorkflowStepDisclosure` and its `useTouchDrawer` branch, including +its `max-h-[80dvh]` surface and fixed header. The curated MobilePickerSheet pattern +supplies a discoverable header entry, inset drawer, focus return, and safe areas. +This is a short temporary step choice, so it belongs in the existing drawer. + +Each visible step row gains the same two-line summary. Details expand inline +inside the drawer's single scroll body; avoid nested popovers or drawers. +Move here, Options, Retry, and details have at least 44px touch hit areas. +Desktop keeps compact 24px action density. Long names truncate in the summary +and wrap in details. Verify 320px, the default mobile viewport, and breakpoint +neighbors 767/768px with the applicable pointer mode. No document horizontal +scroll or separate persisted mobile selection is introduced. + +## Verification + +Backend fixtures compare preview selection/configuration with actual move +outcomes on isolated tasks. Assert preview does not change rows, metadata, +route bindings, messages, or runtime call counters. Cover the historical +Luna-profile/Astra override scenario plus conditional settings, the two +no-session launch gates, task-profile fallback, and deferred moves. Frontend +tests cover live store/reconnect invalidation, queued rows beyond the first two, +and localized field/value/diagnostic rendering. +Browser tests inspect the preview, perform the move, then verify recipient and +model through fixture API state. See the [plan](../../../plans/workflow-move-preview/plan.md). + +## Related decisions + +- [Profile session lifecycle](../../../decisions/2026-08-31-workflow-profile-session-switch-policy.md) +- [Original configuration](../../../decisions/2026-08-01-workflow-session-original-configuration.md) + +This projection preserves those decisions. Shared read-only resolution avoids +frontend routing duplication; an independent frontend approximation would drift +on explicit bindings and runtime overrides. A new ADR is unnecessary for this +additive feature because these boundaries and rationale fit the paired design.