FN-7444: add planning summary deepening checkpoint

Planning Mode now holds completed summaries behind a mandatory deepening prompt before finalization.

- Add shared constants and persistence for pending planning summaries.
- Insert the “Would you like to go deeper?” checkpoint with proceed, themed, and custom-topic responses.
- Update modal handling, API formatting, docs, changeset, and regression coverage for persisted and streamed planning flows.

Files changed:
 .changeset/fn-7444-planning-deepening-checkpoint.md       |   7 +
 docs/dashboard-guide.md                            |   4 +
 packages/core/src/index.ts                         |   2 +-
 packages/core/src/types.ts                         |  12 +
 packages/dashboard/app/components/PlanningModeModal.tsx |  25 ++-
 packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx       | 193 +++++++++++++++-
 packages/dashboard/src/__tests__/planning-interview-formatters.test.ts          |  79 ++++++-
 packages/dashboard/src/__tests__/routes-planning.test.ts          | 207 ++++++++++++++++-
 packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts          |  74 +++++-
 packages/dashboard/src/ai-session-store.ts         |   2 +
 packages/dashboard/src/planning.ts                 | 249 ++++++++++++++++++---
 packages/dashboard/src/test/mockCoreEngine.ts      |   2 +
 12 files changed, 815 insertions(+), 41 deletions(-)

Fusion-Task-Id: FN-7444
Fusion-Task-Lineage: 43ee1853-558b-4168-b52c-ebcc4ece4521
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 13:38:02 -07:00
parent 25fecd7843
commit ec0256f7e5
12 changed files with 815 additions and 41 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a mandatory Planning Mode deepening checkpoint before final summaries.
category: feature
dev: Planning sessions now persist pending summaries behind a "Would you like to go deeper?" checkpoint.

View File

@@ -348,6 +348,10 @@ Use **Show worktree grouping on the board** in **Settings → Worktrees** when y
Planning is a desktop/tablet left-sidebar main-content destination after **Command Center**. It opens the planning-session list and composer in the main content region; mobile continues to use the compact planning entry points. Planning Mode now includes branch controls on the summary screen before you create a task. Planning is a desktop/tablet left-sidebar main-content destination after **Command Center**. It opens the planning-session list and composer in the main content region; mobile continues to use the compact planning entry points. Planning Mode now includes branch controls on the summary screen before you create a task.
<!-- FNXC:PlanningModeDeepeningCheckpoint 2026-07-02-12:18: Planning Mode must pause before every final summary at a mandatory "Would you like to go deeper?" checkpoint so users can request inferred follow-up themes, enter a custom topic, or proceed without deepening. -->
Before Planning Mode shows **Planning Complete!** or the final plan summary, it first asks **Would you like to go deeper?**. Select one or more suggested themes to continue the interview, use **Other** to add a custom topic, or choose **No, continue to final summary** to reveal the pending summary and task-creation actions.
- **Branch strategy** options mirror Subtask Breakdown semantics: - **Branch strategy** options mirror Subtask Breakdown semantics:
- `Use project/default branch` - `Use project/default branch`
- `Create auto-named branch per task` - `Create auto-named branch per task`

View File

