FN-8341: rework planning interviews for reactive validation
Make planning interviews user-controlled and context-aware from validation through task creation. - Replace fixed-depth and deepening checkpoints with reactive follow-up questions - Add explicit planning validation and require it before creating or breaking down work - Expose validation through the API, CLI, routes, types, documentation, and release notes - Update localized copy and PlanningModeModal mocks and assertions for the revised workflow Files changed: .changeset/fn-8341-deepening-checkpoint-removal.md | 7 + .changeset/fn-8341-planning-reactive-backend.md | 7 + docs/dashboard-guide.md | 8 +- packages/cli/src/commands/task.ts | 12 +- packages/core/src/index.gate.ts | 2 +- packages/core/src/index.ts | 2 +- packages/core/src/types.ts | 38 +- packages/dashboard/app/api/legacy.ts | 15 +- .../dashboard/app/components/PlanningModeModal.tsx | 192 +----- .../__tests__/PlanningModeModal.autosize.test.tsx | 4 + .../__tests__/PlanningModeModal.initial.test.tsx | 50 +- .../PlanningModeModal.planning-flow.test.tsx | 442 +------------ .../__tests__/PlanningModeModal.test-helpers.ts | 1 + .../PlanningModeModal.ui-interactions.test.tsx | 3 + .../__tests__/planning-infinite-interview.test.ts | 221 +++++++ packages/dashboard/src/planning.ts | 711 ++++++--------------- .../src/routes/register-planning-subtask-routes.ts | 64 +- packages/i18n/locales/en/app.json | 1 - packages/i18n/locales/es/app.json | 1 - packages/i18n/locales/fr/app.json | 1 - packages/i18n/locales/ko/app.json | 1 - packages/i18n/locales/zh-CN/app.json | 1 - packages/i18n/locales/zh-TW/app.json | 1 - 23 files changed, 536 insertions(+), 1249 deletions(-) Fusion-Task-Id: FN-8341 Fusion-Task-Lineage: 102f88af-2675-43f5-8e08-5403a0e17da8 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8341-deepening-checkpoint-removal.md
Normal file
7
.changeset/fn-8341-deepening-checkpoint-removal.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Remove the Planning Mode deepening checkpoint and fixed interview depth caps.
|
||||
category: breaking
|
||||
dev: User validation replaces AI completion and checkpoint-driven finalization.
|
||||
7
.changeset/fn-8341-planning-reactive-backend.md
Normal file
7
.changeset/fn-8341-planning-reactive-backend.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Make Planning Mode an infinite interview validated explicitly by the user.
|
||||
category: feature
|
||||
dev: Running plans update on every question with normalized alternatives, pros/cons, and Other steering.
|
||||
@@ -518,12 +518,10 @@ When an active Planning AI generation appears stuck, Planning Mode automatically
|
||||
<!-- FNXC:Planning 2026-07-15-00:00: FN-8003 preserves the user’s original planning idea across error and mid-interview recovery surfaces. -->
|
||||
Use **Copy prompt** in the error panel or an active interview question to copy the original “What do you want to build?” text, then paste it into **New session** to restart cleanly.
|
||||
|
||||
<!-- 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. -->
|
||||
<!-- FNXC:PlanningMode 2026-07-05-00:25: The planning AI now proposes plan-specific deepening themes (deepeningThemes on the completion payload) so this checkpoint surfaces topics tailored to the user's actual plan, including angles they had not anticipated, instead of a fixed generic set. The regex-derived generic themes remain the fallback whenever the AI supplies none (FN-7616 / issue #1912). -->
|
||||
<!-- FNXC:PlanningMode 2026-07-16-00:00: FN-8065 / GitHub #2150 requires the deepening checkpoint to show the generated plan read-only above its refinement choices, so users can review the title, Markdown description, and deliverables before proceeding. -->
|
||||
<!-- FNXC:PlanningMode 2026-07-18-16:00: Planning Mode is an infinite, user-controlled interview. Each answer updates the running plan and produces another context-aware high-impact question; only Validate plan finalizes it. -->
|
||||
Planning Mode keeps asking high-impact, context-aware questions until you choose **Validate plan**. The running title, description, and deliverables are available throughout the interview; the AI never ends it on its own. Selection questions provide alternatives with pros and cons plus an **Other** free-text choice, whose wording follows your input language and whose answer steers the next question. You may edit an earlier answer by question ID without losing later answers; Planning re-derives the running plan and appends a fresh next question.
|
||||
|
||||
<!-- FNXC:PlanningJsonRecovery 2026-07-28-12:00: FN-8260 / GitHub #2240 requires the final checkpoint to be reached only with a complete AI payload; incomplete JSON retries once and then surfaces the existing retryable session error instead of showing a chopped plan. -->
|
||||
Before Planning Mode shows **Planning Complete!** or the final plan summary, it first asks **Would you like to go deeper?**. If the final AI response is incomplete, Planning Mode requests a clean complete response once; if that fails, it shows a retryable session error rather than presenting a partial plan. A read-only preview of the generated plan appears above the refinement options so you can review its title, formatted description, and key deliverables before deciding. The suggested themes are plan-specific: the planning AI proposes topics tailored to your plan's title, description, and deliverables as part of its completion response, surfacing angles you may not have anticipated. When the AI does not supply any themes, Planning Mode falls back to a generic, regex-derived set (scope, edge cases, UX, dependencies, testing, rollout) inferred from the interview text. Either way, select one or more suggested themes to continue the interview, use **Other** to add a custom topic, or choose **Proceed to final plan** to reveal the pending summary and task-creation actions.
|
||||
Choose **Validate plan** when the running plan is ready for task creation. Validation is durable and is required before **Create task**, **Create tasks**, or **Start breakdown**; those actions reject unvalidated sessions.
|
||||
|
||||
- **Branch strategy** options mirror Subtask Breakdown semantics:
|
||||
- `Use project/default branch`
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatc
|
||||
import { isInReviewMissingWorktreeSessionStartFailure, runAiMerge, landWorkspaceTask, installBaselineArchiveWorktreeDisposer } from "@fusion/engine";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning";
|
||||
import { createSession, submitResponse, validateSession, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning";
|
||||
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import * as dashboard from "@fusion/dashboard";
|
||||
@@ -2287,12 +2287,18 @@ export async function runTaskPlan(
|
||||
throw promptErr;
|
||||
}
|
||||
|
||||
// Submit response and get next question or summary
|
||||
// `/validate` is an explicit user command available in every text answer.
|
||||
// The session never auto-finalizes: only this command calls validateSession.
|
||||
let result: { type: "question"; data: PlanningQuestion } | { type: "complete"; data: PlanningSummary };
|
||||
|
||||
try {
|
||||
const requestedValidation = Object.values(response).some((value) =>
|
||||
typeof value === "string" && value.trim().toLowerCase() === "/validate",
|
||||
);
|
||||
showThinking();
|
||||
result = await submitResponse(sessionId, response) as typeof result;
|
||||
result = requestedValidation
|
||||
? { type: "complete", data: await validateSession(sessionId) }
|
||||
: await submitResponse(sessionId, response) as typeof result;
|
||||
clearThinking();
|
||||
} catch (err) {
|
||||
clearThinking();
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4811,18 +4811,6 @@ export interface ArchivedTaskEntry {
|
||||
/** Type of planning question presented to the user */
|
||||
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 */
|
||||
export type IsolationMode = "in-process" | "child-process";
|
||||
|
||||
@@ -5486,18 +5474,7 @@ export interface PlanningQuestion {
|
||||
type: PlanningQuestionType;
|
||||
question: string;
|
||||
description?: string;
|
||||
options?: Array<{ id: string; label: string; description?: string }>;
|
||||
/**
|
||||
* FNXC:PlanningMode 2026-07-16-00:00:
|
||||
* FN-8065 / GitHub #2150 requires the deepening checkpoint to carry a read-only preview
|
||||
* of its withheld pendingSummary. Keeping this optional preserves legacy persisted
|
||||
* currentQuestion rows and leaves ordinary interview questions unchanged.
|
||||
*/
|
||||
planPreview?: {
|
||||
title: string;
|
||||
description: string;
|
||||
keyDeliverables: string[];
|
||||
};
|
||||
options?: Array<{ id: string; label: string; description?: string; pros?: string[]; cons?: string[]; isOther?: boolean; customText?: string }>;
|
||||
}
|
||||
|
||||
/** The final summary generated after planning conversation completes */
|
||||
@@ -5508,17 +5485,6 @@ export interface PlanningSummary {
|
||||
priority?: TaskPriority;
|
||||
suggestedDependencies: string[];
|
||||
keyDeliverables: string[];
|
||||
/**
|
||||
* FNXC:PlanningMode 2026-07-05-00:00:
|
||||
* The planning AI proposes plan-specific deepening topics (instead of the
|
||||
* fixed, regex-derived generic buckets) so the "Would you like to go
|
||||
* deeper?" checkpoint surfaces suggestions aligned with the user's actual
|
||||
* plan — including angles they had not anticipated. Optional so existing
|
||||
* persisted rows/payloads without it remain valid; the dashboard falls
|
||||
* back to the generic theme candidates when absent or empty
|
||||
* (FN-7616 / issue #1912).
|
||||
*/
|
||||
deepeningThemes?: Array<{ id?: string; label: string; description?: string }>;
|
||||
}
|
||||
|
||||
/** Response from planning endpoints - either a question or the final summary */
|
||||
@@ -5534,6 +5500,8 @@ export interface PlanningSession {
|
||||
history: Array<{ question: PlanningQuestion; response: unknown }>;
|
||||
currentQuestion?: PlanningQuestion;
|
||||
summary?: PlanningSummary;
|
||||
/** User explicitly validated the continuously maintained running plan. */
|
||||
validated?: boolean;
|
||||
/**
|
||||
* Optional per-session auto-merge override for tasks planned in this session.
|
||||
* Not separately persisted; durable form is a branch_groups row keyed by session id.
|
||||
|
||||
@@ -2638,14 +2638,12 @@ export type AgentOnboardingStreamEvent =
|
||||
export function startPlanning(
|
||||
initialPlan: string,
|
||||
projectId?: string,
|
||||
planningOptions?: { planningDepth?: "small" | "medium" | "large"; customQuestionCount?: number },
|
||||
|
||||
): Promise<PlanningSession> {
|
||||
return api<PlanningSession>(withProjectId("/planning/start", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
initialPlan,
|
||||
planningDepth: planningOptions?.planningDepth,
|
||||
customQuestionCount: planningOptions?.customQuestionCount,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -2671,7 +2669,7 @@ export function startPlanningStreaming(
|
||||
initialPlan: string,
|
||||
projectId?: string,
|
||||
modelOverride?: { planningModelProvider?: string; planningModelId?: string; thinkingLevel?: ThinkingLevel },
|
||||
planningOptions?: { planningDepth?: "small" | "medium" | "large"; customQuestionCount?: number; clarificationEnabled?: boolean },
|
||||
planningOptions?: { clarificationEnabled?: boolean },
|
||||
existingSessionId?: string,
|
||||
): Promise<{ sessionId: string }> {
|
||||
return api<{ sessionId: string }>(withProjectId("/planning/start-streaming", projectId), {
|
||||
@@ -2681,14 +2679,17 @@ export function startPlanningStreaming(
|
||||
planningModelProvider: modelOverride?.planningModelProvider,
|
||||
planningModelId: modelOverride?.planningModelId,
|
||||
thinkingLevel: modelOverride?.thinkingLevel,
|
||||
planningDepth: planningOptions?.planningDepth,
|
||||
customQuestionCount: planningOptions?.customQuestionCount,
|
||||
clarificationEnabled: planningOptions?.clarificationEnabled,
|
||||
...(existingSessionId ? { existingSessionId } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Explicitly validate the current running planning summary before creating work. */
|
||||
export function validatePlanningSession(sessionId: string, projectId?: string): Promise<{ summary: PlanningSummary; validated: boolean }> {
|
||||
return api<{ summary: PlanningSummary; validated: boolean }>(withProjectId(`/planning/${encodeURIComponent(sessionId)}/validate`, projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
/** Submit a response to the current planning question */
|
||||
export function respondToPlanning(
|
||||
sessionId: string,
|
||||
@@ -2705,11 +2706,13 @@ export function respondToPlanning(
|
||||
export function rewindPlanningSession(
|
||||
sessionId: string,
|
||||
projectId?: string,
|
||||
questionId?: string,
|
||||
): Promise<{ currentQuestion: PlanningQuestion; history: Array<{ question: PlanningQuestion; response: unknown; thinkingOutput?: string }> }> {
|
||||
return api<{ currentQuestion: PlanningQuestion; history: Array<{ question: PlanningQuestion; response: unknown; thinkingOutput?: string }> }>(
|
||||
withProjectId(`/planning/${encodeURIComponent(sessionId)}/back`, projectId),
|
||||
{
|
||||
method: "POST",
|
||||
...(questionId ? { body: JSON.stringify({ questionId }) } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import remarkGfm from "remark-gfm";
|
||||
import type { Task, PlanningQuestion, PlanningSummary, TaskPriority, ThinkingLevel } from "@fusion/core";
|
||||
import {
|
||||
DEFAULT_TASK_PRIORITY,
|
||||
PLANNING_DEEPEN_CHECKPOINT_ID,
|
||||
PLANNING_DEEPEN_PROCEED_OPTION_ID,
|
||||
TASK_PRIORITIES,
|
||||
THINKING_LEVELS,
|
||||
getErrorMessage,
|
||||
@@ -20,6 +18,7 @@ import {
|
||||
rewindPlanningSession,
|
||||
retryPlanningSession,
|
||||
createTaskFromPlanning,
|
||||
validatePlanningSession,
|
||||
connectPlanningStream,
|
||||
fetchAiSession,
|
||||
fetchAiSessions,
|
||||
@@ -444,8 +443,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const [planningModelProvider, setPlanningModelProvider] = useState<string | undefined>(undefined);
|
||||
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
|
||||
const [planningThinkingLevel, setPlanningThinkingLevel] = useState<ThinkingLevel | "">("");
|
||||
const [planningDepth, setPlanningDepth] = useState<"small" | "medium" | "large">("medium");
|
||||
const [customQuestionCount, setCustomQuestionCount] = useState("");
|
||||
const [clarificationEnabled, setClarificationEnabled] = useState(true);
|
||||
const [clarificationSettingsLoading, setClarificationSettingsLoading] = useState(true);
|
||||
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>([]);
|
||||
@@ -737,8 +734,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setPlanningModelProvider(undefined);
|
||||
setPlanningModelId(undefined);
|
||||
setPlanningThinkingLevel("");
|
||||
setPlanningDepth("medium");
|
||||
setCustomQuestionCount("");
|
||||
currentSessionIdRef.current = null;
|
||||
}, [resetPlanningAutoRetryBudget]);
|
||||
|
||||
@@ -1138,22 +1133,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
? { planningModelProvider, planningModelId, thinkingLevel: planningThinkingLevel || undefined }
|
||||
: (planningThinkingLevel ? { thinkingLevel: planningThinkingLevel } : undefined);
|
||||
|
||||
const parsedCustomQuestionCount = customQuestionCount.trim()
|
||||
? Number.parseInt(customQuestionCount, 10)
|
||||
: undefined;
|
||||
|
||||
const draftSessionId = draftSessionIdRef.current;
|
||||
const { sessionId } = await startPlanningStreaming(
|
||||
startedPlan,
|
||||
projectId,
|
||||
modelOverride,
|
||||
{
|
||||
planningDepth,
|
||||
customQuestionCount: Number.isInteger(parsedCustomQuestionCount)
|
||||
? parsedCustomQuestionCount
|
||||
: undefined,
|
||||
clarificationEnabled,
|
||||
},
|
||||
{ clarificationEnabled },
|
||||
draftSessionId ?? undefined,
|
||||
);
|
||||
draftSessionIdRef.current = null;
|
||||
@@ -1173,9 +1158,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
clarificationEnabled,
|
||||
clarificationSettingsLoading,
|
||||
connectToPlanningStream,
|
||||
customQuestionCount,
|
||||
initialPlan,
|
||||
planningDepth,
|
||||
planningModelId,
|
||||
planningModelProvider,
|
||||
planningThinkingLevel,
|
||||
@@ -1906,42 +1889,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
[projectId, resetPlanningAutoRetryBudget, view]
|
||||
);
|
||||
|
||||
const handleRefineFurther = useCallback(async () => {
|
||||
if (view.type !== "summary" || refineSummaryInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { session, summary } = view;
|
||||
const sessionId = session.sessionId;
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
refineSummaryInFlightRef.current = true;
|
||||
setIsRefiningSummary(true);
|
||||
setError(null);
|
||||
setIsRetrying(false);
|
||||
resetPlanningAutoRetryBudget();
|
||||
setStreamingOutput("");
|
||||
setView({ type: "loading" });
|
||||
liveGenerationSessionIdRef.current = sessionId;
|
||||
|
||||
connectToPlanningStream(sessionId);
|
||||
|
||||
try {
|
||||
await respondToPlanning(sessionId, { refine: true }, projectId);
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err) || t("planning.failedRefinePlan", "Failed to refine plan");
|
||||
if (/generation already in progress/i.test(message)) {
|
||||
return;
|
||||
}
|
||||
refineSummaryInFlightRef.current = false;
|
||||
setIsRefiningSummary(false);
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
setError(message);
|
||||
setView({ type: "summary", session, summary: editedSummary ?? summary });
|
||||
}
|
||||
}, [connectToPlanningStream, editedSummary, projectId, resetPlanningAutoRetryBudget, view]);
|
||||
|
||||
const handleStopGeneration = useCallback(async () => {
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
if (!sessionId) {
|
||||
@@ -1978,6 +1925,19 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
await startPlanningRetry(view.session, { auto: false });
|
||||
}, [resetPlanningAutoRetryBudget, startPlanningRetry, view]);
|
||||
|
||||
const handleValidatePlan = useCallback(async () => {
|
||||
if (view.type !== "question") return;
|
||||
setError(null);
|
||||
try {
|
||||
const result = await validatePlanningSession(view.session.sessionId, projectId);
|
||||
const summary = normalizePlanningSummary(result.summary);
|
||||
setEditedSummary(summary);
|
||||
setView({ type: "summary", session: { ...view.session, summary }, summary });
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || t("planning.failedValidatePlan", "Failed to validate plan"));
|
||||
}
|
||||
}, [projectId, t, view]);
|
||||
|
||||
const handleCreateTask = useCallback(async () => {
|
||||
if (view.type !== "summary") return;
|
||||
if ((branchMode === "existing" || branchMode === "custom-new") && !branchName.trim()) return;
|
||||
@@ -1987,6 +1947,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
|
||||
try {
|
||||
const completedSessionId = view.session.sessionId;
|
||||
await validatePlanningSession(completedSessionId, projectId);
|
||||
const normalizedSummary = editedSummary ? normalizePlanningSummary(editedSummary) : undefined;
|
||||
const task = await createTaskFromPlanning(completedSessionId, normalizedSummary, projectId, {
|
||||
branchSelection: {
|
||||
@@ -2021,6 +1982,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
|
||||
try {
|
||||
const normalizedSummary = editedSummary ? normalizePlanningSummary(editedSummary) : undefined;
|
||||
await validatePlanningSession(view.session.sessionId, projectId);
|
||||
const result = await startPlanningBreakdown(view.session.sessionId, normalizedSummary, projectId);
|
||||
const normalizedSubtasks = (Array.isArray(result.subtasks) ? result.subtasks : []).map(normalizeSubtaskItem);
|
||||
setView({
|
||||
@@ -2045,6 +2007,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
|
||||
try {
|
||||
const completedSessionId = view.sessionId;
|
||||
await validatePlanningSession(completedSessionId, projectId);
|
||||
const result = await createTasksFromPlanning(
|
||||
completedSessionId,
|
||||
buildCompactPlanningSubtaskDrafts(
|
||||
@@ -2080,8 +2043,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setPlanningModelProvider(undefined);
|
||||
setPlanningModelId(undefined);
|
||||
setPlanningThinkingLevel("");
|
||||
setPlanningDepth("medium");
|
||||
setCustomQuestionCount("");
|
||||
currentSessionIdRef.current = null;
|
||||
setSelectedSessionId(null);
|
||||
handleClose();
|
||||
@@ -2419,50 +2380,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="planning-advanced-section planning-depth-selector">
|
||||
<div className="planning-advanced-section">
|
||||
<label className="checkbox-label" htmlFor="planning-clarification-enabled">
|
||||
<input id="planning-clarification-enabled" type="checkbox" checked={clarificationEnabled} disabled={clarificationSettingsLoading} onChange={(event) => setClarificationEnabled(event.target.checked)} />
|
||||
{t("planning.agentClarification", " Allow follow-up clarification questions")}
|
||||
</label>
|
||||
<p className="planning-advanced-blurb">
|
||||
{t("planning.depthBlurb", "Plan size sets default interview depth. Questions lets you override with an exact count.")}
|
||||
</p>
|
||||
<div className="planning-depth-controls-row">
|
||||
<div className="planning-depth-chip-group" role="group" aria-label={t("planning.planningDepth", "Planning depth")}>
|
||||
{(["small", "medium", "large"] as const).map((depthValue) => {
|
||||
const depthLabels: Record<string, string> = {
|
||||
small: t("planning.depthSmall", "Small"),
|
||||
medium: t("planning.depthMedium", "Medium"),
|
||||
large: t("planning.depthLarge", "Large"),
|
||||
};
|
||||
const depthOption = { value: depthValue, label: depthLabels[depthValue] };
|
||||
return (
|
||||
<button
|
||||
key={depthOption.value}
|
||||
type="button"
|
||||
className={`planning-depth-chip btn ${planningDepth === depthOption.value ? "btn-primary planning-depth-chip-active" : ""}`}
|
||||
onClick={() => setPlanningDepth(depthOption.value)}
|
||||
aria-pressed={planningDepth === depthOption.value}
|
||||
>
|
||||
{depthOption.label}
|
||||
</button>
|
||||
);})}
|
||||
</div>
|
||||
|
||||
<label className="planning-depth-question-count" htmlFor="planning-depth-questions">
|
||||
<span>{t("planning.questionsLabel", "Questions")}</span>
|
||||
<input
|
||||
id="planning-depth-questions"
|
||||
className="input planning-depth-question-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={customQuestionCount}
|
||||
onChange={(e) => setCustomQuestionCount(e.target.value)}
|
||||
placeholder={t("planning.questionsAuto", "Auto")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingDisclosure>
|
||||
@@ -2571,6 +2493,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
isBackPending={isBackPending}
|
||||
onCopyPlanPrompt={activePlanPrompt.trim() ? handleCopyPlanPrompt : undefined}
|
||||
/>
|
||||
<button type="button" className="btn" onClick={() => { void handleValidatePlan(); }}>
|
||||
{t("planning.validatePlan", "Validate plan")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2588,9 +2513,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
onBaseBranchChange={setBaseBranch}
|
||||
onCreateTask={handleCreateTask}
|
||||
onBreakIntoTasks={handleStartBreakdown}
|
||||
onRefine={() => {
|
||||
void handleRefineFurther();
|
||||
}}
|
||||
isCreatingTask={isCreatingTask}
|
||||
isStartingBreakdown={isStartingBreakdown}
|
||||
isRefiningSummary={isRefiningSummary}
|
||||
@@ -2645,16 +2567,6 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
||||
const { t } = useTranslation("app");
|
||||
const question = normalizeQuestionOptions(rawQuestion);
|
||||
const questionOptions = question.options ?? [];
|
||||
const isDeepeningCheckpoint = question.id === PLANNING_DEEPEN_CHECKPOINT_ID;
|
||||
const planPreview = isDeepeningCheckpoint && question.planPreview
|
||||
? {
|
||||
title: typeof question.planPreview.title === "string" ? question.planPreview.title : "",
|
||||
description: typeof question.planPreview.description === "string" ? question.planPreview.description : "",
|
||||
keyDeliverables: Array.isArray(question.planPreview.keyDeliverables)
|
||||
? question.planPreview.keyDeliverables.filter((deliverable): deliverable is string => typeof deliverable === "string")
|
||||
: [],
|
||||
}
|
||||
: undefined;
|
||||
const [response, setResponse] = useState<QuestionResponse>({});
|
||||
const [textValue, setTextValue] = useState("");
|
||||
const [commentValue, setCommentValue] = useState("");
|
||||
@@ -2795,41 +2707,6 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
||||
<span className="planning-progress-text">{t("planning.questionProgress", "Question {{progress}} of ~3", { progress })}</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
FNXC:PlanningMode 2026-07-16-00:00:
|
||||
FN-8065 / GitHub #2150 requires the deepening checkpoint to show its persisted
|
||||
pendingSummary preview before users choose whether to refine or proceed. The strict
|
||||
checkpoint-and-payload guard preserves ordinary questions and legacy checkpoint rows.
|
||||
*/}
|
||||
{planPreview && (
|
||||
<section className="planning-checkpoint-plan-preview" aria-labelledby="planning-checkpoint-plan-preview-heading">
|
||||
<div className="planning-checkpoint-plan-preview-header">
|
||||
<h4 id="planning-checkpoint-plan-preview-heading">
|
||||
{t("planning.checkpointPlanPreviewHeading", "Your plan so far")}
|
||||
</h4>
|
||||
<p className="text-muted">
|
||||
{t("planning.checkpointPlanPreviewDescription", "Review the plan below, then choose to refine further or proceed.")}
|
||||
</p>
|
||||
</div>
|
||||
<h5 className="planning-checkpoint-plan-preview-title">{planPreview.title}</h5>
|
||||
{planPreview.description && (
|
||||
<div className="planning-checkpoint-plan-preview-description markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{planPreview.description}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
{planPreview.keyDeliverables.length > 0 && (
|
||||
<div className="planning-checkpoint-plan-preview-deliverables">
|
||||
<h5>{t("planning.keyDeliverables", "Key Deliverables")}</h5>
|
||||
<ul>
|
||||
{planPreview.keyDeliverables.map((deliverable, index) => (
|
||||
<li key={`${deliverable}-${index}`}>{deliverable}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="planning-question-content">
|
||||
{/*
|
||||
FNXC:PlanningInterview 2026-07-16-00:00:
|
||||
@@ -2924,7 +2801,6 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
||||
<div className="planning-checkbox-group">
|
||||
{questionOptions.map((option) => {
|
||||
const selected = Array.isArray(response[question.id]) ? (response[question.id] as string[]) : [];
|
||||
const isProceedOption = isDeepeningCheckpoint && option.id === PLANNING_DEEPEN_PROCEED_OPTION_ID;
|
||||
return (
|
||||
<label key={option.id} className="planning-option planning-option--checkbox">
|
||||
<input
|
||||
@@ -2933,14 +2809,8 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
||||
checked={selected.includes(option.id)}
|
||||
onChange={(e) => {
|
||||
const newSelected = e.target.checked
|
||||
? isProceedOption
|
||||
? [option.id]
|
||||
: [...selected.filter((id) => id !== PLANNING_DEEPEN_PROCEED_OPTION_ID), option.id]
|
||||
? [...selected, option.id]
|
||||
: selected.filter((id) => id !== option.id);
|
||||
if (isProceedOption && e.target.checked) {
|
||||
setIsOtherSelected(false);
|
||||
setOtherValue("");
|
||||
}
|
||||
setResponse({ [question.id]: newSelected });
|
||||
}}
|
||||
/>
|
||||
@@ -2961,10 +2831,6 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
||||
checked={isOtherSelected}
|
||||
onChange={(e) => {
|
||||
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) {
|
||||
setOtherValue("");
|
||||
}
|
||||
@@ -3094,7 +2960,7 @@ interface SummaryViewProps {
|
||||
onBaseBranchChange: (branch: string) => void;
|
||||
onCreateTask: () => void;
|
||||
onBreakIntoTasks: () => void;
|
||||
onRefine: () => void;
|
||||
onRefine?: () => void;
|
||||
isCreatingTask: boolean;
|
||||
isStartingBreakdown: boolean;
|
||||
isRefiningSummary: boolean;
|
||||
@@ -3340,10 +3206,12 @@ function SummaryView({
|
||||
</div>
|
||||
|
||||
<div className="planning-actions planning-summary-actions">
|
||||
<button className="btn" onClick={onRefine} disabled={isLoading}>
|
||||
<ArrowLeft size={16} className="icon-mr-4" />
|
||||
{t("planning.refineFurther", "Refine Further")}
|
||||
</button>
|
||||
{onRefine && (
|
||||
<button className="btn" onClick={onRefine} disabled={isLoading}>
|
||||
<ArrowLeft size={16} className="icon-mr-4" />
|
||||
{t("planning.refineFurther", "Refine Further")}
|
||||
</button>
|
||||
)}
|
||||
<div className="planning-summary-actions-right">
|
||||
<button className="btn" onClick={onCreateTask} disabled={isLoading || hasInvalidBranchSelection}>
|
||||
{isCreatingTask ? (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
mockStopPlanningGeneration,
|
||||
mockUpdatePlanningSessionDraft,
|
||||
mockCreateTaskFromPlanning,
|
||||
mockValidatePlanningSession,
|
||||
mockStartPlanningBreakdown,
|
||||
mockCreateTasksFromPlanning,
|
||||
mockFetchAiSession,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
mockUseMobileKeyboard,
|
||||
mockTasks,
|
||||
mockModels,
|
||||
mockSummary,
|
||||
} from "./PlanningModeModal.test-helpers";
|
||||
|
||||
const mockAddToast = vi.fn();
|
||||
@@ -57,6 +59,7 @@ vi.mock("../../api", () => ({
|
||||
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
|
||||
updatePlanningSessionDraft: (...args: any[]) => mockUpdatePlanningSessionDraft(...args),
|
||||
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
|
||||
validatePlanningSession: (...args: any[]) => mockValidatePlanningSession(...args),
|
||||
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
|
||||
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
|
||||
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
|
||||
@@ -131,6 +134,7 @@ describe("PlanningModeModal autosize", () => {
|
||||
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" });
|
||||
mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-123", title: "New planning session" });
|
||||
mockRetryPlanningSession.mockResolvedValue({ success: true, sessionId: "session-123" });
|
||||
mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true });
|
||||
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
mockFetchAiSessions.mockResolvedValue([]);
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
mockStopPlanningGeneration,
|
||||
mockUpdatePlanningSessionDraft,
|
||||
mockCreateTaskFromPlanning,
|
||||
mockValidatePlanningSession,
|
||||
mockStartPlanningBreakdown,
|
||||
mockCreateTasksFromPlanning,
|
||||
mockFetchAiSession,
|
||||
@@ -77,6 +78,7 @@ vi.mock("../../api", () => ({
|
||||
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
|
||||
updatePlanningSessionDraft: (...args: any[]) => mockUpdatePlanningSessionDraft(...args),
|
||||
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
|
||||
validatePlanningSession: (...args: any[]) => mockValidatePlanningSession(...args),
|
||||
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
|
||||
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
|
||||
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
|
||||
@@ -165,6 +167,7 @@ describe("PlanningModeModal", () => {
|
||||
// realistically in tests.
|
||||
mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-123", title: "New planning session" });
|
||||
mockRetryPlanningSession.mockResolvedValue({ success: true, sessionId: "session-123" });
|
||||
mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true });
|
||||
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
mockFetchAiSessions.mockResolvedValue([]);
|
||||
@@ -307,9 +310,7 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build a login system from handoff", undefined, undefined, {
|
||||
planningDepth: "medium",
|
||||
clarificationEnabled: false,
|
||||
customQuestionCount: undefined,
|
||||
}, undefined);
|
||||
});
|
||||
|
||||
@@ -655,9 +656,7 @@ describe("PlanningModeModal", () => {
|
||||
planningModelProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
}, {
|
||||
planningDepth: "medium",
|
||||
clarificationEnabled: false,
|
||||
customQuestionCount: undefined,
|
||||
}, undefined);
|
||||
});
|
||||
});
|
||||
@@ -692,40 +691,7 @@ describe("PlanningModeModal", () => {
|
||||
expect(disclosureScope.getByText("openai/gpt-4o")).toBeDefined();
|
||||
});
|
||||
expect(disclosureScope.getByText(/Selects which model runs the planning interview/)).toBeDefined();
|
||||
expect(disclosureScope.getByText(/Plan size sets default interview depth/)).toBeDefined();
|
||||
expect(disclosureScope.getByRole("button", { name: "Small" })).toBeDefined();
|
||||
expect(disclosureScope.getByRole("button", { name: "Medium" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(disclosureScope.getByRole("button", { name: "Large" })).toBeDefined();
|
||||
expect(disclosureScope.getByLabelText("Questions")).toBeDefined();
|
||||
});
|
||||
|
||||
it("updates selected depth and sends custom question count", async () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
|
||||
await waitFor(() => expect(document.querySelector("#planning-clarification-enabled")).not.toBeDisabled());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Large" }));
|
||||
fireEvent.change(screen.getByLabelText("Questions"), { target: { value: "7" } });
|
||||
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
|
||||
target: { value: "Build auth system" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, {
|
||||
planningDepth: "large",
|
||||
clarificationEnabled: false,
|
||||
customQuestionCount: 7,
|
||||
}, undefined);
|
||||
});
|
||||
expect(disclosureScope.getByLabelText("Allow follow-up clarification questions")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls startPlanningStreaming without model override when none selected", async () => {
|
||||
@@ -747,9 +713,7 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, {
|
||||
planningDepth: "medium",
|
||||
clarificationEnabled: false,
|
||||
customQuestionCount: undefined,
|
||||
}, undefined);
|
||||
});
|
||||
});
|
||||
@@ -804,9 +768,7 @@ describe("PlanningModeModal", () => {
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
planningDepth: "medium",
|
||||
clarificationEnabled: false,
|
||||
customQuestionCount: undefined,
|
||||
},
|
||||
"draft-123",
|
||||
);
|
||||
@@ -891,9 +853,7 @@ describe("PlanningModeModal", () => {
|
||||
// Wait for startPlanningStreaming to be called (allow time for setTimeout in useEffect)
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build a login system from new task dialog", undefined, undefined, {
|
||||
planningDepth: "medium",
|
||||
clarificationEnabled: false,
|
||||
customQuestionCount: undefined,
|
||||
}, undefined);
|
||||
}, { timeout: 2000 });
|
||||
|
||||
@@ -918,9 +878,7 @@ describe("PlanningModeModal", () => {
|
||||
// The auto-start should happen with the initial plan (allow time for setTimeout in useEffect)
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Pre-filled plan from new task", undefined, undefined, {
|
||||
planningDepth: "medium",
|
||||
clarificationEnabled: false,
|
||||
customQuestionCount: undefined,
|
||||
}, undefined);
|
||||
}, { timeout: 2000 });
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
mockStopPlanningGeneration,
|
||||
mockUpdatePlanningSessionDraft,
|
||||
mockCreateTaskFromPlanning,
|
||||
mockValidatePlanningSession,
|
||||
mockStartPlanningBreakdown,
|
||||
mockCreateTasksFromPlanning,
|
||||
mockFetchAiSession,
|
||||
@@ -98,6 +99,7 @@ vi.mock("../../api", () => ({
|
||||
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
|
||||
updatePlanningSessionDraft: (...args: any[]) => mockUpdatePlanningSessionDraft(...args),
|
||||
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
|
||||
validatePlanningSession: (...args: any[]) => mockValidatePlanningSession(...args),
|
||||
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
|
||||
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
|
||||
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
|
||||
@@ -196,6 +198,7 @@ describe("PlanningModeModal", () => {
|
||||
// realistically in tests.
|
||||
mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-123", title: "New planning session" });
|
||||
mockRetryPlanningSession.mockResolvedValue({ success: true, sessionId: "session-123" });
|
||||
mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true });
|
||||
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
mockFetchAiSessions.mockResolvedValue([]);
|
||||
@@ -266,154 +269,6 @@ describe("PlanningModeModal", () => {
|
||||
});
|
||||
|
||||
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) => {
|
||||
queuePlanningStreamEvent(() => {
|
||||
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" },
|
||||
],
|
||||
planPreview: {
|
||||
title: "Checkpoint preview title",
|
||||
description: "Checkpoint **Markdown** description",
|
||||
keyDeliverables: ["Preview deliverable one", "Preview deliverable two"],
|
||||
},
|
||||
});
|
||||
});
|
||||
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"));
|
||||
|
||||
const checkpointQuestion = await screen.findByText(PLANNING_DEEPEN_CHECKPOINT_QUESTION);
|
||||
const previewTitle = screen.getByText("Checkpoint preview title");
|
||||
expect(previewTitle.compareDocumentPosition(checkpointQuestion) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(screen.getByText("Markdown").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("Preview deliverable one")).toBeInTheDocument();
|
||||
expect(screen.getByText("Preview deliverable two")).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;
|
||||
queuePlanningStreamEvent(() => {
|
||||
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" },
|
||||
],
|
||||
});
|
||||
});
|
||||
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(await screen.findByTestId("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,
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
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) => {
|
||||
mockViewport(viewportMode);
|
||||
mockStartPlanningStreaming.mockResolvedValueOnce({ sessionId: `session-fn-6977-live-${viewportMode}` });
|
||||
@@ -476,8 +331,6 @@ describe("PlanningModeModal", () => {
|
||||
// Wait for streaming to be called
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, {
|
||||
planningDepth: "medium",
|
||||
customQuestionCount: undefined,
|
||||
clarificationEnabled: true,
|
||||
}, undefined);
|
||||
});
|
||||
@@ -1987,59 +1840,6 @@ describe("PlanningModeModal", () => {
|
||||
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" },
|
||||
],
|
||||
planPreview: {
|
||||
title: "Restored checkpoint plan",
|
||||
description: "Restored plan description",
|
||||
keyDeliverables: ["Restored deliverable"],
|
||||
},
|
||||
}),
|
||||
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"
|
||||
/>
|
||||
);
|
||||
|
||||
const checkpointQuestion = await screen.findByText(PLANNING_DEEPEN_CHECKPOINT_QUESTION);
|
||||
const previewTitle = screen.getByText("Restored checkpoint plan");
|
||||
expect(previewTitle.compareDocumentPosition(checkpointQuestion) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(screen.getByText("Restored plan description")).toBeInTheDocument();
|
||||
expect(screen.getByText("Restored deliverable")).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.each(["desktop", "mobile"] as const)("keeps persisted awaiting-input questions free of reconnecting hints on %s", async (viewportMode) => {
|
||||
mockViewport(viewportMode);
|
||||
let streamHandlers: any;
|
||||
@@ -2253,7 +2053,7 @@ describe("PlanningModeModal", () => {
|
||||
"Plan that needs a specific model",
|
||||
undefined,
|
||||
{ planningModelProvider: "anthropic", planningModelId: "claude-sonnet-4-5", thinkingLevel: undefined },
|
||||
{ planningDepth: "medium", customQuestionCount: undefined, clarificationEnabled: true },
|
||||
{ clarificationEnabled: true },
|
||||
"session-draft-with-model",
|
||||
);
|
||||
});
|
||||
@@ -3434,240 +3234,6 @@ describe("PlanningModeModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refines a resumed complete session without blank question view", async () => {
|
||||
const resumedSummary: PlanningSummary = {
|
||||
title: "Resume-and-refine",
|
||||
description: "Recovered summary for refine",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Implement", "Verify"],
|
||||
};
|
||||
const refinedQuestion: PlanningQuestion = {
|
||||
id: "q-refine",
|
||||
type: "text",
|
||||
question: "Which part should we refine?",
|
||||
description: "Refine follow-up",
|
||||
};
|
||||
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-complete-refine",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Resume-and-refine",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Recover and refine" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(resumedSummary),
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
let streamHandlers: any;
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
streamHandlers = handlers;
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
mockRespondToPlanning.mockImplementationOnce(async () => {
|
||||
queuePlanningStreamEvent(() => {
|
||||
streamHandlers?.onQuestion?.(refinedQuestion);
|
||||
});
|
||||
return { type: "question", data: refinedQuestion };
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId="session-complete-refine"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Refine Further" })).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refine Further" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRespondToPlanning).toHaveBeenCalledWith(
|
||||
"session-complete-refine",
|
||||
{ refine: true },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Which part should we refine?")).toBeDefined();
|
||||
});
|
||||
expect(screen.queryByText("No active question in session")).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["desktop", "mobile"] as const)("keeps resumed Refine Further single-flight on rapid %s activation", async (viewportMode) => {
|
||||
mockViewport(viewportMode);
|
||||
const resumedSummary: PlanningSummary = {
|
||||
title: "Populated summary for duplicate refine",
|
||||
description: "Recovered summary with edited details before refine",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: ["FN-001"],
|
||||
keyDeliverables: ["Keep edits", "Ask follow-up"],
|
||||
};
|
||||
const refinedQuestion: PlanningQuestion = {
|
||||
id: `q-refine-${viewportMode}`,
|
||||
type: "text",
|
||||
question: `What should we refine next on ${viewportMode}?`,
|
||||
description: "Follow-up from the original refine stream",
|
||||
};
|
||||
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: `session-complete-refine-${viewportMode}`,
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: resumedSummary.title,
|
||||
inputPayload: JSON.stringify({ initialPlan: "Recover and refine without duplicate generation" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(resumedSummary),
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
let streamHandlers: any;
|
||||
let streamClosed = false;
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
streamHandlers = handlers;
|
||||
return {
|
||||
close: vi.fn(() => {
|
||||
streamClosed = true;
|
||||
}),
|
||||
isConnected: vi.fn(() => !streamClosed),
|
||||
};
|
||||
});
|
||||
mockRespondToPlanning.mockImplementation(async () => {
|
||||
queuePlanningStreamEvent(() => {
|
||||
if (!streamClosed) {
|
||||
streamHandlers?.onQuestion?.(refinedQuestion);
|
||||
}
|
||||
});
|
||||
return { type: "question", data: refinedQuestion };
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId={`session-complete-refine-${viewportMode}`}
|
||||
/>
|
||||
);
|
||||
|
||||
const refineButton = await screen.findByRole("button", { name: "Refine Further" });
|
||||
fireEvent.click(refineButton);
|
||||
fireEvent.click(refineButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRespondToPlanning).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(mockRespondToPlanning).toHaveBeenCalledWith(
|
||||
`session-complete-refine-${viewportMode}`,
|
||||
{ refine: true },
|
||||
undefined,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(`What should we refine next on ${viewportMode}?`)).toBeDefined();
|
||||
});
|
||||
expect(screen.queryByText(/generation already in progress/i)).toBeNull();
|
||||
expect(screen.queryByText(/generation in progress/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the refine stream alive when the accepted turn reports generation already in progress", async () => {
|
||||
const resumedSummary: PlanningSummary = {
|
||||
title: "Backend conflict refine",
|
||||
description: "Summary that was already accepted for refinement",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Continue stream"],
|
||||
};
|
||||
const refinedQuestion: PlanningQuestion = {
|
||||
id: "q-refine-conflict",
|
||||
type: "text",
|
||||
question: "What detail should the already-running refine turn clarify?",
|
||||
description: "Follow-up from the active refine generation",
|
||||
};
|
||||
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-complete-refine-conflict",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: resumedSummary.title,
|
||||
inputPayload: JSON.stringify({ initialPlan: "Refine active conflict" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(resumedSummary),
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
let streamHandlers: any;
|
||||
let streamClosed = false;
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
streamHandlers = handlers;
|
||||
return {
|
||||
close: vi.fn(() => {
|
||||
streamClosed = true;
|
||||
}),
|
||||
isConnected: vi.fn(() => !streamClosed),
|
||||
};
|
||||
});
|
||||
mockRespondToPlanning.mockImplementationOnce(async () => {
|
||||
queuePlanningStreamEvent(() => {
|
||||
if (!streamClosed) {
|
||||
streamHandlers?.onQuestion?.(refinedQuestion);
|
||||
}
|
||||
});
|
||||
throw new Error("Generation already in progress for this response");
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId="session-complete-refine-conflict"
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refine Further" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What detail should the already-running refine turn clarify?")).toBeDefined();
|
||||
});
|
||||
expect(streamClosed).toBe(false);
|
||||
expect(screen.queryByText(/generation already in progress/i)).toBeNull();
|
||||
expect(screen.queryByText(/generation in progress/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Conversation history", () => {
|
||||
it("hides completed-session Q&A by default behind a summary disclosure", async () => {
|
||||
const resumedSummary: PlanningSummary = {
|
||||
title: "Summary with hidden history",
|
||||
|
||||
@@ -12,6 +12,7 @@ export const mockCancelPlanning = vi.fn();
|
||||
export const mockStopPlanningGeneration = vi.fn();
|
||||
export const mockUpdatePlanningSessionDraft = vi.fn();
|
||||
export const mockCreateTaskFromPlanning = vi.fn();
|
||||
export const mockValidatePlanningSession = vi.fn();
|
||||
export const mockStartPlanningBreakdown = vi.fn();
|
||||
export const mockCreateTasksFromPlanning = vi.fn();
|
||||
export const mockFetchAiSession = vi.fn();
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
mockStopPlanningGeneration,
|
||||
mockUpdatePlanningSessionDraft,
|
||||
mockCreateTaskFromPlanning,
|
||||
mockValidatePlanningSession,
|
||||
mockStartPlanningBreakdown,
|
||||
mockCreateTasksFromPlanning,
|
||||
mockFetchAiSession,
|
||||
@@ -86,6 +87,7 @@ vi.mock("../../api", () => ({
|
||||
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
|
||||
updatePlanningSessionDraft: (...args: any[]) => mockUpdatePlanningSessionDraft(...args),
|
||||
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
|
||||
validatePlanningSession: (...args: any[]) => mockValidatePlanningSession(...args),
|
||||
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
|
||||
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
|
||||
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
|
||||
@@ -192,6 +194,7 @@ describe("PlanningModeModal", () => {
|
||||
// realistically in tests.
|
||||
mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-123", title: "New planning session" });
|
||||
mockRetryPlanningSession.mockResolvedValue({ success: true, sessionId: "session-123" });
|
||||
mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true });
|
||||
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
mockFetchAiSessions.mockResolvedValue([]);
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
resolveMcpServersForStore: async () => ({ servers: [] }),
|
||||
buildSessionSkillContextSync: () => ({ skillSelectionContext: undefined, resolvedSkillNames: [], skillSource: "role-fallback" as const }),
|
||||
createFnAgent: vi.fn(),
|
||||
createWorkflowAuthoringTools: () => [],
|
||||
createChatTaskDocumentTools: () => [],
|
||||
createChatTaskLogsReadTool: () => ({}),
|
||||
}));
|
||||
|
||||
import {
|
||||
__resetPlanningState,
|
||||
__setCreateFnAgent,
|
||||
createSession,
|
||||
createSessionWithAgent,
|
||||
getSession,
|
||||
normalizePlanningQuestion,
|
||||
planningStreamManager,
|
||||
rewindSession,
|
||||
submitResponse,
|
||||
validateSession,
|
||||
} from "../planning.js";
|
||||
|
||||
const MOCK_TASK_STORE = {
|
||||
listTasks: vi.fn(async () => []),
|
||||
getTask: vi.fn(async () => { throw new Error("not found"); }),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
function payload(data: Record<string, unknown>): string {
|
||||
return JSON.stringify({ type: "question", data });
|
||||
}
|
||||
|
||||
function completePayload(): string {
|
||||
return JSON.stringify({
|
||||
type: "complete",
|
||||
data: { title: "The model tried to end the interview", description: "must be ignored" },
|
||||
});
|
||||
}
|
||||
|
||||
/** A scripted planning agent that records every prompt sent through the live session seam. */
|
||||
function installScriptedAgent(responses: string[]) {
|
||||
const prompts: string[] = [];
|
||||
__setCreateFnAgent(vi.fn(async () => {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
return {
|
||||
session: {
|
||||
state: { messages },
|
||||
prompt: vi.fn(async (message: string) => {
|
||||
prompts.push(message);
|
||||
const next = responses.shift();
|
||||
if (!next) throw new Error(`Unexpected planning prompt: ${message}`);
|
||||
messages.push({ role: "assistant", content: next });
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
};
|
||||
}) as never);
|
||||
return prompts;
|
||||
}
|
||||
|
||||
const FIRST_QUESTION = {
|
||||
id: "scope", type: "single_select", question: "Which outcome matters most?",
|
||||
options: [
|
||||
{ id: "secure", label: "Secure defaults", pros: ["Reduces risk"], cons: ["Takes longer"] },
|
||||
{ id: "fast", label: "Fast delivery", pros: ["Ships sooner"], cons: ["May defer hardening"] },
|
||||
{ id: "other", label: "Other (write your own)", isOther: true },
|
||||
],
|
||||
};
|
||||
|
||||
const SECOND_QUESTION = {
|
||||
id: "rollout", type: "single_select", question: "How should rollout work?",
|
||||
options: [
|
||||
{ id: "gradual", label: "Gradual rollout", pros: ["Limits blast radius"], cons: ["Needs flags"] },
|
||||
{ id: "all", label: "All at once", pros: ["Simple release"], cons: ["Higher risk"] },
|
||||
],
|
||||
};
|
||||
|
||||
describe("reactive Planning Mode question contract", () => {
|
||||
beforeEach(() => {
|
||||
__resetPlanningState();
|
||||
});
|
||||
|
||||
it("repairs malformed select options and appends one localized Other option", () => {
|
||||
const question = normalizePlanningQuestion({
|
||||
id: "security",
|
||||
type: "single_select",
|
||||
question: "¿Qué prioridad tiene la seguridad?",
|
||||
options: [{ id: "fast", label: "Rápido", pros: [], cons: [] }],
|
||||
}, "Quiero añadir autenticación para usuarios españoles");
|
||||
|
||||
expect(question.options).toHaveLength(3);
|
||||
const alternatives = question.options!.filter((option) => !option.isOther);
|
||||
expect(alternatives).toHaveLength(2);
|
||||
expect(alternatives.every((option) => option.pros!.length > 0 && option.cons!.length > 0)).toBe(true);
|
||||
expect(question.options!.filter((option) => option.isOther)).toEqual([
|
||||
expect.objectContaining({ label: "Otro (escribe tu respuesta)", isOther: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("upgrades legacy text questions so every question has alternatives and Other", () => {
|
||||
const question = normalizePlanningQuestion({ type: "text", question: "What matters next?", options: [{ id: "bad" }] });
|
||||
expect(question).toEqual(expect.objectContaining({ type: "single_select", question: "What matters next?" }));
|
||||
expect(question.options).toHaveLength(3);
|
||||
expect(question.options?.at(-1)).toEqual(expect.objectContaining({ isOther: true }));
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-18-17:30:
|
||||
A model completion is never a Planning Mode terminal state. This exercises the real
|
||||
createSession/submitResponse agent seam so regression coverage proves the running plan,
|
||||
Other steering, and explicit-only validation invariant rather than only testing normalization.
|
||||
*/
|
||||
it("keeps the streaming agent turn non-terminal after complete, persists its running plan, and validates only on user action", async () => {
|
||||
const prompts = installScriptedAgent([
|
||||
completePayload(),
|
||||
payload(SECOND_QUESTION),
|
||||
]);
|
||||
const sessionId = await createSessionWithAgent(
|
||||
"127.0.0.10",
|
||||
"Build secure account recovery",
|
||||
"/tmp/project",
|
||||
MOCK_TASK_STORE,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ clarificationEnabled: true },
|
||||
);
|
||||
const events: string[] = [];
|
||||
const firstQuestion = new Promise<typeof SECOND_QUESTION>((resolve) => {
|
||||
planningStreamManager.subscribe(sessionId, (event) => {
|
||||
events.push(event.type);
|
||||
if (event.type === "question") resolve(event.data as typeof SECOND_QUESTION);
|
||||
});
|
||||
});
|
||||
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
||||
const fallbackQuestion = await firstQuestion;
|
||||
|
||||
// The streamed processAgentTurn seam must coerce generic complete output into a question.
|
||||
expect(fallbackQuestion.id).not.toBe("complete");
|
||||
expect((await getSession(sessionId))?.summary?.description).toContain("Build secure account recovery");
|
||||
expect((await getSession(sessionId))?.validated).toBe(false);
|
||||
|
||||
const next = await submitResponse(sessionId, {
|
||||
[fallbackQuestion.id]: "other",
|
||||
_other: "Ask about audit-log security before anything else.",
|
||||
}, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
expect(next).toEqual(expect.objectContaining({ type: "question", data: expect.objectContaining({ id: "rollout" }) }));
|
||||
expect(prompts.at(-1)).toContain("Ask about audit-log security before anything else.");
|
||||
expect(events.filter((type) => type === "summary")).toHaveLength(2);
|
||||
|
||||
await validateSession(sessionId);
|
||||
expect(await getSession(sessionId)).toMatchObject({ validated: true, currentQuestion: undefined });
|
||||
});
|
||||
|
||||
it("continues after a model completion with a running plan and only validates on user action", async () => {
|
||||
const prompts = installScriptedAgent([
|
||||
payload(FIRST_QUESTION),
|
||||
completePayload(),
|
||||
payload(SECOND_QUESTION),
|
||||
]);
|
||||
const created = await createSession("127.0.0.1", "Build secure account recovery", MOCK_TASK_STORE, "/tmp/project");
|
||||
|
||||
expect(created.summary.description).toContain("Build secure account recovery");
|
||||
expect(created.validated).toBe(false);
|
||||
expect((await getSession(created.sessionId))?.currentQuestion?.id).toBe("scope");
|
||||
|
||||
const firstNext = await submitResponse(created.sessionId, {
|
||||
scope: "other",
|
||||
_other: "Ask me questions about audit logging security instead.",
|
||||
}, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
expect(firstNext.type).toBe("question");
|
||||
expect(firstNext.data.id).not.toBe("scope");
|
||||
expect(prompts[1]).toContain("Ask me questions about audit logging security instead.");
|
||||
|
||||
const afterCompletion = await getSession(created.sessionId);
|
||||
expect(afterCompletion?.validated).toBe(false);
|
||||
expect(afterCompletion).not.toHaveProperty("pendingSummary");
|
||||
expect(afterCompletion?.summary?.description).toContain("audit logging security");
|
||||
expect(afterCompletion?.currentQuestion).toBeDefined();
|
||||
|
||||
const secondQuestion = afterCompletion!.currentQuestion!;
|
||||
const secondNext = await submitResponse(created.sessionId, { [secondQuestion.id]: "option-1" }, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
expect(secondNext).toEqual(expect.objectContaining({ type: "question" }));
|
||||
expect((await getSession(created.sessionId))?.summary).toBeDefined();
|
||||
expect((await getSession(created.sessionId))?.validated).toBe(false);
|
||||
|
||||
const finalPlan = await validateSession(created.sessionId);
|
||||
expect(finalPlan.description).toContain("Build secure account recovery");
|
||||
expect(await getSession(created.sessionId)).toMatchObject({ validated: true, currentQuestion: undefined });
|
||||
});
|
||||
|
||||
it("replays an edited historical answer while retaining later answers and appending a fresh question", async () => {
|
||||
installScriptedAgent([
|
||||
payload(FIRST_QUESTION),
|
||||
payload(SECOND_QUESTION),
|
||||
payload({ ...SECOND_QUESTION, id: "verification", question: "What verification is required?" }),
|
||||
payload({ ...SECOND_QUESTION, id: "ignored-replay" }),
|
||||
payload({ ...SECOND_QUESTION, id: "ignored-replay-after-edit" }),
|
||||
payload({ ...SECOND_QUESTION, id: "fresh-after-edit", question: "What risk remains after the edit?" }),
|
||||
]);
|
||||
const created = await createSession("127.0.0.2", "Improve audit trails", MOCK_TASK_STORE, "/tmp/project");
|
||||
await submitResponse(created.sessionId, { scope: "secure" }, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
const second = (await getSession(created.sessionId))!.currentQuestion!;
|
||||
await submitResponse(created.sessionId, { [second.id]: "gradual" }, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
|
||||
await rewindSession(created.sessionId, "scope", "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
await submitResponse(created.sessionId, { scope: "fast" }, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
|
||||
const edited = await getSession(created.sessionId);
|
||||
expect(edited?.history).toHaveLength(2);
|
||||
expect(edited?.history[0]?.response).toEqual({ scope: "fast" });
|
||||
expect(edited?.history[1]?.response).toEqual({ [second.id]: "gradual" });
|
||||
expect(edited?.currentQuestion?.id).toBe("fresh-after-edit");
|
||||
expect(edited?.summary?.description).toContain("fast");
|
||||
});
|
||||
});
|
||||
@@ -25,10 +25,6 @@ import type {
|
||||
import {
|
||||
DASHBOARD_USER_ID,
|
||||
DEFAULT_TASK_PRIORITY,
|
||||
PLANNING_DEEPEN_CHECKPOINT_ID,
|
||||
PLANNING_DEEPEN_CHECKPOINT_QUESTION,
|
||||
PLANNING_DEEPEN_PROCEED_OPTION_ID,
|
||||
PLANNING_DEEPEN_PROCEED_RESPONSE_KEY,
|
||||
TASK_PRIORITIES,
|
||||
THINKING_LEVELS,
|
||||
resolvePrompt,
|
||||
@@ -77,8 +73,6 @@ type PlanningSessionOptions = {
|
||||
clarificationEnabled?: boolean;
|
||||
/** Runtime-only mailbox dependency; never serialize this store. */
|
||||
messageStore?: MessageStore;
|
||||
planningDepth?: PlanningDepth;
|
||||
customQuestionCount?: number;
|
||||
pluginRunner?: SkillPluginRunner;
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -229,56 +223,15 @@ async function ensureNtfyHelpersReady(): Promise<void> {
|
||||
// ── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Planning system prompt for the AI agent */
|
||||
export const PLANNING_SYSTEM_PROMPT = `You are a planning assistant for the fn task board system.
|
||||
export const PLANNING_SYSTEM_PROMPT = `You are a planning assistant for the fn task board system. First analyze the codebase and active board with the available read tools, fn_task_list, and fn_task_show. Turn a raw idea into an incrementally maintained plan.
|
||||
|
||||
Your job: help users transform vague, high-level ideas into well-defined, actionable tasks.
|
||||
Ask exactly one next, high-impact question on every turn. Use every prior answer as context, avoid repeated questions, and never decide that the interview is complete or emit a terminal/complete response. The user alone validates the plan.
|
||||
|
||||
## Conversation Flow
|
||||
1. User provides a high-level plan (e.g., "Build a user auth system")
|
||||
2. You ask clarifying questions to understand scope, requirements, and constraints
|
||||
3. You present UI-friendly selection options when appropriate
|
||||
4. Once you have enough information, generate a structured summary
|
||||
Respond only with JSON: {"type":"question","data":{"id":"unique-id","type":"single_select|multi_select","question":"...","description":"...","options":[{"id":"option-a","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"option-b","label":"...","description":"...","pros":["..."],"cons":["...]},{"id":"other","label":"...","isOther":true}]}}.
|
||||
|
||||
## Question Types to Use
|
||||
- "text": Open-ended follow-up questions for detailed input
|
||||
- "single_select": When user must choose one option (e.g., tech stack preference)
|
||||
- "multi_select": When multiple options can apply (e.g., features to include)
|
||||
- "confirm": Yes/No questions for quick decisions
|
||||
Every question must provide at least two alternatives, each with non-empty pros and cons, plus exactly one Other/write-your-own option. Write every label, option, and Other label in the language of the user's original input. Incorporate free-text Other answers verbatim as steering context for the following question.`;
|
||||
|
||||
## Guidelines
|
||||
- Ask 3-7 questions depending on complexity
|
||||
- Start broad, then narrow down specifics
|
||||
- Suggest sensible defaults based on project context
|
||||
- Keep questions focused and actionable
|
||||
- When asking about file scope, reference actual project structure
|
||||
|
||||
## Summary Generation
|
||||
When ready to complete, generate:
|
||||
- A concise but descriptive title (max 80 chars)
|
||||
- A detailed description with context gathered
|
||||
- Size estimate (S/M/L) based on scope
|
||||
- Any suggested dependencies on existing tasks
|
||||
- Key deliverables as a checklist
|
||||
- Optional "deepeningThemes": before completing, think ahead about THIS specific plan (its title, description, and deliverables) and propose 2-5 concrete, plan-aligned topics the user could explore in more depth — including angles they may not have anticipated. Each theme is an object with "label" and "description" string fields. Themes must be specific to this plan, not generic boilerplate (do not just restate "scope", "testing", "edge cases" as bare labels — tie them to this plan's actual concerns). Omit the field entirely if nothing meaningful stands out.
|
||||
|
||||
## Board tools
|
||||
- fn_task_list — list active tasks
|
||||
- fn_task_show — read a task's full details and PROMPT.md
|
||||
Use these to avoid duplicating an existing in-flight plan and to anchor your questions against current backlog context.
|
||||
|
||||
## Response Format
|
||||
Always respond with valid JSON in one of these formats:
|
||||
|
||||
For questions:
|
||||
{\n "type": "question",\n "data": {\n "id": "unique-id",\n "type": "text|single_select|multi_select|confirm",\n "question": "The question text",\n "description": "Helpful context",\n "options": [{"id": "opt1", "label": "Option 1", "description": "Details"}]\n }\n}
|
||||
|
||||
For completion:
|
||||
{\n "type": "complete",\n "data": {\n "title": "Task title",\n "description": "Detailed description",\n "suggestedSize": "S|M|L",\n "suggestedDependencies": [],\n "keyDeliverables": ["Item 1", "Item 2"],\n "deepeningThemes": [{"label": "Plan-specific topic", "description": "Why this is worth exploring further for this plan"}]\n }\n}`;
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-05-00:00:
|
||||
The completion payload's optional deepeningThemes field lets the planning AI "think ahead" and propose topics tailored to the specific plan, instead of the checkpoint always offering the same fixed generic buckets (FN-7616 / issue #1912). See buildDeepeningCheckpointOptions for the AI-first / generic-fallback precedence.
|
||||
*/
|
||||
|
||||
/** Placeholder title for draft sessions before the user starts planning. */
|
||||
export const DRAFT_PLACEHOLDER_TITLE = "New planning session";
|
||||
@@ -306,7 +259,7 @@ export interface DraftInputPayload {
|
||||
modelId?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
summarizedFor?: string;
|
||||
pendingSummary?: PlanningSummary;
|
||||
validated?: boolean;
|
||||
}
|
||||
|
||||
/** Session TTL in milliseconds (7 days) */
|
||||
@@ -334,32 +287,6 @@ const PLANNING_STUCK_ERROR_MESSAGE = "AI generation appears stuck with no new ou
|
||||
const PLANNING_LOOP_ERROR_MESSAGE = "AI generation appears stuck repeating the same output. You can retry or start a new session.";
|
||||
const PLANNING_USER_STOP_ERROR_MESSAGE = "Generation stopped by user. You can retry or start a new session.";
|
||||
|
||||
export type PlanningDepth = "small" | "medium" | "large";
|
||||
|
||||
const PLANNING_DEPTH_PROMPT_SUFFIX: Record<PlanningDepth, string> = {
|
||||
small:
|
||||
"Ask exactly 1-2 focused questions. Prioritize speed and getting to a summary quickly. Skip optional clarification.",
|
||||
medium:
|
||||
"Ask 3-5 well-rounded questions. Balance breadth and depth. This is the default behavior.",
|
||||
large:
|
||||
"Ask 5-8 thorough questions. Deeply explore scope, edge cases, dependencies, and implementation details. Be comprehensive.",
|
||||
};
|
||||
|
||||
export function buildDepthPromptSuffix(
|
||||
depth?: PlanningDepth,
|
||||
customQuestionCount?: number,
|
||||
): string {
|
||||
if (Number.isInteger(customQuestionCount) && (customQuestionCount ?? 0) > 0) {
|
||||
return `Ask exactly ${customQuestionCount} questions. Adjust depth and breadth to fit within that count.`;
|
||||
}
|
||||
|
||||
if (!depth) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return PLANNING_DEPTH_PROMPT_SUFFIX[depth];
|
||||
}
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** SSE event types for planning session streaming */
|
||||
@@ -404,9 +331,11 @@ interface Session {
|
||||
lastMailboxNotifiedQuestionKey?: string;
|
||||
history: PlanningHistoryEntry[];
|
||||
currentQuestion?: PlanningQuestion;
|
||||
/** Question currently being edited; history is preserved rather than truncated. */
|
||||
editingQuestionId?: string;
|
||||
summary?: PlanningSummary;
|
||||
/** Pending AI-completed summary held behind the mandatory user deepening checkpoint. */
|
||||
pendingSummary?: PlanningSummary;
|
||||
/** User-controlled finalization state. */
|
||||
validated: boolean;
|
||||
/** Last terminal error for retry UX */
|
||||
error?: string;
|
||||
/** AI agent session for real-time interaction */
|
||||
@@ -510,8 +439,6 @@ export function normalizePlanningSummaryPayload(
|
||||
? summary.description.trim()
|
||||
: fallback?.description?.trim() || title;
|
||||
|
||||
const deepeningThemes = normalizeDeepeningThemes(summary.deepeningThemes);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
@@ -521,210 +448,9 @@ export function normalizePlanningSummaryPayload(
|
||||
priority: isTaskPriority(summary.priority) ? summary.priority : DEFAULT_TASK_PRIORITY,
|
||||
suggestedDependencies: normalizeStringArray(summary.suggestedDependencies),
|
||||
keyDeliverables: normalizeStringArray(summary.keyDeliverables),
|
||||
...(deepeningThemes ? { deepeningThemes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Max deepeningThemes entries kept per summary; bounds checkpoint size. */
|
||||
const MAX_DEEPENING_THEMES = 6;
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-05-00:10:
|
||||
AI-proposed deepeningThemes are untrusted runtime data (same discipline as the rest of this normalizer). Keep only object entries with a non-empty trimmed label; trim description when present; drop anything malformed (non-object, missing/blank label, non-array value entirely) without ever letting an unchecked shape reach a live stream. Cap at MAX_DEEPENING_THEMES and omit the field entirely (not []) when nothing valid remains, so callers can treat "field absent" as "AI supplied none" and fall back to the generic regex themes (FN-7616 / issue #1912).
|
||||
*/
|
||||
function normalizeDeepeningThemes(value: unknown): Array<{ id?: string; label: string; description?: string }> | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const seenLabels = new Set<string>();
|
||||
const normalized: Array<{ id?: string; label: string; description?: string }> = [];
|
||||
for (const item of value) {
|
||||
if (normalized.length >= MAX_DEEPENING_THEMES) {
|
||||
break;
|
||||
}
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
||||
continue;
|
||||
}
|
||||
const record = item as Record<string, unknown>;
|
||||
const label = typeof record.label === "string" ? record.label.trim() : "";
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
const dedupeKey = label.toLowerCase();
|
||||
if (seenLabels.has(dedupeKey)) {
|
||||
continue;
|
||||
}
|
||||
seenLabels.add(dedupeKey);
|
||||
const description = typeof record.description === "string" && record.description.trim().length > 0
|
||||
? record.description.trim()
|
||||
: undefined;
|
||||
normalized.push({ label, ...(description ? { description } : {}) });
|
||||
}
|
||||
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
const PROCEED_OPTION: NonNullable<PlanningQuestion["options"]>[number] = {
|
||||
id: PLANNING_DEEPEN_PROCEED_OPTION_ID,
|
||||
label: "Proceed to final plan",
|
||||
description: "The plan is detailed enough; show the final editable summary.",
|
||||
};
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-05-00:15:
|
||||
Prefer the planning AI's own deepeningThemes (plan-specific, sometimes-unanticipated topics) over the fixed regex-derived candidates below. The generic CHECKPOINT_THEME_CANDIDATES/FALLBACK_CHECKPOINT_THEME_OPTIONS path remains the safety net for when the AI supplies no themes (FN-7616 / issue #1912).
|
||||
*/
|
||||
export function buildDeepeningCheckpointOptions(
|
||||
history: Array<{ question: PlanningQuestion; response: unknown }>,
|
||||
summary: PlanningSummary,
|
||||
): PlanningQuestion["options"] {
|
||||
if (summary.deepeningThemes && summary.deepeningThemes.length > 0) {
|
||||
const seenIds = new Set<string>([PROCEED_OPTION.id]);
|
||||
const aiOptions = summary.deepeningThemes.flatMap((theme) => {
|
||||
const label = theme.label.trim();
|
||||
if (!label || label === PROCEED_OPTION.label) {
|
||||
return [];
|
||||
}
|
||||
const id = `theme-${slugifyCheckpointTheme(label)}`;
|
||||
if (seenIds.has(id)) {
|
||||
return [];
|
||||
}
|
||||
seenIds.add(id);
|
||||
return [{ id, label, ...(theme.description ? { description: theme.description } : {}) }];
|
||||
});
|
||||
|
||||
if (aiOptions.length > 0) {
|
||||
return [PROCEED_OPTION, ...aiOptions];
|
||||
}
|
||||
}
|
||||
|
||||
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 [
|
||||
PROCEED_OPTION,
|
||||
...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.
|
||||
|
||||
FNXC:PlanningMode 2026-07-05-00:20:
|
||||
buildDeepeningCheckpointOptions prefers the AI's plan-specific deepeningThemes when the completion payload supplied any; it falls back to the generic regex-derived CHECKPOINT_THEME_CANDIDATES only when the AI supplied none (FN-7616 / issue #1912). The reserved proceed option is always first and deterministic in both branches.
|
||||
|
||||
FNXC:PlanningMode 2026-07-16-00:00:
|
||||
FN-8065 / GitHub #2150 places the withheld pendingSummary preview directly on the persisted checkpoint question. That makes the same read-only plan available through fresh SSE, restored sessions, retry recovery, and the missed-SSE poll watchdog without adding another transport path.
|
||||
*/
|
||||
export function buildDeepeningCheckpointQuestion(
|
||||
history: Array<{ question: PlanningQuestion; response: unknown }>,
|
||||
summary: PlanningSummary,
|
||||
): PlanningQuestion {
|
||||
const planPreview = {
|
||||
title: typeof summary.title === "string" ? summary.title : "",
|
||||
description: typeof summary.description === "string" ? summary.description : "",
|
||||
keyDeliverables: normalizeStringArray(summary.keyDeliverables),
|
||||
};
|
||||
|
||||
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),
|
||||
planPreview,
|
||||
};
|
||||
}
|
||||
|
||||
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>(
|
||||
text: string | null,
|
||||
fallback: T,
|
||||
@@ -808,7 +534,7 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
|
||||
...(session.draftModelId ? { modelId: session.draftModelId } : {}),
|
||||
...(session.draftThinkingLevel ? { thinkingLevel: session.draftThinkingLevel } : {}),
|
||||
...(session.draftSummarizedFor ? { summarizedFor: session.draftSummarizedFor } : {}),
|
||||
...(session.pendingSummary ? { pendingSummary: session.pendingSummary } : {}),
|
||||
validated: session.validated,
|
||||
...(typeof session.clarificationEnabled === "boolean"
|
||||
? { clarificationEnabled: session.clarificationEnabled }
|
||||
: {}),
|
||||
@@ -917,10 +643,7 @@ function buildSessionFromRow(row: AiSessionRow): Session {
|
||||
{ title: row.title, description: row.title },
|
||||
)
|
||||
: undefined;
|
||||
const persistedPendingSummary = payload.pendingSummary
|
||||
? normalizePlanningSummaryPayload(payload.pendingSummary, { title: row.title, description: row.title })
|
||||
: undefined;
|
||||
const skippedMandatoryInterview = history.length === 0 && Boolean(persistedSummary || persistedPendingSummary);
|
||||
const skippedMandatoryInterview = false;
|
||||
const currentQuestion = skippedMandatoryInterview
|
||||
? buildMandatoryFirstPlanningQuestion()
|
||||
: row.status === "awaiting_input" && row.currentQuestion
|
||||
@@ -949,8 +672,8 @@ function buildSessionFromRow(row: AiSessionRow): Session {
|
||||
history,
|
||||
currentQuestion,
|
||||
lastNotifiedQuestionKey: currentQuestion ? `${row.id}:${currentQuestion.id}` : undefined,
|
||||
summary: skippedMandatoryInterview ? undefined : persistedSummary,
|
||||
pendingSummary: skippedMandatoryInterview ? undefined : persistedPendingSummary,
|
||||
summary: persistedSummary ?? buildRunningSummary(payload.initialPlan ?? row.title, history),
|
||||
validated: payload.validated === true,
|
||||
thinkingOutput: row.thinkingOutput,
|
||||
lastGeneratedThinking: row.thinkingOutput || "",
|
||||
error: row.error ?? undefined,
|
||||
@@ -975,7 +698,7 @@ export async function rehydrateFromStore(store: AiSessionStore): Promise<number>
|
||||
try {
|
||||
const session = buildSessionFromRow(row);
|
||||
sessions.set(session.id, session);
|
||||
if (session.currentQuestion && session.history.length === 0 && (row.result || safeParseJson<DraftInputPayload>(row.inputPayload, {}).pendingSummary)) {
|
||||
if (session.currentQuestion && session.history.length === 0 && row.result) {
|
||||
/* FNXC:PlanningMode 2026-07-18-11:36: Rehydration repairs legacy no-history summaries into the mandatory interview question so reconnects cannot revive a skipped interview. */
|
||||
persistSession(session, "awaiting_input");
|
||||
}
|
||||
@@ -1227,11 +950,9 @@ export async function createSession(
|
||||
store?: TaskStore,
|
||||
rootDir?: string,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
planningDepth?: PlanningDepth,
|
||||
customQuestionCount?: number,
|
||||
pluginRunner?: SkillPluginRunner,
|
||||
options?: Pick<PlanningSessionOptions, "ntfyConfig" | "messageStore" | "clarificationEnabled">,
|
||||
): Promise<{ sessionId: string; firstQuestion: PlanningQuestion }> {
|
||||
): Promise<{ sessionId: string; firstQuestion: PlanningQuestion; summary: PlanningSummary; validated: boolean }> {
|
||||
// Check rate limit
|
||||
if (!checkRateLimit(ip)) {
|
||||
const resetTime = getRateLimitResetTime(ip);
|
||||
@@ -1256,6 +977,8 @@ export async function createSession(
|
||||
initialPlan,
|
||||
title: initialPlan.slice(0, 120),
|
||||
history: [],
|
||||
summary: buildRunningSummary(initialPlan, []),
|
||||
validated: false,
|
||||
thinkingOutput: "",
|
||||
lastGeneratedThinking: "",
|
||||
createdAt: new Date(),
|
||||
@@ -1273,8 +996,7 @@ export async function createSession(
|
||||
|
||||
// Resolve the effective system prompt (override or default)
|
||||
const baseSystemPrompt = resolvePrompt("planning-system", promptOverrides) || PLANNING_SYSTEM_PROMPT;
|
||||
const depthPromptSuffix = buildDepthPromptSuffix(planningDepth, customQuestionCount);
|
||||
const systemPrompt = depthPromptSuffix ? `${baseSystemPrompt}\n\n${depthPromptSuffix}` : baseSystemPrompt;
|
||||
const systemPrompt = baseSystemPrompt;
|
||||
|
||||
// Create AI agent and get the first question
|
||||
// Only await engineReady if createFnAgent hasn't been set externally (e.g., via __setCreateFnAgent)
|
||||
@@ -1331,7 +1053,8 @@ export async function createSession(
|
||||
await persistSession(session, "awaiting_input");
|
||||
void maybeNotifyPlanningAwaitingInput(session, firstQuestion, true);
|
||||
|
||||
return { sessionId, firstQuestion };
|
||||
session.summary = buildRunningSummary(initialPlan, session.history);
|
||||
return { sessionId, firstQuestion, summary: session.summary, validated: false };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1466,7 +1189,7 @@ async function getFirstQuestionFromAgent(
|
||||
}
|
||||
|
||||
if (parsed.type === "question") {
|
||||
return parsed;
|
||||
return { type: "question", data: normalizePlanningQuestion(parsed.data, session.initialPlan) };
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1478,13 +1201,13 @@ async function getFirstQuestionFromAgent(
|
||||
return requestMandatoryFirstPlanningQuestion(session);
|
||||
}
|
||||
|
||||
function buildMandatoryFirstPlanningQuestion(): PlanningQuestion {
|
||||
return {
|
||||
function buildMandatoryFirstPlanningQuestion(userInput = ""): PlanningQuestion {
|
||||
const fallback = planningFallbackCopy(userInput);
|
||||
return normalizePlanningQuestion({
|
||||
id: "mandatory-planning-clarification",
|
||||
type: "text",
|
||||
question: "What outcome or constraint is most important for this plan?",
|
||||
description: "Answer this first question so the plan can reflect your priorities.",
|
||||
};
|
||||
type: "single_select",
|
||||
question: fallback.question,
|
||||
}, userInput);
|
||||
}
|
||||
|
||||
async function requestMandatoryFirstPlanningQuestion(
|
||||
@@ -1508,7 +1231,9 @@ async function requestMandatoryFirstPlanningQuestion(
|
||||
.join("")
|
||||
: "";
|
||||
const retryResponse = parseAgentResponse(retryText);
|
||||
if (retryResponse.type === "question") return retryResponse;
|
||||
if (retryResponse.type === "question") {
|
||||
return { type: "question", data: normalizePlanningQuestion(retryResponse.data, session.initialPlan) };
|
||||
}
|
||||
} catch (error) {
|
||||
diagnostics.warn("Agent did not supply the mandatory first planning question", {
|
||||
sessionId: session.id,
|
||||
@@ -1516,69 +1241,7 @@ async function requestMandatoryFirstPlanningQuestion(
|
||||
operation: "mandatory-first-question",
|
||||
});
|
||||
}
|
||||
return { type: "question", data: buildMandatoryFirstPlanningQuestion() };
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:AgentClarification 2026-07-16-13:00:
|
||||
* Disabled clarification must never leave a proactive question parked. One
|
||||
* bounded follow-up requests the protocol's complete payload, then hands it
|
||||
* to the existing deepening checkpoint; malformed/question follow-ups fail
|
||||
* visibly instead of creating a prompt loop.
|
||||
*/
|
||||
async function continueToSummaryAfterSuppressedQuestion(
|
||||
session: Session,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<PlanningQuestion> {
|
||||
if (!session.agent) throw new InvalidSessionStateError("Planning session has no AI agent");
|
||||
await (session.agent.session.prompt as (input: string, options?: { signal?: AbortSignal }) => Promise<void>)(
|
||||
'Clarification is disabled. Return ONLY the final {"type":"complete","data":...} JSON summary; do not ask another question.',
|
||||
{ signal: abortSignal },
|
||||
);
|
||||
const followUp = (session.agent.session.state.messages as AgentMessage[])
|
||||
.filter((message) => message.role === "assistant")
|
||||
.pop();
|
||||
let followUpText = typeof followUp?.content === "string"
|
||||
? followUp.content
|
||||
: Array.isArray(followUp?.content)
|
||||
? followUp.content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text" && typeof block.text === "string")
|
||||
.map((block) => block.text)
|
||||
.join("")
|
||||
: "";
|
||||
let complete: PlanningResponse | undefined;
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
|
||||
try {
|
||||
complete = parseAgentResponse(followUpText);
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
if (attempt === MAX_PARSE_RETRIES) break;
|
||||
await (session.agent.session.prompt as (input: string, options?: { signal?: AbortSignal }) => Promise<void>)(
|
||||
'Your previous response was incomplete or invalid. Return ONLY a complete valid JSON object: {"type":"complete","data":{...}}. No markdown or explanation.',
|
||||
{ signal: abortSignal },
|
||||
);
|
||||
const retry = (session.agent.session.state.messages as AgentMessage[])
|
||||
.filter((message) => message.role === "assistant")
|
||||
.pop();
|
||||
followUpText = typeof retry?.content === "string"
|
||||
? retry.content
|
||||
: Array.isArray(retry?.content)
|
||||
? retry.content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text" && typeof block.text === "string")
|
||||
.map((block) => block.text)
|
||||
.join("")
|
||||
: "";
|
||||
}
|
||||
}
|
||||
if (!complete || complete.type !== "complete") {
|
||||
throw new Error(buildRetryableParseErrorMessage(lastError ?? new Error("Clarification-disabled follow-up did not produce a summary")));
|
||||
}
|
||||
return setPendingSummaryCheckpoint(session, normalizePlanningSummaryPayload(complete.data, {
|
||||
title: session.title || session.initialPlan,
|
||||
description: session.initialPlan,
|
||||
}));
|
||||
return { type: "question", data: buildMandatoryFirstPlanningQuestion(session.initialPlan) };
|
||||
}
|
||||
|
||||
export async function createDraftSession(
|
||||
@@ -1622,6 +1285,8 @@ export async function createDraftSession(
|
||||
// applies the request override/global default before generation begins.
|
||||
clarificationEnabled: undefined,
|
||||
history: [],
|
||||
summary: buildRunningSummary(initialPlan, []),
|
||||
validated: false,
|
||||
thinkingOutput: "",
|
||||
lastGeneratedThinking: "",
|
||||
createdAt: new Date(),
|
||||
@@ -1811,7 +1476,7 @@ export async function startExistingSession(
|
||||
persistSession(session, "generating");
|
||||
planningStreamManager.registerInitialTurn(sessionId, () => {
|
||||
session.pluginRunner = pluginRunner;
|
||||
initializeAgent(session, rootDir, store, modelProvider, modelId, session.draftThinkingLevel, promptOverrides, undefined, undefined, pluginRunner).catch((err) => {
|
||||
initializeAgent(session, rootDir, store, modelProvider, modelId, session.draftThinkingLevel, promptOverrides, pluginRunner).catch((err) => {
|
||||
diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" });
|
||||
persistSession(session, "error", err.message || "Failed to initialize AI agent");
|
||||
planningStreamManager.broadcast(sessionId, {
|
||||
@@ -1879,6 +1544,8 @@ export async function createSessionWithAgent(
|
||||
clarificationEnabled: options?.clarificationEnabled === true,
|
||||
messageStore: options?.messageStore,
|
||||
history: [],
|
||||
summary: buildRunningSummary(initialPlan, []),
|
||||
validated: false,
|
||||
thinkingOutput: "",
|
||||
lastGeneratedThinking: "",
|
||||
createdAt: new Date(),
|
||||
@@ -1899,8 +1566,6 @@ export async function createSessionWithAgent(
|
||||
modelId,
|
||||
thinkingLevel,
|
||||
promptOverrides,
|
||||
options?.planningDepth,
|
||||
options?.customQuestionCount,
|
||||
options?.pluginRunner,
|
||||
).catch((err) => {
|
||||
diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" });
|
||||
@@ -1926,8 +1591,6 @@ async function initializeAgent(
|
||||
modelId?: string,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
planningDepth?: PlanningDepth,
|
||||
customQuestionCount?: number,
|
||||
pluginRunner?: SkillPluginRunner,
|
||||
): Promise<void> {
|
||||
try {
|
||||
@@ -1944,8 +1607,6 @@ async function initializeAgent(
|
||||
modelId,
|
||||
thinkingLevel,
|
||||
promptOverrides,
|
||||
planningDepth,
|
||||
customQuestionCount,
|
||||
pluginRunner,
|
||||
);
|
||||
|
||||
@@ -2001,8 +1662,6 @@ async function createPlanningAgent(
|
||||
modelId?: string,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
planningDepth?: PlanningDepth,
|
||||
customQuestionCount?: number,
|
||||
pluginRunner?: SkillPluginRunner,
|
||||
): Promise<AgentResult> {
|
||||
// Ensure engine is loaded before using createFnAgent
|
||||
@@ -2010,8 +1669,7 @@ async function createPlanningAgent(
|
||||
|
||||
// Resolve the effective system prompt (override or default)
|
||||
const baseSystemPrompt = resolvePrompt("planning-system", promptOverrides) || PLANNING_SYSTEM_PROMPT;
|
||||
const depthPromptSuffix = buildDepthPromptSuffix(planningDepth, customQuestionCount);
|
||||
const systemPrompt = depthPromptSuffix ? `${baseSystemPrompt}\n\n${depthPromptSuffix}` : baseSystemPrompt;
|
||||
const systemPrompt = baseSystemPrompt;
|
||||
|
||||
const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, pluginRunner);
|
||||
|
||||
@@ -2110,7 +1768,7 @@ async function ensureSessionAgent(
|
||||
);
|
||||
}
|
||||
|
||||
session.agent = await createPlanningAgent(session, effectiveRootDir, effectiveStore, undefined, undefined, session.draftThinkingLevel, promptOverrides, undefined, undefined, session.pluginRunner);
|
||||
session.agent = await createPlanningAgent(session, effectiveRootDir, effectiveStore, undefined, undefined, session.draftThinkingLevel, promptOverrides, session.pluginRunner);
|
||||
|
||||
if (historyForReplay.length === 0) {
|
||||
return;
|
||||
@@ -2342,55 +2000,94 @@ 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;
|
||||
/**
|
||||
* FNXC:PlanningMode 2026-07-18-16:00:
|
||||
* Planning is an infinite, user-gated interview. A running plan is derived after every
|
||||
* turn and only validateSession may mark it final; model completion payloads are coerced
|
||||
* into another question rather than terminating the session.
|
||||
*/
|
||||
function buildRunningSummary(initialPlan: string, history: PlanningHistoryEntry[]): PlanningSummary {
|
||||
const answers = history.map((entry) => `${entry.question.question}: ${JSON.stringify(entry.response)}`);
|
||||
return normalizePlanningSummaryPayload({
|
||||
title: initialPlan.slice(0, 80) || "Untitled planning task",
|
||||
description: [initialPlan, ...answers].filter(Boolean).join("\n\n"),
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: history.map((entry) => entry.question.question),
|
||||
}, { title: initialPlan, description: initialPlan });
|
||||
}
|
||||
|
||||
function planningFallbackCopy(input: string): { question: string; option: (n: number) => string; pro: string; con: string; other: string } {
|
||||
if (/[一-龯]/.test(input)) {
|
||||
const traditional = /[繁體臺灣與為這個]/.test(input);
|
||||
return traditional
|
||||
? { question: "下一步最需要釐清的細節或限制是什麼?", option: (n) => `替代方案 ${n}`, pro: "提供明確的前進方向", con: "可能限制後續選擇", other: "其他(自行填寫)" }
|
||||
: { question: "接下来最需要细化的细节或限制是什么?", option: (n) => `替代方案 ${n}`, pro: "提供清晰的推进方向", con: "可能限制后续选择", other: "其他(自行填写)" };
|
||||
}
|
||||
if (/[가-힣]/.test(input)) return { question: "다음으로 구체화할 가장 중요한 세부 사항이나 제약은 무엇인가요?", option: (n) => `대안 ${n}`, pro: "명확한 진행 방향을 제공합니다", con: "후속 선택을 제한할 수 있습니다", other: "기타(직접 입력)" };
|
||||
if (/\b(le|la|les|une|fonctionnalité)\b/i.test(input)) return { question: "Quel est le prochain détail ou contrainte le plus important à préciser ?", option: (n) => `Alternative ${n}`, pro: "Donne une direction claire", con: "Peut limiter les choix ultérieurs", other: "Autre (écrivez votre réponse)" };
|
||||
if (/\b(el|la|los|una|función|característica|español(?:es)?)\b/i.test(input)) return { question: "¿Cuál es el siguiente detalle o restricción más importante que debemos precisar?", option: (n) => `Alternativa ${n}`, pro: "Ofrece una dirección clara", con: "Puede limitar decisiones posteriores", other: "Otro (escribe tu respuesta)" };
|
||||
return { question: "What is the next most important detail or constraint to refine?", option: (n) => `Option ${n}`, pro: "Provides a clear path forward", con: "May constrain later choices", other: "Other (write your own)" };
|
||||
}
|
||||
|
||||
/** Normalizes untrusted model output so select questions always meet the public option contract. */
|
||||
export function normalizePlanningQuestion(input: unknown, userInput = ""): PlanningQuestion {
|
||||
const source = input && typeof input === "object" ? input as Record<string, unknown> : {};
|
||||
const fallback = planningFallbackCopy(userInput);
|
||||
// Every Planning question needs alternatives and an Other steer, so a model's legacy
|
||||
// text question is upgraded to a select question instead of losing that escape hatch.
|
||||
const type = source.type === "multi_select" || source.type === "single_select" ? source.type : "single_select";
|
||||
const question = typeof source.question === "string" && source.question.trim() ? source.question.trim() : fallback.question;
|
||||
const normalized: PlanningQuestion = { id: typeof source.id === "string" && source.id.trim() ? source.id : randomUUID(), type, question,
|
||||
...(typeof source.description === "string" && source.description.trim() ? { description: source.description.trim() } : {}) };
|
||||
const raw = Array.isArray(source.options) ? source.options : [];
|
||||
const alternatives = raw.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !(item as Record<string, unknown>).isOther)
|
||||
.slice(0, 2).map((item, index) => ({
|
||||
id: typeof item.id === "string" && item.id.trim() ? item.id : `option-${index + 1}`,
|
||||
label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : fallback.option(index + 1),
|
||||
...(typeof item.description === "string" && item.description.trim() ? { description: item.description.trim() } : {}),
|
||||
pros: Array.isArray(item.pros) && item.pros.some((v) => typeof v === "string" && v.trim()) ? item.pros.filter((v): v is string => typeof v === "string" && Boolean(v.trim())).map((v) => v.trim()) : [fallback.pro],
|
||||
cons: Array.isArray(item.cons) && item.cons.some((v) => typeof v === "string" && v.trim()) ? item.cons.filter((v): v is string => typeof v === "string" && Boolean(v.trim())).map((v) => v.trim()) : [fallback.con],
|
||||
}));
|
||||
while (alternatives.length < 2) {
|
||||
const n = alternatives.length + 1;
|
||||
alternatives.push({ id: `option-${n}`, label: fallback.option(n), pros: [fallback.pro], cons: [fallback.con] });
|
||||
}
|
||||
normalized.options = [...alternatives, { id: "other", label: fallback.other, isOther: true }];
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function coerceQuestionResponse(response: PlanningResponse, session: Session): PlanningQuestion {
|
||||
if (response.type === "question") return normalizePlanningQuestion(response.data, session.initialPlan);
|
||||
return normalizePlanningQuestion({ id: randomUUID(), type: "single_select", question: planningFallbackCopy(session.initialPlan).question }, session.initialPlan);
|
||||
}
|
||||
|
||||
export async function validateSession(sessionId: string): Promise<PlanningSummary> {
|
||||
const session = await getSession(sessionId);
|
||||
if (!session) throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
||||
|
||||
// FNXC:PlanningMode 2026-07-18-16:00: Validation is the terminal user action.
|
||||
// Cancel a concurrent turn and clear its question so it cannot race the completed row
|
||||
// back to awaiting_input after the user has finalized the running plan.
|
||||
const activeGeneration = activeGenerations.get(session.id);
|
||||
if (activeGeneration) {
|
||||
activeGeneration.abortReason = "user-stop";
|
||||
clearTimeout(activeGeneration.timer);
|
||||
activeGeneration.abortTeardown();
|
||||
activeGeneration.abortController.abort();
|
||||
activeGenerations.delete(session.id);
|
||||
}
|
||||
|
||||
session.summary = buildRunningSummary(session.initialPlan, session.history);
|
||||
session.currentQuestion = undefined;
|
||||
session.editingQuestionId = undefined;
|
||||
session.validated = true;
|
||||
session.error = undefined;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "complete");
|
||||
planningStreamManager.broadcast(session.id, {
|
||||
type: "summary",
|
||||
data: summary,
|
||||
});
|
||||
await persistSession(session, "complete");
|
||||
planningStreamManager.broadcast(session.id, { type: "summary", data: session.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");
|
||||
return session.summary;
|
||||
}
|
||||
|
||||
async function continueAgentConversation(session: Session, message: string): Promise<void> {
|
||||
@@ -2543,42 +2240,26 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
}
|
||||
|
||||
if (parsed.type === "question") {
|
||||
session.currentQuestion = parsed.data;
|
||||
session.summary = undefined;
|
||||
session.pendingSummary = undefined;
|
||||
session.currentQuestion = coerceQuestionResponse(parsed, session);
|
||||
session.summary = buildRunningSummary(session.initialPlan, session.history);
|
||||
session.error = undefined;
|
||||
session.lastGeneratedThinking = session.thinkingOutput;
|
||||
session.updatedAt = new Date();
|
||||
if (!session.clarificationEnabled && session.history.length > 0) {
|
||||
try {
|
||||
await continueToSummaryAfterSuppressedQuestion(session, abortSignal);
|
||||
} catch (error) {
|
||||
setSessionError(session, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Persist after deriving the plan: reloads must see the running summary on every turn.
|
||||
persistSession(session, "awaiting_input");
|
||||
void maybeNotifyPlanningAwaitingInput(session, parsed.data, true);
|
||||
planningStreamManager.broadcast(session.id, { type: "question", data: parsed.data });
|
||||
} else if (parsed.type === "complete") {
|
||||
if (session.history.length === 0) {
|
||||
const mandatoryQuestion = await requestMandatoryFirstPlanningQuestion(session, abortSignal);
|
||||
session.currentQuestion = mandatoryQuestion.data;
|
||||
session.summary = undefined;
|
||||
session.pendingSummary = undefined;
|
||||
session.error = undefined;
|
||||
session.lastGeneratedThinking = session.thinkingOutput;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "awaiting_input");
|
||||
void maybeNotifyPlanningAwaitingInput(session, mandatoryQuestion.data, true);
|
||||
planningStreamManager.broadcast(session.id, { type: "question", data: mandatoryQuestion.data });
|
||||
return;
|
||||
}
|
||||
const summary = normalizePlanningSummaryPayload(parsed.data, {
|
||||
title: session.title || session.initialPlan,
|
||||
description: session.initialPlan,
|
||||
});
|
||||
setPendingSummaryCheckpoint(session, summary);
|
||||
void maybeNotifyPlanningAwaitingInput(session, session.currentQuestion, true);
|
||||
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
|
||||
planningStreamManager.broadcast(session.id, { type: "question", data: session.currentQuestion });
|
||||
} else {
|
||||
// A generic engine completion is never terminal in Planning Mode.
|
||||
session.currentQuestion = coerceQuestionResponse(parsed, session);
|
||||
session.summary = buildRunningSummary(session.initialPlan, session.history);
|
||||
session.error = undefined;
|
||||
session.lastGeneratedThinking = session.thinkingOutput;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "awaiting_input");
|
||||
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
|
||||
planningStreamManager.broadcast(session.id, { type: "question", data: session.currentQuestion });
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -2859,6 +2540,15 @@ currentQuestion is cleared once an answer is accepted, so a duplicate re-submit
|
||||
in-flight generation is detected against the last history entry (the turn being generated)
|
||||
instead of the now-cleared currentQuestion.
|
||||
*/
|
||||
function captureOtherCustomText(question: PlanningQuestion, responses: Record<string, unknown>): PlanningQuestion {
|
||||
const customText = typeof responses._other === "string" ? responses._other.trim() : "";
|
||||
if (!customText || !question.options?.some((option) => option.isOther)) return question;
|
||||
return {
|
||||
...question,
|
||||
options: question.options.map((option) => option.isOther ? { ...option, customText } : option),
|
||||
};
|
||||
}
|
||||
|
||||
function didSubmitSameAnswer(
|
||||
session: Session,
|
||||
responses: Record<string, unknown>,
|
||||
@@ -2882,6 +2572,10 @@ export async function submitResponse(
|
||||
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
if (session.validated) {
|
||||
throw new InvalidSessionStateError("Planning session has already been validated");
|
||||
}
|
||||
|
||||
// Stash store/rootDir on the session so subsequent ensureSessionAgent calls
|
||||
// (after the agent is disposed for retry/rewind) can rebuild without the
|
||||
// caller having to thread context through every API.
|
||||
@@ -2916,14 +2610,13 @@ export async function submitResponse(
|
||||
}
|
||||
|
||||
session.error = undefined;
|
||||
session.pendingSummary = undefined;
|
||||
persistSession(session, "generating");
|
||||
|
||||
await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store);
|
||||
const refineMessage = formatRefineRequestForAgent(session.summary);
|
||||
await continueAgentConversation(session, refineMessage);
|
||||
} else {
|
||||
const currentQuestion = session.currentQuestion;
|
||||
const currentQuestion = captureOtherCustomText(session.currentQuestion, responses);
|
||||
const historyEntry = {
|
||||
question: currentQuestion,
|
||||
response: responses,
|
||||
@@ -2935,50 +2628,41 @@ export async function submitResponse(
|
||||
FNXC:DashboardSessionPersistence 2026-06-14-09:09:
|
||||
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);
|
||||
const editIndex = session.editingQuestionId
|
||||
? session.history.findIndex((entry) => entry.question.id === session.editingQuestionId)
|
||||
: -1;
|
||||
const isEditingPriorAnswer = editIndex >= 0;
|
||||
if (isEditingPriorAnswer) {
|
||||
session.history[editIndex] = historyEntry;
|
||||
session.editingQuestionId = undefined;
|
||||
// Existing agent context contains the old answer; rebuild it from the preserved history.
|
||||
disposeSessionAgentForRetry(session);
|
||||
} else {
|
||||
session.history.push(historyEntry);
|
||||
}
|
||||
answeredQuestion = currentQuestion;
|
||||
|
||||
if (isDeepeningCheckpointQuestion(currentQuestion)) {
|
||||
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;
|
||||
// FNXC:PlanningRetry 2026-07-14-00:00: answer accepted — the checkpoint is no longer awaiting input.
|
||||
session.currentQuestion = 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 {
|
||||
// FNXC:PlanningRetry 2026-07-14-00:00: answer accepted — clear before generating so SSE catch-up cannot re-emit the answered question.
|
||||
session.currentQuestion = undefined;
|
||||
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);
|
||||
// Answered questions always lead to another question; no answer can validate or complete.
|
||||
session.currentQuestion = undefined;
|
||||
persistSession(session, "generating");
|
||||
if (!session.agent) {
|
||||
// An edited older answer must be replayed in its original position with every
|
||||
// later answer retained; only a newly appended answer is sent after replay.
|
||||
await ensureSessionAgent(
|
||||
session,
|
||||
rootDir,
|
||||
isEditingPriorAnswer ? session.history : session.history.slice(0, -1),
|
||||
promptOverrides,
|
||||
store,
|
||||
);
|
||||
}
|
||||
const message = isEditingPriorAnswer
|
||||
? "An earlier answer was edited. Use the complete preserved interview context above, re-derive the running plan, and ask exactly one new high-impact next question."
|
||||
: formatResponseForAgent(currentQuestion, responses);
|
||||
await continueAgentConversation(session, message);
|
||||
}
|
||||
|
||||
// Return the current state (will be updated via SSE)
|
||||
if (session.summary) {
|
||||
return { type: "complete", data: session.summary };
|
||||
}
|
||||
if (session.currentQuestion) {
|
||||
return { type: "question", data: session.currentQuestion };
|
||||
}
|
||||
@@ -3010,6 +2694,10 @@ export async function retrySession(
|
||||
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
if (session.validated) {
|
||||
throw new InvalidSessionStateError("Planning session has already been validated");
|
||||
}
|
||||
|
||||
if (store && !session.store) session.store = store;
|
||||
if (rootDir && !session.rootDir) session.rootDir = rootDir;
|
||||
|
||||
@@ -3027,7 +2715,6 @@ export async function retrySession(
|
||||
|
||||
session.error = undefined;
|
||||
session.summary = undefined;
|
||||
session.pendingSummary = undefined;
|
||||
/*
|
||||
FNXC:PlanningRetry 2026-07-14-00:00:
|
||||
A retry regenerates the last turn, so no question is awaiting input. Clearing here also
|
||||
@@ -3063,6 +2750,7 @@ export interface PlanningRewindResult {
|
||||
|
||||
export async function rewindSession(
|
||||
sessionId: string,
|
||||
questionId?: string,
|
||||
rootDir?: string,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
store?: TaskStore,
|
||||
@@ -3072,6 +2760,10 @@ export async function rewindSession(
|
||||
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
if (session.validated) {
|
||||
throw new InvalidSessionStateError("Planning session has already been validated");
|
||||
}
|
||||
|
||||
if (store && !session.store) session.store = store;
|
||||
if (rootDir && !session.rootDir) session.rootDir = rootDir;
|
||||
|
||||
@@ -3079,16 +2771,21 @@ export async function rewindSession(
|
||||
throw new InvalidSessionStateError("Planning session has no previous question to rewind to");
|
||||
}
|
||||
|
||||
const rewindEntry = session.history.pop();
|
||||
if (!rewindEntry) {
|
||||
throw new InvalidSessionStateError("Planning session has no previous question to rewind to");
|
||||
const rewindIndex = questionId
|
||||
? session.history.findIndex((entry) => entry.question.id === questionId)
|
||||
: session.history.length - 1;
|
||||
if (rewindIndex < 0) {
|
||||
throw new InvalidSessionStateError("Planning question to edit was not found");
|
||||
}
|
||||
const rewindEntry = session.history[rewindIndex]!;
|
||||
if (!questionId) session.history.pop();
|
||||
|
||||
disposeSessionAgentForRetry(session);
|
||||
|
||||
session.currentQuestion = rewindEntry.question;
|
||||
session.summary = undefined;
|
||||
session.pendingSummary = undefined;
|
||||
session.editingQuestionId = questionId ? questionId : undefined;
|
||||
// Keep the plan available while the user edits; it is re-derived after submit.
|
||||
session.summary = buildRunningSummary(session.initialPlan, session.history);
|
||||
session.error = undefined;
|
||||
session.lastGeneratedThinking = session.history[session.history.length - 1]?.thinkingOutput ?? "";
|
||||
session.thinkingOutput = "";
|
||||
|
||||
@@ -501,27 +501,12 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
*/
|
||||
router.post("/planning/start", async (req, res) => {
|
||||
try {
|
||||
const { initialPlan, planningDepth, customQuestionCount } = req.body;
|
||||
const { initialPlan } = req.body;
|
||||
|
||||
if (!initialPlan || typeof initialPlan !== "string") {
|
||||
throw badRequest("initialPlan is required and must be a string");
|
||||
}
|
||||
|
||||
if (
|
||||
planningDepth !== undefined
|
||||
&& planningDepth !== "small"
|
||||
&& planningDepth !== "medium"
|
||||
&& planningDepth !== "large"
|
||||
) {
|
||||
throw badRequest('planningDepth must be one of "small", "medium", or "large" when provided');
|
||||
}
|
||||
|
||||
if (
|
||||
customQuestionCount !== undefined
|
||||
&& (!Number.isInteger(customQuestionCount) || customQuestionCount < 1 || customQuestionCount > 20)
|
||||
) {
|
||||
throw badRequest("customQuestionCount must be an integer between 1 and 20 when provided");
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
@@ -542,8 +527,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
scopedStore,
|
||||
rootDir,
|
||||
settings.promptOverrides,
|
||||
planningDepth,
|
||||
customQuestionCount,
|
||||
ctx.options?.pluginRunner as SkillPluginRunner,
|
||||
runtime,
|
||||
);
|
||||
@@ -649,8 +632,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
initialPlan,
|
||||
planningModelProvider,
|
||||
planningModelId,
|
||||
planningDepth,
|
||||
customQuestionCount,
|
||||
existingSessionId,
|
||||
thinkingLevel,
|
||||
clarificationEnabled,
|
||||
@@ -668,21 +649,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
throw badRequest("planningModelId must be a string when provided");
|
||||
}
|
||||
|
||||
if (
|
||||
planningDepth !== undefined
|
||||
&& planningDepth !== "small"
|
||||
&& planningDepth !== "medium"
|
||||
&& planningDepth !== "large"
|
||||
) {
|
||||
throw badRequest('planningDepth must be one of "small", "medium", or "large" when provided');
|
||||
}
|
||||
|
||||
if (
|
||||
customQuestionCount !== undefined
|
||||
&& (!Number.isInteger(customQuestionCount) || customQuestionCount < 1 || customQuestionCount > 20)
|
||||
) {
|
||||
throw badRequest("customQuestionCount must be an integer between 1 and 20 when provided");
|
||||
}
|
||||
|
||||
if (clarificationEnabled !== undefined && typeof clarificationEnabled !== "boolean") {
|
||||
throw badRequest("clarificationEnabled must be a boolean when provided");
|
||||
@@ -777,8 +743,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
const planningOptions = {
|
||||
projectId,
|
||||
...runtime,
|
||||
planningDepth,
|
||||
customQuestionCount,
|
||||
pluginRunner: ctx.options?.pluginRunner as SkillPluginRunner,
|
||||
};
|
||||
const sessionId = validatedThinkingLevel
|
||||
@@ -914,12 +878,15 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
const questionId = req.body?.questionId;
|
||||
if (questionId !== undefined && typeof questionId !== "string") throw badRequest("questionId must be a string when provided");
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const { rewindSession, attachPlanningRuntime } = await import("../planning.js");
|
||||
await attachPlanningRuntime(sessionId, await planningRuntime(req, settings));
|
||||
const rewound = await rewindSession(
|
||||
sessionId,
|
||||
questionId,
|
||||
scopedStore.getRootDir(),
|
||||
settings.promptOverrides,
|
||||
scopedStore,
|
||||
@@ -1000,6 +967,21 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
* Cancel and cleanup a planning session.
|
||||
* Body: { sessionId: string }
|
||||
*/
|
||||
/** The sole HTTP transition that validates a continuously maintained plan. */
|
||||
router.post("/planning/:sessionId/validate", async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
if (!sessionId || typeof sessionId !== "string") throw badRequest("sessionId is required");
|
||||
const { validateSession } = await import("../planning.js");
|
||||
const summary = await validateSession(sessionId);
|
||||
res.json({ summary, validated: true });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
if (err instanceof Error && err.name === "SessionNotFoundError") throw notFound(err.message);
|
||||
rethrowAsApiError(err, "Failed to validate planning session");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/planning/cancel", async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.body;
|
||||
@@ -1101,6 +1083,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
const { getSession, getSummary, releaseSession } = await import("../planning.js");
|
||||
|
||||
const session = await getSession(sessionId);
|
||||
if (session && !session.validated) throw badRequest("Planning session must be validated before creating tasks");
|
||||
let summary = summaryOverride ?? getSummary(sessionId);
|
||||
let initialPlan = session?.initialPlan;
|
||||
|
||||
@@ -1114,8 +1097,9 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
throw notFound(`Planning session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
if (persistedSession.status !== "complete") {
|
||||
throw badRequest("Planning session is not complete");
|
||||
const persistedInput = JSON.parse(persistedSession.inputPayload) as { validated?: unknown };
|
||||
if (persistedSession.status !== "complete" || persistedInput.validated !== true) {
|
||||
throw badRequest("Planning session must be validated before creating tasks");
|
||||
}
|
||||
|
||||
if (!persistedSession.result) {
|
||||
@@ -1243,6 +1227,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
if (!session) {
|
||||
throw notFound(`Planning session ${sessionId} not found or expired`);
|
||||
}
|
||||
if (!session.validated) throw badRequest("Planning session must be validated before creating tasks");
|
||||
|
||||
if (summaryOverride) {
|
||||
session.summary = summaryOverride;
|
||||
@@ -1312,6 +1297,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
if (!session) {
|
||||
throw notFound(`Planning session ${planningSessionId} not found or expired`);
|
||||
}
|
||||
if (!session.validated) throw badRequest("Planning session must be validated before creating tasks");
|
||||
|
||||
if (!session.summary) {
|
||||
throw badRequest("Planning session is not complete");
|
||||
|
||||
@@ -4645,7 +4645,6 @@
|
||||
"plain": "Plain",
|
||||
"planningComplete": "Planning Complete!",
|
||||
"planningCompleteSubheading": "Review and refine your task before creating it.",
|
||||
"planningDepth": "Planning depth",
|
||||
"planningModel": "Planning Model",
|
||||
"planningModelBlurb": "Selects which model runs the planning interview and writes the final draft.",
|
||||
"planningSessions": "Planning sessions",
|
||||
|
||||
@@ -4634,7 +4634,6 @@
|
||||
"plain": "Texto plano",
|
||||
"planningComplete": "¡Planificación completada!",
|
||||
"planningCompleteSubheading": "Revisa y perfecciona tu tarea antes de crearla.",
|
||||
"planningDepth": "Profundidad de planificación",
|
||||
"planningModel": "Modelo de planificación",
|
||||
"planningModelBlurb": "Selecciona qué modelo realiza la entrevista de planificación y redacta el borrador final.",
|
||||
"planningSessions": "Sesiones de planificación",
|
||||
|
||||
@@ -4634,7 +4634,6 @@
|
||||
"plain": "Texte brut",
|
||||
"planningComplete": "Planification terminée !",
|
||||
"planningCompleteSubheading": "Vérifiez et affinez votre tâche avant de la créer.",
|
||||
"planningDepth": "Profondeur de planification",
|
||||
"planningModel": "Modèle de planification",
|
||||
"planningModelBlurb": "Choisit le modèle qui conduit l'entretien de planification et rédige l'ébauche finale.",
|
||||
"planningSessions": "Sessions de planification",
|
||||
|
||||
@@ -4634,7 +4634,6 @@
|
||||
"plain": "일반",
|
||||
"planningComplete": "계획 완료!",
|
||||
"planningCompleteSubheading": "작업을 생성하기 전에 검토하고 다듬으세요.",
|
||||
"planningDepth": "계획 깊이",
|
||||
"planningModel": "계획 모델",
|
||||
"planningModelBlurb": "계획 인터뷰를 실행하고 최종 초안을 작성할 모델을 선택합니다.",
|
||||
"planningSessions": "계획 세션",
|
||||
|
||||
@@ -4634,7 +4634,6 @@
|
||||
"plain": "纯文本",
|
||||
"planningComplete": "规划完成!",
|
||||
"planningCompleteSubheading": "在创建任务之前,请审查并完善您的任务。",
|
||||
"planningDepth": "规划深度",
|
||||
"planningModel": "规划模型",
|
||||
"planningModelBlurb": "选择运行规划访谈并撰写最终草稿的模型。",
|
||||
"planningSessions": "规划会话",
|
||||
|
||||
@@ -4634,7 +4634,6 @@
|
||||
"plain": "純文字",
|
||||
"planningComplete": "規劃完成!",
|
||||
"planningCompleteSubheading": "在建立任務之前,請審查並完善您的任務。",
|
||||
"planningDepth": "規劃深度",
|
||||
"planningModel": "規劃模型",
|
||||
"planningModelBlurb": "選擇執行規劃訪談並撰寫最終草稿的模型。",
|
||||
"planningSessions": "規劃工作階段",
|
||||
|
||||
Reference in New Issue
Block a user