fix(planning): keep plan visible through sequential questions
Regenerate the Markdown plan after every answer while preserving the responsive plan-and-question workspace and focused refinement flow.
This commit is contained in:
7
.changeset/improve-sequential-planning-workspace.md
Normal file
7
.changeset/improve-sequential-planning-workspace.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Keep the evolving plan visible while Planning Mode asks sequential questions.
|
||||
category: fix
|
||||
dev: Adds responsive plan/question panes, a Next action, focused refinement, and a full-workspace generation state.
|
||||
@@ -921,6 +921,78 @@ An empty footer must NOT reserve vertical space or paint its divider band. When
|
||||
}
|
||||
|
||||
/* Question View */
|
||||
.planning-workspace {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-areas: "question plan";
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.18fr);
|
||||
gap: var(--space-lg);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: var(--space-lg);
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--card) 40%, var(--surface));
|
||||
}
|
||||
|
||||
.planning-plan-pane,
|
||||
.planning-question-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: solid var(--btn-border-width) color-mix(in srgb, var(--border) 82%, transparent);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.planning-plan-pane {
|
||||
grid-area: plan;
|
||||
}
|
||||
|
||||
.planning-workspace--plan-only {
|
||||
grid-template-areas: "plan";
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.planning-question-pane {
|
||||
grid-area: question;
|
||||
}
|
||||
|
||||
.planning-workspace--generating > .planning-plan-pane,
|
||||
.planning-workspace--generating > .planning-question-pane {
|
||||
pointer-events: none;
|
||||
filter: saturate(0.65);
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.planning-workspace-loader {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: var(--z-popover);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-xl);
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
background: color-mix(in srgb, var(--surface) 72%, transparent);
|
||||
backdrop-filter: blur(var(--space-xs));
|
||||
}
|
||||
|
||||
.planning-workspace-loader strong {
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.planning-workspace-loader span {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.planning-question {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1210,6 +1282,10 @@ its refinement menu remain reachable at the bottom of bounded desktop and mobile
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.planning-plan-review > .planning-plan-pane {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.planning-plan-document {
|
||||
width: min(100%, calc(var(--space-2xl) * 24));
|
||||
}
|
||||
@@ -1865,6 +1941,26 @@ Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.planning-workspace {
|
||||
grid-template-areas:
|
||||
"plan"
|
||||
"question";
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.planning-workspace--plan-only {
|
||||
grid-template-areas: "plan";
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.planning-plan-pane,
|
||||
.planning-question-pane {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
/* FNXC:PlanningMode 2026-06-22-15:30: this legacy full-viewport sheet sizing
|
||||
is for the dialog presentation only. The :not(.planning-modal--embedded)
|
||||
guard prevents the embedded view from being yanked to 100vw/100dvh, which
|
||||
|
||||
@@ -355,6 +355,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
// FNXC:PlanningMode 2026-07-19-15:35: FN-8400 keeps the in-progress plan independent of the center-pane view so it remains visible while the next question is generating.
|
||||
const [runningSummary, setRunningSummary] = useState<PlanningSummary | null>(null);
|
||||
const runningSummaryRef = useRef<PlanningSummary | null>(null);
|
||||
const [workspaceQuestion, setWorkspaceQuestion] = useState<PlanningQuestion | null>(null);
|
||||
const [branchMode, setBranchMode] = useState<"project-default" | "auto-new" | "existing" | "custom-new">("project-default");
|
||||
const [branchName, setBranchName] = useState("");
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
@@ -772,8 +773,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
if (cancelled || !session) return;
|
||||
if (currentSessionIdRef.current !== sessionId) return;
|
||||
if (session.status === "awaiting_input" && !session.currentQuestion && session.result) {
|
||||
// The initial plan and answer updates intentionally settle without a question.
|
||||
// Recover that plan when its SSE summary event was missed instead of polling forever.
|
||||
// Recover a legacy or partially persisted plan when its question event was missed.
|
||||
// New sequential turns normally settle with both result and currentQuestion.
|
||||
resetPlanningAutoRetryBudget();
|
||||
const history = parseConversationHistory(session.conversationHistory);
|
||||
const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary);
|
||||
@@ -807,6 +808,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
.map((entry) => entry.response)
|
||||
.filter((response): response is QuestionResponse => Boolean(response && typeof response === "object" && !Array.isArray(response))));
|
||||
setRunningSummary(summary);
|
||||
setWorkspaceQuestion(question);
|
||||
setView({
|
||||
type: "question",
|
||||
session: { sessionId, currentQuestion: question, summary },
|
||||
@@ -886,6 +888,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setConversationHistory([]);
|
||||
setEditedSummary(null);
|
||||
setRunningSummary(null);
|
||||
setWorkspaceQuestion(null);
|
||||
setLoadedSessionTitle(null);
|
||||
setBranchMode("project-default");
|
||||
setBranchName("");
|
||||
@@ -993,6 +996,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
onQuestion: (question) => {
|
||||
if (isStaleEvent()) return;
|
||||
const normalizedQuestion = normalizeQuestionOptions(question);
|
||||
setWorkspaceQuestion(normalizedQuestion);
|
||||
const isAnsweredQuestion = conversationHistoryRef.current.some(
|
||||
(entry) => entry.question?.id === normalizedQuestion.id && entry.response !== undefined,
|
||||
);
|
||||
@@ -1059,9 +1063,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
*/
|
||||
runningSummaryRef.current = normalizedSummary;
|
||||
setRunningSummary(normalizedSummary);
|
||||
setView((previous) => previous.type === "loading"
|
||||
? { type: "plan_review", session: { sessionId, currentQuestion: null, summary: normalizedSummary }, summary: normalizedSummary }
|
||||
: previous.type === "question"
|
||||
setView((previous) => previous.type === "question"
|
||||
? { ...previous, session: { ...previous.session, summary: normalizedSummary } }
|
||||
: previous);
|
||||
setStreamingOutput("");
|
||||
@@ -1414,6 +1416,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setConversationHistory([]);
|
||||
setEditedSummary(null);
|
||||
setRunningSummary(null);
|
||||
setWorkspaceQuestion(null);
|
||||
setLoadedSessionTitle(null);
|
||||
setIsRetrying(false);
|
||||
setIsRefiningSummary(false);
|
||||
@@ -1486,6 +1489,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
? normalizePlanningSummary(JSON.parse(session.result))
|
||||
: null;
|
||||
setRunningSummary(persistedRunningSummary);
|
||||
if (session.status === "generating") {
|
||||
setWorkspaceQuestion(parsedHistory.at(-1)?.question ?? null);
|
||||
}
|
||||
|
||||
if (session.status === "error") {
|
||||
const errorMessage = session.error || t("planning.sessionFailed2", "Session failed");
|
||||
@@ -1543,6 +1549,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
resetPlanningAutoRetryBudget();
|
||||
clearPlanningDescription(projectId);
|
||||
const question = normalizeQuestionOptions(JSON.parse(session.currentQuestion));
|
||||
setWorkspaceQuestion(question);
|
||||
setView({ type: "question", session: { sessionId, currentQuestion: question, summary: persistedRunningSummary } });
|
||||
// Transfer persisted thinking into conversation history so it's
|
||||
// visible as expandable reasoning in the question view, instead of
|
||||
@@ -2159,12 +2166,23 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
liveGenerationSessionIdRef.current = sessionId;
|
||||
|
||||
try {
|
||||
// Submit response - AI will broadcast events via the already-connected stream
|
||||
// Submit response. SSE remains the primary live path, while the HTTP payload closes
|
||||
// the gap for restored sessions whose original stream is no longer connected.
|
||||
const response = await respondToPlanning(sessionId, responses, projectId);
|
||||
// FNXC:PlanningMode 2026-07-20-15:45: FN-8442 makes the SSE summary the
|
||||
// sole answer-turn transition authority. An answer updates the plan and clears
|
||||
// the question; only the explicit Refine request may publish another question.
|
||||
void response;
|
||||
const responseQuestion = "type" in response ? response.data : response.currentQuestion;
|
||||
const responseSummary = "type" in response ? null : response.summary;
|
||||
const nextSummary = responseSummary
|
||||
? normalizePlanningSummary(responseSummary)
|
||||
: runningSummaryRef.current;
|
||||
if (nextSummary) {
|
||||
runningSummaryRef.current = nextSummary;
|
||||
setRunningSummary(nextSummary);
|
||||
}
|
||||
if (responseQuestion) {
|
||||
const nextQuestion = normalizeQuestionOptions(responseQuestion);
|
||||
setWorkspaceQuestion(nextQuestion);
|
||||
setView({ type: "question", session: { sessionId, currentQuestion: nextQuestion, summary: nextSummary } });
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err) || t("planning.failedSubmitResponse", "Failed to submit response");
|
||||
/*
|
||||
@@ -2199,6 +2217,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
.filter((response): response is QuestionResponse => Boolean(response && typeof response === "object" && !Array.isArray(response))));
|
||||
setRunningSummary(summary);
|
||||
setError(errorMessage);
|
||||
setWorkspaceQuestion(currentQuestion);
|
||||
setView({ type: "question", session: { sessionId, currentQuestion, summary } });
|
||||
return;
|
||||
}
|
||||
@@ -2221,6 +2240,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
conversationHistoryRef.current = historyBeforeSubmit;
|
||||
setConversationHistory(historyBeforeSubmit);
|
||||
setError(errorMessage);
|
||||
setWorkspaceQuestion(activeQuestion);
|
||||
setView({ type: "question", session: { ...session, summary: runningSummaryRef.current } });
|
||||
}
|
||||
},
|
||||
@@ -2268,24 +2288,28 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
may finalize during loading or recoverable error states; the server cancels an active turn safely.
|
||||
*/
|
||||
const handleRefineFromPlan = useCallback(async () => {
|
||||
if (view.type !== "plan_review" || !combinedRefineFocus) return;
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
const summary = runningSummaryRef.current;
|
||||
if (!sessionId || !summary || !combinedRefineFocus) return;
|
||||
setError(null);
|
||||
setGenerationActivity("question");
|
||||
setIsRefineMenuOpen(false);
|
||||
setView({ type: "loading" });
|
||||
try {
|
||||
const response = await respondToPlanning(view.session.sessionId, { refine: true, focus: combinedRefineFocus }, projectId);
|
||||
const response = await respondToPlanning(sessionId, { refine: true, focus: combinedRefineFocus }, projectId);
|
||||
const responseQuestion = "type" in response ? response.data : response.currentQuestion;
|
||||
const responseSummary = "type" in response ? null : response.summary;
|
||||
const nextSummary = responseSummary ? normalizePlanningSummary(responseSummary) : view.summary;
|
||||
const nextSummary = responseSummary ? normalizePlanningSummary(responseSummary) : summary;
|
||||
runningSummaryRef.current = nextSummary;
|
||||
setRunningSummary(nextSummary);
|
||||
if (responseQuestion) {
|
||||
const nextQuestion = normalizeQuestionOptions(responseQuestion);
|
||||
setWorkspaceQuestion(nextQuestion);
|
||||
setView({
|
||||
type: "question",
|
||||
session: {
|
||||
sessionId: view.session.sessionId,
|
||||
currentQuestion: normalizeQuestionOptions(responseQuestion),
|
||||
sessionId,
|
||||
currentQuestion: nextQuestion,
|
||||
summary: nextSummary,
|
||||
},
|
||||
});
|
||||
@@ -2294,41 +2318,48 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setCustomRefineFocus("");
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || t("planning.failedSubmitResponse", "Failed to refine plan"));
|
||||
setView({ type: "plan_review", session: view.session, summary: view.summary });
|
||||
if (workspaceQuestion) {
|
||||
setView({ type: "question", session: { sessionId, currentQuestion: workspaceQuestion, summary } });
|
||||
} else {
|
||||
setView({ type: "plan_review", session: { sessionId, currentQuestion: null, summary }, summary });
|
||||
}
|
||||
}
|
||||
}, [combinedRefineFocus, projectId, t, view]);
|
||||
}, [combinedRefineFocus, projectId, t, workspaceQuestion]);
|
||||
|
||||
const handleValidatePlan = useCallback(async () => {
|
||||
if (view.type !== "plan_review" || validateCreateInFlightRef.current) return;
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
const summary = runningSummaryRef.current;
|
||||
if (!sessionId || !summary || validateCreateInFlightRef.current) return;
|
||||
const session = { sessionId, currentQuestion: workspaceQuestion, summary };
|
||||
validateCreateInFlightRef.current = true;
|
||||
setError(null);
|
||||
setView({ type: "creating_task", session: view.session, summary: view.summary });
|
||||
setView({ type: "creating_task", session, summary });
|
||||
try {
|
||||
try {
|
||||
await validatePlanningSession(view.session.sessionId, projectId);
|
||||
await validatePlanningSession(sessionId, projectId);
|
||||
} catch (err) {
|
||||
// A lost validation response is ambiguous, but a rejected validation must not strand
|
||||
// the user in create-only retry: inspect durable state before choosing the next phase.
|
||||
const persisted = await fetchAiSession(view.session.sessionId).catch(() => null);
|
||||
const persisted = await fetchAiSession(sessionId).catch(() => null);
|
||||
if (persisted && isValidatedPlanningSession(persisted)) {
|
||||
setView({ type: "create_retry", session: view.session, summary: view.summary, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") });
|
||||
setView({ type: "create_retry", session, summary, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") });
|
||||
} else {
|
||||
setError(getErrorMessage(err) || t("planning.failedCreateTask", "Failed to validate plan"));
|
||||
setView({ type: "plan_review", session: view.session, summary: view.summary });
|
||||
setView({ type: "plan_review", session, summary });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const task = await createTaskFromPlanning(view.session.sessionId, view.summary, projectId, { ...(workflowId !== undefined ? { workflowId } : {}) });
|
||||
const task = await createTaskFromPlanning(sessionId, summary, projectId, { ...(workflowId !== undefined ? { workflowId } : {}) });
|
||||
onTaskCreated(task);
|
||||
clearPlanningActiveSession(projectId);
|
||||
setSelectedSessionId(null);
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
setView({ type: "create_retry", session: view.session, summary: view.summary, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") });
|
||||
setView({ type: "create_retry", session, summary, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") });
|
||||
} finally {
|
||||
validateCreateInFlightRef.current = false;
|
||||
}
|
||||
}, [handleClose, onTaskCreated, projectId, t, view, workflowId]);
|
||||
}, [handleClose, onTaskCreated, projectId, t, workflowId, workspaceQuestion]);
|
||||
|
||||
const handleRetryCreateTask = useCallback(async () => {
|
||||
if (view.type !== "create_retry" || validateCreateInFlightRef.current) return;
|
||||
@@ -2482,6 +2513,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const nextSummary = rewound.summary ? normalizePlanningSummary(rewound.summary) : runningSummaryRef.current;
|
||||
runningSummaryRef.current = nextSummary;
|
||||
setRunningSummary(nextSummary);
|
||||
setWorkspaceQuestion(rewound.currentQuestion);
|
||||
setView({ type: "question", session: { ...view.session, currentQuestion: rewound.currentQuestion, summary: nextSummary } });
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || t("planning.failedGoBack", "Failed to edit the selected answer"));
|
||||
@@ -2517,6 +2549,94 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
*/
|
||||
if (!isOpen) return null;
|
||||
|
||||
const renderPlanPane = (summary: PlanningSummary) => (
|
||||
<section className="planning-plan-pane" data-testid="planning-plan-pane" aria-label={t("planning.currentPlan", "Current plan")}>
|
||||
<div className="planning-view-scroll planning-summary-scroll planning-plan-scroll" data-testid="planning-plan-scroll">
|
||||
<article className="planning-plan-document">
|
||||
<MailboxMessageContent
|
||||
className="planning-plan-markdown markdown-body"
|
||||
content={formatPlanningPlanMd(summary)}
|
||||
testId="planning-plan-markdown"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
<div className="planning-actions planning-summary-actions planning-plan-actions" data-testid="planning-plan-actions">
|
||||
{isRefineMenuOpen && (
|
||||
<div
|
||||
id="planning-refine-menu"
|
||||
ref={refineMenuRef}
|
||||
className="planning-refine-menu"
|
||||
data-testid="planning-refine-menu"
|
||||
role="dialog"
|
||||
aria-label={t("planning.chooseRefinementAreas", "Choose areas to refine")}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="planning-refine-menu-header">
|
||||
<h4>{t("planning.refineQuestion", "What should the next question focus on?")}</h4>
|
||||
<p>{t("planning.refineQuestionHint", "Choose one or more areas, or describe your own.")}</p>
|
||||
</div>
|
||||
<div className="planning-refine-menu-options">
|
||||
{(summary.suggestedRefinements ?? []).map((focus) => (
|
||||
<label key={focus} className="planning-refine-menu-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRefineFocuses.includes(focus)}
|
||||
onChange={() => setSelectedRefineFocuses((previous) => previous.includes(focus)
|
||||
? previous.filter((item) => item !== focus)
|
||||
: [...previous, focus])}
|
||||
/>
|
||||
<span>{focus}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="planning-refine-menu-custom">
|
||||
<span>{t("planning.otherRefineFocus", "Or describe another focus")}</span>
|
||||
<textarea
|
||||
className="input"
|
||||
value={customRefineFocus}
|
||||
onChange={(event) => setCustomRefineFocus(event.target.value)}
|
||||
placeholder={t("planning.refineFocusPlaceholder", "Describe what the next question should focus on")}
|
||||
rows={2}
|
||||
/>
|
||||
</label>
|
||||
<div className="planning-refine-menu-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setIsRefineMenuOpen(false);
|
||||
setSelectedRefineFocuses([]);
|
||||
setCustomRefineFocus("");
|
||||
refineTriggerRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={!combinedRefineFocus} onClick={() => void handleRefineFromPlan()}>
|
||||
{t("planning.askNextQuestion", "Ask next question")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
ref={refineTriggerRef}
|
||||
type="button"
|
||||
className="btn"
|
||||
aria-expanded={isRefineMenuOpen}
|
||||
aria-controls={isRefineMenuOpen ? "planning-refine-menu" : undefined}
|
||||
onClick={() => {
|
||||
setSelectedRefineFocuses([]);
|
||||
setCustomRefineFocus("");
|
||||
setIsRefineMenuOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
{t("planning.refine", "Refine")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void handleValidatePlan()}>{t("planning.proceedWithPlan", "Proceed with plan")}</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={isEmbedded ? "planning-view open" : "modal-overlay open"}
|
||||
@@ -2832,7 +2952,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "loading" && (
|
||||
{view.type === "loading" && !runningSummary && (
|
||||
<div className="planning-loading">
|
||||
<Loader2 size={40} className="spin icon-todo" />
|
||||
<p>
|
||||
@@ -2842,7 +2962,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
max: MAX_PLANNING_AUTO_RETRIES,
|
||||
})
|
||||
: generationActivity === "plan_update"
|
||||
? t("planning.updatingPlan", "Updating plan…")
|
||||
? t("planning.generatingPlan", "Generating plan…")
|
||||
: generationActivity === "question"
|
||||
? t("planning.generatingQuestion", "Generating next question…")
|
||||
: t("planning.generatingInitialPlan", "Generating initial plan…")}
|
||||
@@ -2911,109 +3031,41 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "question" && view.session.currentQuestion && (
|
||||
<div className="planning-question">
|
||||
<QuestionForm
|
||||
question={view.session.currentQuestion}
|
||||
initialResponse={editingQuestionId
|
||||
? conversationHistory.find((entry) => entry.question?.id === editingQuestionId)?.response
|
||||
: undefined}
|
||||
onSubmit={handleSubmitResponse}
|
||||
/>
|
||||
{(view.type === "question" || view.type === "loading") && runningSummary && (
|
||||
<div
|
||||
className={`planning-workspace${view.type === "loading" ? " planning-workspace--generating" : ""}${workspaceQuestion ? "" : " planning-workspace--plan-only"}`}
|
||||
data-testid="planning-workspace"
|
||||
aria-busy={view.type === "loading"}
|
||||
>
|
||||
{renderPlanPane(runningSummary)}
|
||||
{workspaceQuestion && (
|
||||
<section className="planning-question planning-question-pane" data-testid="planning-question-pane" aria-label={t("planning.currentQuestion", "Current question")}>
|
||||
<QuestionForm
|
||||
question={workspaceQuestion}
|
||||
initialResponse={editingQuestionId
|
||||
? conversationHistory.find((entry) => entry.question?.id === editingQuestionId)?.response
|
||||
: undefined}
|
||||
onSubmit={handleSubmitResponse}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{view.type === "loading" && (
|
||||
<div className="planning-workspace-loader" role="status" aria-live="polite">
|
||||
<Loader2 size={40} className="spin" />
|
||||
<strong>{t("planning.generatingPlan", "Generating plan…")}</strong>
|
||||
{generationStartTime && <span>{t("planning.thinkingElapsed", "Thinking… ({{seconds}}s)", { seconds: elapsedSeconds })}</span>}
|
||||
<button className="btn planning-stop-btn" type="button" onClick={() => void handleStopGeneration()}>
|
||||
<StopCircle size={14} />
|
||||
<span className="icon-ml-6">{t("planning.stop", "Stop")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "plan_review" && (
|
||||
<div className="planning-summary planning-plan-review" data-testid="planning-plan-review">
|
||||
<div
|
||||
className="planning-view-scroll planning-summary-scroll planning-plan-scroll"
|
||||
data-testid="planning-plan-scroll"
|
||||
>
|
||||
<article className="planning-plan-document">
|
||||
<MailboxMessageContent
|
||||
className="planning-plan-markdown markdown-body"
|
||||
content={formatPlanningPlanMd(view.summary)}
|
||||
testId="planning-plan-markdown"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
<div
|
||||
className="planning-actions planning-summary-actions planning-plan-actions"
|
||||
data-testid="planning-plan-actions"
|
||||
>
|
||||
{isRefineMenuOpen && (
|
||||
<div
|
||||
id="planning-refine-menu"
|
||||
ref={refineMenuRef}
|
||||
className="planning-refine-menu"
|
||||
data-testid="planning-refine-menu"
|
||||
role="dialog"
|
||||
aria-label={t("planning.chooseRefinementAreas", "Choose areas to refine")}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="planning-refine-menu-header">
|
||||
<h4>{t("planning.refineQuestion", "What should the next question focus on?")}</h4>
|
||||
<p>{t("planning.refineQuestionHint", "Choose one or more areas, or describe your own.")}</p>
|
||||
</div>
|
||||
<div className="planning-refine-menu-options">
|
||||
{(view.summary.suggestedRefinements ?? []).map((focus) => (
|
||||
<label key={focus} className="planning-refine-menu-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRefineFocuses.includes(focus)}
|
||||
onChange={() => setSelectedRefineFocuses((previous) => previous.includes(focus)
|
||||
? previous.filter((item) => item !== focus)
|
||||
: [...previous, focus])}
|
||||
/>
|
||||
<span>{focus}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="planning-refine-menu-custom">
|
||||
<span>{t("planning.otherRefineFocus", "Or describe another focus")}</span>
|
||||
<textarea
|
||||
className="input"
|
||||
value={customRefineFocus}
|
||||
onChange={(event) => setCustomRefineFocus(event.target.value)}
|
||||
placeholder={t("planning.refineFocusPlaceholder", "Describe what the next question should focus on")}
|
||||
rows={2}
|
||||
/>
|
||||
</label>
|
||||
<div className="planning-refine-menu-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setIsRefineMenuOpen(false);
|
||||
setSelectedRefineFocuses([]);
|
||||
setCustomRefineFocus("");
|
||||
refineTriggerRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={!combinedRefineFocus} onClick={() => void handleRefineFromPlan()}>
|
||||
{t("planning.askNextQuestion", "Ask next question")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
ref={refineTriggerRef}
|
||||
type="button"
|
||||
className="btn"
|
||||
aria-expanded={isRefineMenuOpen}
|
||||
aria-controls={isRefineMenuOpen ? "planning-refine-menu" : undefined}
|
||||
onClick={() => {
|
||||
setSelectedRefineFocuses([]);
|
||||
setCustomRefineFocus("");
|
||||
setIsRefineMenuOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
{t("planning.refine", "Refine")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void handleValidatePlan()}>{t("planning.proceedWithPlan", "Proceed with plan")}</button>
|
||||
</div>
|
||||
{renderPlanPane(view.summary)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3430,7 +3482,7 @@ function QuestionForm({ question: rawQuestion, initialResponse, onSubmit }: Ques
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid()}
|
||||
>
|
||||
{t("planning.continueToPlan", "Continue to plan")}
|
||||
{t("planning.nextQuestion", "Next")}
|
||||
<ArrowRight size={16} className="icon-ml-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -69,28 +69,16 @@ describe("PlanningModeModal CSS responsive action contract", () => {
|
||||
expect(embeddedRule).toMatch(/max-height\s*:\s*100%\s*;/);
|
||||
});
|
||||
|
||||
it("uses the compact interview switcher for mobile, short landscape, and tablet instead of crushing three panes", () => {
|
||||
it("keeps question left and plan right on desktop, then stacks plan above question on mobile", () => {
|
||||
const css = loadPlanningCss();
|
||||
const shortShellCss = getMediaBlocks(css, MOBILE_PLANNING_SHELL_QUERY).join("\n");
|
||||
const tabletCss = getMediaBlocks(css, TABLET_SUMMARY_ACTIONS_QUERY).join("\n");
|
||||
const desktopRule = findRule(css, ".planning-workspace");
|
||||
expect(desktopRule).toMatch(/grid-template-areas\s*:\s*"question plan"\s*;/);
|
||||
expect(findRule(css, ".planning-plan-pane")).toMatch(/grid-area\s*:\s*plan\s*;/);
|
||||
expectSomeRule(css, ".planning-question-pane", /grid-area\s*:\s*question\s*;/);
|
||||
|
||||
expect(findRule(shortShellCss, ".planning-modal-body--compact-interview")).toMatch(/flex-direction\s*:\s*column\s*;/);
|
||||
expectSomeRule(shortShellCss, ".planning-compact-pane-switcher", /display\s*:\s*flex\s*;/);
|
||||
/*
|
||||
FNXC:PlanningModeCompactSwitcher 2026-07-20-11:00:
|
||||
FN-8445 keeps the tabs above Answered questions despite its earlier DOM position. jsdom
|
||||
cannot measure flex layout, so both compact media contracts assert the visual-order pin.
|
||||
*/
|
||||
expect(findRule(shortShellCss, ".planning-modal-body--compact-interview .planning-compact-pane-switcher")).toMatch(/order\s*:\s*-1\s*;/);
|
||||
expect(findRule(shortShellCss, ".planning-compact-pane-switcher .btn")).toMatch(/min-height\s*:\s*calc\(var\(--space-md\) \* 2\.25\)\s*;/);
|
||||
expect(shortShellCss).toContain(".planning-modal-body--compact-question .planning-running-plan");
|
||||
expect(shortShellCss).toContain(".planning-modal-body--compact-plan .planning-detail");
|
||||
expect(shortShellCss).toContain(".planning-modal-body--compact-history .planning-detail");
|
||||
expect(findRule(tabletCss, ".planning-modal-body--compact-interview")).toMatch(/flex-direction\s*:\s*column\s*;/);
|
||||
expect(findRule(tabletCss, ".planning-modal-body--compact-interview .planning-compact-pane-switcher")).toMatch(/order\s*:\s*-1\s*;/);
|
||||
expect(tabletCss).toContain(".planning-modal-body--compact-question .planning-running-plan");
|
||||
|
||||
expectSomeRule(css, ".planning-running-plan", /flex\s*:\s*0 1 24rem\s*;/);
|
||||
const mobileCss = getMediaBlocks(css, MOBILE_ACTIONS_QUERY).join("\n");
|
||||
expect(findRule(mobileCss, ".planning-workspace")).toMatch(/grid-template-areas\s*:\s*"plan"\s*"question"\s*;/);
|
||||
expect(findRule(mobileCss, ".planning-workspace")).toMatch(/grid-template-rows\s*:\s*minmax\(0, 1fr\) minmax\(0, 1fr\)\s*;/);
|
||||
});
|
||||
|
||||
it("keeps the mobile sessions list scrolling above the bottom-pinned New session footer", () => {
|
||||
@@ -120,7 +108,6 @@ describe("PlanningModeModal CSS responsive action contract", () => {
|
||||
expect(footerRule).toBeTruthy();
|
||||
expect(footerRule).toMatch(/flex-shrink\s*:\s*0\s*;/);
|
||||
|
||||
expect(mobileShellCss).toMatch(/\.planning-modal-body--show-list \.planning-running-plan[\s\S]*?display\s*:\s*none\s*;/);
|
||||
const mobileBackRule = findRule(mobileShellCss, ".planning-mobile-back");
|
||||
expect(mobileBackRule).toMatch(/display\s*:\s*inline-flex\s*;/);
|
||||
expect(mobileBackRule).toMatch(/min-height\s*:\s*calc\(var\(--space-md\) \* 2\.25\)\s*;/);
|
||||
|
||||
@@ -41,11 +41,18 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
await waitFor(() => expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build secure accounts", "project-1", undefined, { clarificationEnabled: true }, "draft-1"));
|
||||
expect(localStorage.getItem("kb:project-1:kb-planning-active-session")).toBe("draft-1");
|
||||
});
|
||||
it("renders plan review after an answered turn without retired interview panes", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: JSON.stringify({ initialPlan: "Secure accounts" }) });
|
||||
it("keeps the plan visible beside the active question", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
status: "awaiting_input",
|
||||
currentQuestion: JSON.stringify({ id: "q-1", type: "single_select", question: "Which outcome matters most?", options: [{ id: "secure", label: "Secure defaults" }, { id: "fast", label: "Fast delivery" }] }),
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: JSON.stringify({ initialPlan: "Secure accounts" }),
|
||||
});
|
||||
renderSession({});
|
||||
const planReview = await screen.findByTestId("planning-plan-review");
|
||||
expect(planReview).toHaveTextContent("Build authentication system");
|
||||
const workspace = await screen.findByTestId("planning-workspace");
|
||||
expect(workspace).toHaveTextContent("Build authentication system");
|
||||
expect(workspace).toHaveTextContent("Which outcome matters most?");
|
||||
expect(screen.getByTestId("planning-plan-markdown").querySelector("h1")).toHaveTextContent("Build authentication system");
|
||||
expect(screen.getByTestId("planning-plan-markdown").querySelector("strong")).toHaveTextContent("reviewed");
|
||||
expect(screen.getByRole("link", { name: "runbook" })).toHaveAttribute("href", "https://example.com/runbook");
|
||||
@@ -61,12 +68,37 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
const scrollRegion = screen.getByTestId("planning-plan-scroll");
|
||||
const actionBar = screen.getByTestId("planning-plan-actions");
|
||||
expect(scrollRegion).not.toContainElement(actionBar);
|
||||
expect(planReview).toContainElement(actionBar);
|
||||
expect(document.querySelector(".planning-running-plan")).toBeNull();
|
||||
expect(screen.getByTestId("planning-plan-pane")).toContainElement(actionBar);
|
||||
expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument();
|
||||
expect(document.querySelector(".planning-answered-history")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps both panes visible under a generating-plan overlay after Next", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
status: "awaiting_input",
|
||||
currentQuestion: JSON.stringify({ id: "q-1", type: "single_select", question: "Which outcome matters most?", options: [{ id: "secure", label: "Secure defaults" }, { id: "fast", label: "Fast delivery" }] }),
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
mockRespondToPlanning.mockReturnValue(new Promise(() => undefined));
|
||||
renderSession({});
|
||||
fireEvent.click(await screen.findByLabelText("Secure defaults"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
const workspace = screen.getByTestId("planning-workspace");
|
||||
expect(workspace).toHaveAttribute("aria-busy", "true");
|
||||
expect(screen.getByText("Generating plan…")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("planning-plan-pane")).toHaveTextContent("Build authentication system");
|
||||
expect(screen.getByTestId("planning-question-pane")).toHaveTextContent("Which outcome matters most?");
|
||||
});
|
||||
it("opens a multi-select refinement menu and sends every selected or custom focus", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: "{}" });
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
status: "awaiting_input",
|
||||
currentQuestion: JSON.stringify({ id: "q-current", type: "single_select", question: "What should the plan prioritize?", options: [{ id: "security", label: "Security" }, { id: "speed", label: "Speed" }] }),
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
mockRespondToPlanning.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
currentQuestion: {
|
||||
@@ -82,6 +114,8 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
});
|
||||
renderSession({});
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refine" }));
|
||||
expect(screen.getByTestId("planning-plan-pane")).toHaveTextContent("Build authentication system");
|
||||
expect(screen.getByTestId("planning-question-pane")).toHaveTextContent("What should the plan prioritize?");
|
||||
expect(screen.getByRole("dialog", { name: "Choose areas to refine" })).toBeInTheDocument();
|
||||
expect(screen.getByText("What should the next question focus on?")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Security boundaries" }));
|
||||
@@ -94,7 +128,7 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
it("restores the updating-plan progress state after refresh", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({ ...base, status: "generating", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: JSON.stringify({ generationPurpose: "plan_update" }) });
|
||||
renderSession({});
|
||||
expect(await screen.findByText("Updating plan…")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Generating plan…")).toBeInTheDocument();
|
||||
});
|
||||
it("renders exactly one write-your-own choice for normalized select questions", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
|
||||
@@ -3,9 +3,11 @@ import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
describe("PlanningModeModal sequential layout", () => {
|
||||
it("removes retired three-pane and compact interview selectors across responsive surfaces", () => {
|
||||
it("uses one persistent responsive plan-and-question workspace", () => {
|
||||
const css = readFileSync(resolve(process.cwd(), "app/components/PlanningModeModal.css"), "utf8");
|
||||
expect(css).not.toMatch(/planning-compact-pane-switcher|planning-running-plan|planning-answered-history/);
|
||||
expect(css).not.toMatch(/planning-compact-pane-switcher|planning-answered-history/);
|
||||
expect(css).toContain("planning-workspace");
|
||||
expect(css).toContain('grid-template-areas: "question plan"');
|
||||
expect(css).toContain("planning-summary-actions");
|
||||
});
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ const summary = {
|
||||
"Open refinement areas only from the Refine action",
|
||||
"Allow multiple suggested refinement areas",
|
||||
"Accept an operator-authored refinement focus",
|
||||
"Ask one focused question after refinement",
|
||||
"Return to plan review after each answer",
|
||||
"Ask one focused question after every answer",
|
||||
"Keep the plan visible throughout the interview",
|
||||
"Preserve the plan document while refinement is open",
|
||||
"Keep the refinement menu usable on narrow screens",
|
||||
"Expose a clear Proceed with plan action",
|
||||
@@ -32,7 +32,9 @@ const summary = {
|
||||
suggestedRefinements: ["Security boundaries", "Rollout strategy", "Failure recovery", "Accessibility", "Observability", "Data migration", "Performance", "Operational readiness", "API compatibility", "Privacy", "Analytics", "Localization", "Offline behavior", "Permissions", "Documentation", "Support readiness"],
|
||||
};
|
||||
|
||||
const showPlanReview = new URLSearchParams(window.location.search).get("surface") === "plan-review";
|
||||
const fixtureParams = new URLSearchParams(window.location.search);
|
||||
if (fixtureParams.has("reset")) localStorage.clear();
|
||||
const showPlanReview = fixtureParams.get("surface") === "plan-review";
|
||||
|
||||
const questions = [
|
||||
{
|
||||
@@ -92,17 +94,11 @@ class MockEventSource {
|
||||
|
||||
window.EventSource = MockEventSource as unknown as typeof EventSource;
|
||||
|
||||
function emitTurn(questionIndex: number): void {
|
||||
function emitTurn(questionIndex: number, delay = 40): void {
|
||||
setTimeout(() => {
|
||||
streams.forEach((stream) => stream.emit("summary", summary));
|
||||
streams.forEach((stream) => stream.emit("question", questions[questionIndex]));
|
||||
}, 20);
|
||||
}
|
||||
|
||||
function emitSummary(): void {
|
||||
setTimeout(() => {
|
||||
streams.forEach((stream) => stream.emit("summary", summary));
|
||||
}, 20);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
@@ -111,16 +107,16 @@ window.fetch = async (input, init = {}) => {
|
||||
const method = init.method ?? "GET";
|
||||
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||
if (url.includes("/planning/start-streaming") && method === "POST") {
|
||||
emitSummary();
|
||||
emitTurn(0);
|
||||
return json({ sessionId: "planning-browser-e2e" });
|
||||
}
|
||||
if (url.includes("/planning/respond") && method === "POST") {
|
||||
const body = typeof init.body === "string" ? JSON.parse(init.body) as { responses?: { refine?: boolean } } : {};
|
||||
if (body.responses?.refine) {
|
||||
emitTurn(1);
|
||||
return json({ sessionId: "planning-browser-e2e", currentQuestion: questions[1], summary });
|
||||
emitTurn(2, 180);
|
||||
return json({ sessionId: "planning-browser-e2e", currentQuestion: null, summary });
|
||||
}
|
||||
emitSummary();
|
||||
emitTurn(1, 180);
|
||||
return json({ sessionId: "planning-browser-e2e", currentQuestion: null, summary });
|
||||
}
|
||||
if (url.includes("/planning/planning-browser-e2e/back") && method === "POST") {
|
||||
@@ -130,7 +126,7 @@ window.fetch = async (input, init = {}) => {
|
||||
if (url.includes("/planning/planning-browser-e2e/validate") && method === "POST") return json({ summary, validated: true });
|
||||
if (url.includes("/planning/create-task") && method === "POST") return json({ task: { id: "FN-BROWSER", description: summary.description, column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, alreadyCreated: false });
|
||||
if (showPlanReview && url.includes("/ai-sessions/planning-browser-e2e")) {
|
||||
return json({ id: "planning-browser-e2e", title: summary.title, projectId: "project-browser", status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summary), inputPayload: "{}", conversationHistory: "[]", thinkingOutput: "", updatedAt: new Date().toISOString(), archived: false });
|
||||
return json({ id: "planning-browser-e2e", title: summary.title, projectId: "project-browser", status: "awaiting_input", currentQuestion: JSON.stringify(questions[0]), result: JSON.stringify(summary), inputPayload: "{}", conversationHistory: "[]", thinkingOutput: "", updatedAt: new Date().toISOString(), archived: false });
|
||||
}
|
||||
if (url.includes("/ai-sessions")) return json({ sessions: [] });
|
||||
if (url.includes("/models")) return json({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
|
||||
@@ -78,81 +78,74 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
||||
await server.pluginContainer.close();
|
||||
}, 10_000);
|
||||
|
||||
it("starts an AI plan session and asks a focused question only after Refine", async () => {
|
||||
it("starts with one question and regenerates the visible plan before the next question", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
page.on("console", (event) => console.log(`[planning-browser-e2e] ${event.text?.() ?? ""}`));
|
||||
page.on("pageerror", (event) => console.error(`[planning-browser-e2e] ${event.message ?? ""}`));
|
||||
await page.goto(`${baseUrl}app/planning-browser-e2e-fixture.html`);
|
||||
await page.goto(`${baseUrl}app/planning-browser-e2e-fixture.html?reset=1`);
|
||||
|
||||
await page.getByLabel("What do you want to build?").fill("Make Planning Mode adaptive");
|
||||
await page.getByRole("button", { name: "Start Planning" }).click();
|
||||
await expectVisible(page.getByRole("heading", { name: "Adaptive planning workflow" }));
|
||||
await expectVisible(page.locator("[data-testid='planning-plan-markdown'] h1"));
|
||||
await expectVisible(page.getByText("Which user outcome matters most?"));
|
||||
await expectVisible(page.getByRole("button", { name: "Proceed with plan" }));
|
||||
expect(await page.getByText("Who should receive this first?").isVisible()).toBe(false);
|
||||
|
||||
await page.getByRole("button", { name: "Refine" }).click();
|
||||
await page.getByLabel("Security boundaries").check();
|
||||
await page.getByRole("button", { name: "Ask next question" }).click();
|
||||
await page.getByLabel("Speed").check();
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
await expectVisible(page.getByText("Generating plan…"));
|
||||
expect(await page.locator("[data-testid='planning-plan-markdown'] h1").isVisible()).toBe(true);
|
||||
expect(await page.getByText("Which user outcome matters most?").isVisible()).toBe(true);
|
||||
await expectVisible(page.getByText("Who should receive this first?"));
|
||||
await page.getByLabel("Operators").check();
|
||||
await page.getByRole("button", { name: "Continue to plan" }).click();
|
||||
await expectVisible(page.getByRole("button", { name: "Proceed with plan" }));
|
||||
await page.close();
|
||||
}, 30_000);
|
||||
|
||||
it("keeps the Markdown plan scrollable above a bottom action bar on desktop and mobile", async () => {
|
||||
for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 568 }]) {
|
||||
const page = await browser.newPage({ viewport });
|
||||
await page.goto(`${baseUrl}app/planning-browser-e2e-fixture.html?surface=plan-review`);
|
||||
await expectVisible(page.getByRole("heading", { name: "Adaptive planning workflow" }));
|
||||
await expectVisible(page.getByRole("button", { name: "Proceed with plan" }));
|
||||
async function verifyResponsiveWorkspace(viewport: { width: number; height: number }, mobile: boolean): Promise<void> {
|
||||
const page = await browser.newPage({ viewport });
|
||||
await page.goto(`${baseUrl}app/planning-browser-e2e-fixture.html?surface=plan-review&reset=1`);
|
||||
await expectVisible(page.locator("[data-testid='planning-plan-markdown'] h1"));
|
||||
await expectVisible(page.getByText("Which user outcome matters most?"));
|
||||
await expectVisible(page.getByRole("button", { name: "Proceed with plan" }));
|
||||
|
||||
const layout = await page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("[data-testid='planning-plan-review']")!;
|
||||
const scroll = document.querySelector<HTMLElement>("[data-testid='planning-plan-scroll']")!;
|
||||
const actions = document.querySelector<HTMLElement>("[data-testid='planning-plan-actions']")!;
|
||||
const buttons = [...actions.querySelectorAll<HTMLElement>("button")];
|
||||
const reviewRect = review.getBoundingClientRect();
|
||||
const scrollRect = scroll.getBoundingClientRect();
|
||||
const actionsRect = actions.getBoundingClientRect();
|
||||
return {
|
||||
actionsInsideReview: review.contains(actions),
|
||||
actionsInsideScroll: scroll.contains(actions),
|
||||
actionsAtBottom: Math.abs(reviewRect.bottom - actionsRect.bottom) <= 1,
|
||||
scrollEndsAtActions: Math.abs(scrollRect.bottom - actionsRect.top) <= 1,
|
||||
scrollable: scroll.scrollHeight > scroll.clientHeight,
|
||||
scrollOwnerConfigured: getComputedStyle(scroll).overflowY === "auto",
|
||||
buttonsShareRow: buttons.length === 2 && Math.abs(buttons[0]!.getBoundingClientRect().top - buttons[1]!.getBoundingClientRect().top) <= 1,
|
||||
markdownRendered: Boolean(review.querySelector("h1") && review.querySelector("strong")),
|
||||
};
|
||||
});
|
||||
const layout = await page.evaluate(() => {
|
||||
const workspace = document.querySelector<HTMLElement>("[data-testid='planning-workspace']")!;
|
||||
const plan = document.querySelector<HTMLElement>("[data-testid='planning-plan-pane']")!;
|
||||
const question = document.querySelector<HTMLElement>("[data-testid='planning-question-pane']")!;
|
||||
const scroll = document.querySelector<HTMLElement>("[data-testid='planning-plan-scroll']")!;
|
||||
const actions = document.querySelector<HTMLElement>("[data-testid='planning-plan-actions']")!;
|
||||
const planRect = plan.getBoundingClientRect();
|
||||
const questionRect = question.getBoundingClientRect();
|
||||
const scrollRect = scroll.getBoundingClientRect();
|
||||
const actionsRect = actions.getBoundingClientRect();
|
||||
return {
|
||||
planVisible: planRect.width > 0 && planRect.height > 0,
|
||||
questionVisible: questionRect.width > 0 && questionRect.height > 0,
|
||||
planRightOfQuestion: planRect.left >= questionRect.right,
|
||||
planAboveQuestion: planRect.bottom <= questionRect.top,
|
||||
panesInsideWorkspace: workspace.contains(plan) && workspace.contains(question),
|
||||
actionsInsideScroll: scroll.contains(actions),
|
||||
actionsAtBottom: Math.abs(planRect.bottom - actionsRect.bottom) <= 1,
|
||||
scrollEndsAtActions: Math.abs(scrollRect.bottom - actionsRect.top) <= 1,
|
||||
scrollable: scroll.scrollHeight > scroll.clientHeight,
|
||||
scrollOwnerConfigured: getComputedStyle(scroll).overflowY === "auto",
|
||||
markdownRendered: Boolean(plan.querySelector("h1") && plan.querySelector("strong")),
|
||||
};
|
||||
});
|
||||
|
||||
expect(layout).toEqual({
|
||||
actionsInsideReview: true,
|
||||
actionsInsideScroll: false,
|
||||
actionsAtBottom: true,
|
||||
scrollEndsAtActions: true,
|
||||
scrollable: true,
|
||||
scrollOwnerConfigured: true,
|
||||
buttonsShareRow: true,
|
||||
markdownRendered: true,
|
||||
});
|
||||
if (viewport.width > 1024) {
|
||||
await page.getByRole("button", { name: "Refine" }).click();
|
||||
await expectVisible(page.getByRole("dialog", { name: "Choose areas to refine" }));
|
||||
await page.getByLabel("Security boundaries").check();
|
||||
await page.getByLabel("Observability").check();
|
||||
await page.getByLabel("Or describe another focus").fill("Migration sequencing");
|
||||
await page.getByRole("button", { name: "Ask next question" }).click();
|
||||
await expectVisible(page.getByText("Who should receive this first?"));
|
||||
} else {
|
||||
await page.getByRole("button", { name: "Proceed with plan" }).click();
|
||||
for (let attempt = 0; attempt < 20 && await page.evaluate(() => document.body.dataset.createdTask) !== "FN-BROWSER"; attempt += 1) {
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
expect(await page.locator("body").getAttribute("data-created-task")).toBe("FN-BROWSER");
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
}, 30_000);
|
||||
expect(layout).toMatchObject({
|
||||
planVisible: true,
|
||||
questionVisible: true,
|
||||
planRightOfQuestion: !mobile,
|
||||
planAboveQuestion: mobile,
|
||||
panesInsideWorkspace: true,
|
||||
actionsInsideScroll: false,
|
||||
actionsAtBottom: true,
|
||||
scrollEndsAtActions: true,
|
||||
scrollable: true,
|
||||
scrollOwnerConfigured: true,
|
||||
markdownRendered: true,
|
||||
});
|
||||
await page.close();
|
||||
}
|
||||
|
||||
it("keeps the Markdown plan right of the question on desktop", () => verifyResponsiveWorkspace({ width: 1440, height: 900 }, false), 30_000);
|
||||
it("keeps the Markdown plan above the question on mobile", () => verifyResponsiveWorkspace({ width: 390, height: 568 }, true), 30_000);
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ describe("reactive Planning Mode question contract", () => {
|
||||
for (const prompt of prompts) {
|
||||
expect(prompt).toMatch(/plan in Markdown/i);
|
||||
}
|
||||
expect(prompts.at(-1)).toMatch(/without asking another question/i);
|
||||
expect(prompts.at(-1)).toMatch(/ask exactly one next question/i);
|
||||
expect(PLANNING_SYSTEM_PROMPT).toMatch(/Proceed with plan serializes the plan as plan\.md/i);
|
||||
});
|
||||
|
||||
@@ -192,7 +192,14 @@ describe("reactive Planning Mode question contract", () => {
|
||||
Other steering, and explicit-only validation invariant rather than only testing normalization.
|
||||
*/
|
||||
it("delivers planning-clarification metadata that can reopen the exact session", async () => {
|
||||
installScriptedAgent([completePayload(), payload(FIRST_QUESTION)]);
|
||||
installScriptedAgent([payload({
|
||||
...FIRST_QUESTION,
|
||||
runningPlan: {
|
||||
title: "Secure account recovery delivery",
|
||||
description: "Build a reviewed recovery workflow with audit coverage.",
|
||||
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
|
||||
},
|
||||
})]);
|
||||
let resolveDelivered: ((message: Record<string, unknown>) => void) | undefined;
|
||||
const delivered = new Promise<Record<string, unknown>>((resolve) => {
|
||||
resolveDelivered = resolve;
|
||||
@@ -219,8 +226,6 @@ describe("reactive Planning Mode question contract", () => {
|
||||
});
|
||||
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
||||
await initialPlanReady;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await submitResponse(sessionId, { refine: true, focus: "Security boundaries" }, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
const message = await delivered;
|
||||
|
||||
expect(message).toMatchObject({
|
||||
@@ -234,10 +239,19 @@ describe("reactive Planning Mode question contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("generates a durable initial plan before any question and validates only on user action", async () => {
|
||||
it("generates a durable initial plan with one question and validates only on user action", async () => {
|
||||
const prompts = installScriptedAgent([
|
||||
completePayload(),
|
||||
payload(SECOND_QUESTION),
|
||||
payload({
|
||||
...SECOND_QUESTION,
|
||||
runningPlan: {
|
||||
title: "Secure account recovery delivery",
|
||||
description: "Build a reviewed recovery workflow with audit coverage.",
|
||||
proposedChanges: ["Add recovery-token lifecycle handling", "Expose recovery audit events"],
|
||||
acceptanceCriteria: ["Users can recover accounts securely", "Every recovery attempt is auditable"],
|
||||
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
|
||||
suggestedRefinements: ["Security boundaries", "Rollout strategy", "Failure recovery"],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const sessionId = await createSessionWithAgent(
|
||||
"127.0.0.10",
|
||||
@@ -264,27 +278,43 @@ describe("reactive Planning Mode question contract", () => {
|
||||
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
|
||||
suggestedRefinements: ["Security boundaries", "Rollout strategy", "Failure recovery"],
|
||||
});
|
||||
expect((await getSession(sessionId))?.currentQuestion).toBeUndefined();
|
||||
expect((await getSession(sessionId))?.currentQuestion?.id).toBe("rollout");
|
||||
expect((await getSession(sessionId))?.validated).toBe(false);
|
||||
expect(events).not.toContain("question");
|
||||
expect(events).toContain("question");
|
||||
expect(prompts[0]).toContain("initial implementation plan");
|
||||
|
||||
const refine = await submitResponse(sessionId, {
|
||||
refine: true,
|
||||
focus: "Security boundaries",
|
||||
}, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
expect(refine).toEqual(expect.objectContaining({ type: "question" }));
|
||||
expect(prompts.at(-1)).toContain("Security boundaries");
|
||||
|
||||
await validateSession(sessionId);
|
||||
expect(await getSession(sessionId)).toMatchObject({ validated: true, currentQuestion: undefined });
|
||||
});
|
||||
|
||||
it("uses Refine to replace the active question without recording a fake answer", async () => {
|
||||
const prompts = installScriptedAgent([
|
||||
payload(FIRST_QUESTION),
|
||||
payload(SECOND_QUESTION),
|
||||
]);
|
||||
const created = await createSession("127.0.0.18", "Build secure account recovery", MOCK_TASK_STORE, "/tmp/project");
|
||||
|
||||
const refined = await submitResponse(created.sessionId, {
|
||||
refine: true,
|
||||
focus: "Rollout safety, observability",
|
||||
}, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
|
||||
expect(refined).toEqual(expect.objectContaining({ type: "question", data: expect.objectContaining({ id: "rollout" }) }));
|
||||
expect((await getSession(created.sessionId))?.history).toEqual([]);
|
||||
expect(prompts.at(-1)).toContain("Rollout safety, observability");
|
||||
});
|
||||
|
||||
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),
|
||||
payload({
|
||||
...SECOND_QUESTION,
|
||||
runningPlan: {
|
||||
title: "Secure account recovery delivery",
|
||||
description: "Build a reviewed recovery workflow with audit coverage.",
|
||||
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const created = await createSession("127.0.0.1", "Build secure account recovery", MOCK_TASK_STORE, "/tmp/project");
|
||||
|
||||
@@ -296,8 +326,8 @@ describe("reactive Planning Mode question contract", () => {
|
||||
scope: "other",
|
||||
_other: "Ask me questions about audit logging security instead.",
|
||||
}, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
expect(firstNext.type).toBe("complete");
|
||||
expect((await getSession(created.sessionId))?.currentQuestion).toBeUndefined();
|
||||
expect(firstNext.type).toBe("question");
|
||||
expect((await getSession(created.sessionId))?.currentQuestion?.id).toBe("rollout");
|
||||
expect(prompts[1]).toContain("Ask me questions about audit logging security instead.");
|
||||
|
||||
const afterCompletion = await getSession(created.sessionId);
|
||||
@@ -307,7 +337,7 @@ describe("reactive Planning Mode question contract", () => {
|
||||
title: "Secure account recovery delivery",
|
||||
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
|
||||
});
|
||||
expect(afterCompletion?.currentQuestion).toBeUndefined();
|
||||
expect(afterCompletion?.currentQuestion?.id).toBe("rollout");
|
||||
expect((await getSession(created.sessionId))?.summary).toBeDefined();
|
||||
expect((await getSession(created.sessionId))?.validated).toBe(false);
|
||||
|
||||
@@ -338,7 +368,7 @@ describe("reactive Planning Mode question contract", () => {
|
||||
expect(created.summary.description).not.toBe(created.firstQuestion.question);
|
||||
});
|
||||
|
||||
it("uses a model-authored initial plan on the streaming first turn without exposing its question", async () => {
|
||||
it("uses a model-authored initial plan on the streaming first turn and exposes one question", async () => {
|
||||
installScriptedAgent([payload({
|
||||
...FIRST_QUESTION,
|
||||
runningPlan: {
|
||||
@@ -366,7 +396,7 @@ describe("reactive Planning Mode question contract", () => {
|
||||
description: "Stage a secure recovery flow with observability.",
|
||||
keyDeliverables: ["Design recovery token lifecycle", "Test recovery telemetry"],
|
||||
});
|
||||
expect(events).not.toContain("question");
|
||||
expect(events).toContain("question");
|
||||
});
|
||||
|
||||
it("recovers a plan-shaped streaming first turn when the model omits runningPlan", async () => {
|
||||
@@ -392,7 +422,7 @@ describe("reactive Planning Mode question contract", () => {
|
||||
});
|
||||
expect(session?.summary?.description).not.toBe(FIRST_QUESTION.question);
|
||||
expect(session?.summary?.keyDeliverables).not.toEqual([FIRST_QUESTION.question]);
|
||||
expect(events).not.toContain("question");
|
||||
expect(events).toContain("question");
|
||||
});
|
||||
|
||||
it("merges a partial model running-plan update with the prior work product", async () => {
|
||||
@@ -452,7 +482,7 @@ describe("reactive Planning Mode question contract", () => {
|
||||
expect(session?.validated).toBe(false);
|
||||
});
|
||||
|
||||
it("replays an edited historical answer into plan review without automatically asking a question", async () => {
|
||||
it("replays an edited historical answer and asks the next question", async () => {
|
||||
installScriptedAgent([payload(FIRST_QUESTION), completePayload(), completePayload(), completePayload(), completePayload()]);
|
||||
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);
|
||||
@@ -461,10 +491,10 @@ describe("reactive Planning Mode question contract", () => {
|
||||
const revised = await submitResponse(created.sessionId, { scope: "fast" }, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
|
||||
const edited = await getSession(created.sessionId);
|
||||
expect(revised.type).toBe("complete");
|
||||
expect(revised.type).toBe("question");
|
||||
expect(edited?.history).toHaveLength(1);
|
||||
expect(edited?.history[0]?.response).toEqual({ scope: "fast" });
|
||||
expect(edited?.currentQuestion).toBeUndefined();
|
||||
expect(edited?.currentQuestion).toBeDefined();
|
||||
expect(edited?.summary).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -240,9 +240,9 @@ export const PLANNING_SYSTEM_PROMPT = `## Planning Mode interaction adapter
|
||||
|
||||
First analyze the codebase and active board with the available readonly tools, fn_task_list, and fn_task_show. Treat the workflow planning template above as the quality bar and PROMPT.md structure for the evolving plan, but do not write PROMPT.md or use write tools during this interview.
|
||||
|
||||
Start by producing a concrete initial plan for review without asking a question. Author the operator-facing plan in Markdown: write the description as concise GitHub-flavored Markdown, while the structured change, acceptance, dependency, and deliverable fields become its Markdown sections and lists. Ask exactly one next, high-impact question only when the user explicitly requests a refine turn. After an answer, update the running plan and do not ask a question. A JSON response with type "complete" means only that the current plan update is ready for review; it never validates or terminates the session. Only the user can validate the plan.
|
||||
Start by producing a concrete initial plan and exactly one high-impact question. Author the operator-facing plan in Markdown: write the description as concise GitHub-flavored Markdown, while the structured change, acceptance, dependency, and deliverable fields become its Markdown sections and lists. After every answer, regenerate the plan and ask exactly one consequential next question. A refine turn uses the selected or free-text focus to choose that next question. The model never validates or terminates the session. Only the user can validate it through the visible Proceed with plan action.
|
||||
|
||||
For a refine turn 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}],"runningPlan":{"title":"...","description":"...","proposedChanges":["specific change"],"acceptanceCriteria":["observable outcome"],"suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"],"suggestedRefinements":["next focus 1","next focus 2"]}}}. For an initial-plan or answer-update turn respond only with {"type":"complete","data":{"title":"...","description":"...","proposedChanges":["specific change"],"acceptanceCriteria":["observable outcome"],"suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"],"suggestedRefinements":["next focus 1","next focus 2"]}}.
|
||||
For every initial, answer, or refine turn 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}],"runningPlan":{"title":"...","description":"...","proposedChanges":["specific change"],"acceptanceCriteria":["observable outcome"],"suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"],"suggestedRefinements":["next focus 1","next focus 2"]}}}.
|
||||
|
||||
Every turn must include the running-plan fields: only title, description, concrete proposedChanges, observable acceptanceCriteria, suggestedSize, optional priority, suggestedDependencies, concrete keyDeliverables, and concise suggestedRefinements informed by the idea and answers so far. Include every distinct, high-value unresolved refinement area; do not cap the list at three. Never use interview question text as a deliverable. Do not put PROMPT.md sections (Mission, Before → After, Steps, File Scope, Review Level, Completion Criteria, or Do NOT) in runningPlan or free text: triage writes PROMPT.md only after the operator proceeds with the plan. Proceed with plan serializes the plan as plan.md without priority or suggestedRefinements; priority remains a task field. 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.`;
|
||||
|
||||
@@ -2156,14 +2156,14 @@ export function formatInitialPlanRequestForAgent(initialPlan: string): string {
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
/** Streaming Planning Mode starts with a reviewable work product, never an unsolicited question. */
|
||||
/** Streaming Planning Mode starts with a reviewable work product and one focused question. */
|
||||
export function formatInitialRunningPlanRequestForAgent(initialPlan: string): string {
|
||||
return [
|
||||
"Create a concrete initial implementation plan from this operator idea.",
|
||||
"Author the operator-facing plan in Markdown. Write the description as concise GitHub-flavored Markdown; the structured proposed changes, acceptance criteria, dependencies, and deliverables will render as Markdown sections and lists.",
|
||||
"Inspect the relevant codebase and active-board context before drafting it. Make the description specific about the affected behavior and intended outcome. Provide concrete proposedChanges that name what behavior, component, interface, data, or configuration should change, and acceptanceCriteria stated as observable pass/fail outcomes. Make every key deliverable an actionable work item rather than generic planning advice.",
|
||||
"Also propose concise suggestedRefinements covering every distinct, high-value unresolved area the operator could explore next; do not cap the list at three.",
|
||||
"Return only type:\"complete\" JSON with title, description, proposedChanges, acceptanceCriteria, suggestedSize, priority, suggestedDependencies, keyDeliverables, and suggestedRefinements. Do not ask a question yet and do not validate the plan; the operator will review it and explicitly choose Refine or Validate.",
|
||||
"Return only type:\"question\" JSON with the complete plan in runningPlan and exactly one high-impact next question. Give that question at least two useful alternatives with pros and cons plus one write-your-own option. Do not validate the plan; only the operator can proceed with it.",
|
||||
"Operator idea:",
|
||||
initialPlan,
|
||||
].join("\n\n");
|
||||
@@ -2479,15 +2479,8 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
session.error = undefined;
|
||||
session.lastGeneratedThinking = session.thinkingOutput;
|
||||
session.updatedAt = new Date();
|
||||
const planUpdate = session.generationPurpose === "plan_update" || session.generationPurpose === "initial_plan";
|
||||
session.generationPurpose = undefined;
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-20-15:45:
|
||||
A model can accidentally return question-shaped JSON during an answer update. FN-8442
|
||||
deliberately strips that question instead of exposing it: the next question is exclusively
|
||||
triggered by the plan-review Refine action, optionally with operator focus text.
|
||||
*/
|
||||
session.currentQuestion = planUpdate ? undefined : coerceQuestionResponse(parsed, session);
|
||||
session.currentQuestion = coerceQuestionResponse(parsed, session);
|
||||
await persistSession(session, "awaiting_input");
|
||||
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
|
||||
if (session.currentQuestion) {
|
||||
@@ -2839,11 +2832,10 @@ export async function submitResponse(
|
||||
*/
|
||||
let answeredQuestion: PlanningQuestion | undefined;
|
||||
|
||||
if (!session.currentQuestion) {
|
||||
if (!isRefineRequest(responses) || !session.summary) {
|
||||
throw new InvalidSessionStateError("No active question in session");
|
||||
}
|
||||
|
||||
if (isRefineRequest(responses) && session.summary) {
|
||||
// Refinement steers which question comes next; it is never an answer to the
|
||||
// currently displayed question and therefore must not create a history entry.
|
||||
session.currentQuestion = undefined;
|
||||
session.generationPurpose = "question";
|
||||
session.error = undefined;
|
||||
await persistSession(session, "generating");
|
||||
@@ -2852,6 +2844,8 @@ export async function submitResponse(
|
||||
const focus = typeof responses.focus === "string" ? responses.focus.trim() : undefined;
|
||||
const refineMessage = formatRefineRequestForAgent(session.summary, focus);
|
||||
await continueAgentConversation(session, refineMessage);
|
||||
} else if (!session.currentQuestion) {
|
||||
throw new InvalidSessionStateError("No active question in session");
|
||||
} else {
|
||||
const currentQuestion = captureOtherCustomText(session.currentQuestion, responses);
|
||||
const historyEntry = {
|
||||
@@ -2881,12 +2875,8 @@ export async function submitResponse(
|
||||
}
|
||||
answeredQuestion = currentQuestion;
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-20-15:45:
|
||||
FN-8442 makes answering a plan-update turn. Clear the active question before generation
|
||||
and record its purpose so only an explicit `{ refine: true, focus? }` may surface another
|
||||
question; this keeps the plan-review decision point under operator control.
|
||||
*/
|
||||
// Clear the answered question while generation is active so reconnects cannot replay it.
|
||||
// The completed turn persists and broadcasts exactly one newly generated question.
|
||||
session.currentQuestion = undefined;
|
||||
session.generationPurpose = "plan_update";
|
||||
await persistSession(session, "generating");
|
||||
@@ -2902,8 +2892,8 @@ export async function submitResponse(
|
||||
);
|
||||
}
|
||||
const message = isEditingPriorAnswer
|
||||
? "An earlier answer was edited. Use the complete preserved interview context above and return only the updated plan JSON; do not ask another question."
|
||||
: `${formatResponseForAgent(currentQuestion, responses)}\n\nUpdate the running plan only. Do not ask a next question; the operator may explicitly refine after reviewing the plan.`;
|
||||
? "An earlier answer was edited. Use the complete preserved interview context above, regenerate the running plan, and ask exactly one next question."
|
||||
: formatResponseForAgent(currentQuestion, responses);
|
||||
await continueAgentConversation(session, message);
|
||||
}
|
||||
|
||||
@@ -2985,10 +2975,10 @@ export async function retrySession(
|
||||
const lastEntry = session.history[session.history.length - 1];
|
||||
|
||||
await ensureSessionAgent(session, rootDir, replayHistory, promptOverrides, store);
|
||||
const replayMessage = `${formatResponseForAgent(
|
||||
const replayMessage = formatResponseForAgent(
|
||||
lastEntry.question,
|
||||
coerceResponseRecord(lastEntry.question, lastEntry.response),
|
||||
)}\n\nUpdate the running plan only. Do not ask a next question; the operator may explicitly refine after reviewing the plan.`;
|
||||
);
|
||||
await continueAgentConversation(session, replayMessage);
|
||||
}
|
||||
|
||||
@@ -3147,7 +3137,7 @@ export function formatResponseForAgent(
|
||||
System prompts can be displaced by long tool/context turns. Repeat the per-answer contract at the invocation boundary
|
||||
so every submitted answer steers the following high-impact question instead of inviting a model-generated completion.
|
||||
*/
|
||||
return `${answerContext}\n\nUpdate only the runningPlan fields (title, description, concrete proposedChanges, observable acceptanceCriteria, suggestedSize, optional priority, suggestedDependencies, concrete keyDeliverables, and all distinct high-value suggestedRefinements) informed by this answer; do not cap suggestedRefinements at three. Author the operator-facing plan in Markdown: use concise GitHub-flavored Markdown in the description, with the structured fields supplying its Markdown sections and lists. Never list interview questions as deliverables or PROMPT.md sections such as Mission, Steps, File Scope, Review Level, Completion Criteria, or Do NOT. Return the updated plan for review without asking another question. Do not validate the plan; only the user can validate it.`;
|
||||
return `${answerContext}\n\nRegenerate the runningPlan fields (title, description, concrete proposedChanges, observable acceptanceCriteria, suggestedSize, optional priority, suggestedDependencies, concrete keyDeliverables, and all distinct high-value suggestedRefinements) informed by this answer; do not cap suggestedRefinements at three. Author the operator-facing plan in Markdown: use concise GitHub-flavored Markdown in the description, with the structured fields supplying its Markdown sections and lists. Never list interview questions as deliverables or PROMPT.md sections such as Mission, Steps, File Scope, Review Level, Completion Criteria, or Do NOT. Return type:"question" with that complete runningPlan and ask exactly one next question. Do not validate the plan; only the user can proceed with it.`;
|
||||
}
|
||||
|
||||
function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {
|
||||
|
||||
Reference in New Issue
Block a user