@@ -1,4 +1,4 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef } from "./types.js"; export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, PLANNING_DEEPEN_CHECKPOINT_ID, PLANNING_DEEPEN_CHECKPOINT_QUESTION, PLANNING_DEEPEN_PROCEED_OPTION_ID, PLANNING_DEEPEN_PROCEED_RESPONSE_KEY, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef } from "./types.js";
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType } from "./types.js"; export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js";
export { export {

View File

@@ -4941,6 +4941,18 @@ export interface ArchivedTaskEntry {
/** Type of planning question presented to the user */ /** Type of planning question presented to the user */
export type PlanningQuestionType = "text" | "single_select" | "multi_select" | "confirm"; export type PlanningQuestionType = "text" | "single_select" | "multi_select" | "confirm";
/** Exact Planning Mode checkpoint prompt shown before a final summary can be displayed. */
export const PLANNING_DEEPEN_CHECKPOINT_QUESTION = "Would you like to go deeper?";
/** Reserved question id for the server-owned Planning Mode deepening checkpoint. */
export const PLANNING_DEEPEN_CHECKPOINT_ID = "__planning_deepen_checkpoint__";
/** Reserved checkbox option id that lets the user accept the pending final summary. */
export const PLANNING_DEEPEN_PROCEED_OPTION_ID = "__planning_deepen_proceed_to_final__";
/** Reserved response key accepted as an explicit proceed signal for the deepening checkpoint. */
export const PLANNING_DEEPEN_PROCEED_RESPONSE_KEY = "__planning_deepen_proceed__";
/** Isolation mode for project execution */ /** Isolation mode for project execution */
export type IsolationMode = "in-process" | "child-process"; export type IsolationMode = "in-process" | "child-process";

View File

@@ -5,7 +5,13 @@ import { useState, useCallback, useEffect, useRef, useMemo, type MouseEvent } fr
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import type { Task, PlanningQuestion, PlanningSummary, TaskPriority } from "@fusion/core"; import type { Task, PlanningQuestion, PlanningSummary, TaskPriority } from "@fusion/core";
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core"; import {
DEFAULT_TASK_PRIORITY,
PLANNING_DEEPEN_CHECKPOINT_ID,
PLANNING_DEEPEN_PROCEED_OPTION_ID,
TASK_PRIORITIES,
getErrorMessage,
} from "@fusion/core";
import { import {
startPlanningStreaming, startPlanningStreaming,
createPlanningDraft, createPlanningDraft,
@@ -2478,6 +2484,7 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const question = normalizeQuestionOptions(rawQuestion); const question = normalizeQuestionOptions(rawQuestion);
const questionOptions = question.options ?? []; const questionOptions = question.options ?? [];
const isDeepeningCheckpoint = question.id === PLANNING_DEEPEN_CHECKPOINT_ID;
const [response, setResponse] = useState<QuestionResponse>({}); const [response, setResponse] = useState<QuestionResponse>({});
const [textValue, setTextValue] = useState(""); const [textValue, setTextValue] = useState("");
const [commentValue, setCommentValue] = useState(""); const [commentValue, setCommentValue] = useState("");
@@ -2530,6 +2537,9 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
/* /*
FNXC:PlanningInterview 2026-06-26-00:00: FNXC:PlanningInterview 2026-06-26-00:00:
Multi-select planning questions can combine provided choices with a user-authored Other answer. Preserve selected option ids and append `_other` only while the Other checkbox is active and non-empty. Multi-select planning questions can combine provided choices with a user-authored Other answer. Preserve selected option ids and append `_other` only while the Other checkbox is active and non-empty.
FNXC:PlanningMode 2026-07-02-00:00:
The mandatory deepening checkpoint reuses multi-select payloads, with the server-reserved proceed option acting as an explicit final-summary gate. Other/custom topics stay additive only for deepening choices, never hidden final-summary actions.
*/ */
nextResponse = isOtherSelected && trimmedOther.length > 0 nextResponse = isOtherSelected && trimmedOther.length > 0
? { ...response, [PLANNING_OTHER_RESPONSE_KEY]: trimmedOther } ? { ...response, [PLANNING_OTHER_RESPONSE_KEY]: trimmedOther }
@@ -2682,6 +2692,7 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
<div className="planning-checkbox-group"> <div className="planning-checkbox-group">
{questionOptions.map((option) => { {questionOptions.map((option) => {
const selected = Array.isArray(response[question.id]) ? (response[question.id] as string[]) : []; const selected = Array.isArray(response[question.id]) ? (response[question.id] as string[]) : [];
const isProceedOption = isDeepeningCheckpoint && option.id === PLANNING_DEEPEN_PROCEED_OPTION_ID;
return ( return (
<label key={option.id} className="planning-option planning-option--checkbox"> <label key={option.id} className="planning-option planning-option--checkbox">
<input <input
@@ -2690,8 +2701,14 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
checked={selected.includes(option.id)} checked={selected.includes(option.id)}
onChange={(e) => { onChange={(e) => {
const newSelected = e.target.checked const newSelected = e.target.checked
? [...selected, option.id] ? isProceedOption
? [option.id]
: [...selected.filter((id) => id !== PLANNING_DEEPEN_PROCEED_OPTION_ID), option.id]
: selected.filter((id) => id !== option.id); : selected.filter((id) => id !== option.id);
if (isProceedOption && e.target.checked) {
setIsOtherSelected(false);
setOtherValue("");
}
setResponse({ [question.id]: newSelected }); setResponse({ [question.id]: newSelected });
}} }}
/> />
@@ -2712,6 +2729,10 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
checked={isOtherSelected} checked={isOtherSelected}
onChange={(e) => { onChange={(e) => {
setIsOtherSelected(e.target.checked); setIsOtherSelected(e.target.checked);
if (e.target.checked && isDeepeningCheckpoint) {
const selected = Array.isArray(response[question.id]) ? (response[question.id] as string[]) : [];
setResponse({ [question.id]: selected.filter((id) => id !== PLANNING_DEEPEN_PROCEED_OPTION_ID) });
}
if (!e.target.checked) { if (!e.target.checked) {
setOtherValue(""); setOtherValue("");
} }

View File

@@ -13,6 +13,11 @@ import { PlanningModeModal, dedupeSessionsById } from "../PlanningModeModal";
import { TaskDetailModal } from "../TaskDetailModal"; import { TaskDetailModal } from "../TaskDetailModal";
import { useSessionLock } from "../../hooks/useSessionLock"; import { useSessionLock } from "../../hooks/useSessionLock";
import { getSessionTabId } from "../../utils/getSessionTabId"; import { getSessionTabId } from "../../utils/getSessionTabId";
import {
PLANNING_DEEPEN_CHECKPOINT_ID,
PLANNING_DEEPEN_CHECKPOINT_QUESTION,
PLANNING_DEEPEN_PROCEED_OPTION_ID,
} from "@fusion/core";
import type { MergeResult } from "@fusion/core"; import type { MergeResult } from "@fusion/core";
const mockUseAiSessionSync = vi.fn(); const mockUseAiSessionSync = vi.fn();
@@ -226,6 +231,146 @@ describe("PlanningModeModal", () => {
}); });
describe("Planning flow", () => { describe("Planning flow", () => {
it.each(["desktop", "mobile"] as const)("renders the mandatory deepening checkpoint before summary actions on %s", async (viewportMode) => {
mockViewport(viewportMode);
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onQuestion?.({
id: PLANNING_DEEPEN_CHECKPOINT_ID,
type: "multi_select",
question: PLANNING_DEEPEN_CHECKPOINT_QUESTION,
description: "Select areas to explore or proceed.",
options: [
{ id: PLANNING_DEEPEN_PROCEED_OPTION_ID, label: "Proceed to final plan" },
{ id: "theme-ux", label: "UX and interaction details" },
{ id: "theme-testing", label: "Testing and verification" },
],
});
}, 0);
return { close: vi.fn() };
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Plan a checkpoint flow" },
});
fireEvent.click(screen.getByText("Start Planning"));
expect(await screen.findByText(PLANNING_DEEPEN_CHECKPOINT_QUESTION)).toBeInTheDocument();
expect(screen.queryByText("Planning Complete!")).toBeNull();
expect(screen.queryByRole("button", { name: "Create Single Task" })).toBeNull();
expect(screen.queryByRole("button", { name: "Break into Tasks" })).toBeNull();
expect(screen.queryByRole("button", { name: "Refine Further" })).toBeNull();
expect(screen.getByText("Proceed to final plan")).toBeInTheDocument();
expect(screen.getByTestId("planning-option-other")).toBeInTheDocument();
});
it("submits selected checkpoint themes with a custom topic and can proceed to the final summary", async () => {
let streamHandlers: any;
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
setTimeout(() => {
handlers.onQuestion?.({
id: PLANNING_DEEPEN_CHECKPOINT_ID,
type: "multi_select",
question: PLANNING_DEEPEN_CHECKPOINT_QUESTION,
options: [
{ id: PLANNING_DEEPEN_PROCEED_OPTION_ID, label: "Proceed to final plan" },
{ id: "theme-ux", label: "UX and interaction details" },
],
});
}, 0);
return { close: vi.fn() };
});
mockRespondToPlanning.mockResolvedValue({ type: "question", data: null });
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Plan iterative deepening" },
});
fireEvent.click(screen.getByText("Start Planning"));
await screen.findByText(PLANNING_DEEPEN_CHECKPOINT_QUESTION);
fireEvent.click(screen.getByText("UX and interaction details"));
fireEvent.click(screen.getByTestId("planning-option-other"));
fireEvent.change(screen.getByTestId("planning-other-input"), { target: { value: " Explore rollout risk " } });
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenCalledWith(
"session-123",
{
[PLANNING_DEEPEN_CHECKPOINT_ID]: ["theme-ux"],
_other: "Explore rollout risk",
},
undefined,
expect.any(String),
);
});
act(() => {
streamHandlers.onQuestion?.({
id: "q-follow-up",
type: "text",
question: "What rollout risk matters?",
});
});
fireEvent.change(screen.getByPlaceholderText("Type your answer here..."), { target: { value: "Operator docs" } });
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
act(() => {
streamHandlers.onQuestion?.({
id: PLANNING_DEEPEN_CHECKPOINT_ID,
type: "multi_select",
question: PLANNING_DEEPEN_CHECKPOINT_QUESTION,
options: [
{ id: PLANNING_DEEPEN_PROCEED_OPTION_ID, label: "Proceed to final plan" },
{ id: "theme-testing", label: "Testing and verification" },
],
});
});
await waitFor(() => {
expect(screen.getAllByText(PLANNING_DEEPEN_CHECKPOINT_QUESTION).length).toBeGreaterThan(0);
});
fireEvent.click(screen.getByText("Proceed to final plan"));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenLastCalledWith(
"session-123",
{ [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
undefined,
expect.any(String),
);
});
act(() => {
streamHandlers.onSummary?.(mockSummary);
streamHandlers.onComplete?.();
});
expect(await screen.findByText("Planning Complete!")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create Single Task" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Break into Tasks" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Refine Further" })).toBeEnabled();
});
it.each(["desktop", "mobile"] as const)("FN-6977 renders malformed live summary without generic error on %s", async (viewportMode) => { it.each(["desktop", "mobile"] as const)("FN-6977 renders malformed live summary without generic error on %s", async (viewportMode) => {
mockViewport(viewportMode); mockViewport(viewportMode);
mockStartPlanningStreaming.mockResolvedValueOnce({ sessionId: `session-fn-6977-live-${viewportMode}` }); mockStartPlanningStreaming.mockResolvedValueOnce({ sessionId: `session-fn-6977-live-${viewportMode}` });
@@ -548,7 +693,7 @@ describe("PlanningModeModal", () => {
}); });
const continueButton = screen.getByRole("button", { name: "Continue" }); const continueButton = screen.getByRole("button", { name: "Continue" });
fireEvent.click(screen.getByTestId("planning-option-other")); fireEvent.click(within(screen.getByTestId("planning-option-other")).getByRole("checkbox"));
expect(continueButton).toBeDisabled(); expect(continueButton).toBeDisabled();
const otherInput = screen.getByTestId("planning-other-input"); const otherInput = screen.getByTestId("planning-other-input");
@@ -608,7 +753,7 @@ describe("PlanningModeModal", () => {
const continueButton = screen.getByRole("button", { name: "Continue" }); const continueButton = screen.getByRole("button", { name: "Continue" });
fireEvent.click(screen.getByText("Speed")); fireEvent.click(screen.getByText("Speed"));
fireEvent.click(screen.getByTestId("planning-option-other")); fireEvent.click(within(screen.getByTestId("planning-option-other")).getByRole("checkbox"));
const otherInput = screen.getByTestId("planning-other-input"); const otherInput = screen.getByTestId("planning-other-input");
fireEvent.change(otherInput, { target: { value: " Preserve operator control " } }); fireEvent.change(otherInput, { target: { value: " Preserve operator control " } });
expect(continueButton).toBeEnabled(); expect(continueButton).toBeEnabled();
@@ -1527,6 +1672,50 @@ describe("PlanningModeModal", () => {
expect(screen.queryByText(/Something went wrong/i)).toBeNull(); expect(screen.queryByText(/Something went wrong/i)).toBeNull();
}); });
it("resumes awaiting deepening checkpoint sessions without showing summary actions", async () => {
mockFetchAiSession.mockResolvedValueOnce({
id: "session-awaiting-checkpoint",
type: "planning",
status: "awaiting_input",
title: "Awaiting checkpoint",
inputPayload: JSON.stringify({ initialPlan: "Resume pending checkpoint" }),
conversationHistory: "[]",
currentQuestion: JSON.stringify({
id: PLANNING_DEEPEN_CHECKPOINT_ID,
type: "multi_select",
question: PLANNING_DEEPEN_CHECKPOINT_QUESTION,
options: [
{ id: PLANNING_DEEPEN_PROCEED_OPTION_ID, label: "Proceed to final plan" },
{ id: "theme-testing", label: "Testing and verification" },
],
}),
result: null,
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-awaiting-checkpoint"
/>
);
expect(await screen.findByText(PLANNING_DEEPEN_CHECKPOINT_QUESTION)).toBeInTheDocument();
expect(screen.getByText("Proceed to final plan")).toBeInTheDocument();
expect(screen.queryByText("Planning Complete!")).toBeNull();
expect(screen.queryByRole("button", { name: "Create Single Task" })).toBeNull();
expect(screen.queryByRole("button", { name: "Break into Tasks" })).toBeNull();
expect(screen.queryByRole("button", { name: "Refine Further" })).toBeNull();
});
it("shows summary view when resuming a complete persisted session", async () => { it("shows summary view when resuming a complete persisted session", async () => {
const resumedSummary: PlanningSummary = { const resumedSummary: PlanningSummary = {
title: "Resume-ready planning output", title: "Resume-ready planning output",

View File

@@ -1,6 +1,17 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { PlanningQuestion } from "@fusion/core"; import {
import { formatInterviewQA, formatResponseForAgent } from "../planning"; PLANNING_DEEPEN_CHECKPOINT_ID,
PLANNING_DEEPEN_CHECKPOINT_QUESTION,
PLANNING_DEEPEN_PROCEED_OPTION_ID,
} from "@fusion/core";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import {
buildDeepeningCheckpointOptions,
buildDeepeningCheckpointQuestion,
classifyDeepeningCheckpointResponse,
formatInterviewQA,
formatResponseForAgent,
} from "../planning";
const singleSelectQuestion: PlanningQuestion = { const singleSelectQuestion: PlanningQuestion = {
id: "scope", id: "scope",
@@ -28,6 +39,70 @@ const confirmQuestion: PlanningQuestion = {
question: "Proceed with this plan?", question: "Proceed with this plan?",
}; };
const summaryWithSurfaces: PlanningSummary = {
title: "Improve mobile UX testing",
description: "Handle empty and duplicate data states for a responsive mobile workflow.",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Add keyboard UX", "Verify regression tests"],
};
describe("planning deepening checkpoint helpers", () => {
it("builds the mandatory checkpoint question with deterministic proceed and inferred theme options", () => {
const question = buildDeepeningCheckpointQuestion(
[{ question: multiSelectQuestion, response: { priorities: ["quality"] } }],
summaryWithSurfaces,
);
expect(question.id).toBe(PLANNING_DEEPEN_CHECKPOINT_ID);
expect(question.question).toBe(PLANNING_DEEPEN_CHECKPOINT_QUESTION);
expect(question.type).toBe("multi_select");
expect(question.options?.[0]?.id).toBe(PLANNING_DEEPEN_PROCEED_OPTION_ID);
expect(question.options?.map((option) => option.label)).toEqual([
"Proceed to final plan",
"Edge cases and data states",
"UX and interaction details",
"Testing and verification",
]);
});
it("falls back to safe default themes when no conversation themes are inferred", () => {
const options = buildDeepeningCheckpointOptions([], {
title: "Tiny task",
description: "Do the thing.",
suggestedSize: "S",
suggestedDependencies: [],
keyDeliverables: [],
});
expect(options?.map((option) => option.label)).toEqual([
"Proceed to final plan",
"Scope and non-goals",
"Edge cases and data states",
"UX and interaction details",
"Testing and verification",
]);
});
it("classifies proceed separately from selected themes and custom topics", () => {
const question = buildDeepeningCheckpointQuestion([], summaryWithSurfaces);
expect(classifyDeepeningCheckpointResponse(question, {
[question.id]: [PLANNING_DEEPEN_PROCEED_OPTION_ID],
})).toMatchObject({ proceed: true, selectedThemeLabels: [] });
expect(classifyDeepeningCheckpointResponse(question, {
[question.id]: ["theme-testing"],
_other: "Explore rollout risk",
})).toMatchObject({
proceed: false,
selectedThemeIds: ["theme-testing"],
selectedThemeLabels: ["Testing and verification"],
customTopic: "Explore rollout risk",
});
});
});
describe("planning interview formatter Other answers", () => { describe("planning interview formatter Other answers", () => {
it("formats Other-only single-select answers for the planning agent and Q&A history", () => { it("formats Other-only single-select answers for the planning agent and Q&A history", () => {
const response = { _other: "Run discovery first" }; const response = { _other: "Run discovery first" };

View File

@@ -18,6 +18,11 @@ import {
import { GitHubClient } from "../github.js"; import { GitHubClient } from "../github.js";
import * as resolveDiffBaseModule from "../routes/resolve-diff-base.js"; import * as resolveDiffBaseModule from "../routes/resolve-diff-base.js";
import { githubRateLimiter } from "../github-poll.js"; import { githubRateLimiter } from "../github-poll.js";
import {
PLANNING_DEEPEN_CHECKPOINT_ID,
PLANNING_DEEPEN_CHECKPOINT_QUESTION,
PLANNING_DEEPEN_PROCEED_OPTION_ID,
} from "@fusion/core";
import type { TaskStore, TaskAttachment, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult, ChatSession, ChatMessage } from "@fusion/core"; import type { TaskStore, TaskAttachment, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult, ChatSession, ChatMessage } from "@fusion/core";
import type { TaskDetail } from "@fusion/core"; import type { TaskDetail } from "@fusion/core";
import type { AuthStorageLike, ModelRegistryLike } from "../routes.js"; import type { AuthStorageLike, ModelRegistryLike } from "../routes.js";
@@ -434,6 +439,10 @@ describe("Planning Mode Routes", () => {
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { confirm: true } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({
sessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
return sessionId; return sessionId;
} }
@@ -1352,7 +1361,7 @@ describe("Planning Mode Routes", () => {
expect(res.body.data).toBeDefined(); expect(res.body.data).toBeDefined();
}); });
it("returns summary after completing all questions", async () => { it("returns checkpoint before summary, then completes only after explicit proceed", async () => {
// Create a session // Create a session
const startRes = await REQUEST( const startRes = await REQUEST(
buildApp(), buildApp(),
@@ -1380,7 +1389,7 @@ describe("Planning Mode Routes", () => {
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
const finalRes = await REQUEST( const checkpointRes = await REQUEST(
buildApp(), buildApp(),
"POST", "POST",
"/api/planning/respond", "/api/planning/respond",
@@ -1388,6 +1397,22 @@ describe("Planning Mode Routes", () => {
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
expect(checkpointRes.status).toBe(200);
expect(checkpointRes.body.type).toBe("question");
expect(checkpointRes.body.data.id).toBe(PLANNING_DEEPEN_CHECKPOINT_ID);
expect(checkpointRes.body.data.question).toBe(PLANNING_DEEPEN_CHECKPOINT_QUESTION);
const finalRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({
sessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}),
{ "Content-Type": "application/json" }
);
expect(finalRes.status).toBe(200); expect(finalRes.status).toBe(200);
expect(finalRes.body.type).toBe("complete"); expect(finalRes.body.type).toBe("complete");
expect(finalRes.body.data.title).toBeDefined(); expect(finalRes.body.data.title).toBeDefined();
@@ -1437,7 +1462,7 @@ describe("Planning Mode Routes", () => {
); );
const sessionId = startRes.body.sessionId; const sessionId = startRes.body.sessionId;
const finalRes = await REQUEST( const checkpointRes = await REQUEST(
buildApp(), buildApp(),
"POST", "POST",
"/api/planning/respond", "/api/planning/respond",
@@ -1445,7 +1470,26 @@ describe("Planning Mode Routes", () => {
{ "Content-Type": "application/json" }, { "Content-Type": "application/json" },
); );
expect(finalRes.status).toBe(200); expect(checkpointRes.status).toBe(200);
expect(checkpointRes.body).toMatchObject({
type: "question",
data: {
id: PLANNING_DEEPEN_CHECKPOINT_ID,
question: PLANNING_DEEPEN_CHECKPOINT_QUESTION,
},
});
expect(planningModule.getSummary(sessionId)).toBeUndefined();
const finalRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({
sessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}),
{ "Content-Type": "application/json" },
);
expect(finalRes.body).toMatchObject({ expect(finalRes.body).toMatchObject({
type: "complete", type: "complete",
data: { data: {
@@ -1460,6 +1504,115 @@ describe("Planning Mode Routes", () => {
}); });
}); });
it("continues agent conversation when checkpoint themes or custom topics are selected", async () => {
const responses = [
JSON.stringify({
type: "question",
data: { id: "q-one", type: "text", question: "What should be planned?" },
}),
JSON.stringify({
type: "complete",
data: {
title: "Plan mobile UX",
description: "Improve responsive mobile UX and add tests.",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Mobile UX", "Tests"],
},
}),
JSON.stringify({
type: "question",
data: { id: "q-deeper", type: "text", question: "Which mobile edge cases matter?" },
}),
JSON.stringify({
type: "complete",
data: {
title: "Plan mobile UX deeply",
description: "Improve responsive mobile UX after exploring custom rollout risks.",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Mobile UX", "Tests", "Rollout notes"],
},
}),
];
const messages: Array<{ role: string; content: string }> = [];
let callIndex = 0;
__setCreateFnAgent(async () => ({
session: {
state: { messages },
prompt: vi.fn(async (message: string) => {
messages.push({ role: "user", content: message });
messages.push({ role: "assistant", content: responses[callIndex++] ?? responses[responses.length - 1] });
}),
dispose: vi.fn(),
},
}));
const startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Plan a mobile UX change" }),
{ "Content-Type": "application/json" },
);
const sessionId = startRes.body.sessionId;
const checkpointRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { "q-one": "Make it responsive and tested" } }),
{ "Content-Type": "application/json" },
);
expect(checkpointRes.body.data.question).toBe(PLANNING_DEEPEN_CHECKPOINT_QUESTION);
const deepeningRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({
sessionId,
responses: {
[PLANNING_DEEPEN_CHECKPOINT_ID]: ["theme-ux"],
_other: "Explore rollout risk",
},
}),
{ "Content-Type": "application/json" },
);
expect(deepeningRes.body).toMatchObject({
type: "question",
data: { id: "q-deeper", question: "Which mobile edge cases matter?" },
});
expect(messages.some((message) => message.role === "user" && message.content.includes("Explore rollout risk"))).toBe(true);
const secondCheckpointRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { "q-deeper": "Keyboard and touch interactions" } }),
{ "Content-Type": "application/json" },
);
expect(secondCheckpointRes.body).toMatchObject({
type: "question",
data: { id: PLANNING_DEEPEN_CHECKPOINT_ID, question: PLANNING_DEEPEN_CHECKPOINT_QUESTION },
});
const finalRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({
sessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}),
{ "Content-Type": "application/json" },
);
expect(finalRes.body).toMatchObject({
type: "complete",
data: { title: "Plan mobile UX deeply" },
});
});
it("allows refine requests from completed sessions", async () => { it("allows refine requests from completed sessions", async () => {
const startRes = await REQUEST( const startRes = await REQUEST(
buildApp(), buildApp(),
@@ -1491,6 +1644,16 @@ describe("Planning Mode Routes", () => {
JSON.stringify({ sessionId, responses: { confirm: true } }), JSON.stringify({ sessionId, responses: { confirm: true } }),
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({
sessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}),
{ "Content-Type": "application/json" }
);
const refineRes = await REQUEST( const refineRes = await REQUEST(
buildApp(), buildApp(),
@@ -1873,6 +2036,10 @@ describe("Planning Mode Routes", () => {
JSON.stringify({ sessionId, responses: { confirm: true } }), JSON.stringify({ sessionId, responses: { confirm: true } }),
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({
sessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
// Create task from planning // Create task from planning
const res = await REQUEST( const res = await REQUEST(
@@ -2371,6 +2538,10 @@ describe("Planning Mode Routes", () => {
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { confirm: true } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({
sessionId: sessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
const res = await REQUEST( const res = await REQUEST(
buildApp(), buildApp(),
@@ -2440,6 +2611,10 @@ describe("Planning Mode Routes", () => {
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({
sessionId: planningSessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
const breakdownRes = await REQUEST( const breakdownRes = await REQUEST(
buildApp(), buildApp(),
@@ -2544,6 +2719,10 @@ describe("Planning Mode Routes", () => {
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({
sessionId: planningSessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
const breakdownRes = await REQUEST( const breakdownRes = await REQUEST(
buildApp(), buildApp(),
@@ -2633,6 +2812,10 @@ describe("Planning Mode Routes", () => {
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { scope: "large" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { scope: "large" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must support auth, settings, dashboards, workflows, imports, sync, audits, search, mobile, docs, QA, releases, telemetry, reliability, and security." } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must support auth, settings, dashboards, workflows, imports, sync, audits, search, mobile, docs, QA, releases, telemetry, reliability, and security." } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({
sessionId: planningSessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
const summaryOverride = { const summaryOverride = {
title: "Large planning summary", title: "Large planning summary",
@@ -2726,6 +2909,10 @@ describe("Planning Mode Routes", () => {
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { confirm: true } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({
sessionId: sessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
const res = await REQUEST( const res = await REQUEST(
buildApp(), buildApp(),
@@ -2773,6 +2960,10 @@ describe("Planning Mode Routes", () => {
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { confirm: true } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({
sessionId: sessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
const res = await REQUEST( const res = await REQUEST(
buildApp(), buildApp(),
@@ -2829,6 +3020,10 @@ describe("Planning Mode Routes", () => {
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({
sessionId: planningSessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
const res = await REQUEST( const res = await REQUEST(
buildApp(), buildApp(),
@@ -2998,6 +3193,10 @@ describe("Planning Mode Routes", () => {
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({
sessionId: planningSessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
const session = planningModule.getSession(planningSessionId); const session = planningModule.getSession(planningSessionId);
if (!session) { if (!session) {

View File

@@ -10,13 +10,21 @@ import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { Database, TaskStore } from "@fusion/core"; import {
Database,
PLANNING_DEEPEN_CHECKPOINT_ID,
PLANNING_DEEPEN_CHECKPOINT_QUESTION,
PLANNING_DEEPEN_PROCEED_OPTION_ID,
TaskStore,
} from "@fusion/core";
import { AiSessionStore, type AiSessionRow } from "../ai-session-store.js"; import { AiSessionStore, type AiSessionRow } from "../ai-session-store.js";
import { import {
__resetPlanningState, __resetPlanningState,
__setCreateFnAgent, __setCreateFnAgent,
cancelSession, cancelSession,
createSession, createSession,
getCurrentQuestion,
rehydrateFromStore,
setAiSessionStore as setPlanningAiSessionStore, setAiSessionStore as setPlanningAiSessionStore,
submitResponse, submitResponse,
} from "../planning.js"; } from "../planning.js";
@@ -50,6 +58,7 @@ vi.mock("@fusion/engine", () => ({
resolvedSkillNames: [], resolvedSkillNames: [],
skillSource: "none" as const, skillSource: "none" as const,
})), })),
resolveMcpServersForStore: vi.fn(async () => ({ servers: [] })),
createFnAgent: mockCreateFnAgent, createFnAgent: mockCreateFnAgent,
})); }));
@@ -127,7 +136,7 @@ describe("session persistence round-trip", () => {
await rm(tmpDir, { recursive: true, force: true }); await rm(tmpDir, { recursive: true, force: true });
}); });
it("persists planning session transitions across generating → awaiting_input → complete", async () => { it("persists planning session transitions across generating → awaiting checkpoint → complete", async () => {
__setCreateFnAgent( __setCreateFnAgent(
async () => async () =>
createMockAgent([ createMockAgent([
@@ -166,13 +175,72 @@ describe("session persistence round-trip", () => {
expect(JSON.parse(afterFirstResponse?.conversationHistory ?? "[]")).toHaveLength(1); expect(JSON.parse(afterFirstResponse?.conversationHistory ?? "[]")).toHaveLength(1);
await submitResponse(sessionId, { "q-2": "Answer two" }, "/tmp/project"); await submitResponse(sessionId, { "q-2": "Answer two" }, "/tmp/project");
const afterCheckpoint = aiSessionStore.get(sessionId);
expect(afterCheckpoint?.status).toBe("awaiting_input");
expect(JSON.parse(afterCheckpoint?.currentQuestion ?? "null")).toMatchObject({
id: PLANNING_DEEPEN_CHECKPOINT_ID,
question: PLANNING_DEEPEN_CHECKPOINT_QUESTION,
});
expect(JSON.parse(afterCheckpoint?.conversationHistory ?? "[]")).toHaveLength(2);
expect(afterCheckpoint?.result).toBeNull();
expect(JSON.parse(afterCheckpoint?.inputPayload ?? "{}").pendingSummary.title).toBe("Planned");
await submitResponse(
sessionId,
{ [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
"/tmp/project",
);
const afterComplete = aiSessionStore.get(sessionId); const afterComplete = aiSessionStore.get(sessionId);
expect(afterComplete?.status).toBe("complete"); expect(afterComplete?.status).toBe("complete");
expect(afterComplete?.currentQuestion).toBeNull(); expect(afterComplete?.currentQuestion).toBeNull();
expect(JSON.parse(afterComplete?.conversationHistory ?? "[]")).toHaveLength(2); expect(JSON.parse(afterComplete?.conversationHistory ?? "[]")).toHaveLength(3);
expect(JSON.parse(afterComplete?.inputPayload ?? "{}").pendingSummary).toBeUndefined();
expect(JSON.parse(afterComplete?.result ?? "null")?.title).toBe("Planned"); expect(JSON.parse(afterComplete?.result ?? "null")?.title).toBe("Planned");
}); });
it("rehydrates awaiting deepening checkpoint sessions without exposing pending summary as complete", () => {
const now = new Date().toISOString();
aiSessionStore.upsert({
id: "planning-checkpoint-recover",
type: "planning",
status: "awaiting_input",
title: "Planning checkpoint recover",
inputPayload: JSON.stringify({
ip: "127.0.0.1",
initialPlan: "Plan",
pendingSummary: {
title: "Recovered pending summary",
description: "Summary is waiting behind checkpoint",
suggestedSize: "M",
suggestedDependencies: ["FN-1", "FN-1"],
keyDeliverables: ["One"],
},
}),
conversationHistory: JSON.stringify([{ question: { id: "q-1", type: "text", question: "First?" }, response: { "q-1": "a" } }]),
currentQuestion: JSON.stringify({
id: PLANNING_DEEPEN_CHECKPOINT_ID,
type: "multi_select",
question: PLANNING_DEEPEN_CHECKPOINT_QUESTION,
options: [{ id: PLANNING_DEEPEN_PROCEED_OPTION_ID, label: "Proceed to final plan" }],
}),
result: null,
thinkingOutput: "",
error: null,
projectId: null,
createdAt: now,
updatedAt: now,
lockedByTab: null,
lockedAt: null,
});
expect(rehydrateFromStore(aiSessionStore)).toBe(1);
expect(getCurrentQuestion("planning-checkpoint-recover")).toMatchObject({
id: PLANNING_DEEPEN_CHECKPOINT_ID,
question: PLANNING_DEEPEN_CHECKPOINT_QUESTION,
});
expect(aiSessionStore.get("planning-checkpoint-recover")?.result).toBeNull();
});
it("recovers generating sessions from SQLite while preserving history and currentQuestion", () => { it("recovers generating sessions from SQLite while preserving history and currentQuestion", () => {
const now = new Date().toISOString(); const now = new Date().toISOString();

View File

@@ -109,6 +109,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
*/ */
upsert(session: AiSessionRow): void { upsert(session: AiSessionRow): void {
const now = new Date().toISOString(); const now = new Date().toISOString();
// FNXC:PlanningMode 2026-07-02-00:00: Planning checkpoints persist pending summaries inside inputPayload, so every session upsert must refresh inputPayload on existing rows instead of treating it as create-only draft metadata.
const thinking = trimThinking(session.thinkingOutput); const thinking = trimThinking(session.thinkingOutput);
this.db this.db
@@ -118,6 +119,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
status = excluded.status, status = excluded.status,
title = excluded.title, title = excluded.title,
inputPayload = excluded.inputPayload,
conversationHistory = excluded.conversationHistory, conversationHistory = excluded.conversationHistory,
currentQuestion = excluded.currentQuestion, currentQuestion = excluded.currentQuestion,
result = excluded.result, result = excluded.result,

View File

@@ -20,7 +20,17 @@ import type {
TaskStore, TaskStore,
NtfyNotificationEvent, NtfyNotificationEvent,
} from "@fusion/core"; } from "@fusion/core";
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, resolvePrompt, summarizeTitle, type PromptOverrideMap } from "@fusion/core"; import {
DEFAULT_TASK_PRIORITY,
PLANNING_DEEPEN_CHECKPOINT_ID,
PLANNING_DEEPEN_CHECKPOINT_QUESTION,
PLANNING_DEEPEN_PROCEED_OPTION_ID,
PLANNING_DEEPEN_PROCEED_RESPONSE_KEY,
TASK_PRIORITIES,
resolvePrompt,
summarizeTitle,
type PromptOverrideMap,
} from "@fusion/core";
import type { SubtaskItem } from "./subtask-breakdown.js"; import type { SubtaskItem } from "./subtask-breakdown.js";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
@@ -247,6 +257,7 @@ export interface DraftInputPayload {
modelProvider?: string; modelProvider?: string;
modelId?: string; modelId?: string;
summarizedFor?: string; summarizedFor?: string;
pendingSummary?: PlanningSummary;
} }
/** Session TTL in milliseconds (7 days) */ /** Session TTL in milliseconds (7 days) */
@@ -337,6 +348,8 @@ interface Session {
history: PlanningHistoryEntry[]; history: PlanningHistoryEntry[];
currentQuestion?: PlanningQuestion; currentQuestion?: PlanningQuestion;
summary?: PlanningSummary; summary?: PlanningSummary;
/** Pending AI-completed summary held behind the mandatory user deepening checkpoint. */
pendingSummary?: PlanningSummary;
/** Last terminal error for retry UX */ /** Last terminal error for retry UX */
error?: string; error?: string;
/** AI agent session for real-time interaction */ /** AI agent session for real-time interaction */
@@ -449,6 +462,123 @@ export function normalizePlanningSummaryPayload(
}; };
} }
export interface PlanningDeepeningDecision {
proceed: boolean;
selectedThemeIds: string[];
selectedThemeLabels: string[];
customTopic?: string;
}
const CHECKPOINT_THEME_CANDIDATES: Array<{ id: string; label: string; description: string; patterns: RegExp[] }> = [
{ id: "scope", label: "Scope and non-goals", description: "Clarify boundaries, trade-offs, and what should stay out of this task.", patterns: [/\bscope\b/i, /non[- ]?goal/i, /boundary/i, /trade[- ]?off/i] },
{ id: "edge-cases", label: "Edge cases and data states", description: "Explore empty, duplicate, malformed, missing, or unusual states before implementation.", patterns: [/edge case/i, /empty/i, /undefined/i, /duplicate/i, /malformed/i, /data state/i] },
{ id: "ux", label: "UX and interaction details", description: "Tighten user-facing copy, responsive behavior, accessibility, and interaction flow.", patterns: [/\bux\b/i, /user/i, /mobile/i, /responsive/i, /accessibility/i, /keyboard/i, /button/i] },
{ id: "dependencies", label: "Dependencies and integrations", description: "Identify prerequisite tasks, third-party systems, and integration constraints.", patterns: [/dependenc/i, /integration/i, /api\b/i, /external/i, /provider/i, /service/i] },
{ id: "testing", label: "Testing and verification", description: "Deepen acceptance criteria, regression coverage, and validation commands.", patterns: [/test/i, /verify/i, /validation/i, /acceptance/i, /regression/i] },
{ id: "rollout", label: "Rollout and operations", description: "Discuss migration, release, observability, documentation, or support considerations.", patterns: [/rollout/i, /migration/i, /release/i, /observability/i, /docs?\b/i, /operator/i] },
];
const FALLBACK_CHECKPOINT_THEME_OPTIONS = ["scope", "edge-cases", "ux", "testing"];
function slugifyCheckpointTheme(label: string): string {
return label
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48) || "topic";
}
function collectCheckpointThemeText(
history: Array<{ question: PlanningQuestion; response: unknown }>,
summary: PlanningSummary,
): string {
return [
summary.title,
summary.description,
summary.keyDeliverables.join("\n"),
summary.suggestedDependencies.join("\n"),
...history.flatMap((entry) => [
entry.question.question,
entry.question.description ?? "",
JSON.stringify(entry.response),
]),
].join("\n");
}
export function buildDeepeningCheckpointOptions(
history: Array<{ question: PlanningQuestion; response: unknown }>,
summary: PlanningSummary,
): PlanningQuestion["options"] {
const text = collectCheckpointThemeText(history, summary);
const matched = CHECKPOINT_THEME_CANDIDATES.filter((candidate) =>
candidate.patterns.some((pattern) => pattern.test(text)),
);
const selected = matched.length > 0
? matched
: CHECKPOINT_THEME_CANDIDATES.filter((candidate) => FALLBACK_CHECKPOINT_THEME_OPTIONS.includes(candidate.id));
const seen = new Set<string>();
const options = selected.flatMap((candidate) => {
const id = `theme-${slugifyCheckpointTheme(candidate.id)}`;
if (seen.has(id) || candidate.label === "Proceed to final plan") {
return [];
}
seen.add(id);
return [{ id, label: candidate.label, description: candidate.description }];
});
return [
{ id: PLANNING_DEEPEN_PROCEED_OPTION_ID, label: "Proceed to final plan", description: "The plan is detailed enough; show the final editable summary." },
...options,
];
}
/*
FNXC:PlanningMode 2026-07-02-00:00:
Planning Mode final summaries are user-gated, not AI-gated. Every AI completion becomes the exact “Would you like to go deeper?” checkpoint with a persisted pending summary; users may loop on selected themes/custom topics indefinitely, or explicitly proceed to reveal the final summary actions.
*/
export function buildDeepeningCheckpointQuestion(
history: Array<{ question: PlanningQuestion; response: unknown }>,
summary: PlanningSummary,
): PlanningQuestion {
return {
id: PLANNING_DEEPEN_CHECKPOINT_ID,
type: "multi_select",
question: PLANNING_DEEPEN_CHECKPOINT_QUESTION,
description: "Select any areas you want to explore further, write an unlisted topic, or proceed to the final plan.",
options: buildDeepeningCheckpointOptions(history, summary),
};
}
export function classifyDeepeningCheckpointResponse(
question: PlanningQuestion,
responses: Record<string, unknown>,
): PlanningDeepeningDecision {
const rawSelected = responses[question.id];
const selectedIds = Array.isArray(rawSelected)
? rawSelected.filter((id): id is string => typeof id === "string")
: [];
const customTopic = typeof responses._other === "string" && responses._other.trim().length > 0
? responses._other.trim()
: undefined;
const proceed = responses[PLANNING_DEEPEN_PROCEED_RESPONSE_KEY] === true
|| selectedIds.includes(PLANNING_DEEPEN_PROCEED_OPTION_ID);
const themeIds = selectedIds.filter((id) => id !== PLANNING_DEEPEN_PROCEED_OPTION_ID);
const selectedThemeLabels = themeIds.map((id) => question.options?.find((option) => option.id === id)?.label || id);
return {
proceed,
selectedThemeIds: themeIds,
selectedThemeLabels,
...(customTopic ? { customTopic } : {}),
};
}
function isDeepeningCheckpointQuestion(question: PlanningQuestion | undefined): boolean {
return question?.id === PLANNING_DEEPEN_CHECKPOINT_ID
&& question.question === PLANNING_DEEPEN_CHECKPOINT_QUESTION;
}
function safeParseJson<T>( function safeParseJson<T>(
text: string | null, text: string | null,
fallback: T, fallback: T,
@@ -527,6 +657,7 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
...(session.draftModelProvider ? { modelProvider: session.draftModelProvider } : {}), ...(session.draftModelProvider ? { modelProvider: session.draftModelProvider } : {}),
...(session.draftModelId ? { modelId: session.draftModelId } : {}), ...(session.draftModelId ? { modelId: session.draftModelId } : {}),
...(session.draftSummarizedFor ? { summarizedFor: session.draftSummarizedFor } : {}), ...(session.draftSummarizedFor ? { summarizedFor: session.draftSummarizedFor } : {}),
...(session.pendingSummary ? { pendingSummary: session.pendingSummary } : {}),
}), }),
conversationHistory: JSON.stringify(session.history), conversationHistory: JSON.stringify(session.history),
currentQuestion: session.currentQuestion ? JSON.stringify(session.currentQuestion) : null, currentQuestion: session.currentQuestion ? JSON.stringify(session.currentQuestion) : null,
@@ -605,6 +736,9 @@ function buildSessionFromRow(row: AiSessionRow): Session {
{ title: row.title, description: row.title }, { title: row.title, description: row.title },
) )
: undefined, : undefined,
pendingSummary: payload.pendingSummary
? normalizePlanningSummaryPayload(payload.pendingSummary, { title: row.title, description: row.title })
: undefined,
thinkingOutput: row.thinkingOutput, thinkingOutput: row.thinkingOutput,
lastGeneratedThinking: row.thinkingOutput || "", lastGeneratedThinking: row.thinkingOutput || "",
error: row.error ?? undefined, error: row.error ?? undefined,
@@ -1109,20 +1243,11 @@ async function getFirstQuestionFromAgent(
} }
if (parsed.type === "complete") { if (parsed.type === "complete") {
// AI returned a summary instead of a question — return a minimal question
// so the caller can present the summary
const summary = normalizePlanningSummaryPayload(parsed.data, { const summary = normalizePlanningSummaryPayload(parsed.data, {
title: session.title || session.initialPlan, title: session.title || session.initialPlan,
description: session.initialPlan, description: session.initialPlan,
}); });
session.summary = summary; return setPendingSummaryCheckpoint(session, summary);
persistSession(session, "complete");
return {
id: "q-direct-summary",
type: "confirm",
question: `The AI has generated a plan: "${summary.title}". Proceed with this?`,
description: summary.description,
};
} }
return parsed.data; return parsed.data;
@@ -1815,6 +1940,57 @@ async function runGenerationWithTimeout<T>(session: Session, operation: (abortSi
} }
} }
function finalizePendingSummary(session: Session): PlanningSummary {
const summary = normalizePlanningSummaryPayload(session.pendingSummary ?? session.summary, {
title: session.title || session.initialPlan,
description: session.initialPlan,
});
session.summary = summary;
session.pendingSummary = undefined;
session.currentQuestion = undefined;
session.error = undefined;
session.updatedAt = new Date();
persistSession(session, "complete");
planningStreamManager.broadcast(session.id, {
type: "summary",
data: summary,
});
planningStreamManager.broadcast(session.id, { type: "complete" });
return summary;
}
function setPendingSummaryCheckpoint(session: Session, summary: PlanningSummary): PlanningQuestion {
const checkpoint = buildDeepeningCheckpointQuestion(session.history, summary);
session.pendingSummary = summary;
session.summary = undefined;
session.currentQuestion = checkpoint;
session.error = undefined;
session.lastGeneratedThinking = session.thinkingOutput;
session.updatedAt = new Date();
persistSession(session, "awaiting_input");
void maybeNotifyPlanningAwaitingInput(session, checkpoint);
planningStreamManager.broadcast(session.id, {
type: "question",
data: checkpoint,
});
return checkpoint;
}
function formatDeepeningRequestForAgent(decision: PlanningDeepeningDecision, pendingSummary: PlanningSummary): string {
const requestedTopics = [
...decision.selectedThemeLabels,
...(decision.customTopic ? [decision.customTopic] : []),
];
return [
"The user chose to go deeper before accepting the final planning summary.",
"Continue the planning interview and explore these specific topics in more detail before producing another completion summary:",
requestedTopics.map((topic) => `- ${topic}`).join("\n"),
"Do not skip directly to the final summary unless the additional details are addressed; when you next complete, return the normal JSON complete payload.",
"Pending summary that was withheld from the user:",
JSON.stringify(pendingSummary),
].join("\n\n");
}
async function continueAgentConversation(session: Session, message: string): Promise<void> { async function continueAgentConversation(session: Session, message: string): Promise<void> {
if (!session.agent) { if (!session.agent) {
throw new InvalidSessionStateError("AI agent not initialized"); throw new InvalidSessionStateError("AI agent not initialized");
@@ -1950,6 +2126,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
if (parsed.type === "question") { if (parsed.type === "question") {
session.currentQuestion = parsed.data; session.currentQuestion = parsed.data;
session.summary = undefined; session.summary = undefined;
session.pendingSummary = undefined;
session.error = undefined; session.error = undefined;
session.lastGeneratedThinking = session.thinkingOutput; session.lastGeneratedThinking = session.thinkingOutput;
session.updatedAt = new Date(); session.updatedAt = new Date();
@@ -1964,16 +2141,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
title: session.title || session.initialPlan, title: session.title || session.initialPlan,
description: session.initialPlan, description: session.initialPlan,
}); });
session.summary = summary; setPendingSummaryCheckpoint(session, summary);
session.currentQuestion = undefined;
session.error = undefined;
session.updatedAt = new Date();
persistSession(session, "complete");
planningStreamManager.broadcast(session.id, {
type: "summary",
data: summary,
});
planningStreamManager.broadcast(session.id, { type: "complete" });
} }
}); });
} catch (err) { } catch (err) {
@@ -2257,6 +2425,7 @@ export async function submitResponse(
} }
session.error = undefined; session.error = undefined;
session.pendingSummary = undefined;
persistSession(session, "generating"); persistSession(session, "generating");
await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store); await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store);
@@ -2276,14 +2445,38 @@ export async function submitResponse(
Persist the user's answered planning turn before the agent generates the next question or errors. AiSessionStore snapshots happen inside continueAgentConversation, so history must already include the submitted answer for retry replay and SQLite round-trip tests to observe durable state. Persist the user's answered planning turn before the agent generates the next question or errors. AiSessionStore snapshots happen inside continueAgentConversation, so history must already include the submitted answer for retry replay and SQLite round-trip tests to observe durable state.
*/ */
session.history.push(historyEntry); session.history.push(historyEntry);
persistSession(session, "generating");
if (!session.agent) { if (isDeepeningCheckpointQuestion(currentQuestion)) {
await ensureSessionAgent(session, rootDir, session.history.slice(0, -1), promptOverrides, store); const pendingSummary = session.pendingSummary;
if (!pendingSummary) {
throw new InvalidSessionStateError("Planning checkpoint is missing its pending summary");
}
const decision = classifyDeepeningCheckpointResponse(currentQuestion, responses);
if (decision.proceed) {
finalizePendingSummary(session);
} else {
const hasDeepeningTopic = decision.selectedThemeLabels.length > 0 || Boolean(decision.customTopic);
if (!hasDeepeningTopic) {
session.history.pop();
throw new InvalidSessionStateError("Select a topic to explore or proceed to the final plan");
}
session.pendingSummary = undefined;
persistSession(session, "generating");
if (!session.agent) {
await ensureSessionAgent(session, rootDir, session.history.slice(0, -1), promptOverrides, store);
}
await continueAgentConversation(session, formatDeepeningRequestForAgent(decision, pendingSummary));
}
} else {
persistSession(session, "generating");
if (!session.agent) {
await ensureSessionAgent(session, rootDir, session.history.slice(0, -1), promptOverrides, store);
}
const message = formatResponseForAgent(currentQuestion, responses);
await continueAgentConversation(session, message);
} }
const message = formatResponseForAgent(currentQuestion, responses);
await continueAgentConversation(session, message);
} }
// Return the current state (will be updated via SSE) // Return the current state (will be updated via SSE)
@@ -2326,6 +2519,7 @@ export async function retrySession(
session.error = undefined; session.error = undefined;
session.summary = undefined; session.summary = undefined;
session.pendingSummary = undefined;
session.updatedAt = new Date(); session.updatedAt = new Date();
persistSession(session, "generating"); persistSession(session, "generating");
@@ -2378,6 +2572,7 @@ export async function rewindSession(
session.currentQuestion = rewindEntry.question; session.currentQuestion = rewindEntry.question;
session.summary = undefined; session.summary = undefined;
session.pendingSummary = undefined;
session.error = undefined; session.error = undefined;
session.lastGeneratedThinking = session.history[session.history.length - 1]?.thinkingOutput ?? ""; session.lastGeneratedThinking = session.history[session.history.length - 1]?.thinkingOutput ?? "";
session.thinkingOutput = ""; session.thinkingOutput = "";

View File

@@ -66,6 +66,8 @@ export function createEngineMock(overrides: AnyModule = {}): AnyModule {
*/ */
createChatTaskDocumentTools: vi.fn(() => []), createChatTaskDocumentTools: vi.fn(() => []),
createChatArtifactTools: vi.fn(() => []), createChatArtifactTools: vi.fn(() => []),
// FNXC:McpConfig 2026-07-02-00:00: Planning/mission route tests share this engine mock; MCP resolution must return a shaped empty server set so readonly session creation can proceed without importing real engine stores.
resolveMcpServersForStore: vi.fn(async () => ({ servers: [] })),
...overrides, ...overrides,
}); });
} }