fix(FN-8442): make planning generation durable and detailed
Generate a reviewable initial plan before clarification, persist generation purpose across refreshes, and surface concrete changes and acceptance criteria with focused refinement choices.
This commit is contained in:
7
.changeset/planning-initial-plan-refresh.md
Normal file
7
.changeset/planning-initial-plan-refresh.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Make Planning Mode generate a durable initial plan before asking optional refinement questions.
|
||||||
|
category: fix
|
||||||
|
dev: Persists generation purpose, adds detailed change/acceptance sections and suggested focuses, and deduplicates Other.
|
||||||
@@ -101,7 +101,7 @@ gesture delivery enabled across `cap sync` regenerations.
|
|||||||
|
|
||||||
### Planning Mode
|
### Planning Mode
|
||||||
|
|
||||||
Planning Mode opens directly into the composer pane on mobile when no planning sessions exist, avoiding an empty-sidebar dead end. Every viewport uses the same sequential surface: idea, one question, then plan review. Plan review provides an optional focus field followed by **Refine** and **Validate**; refine asks the next question and validate creates the task. There is no three-pane interview or Question/Running plan/Answered questions tab switcher. On mobile, opening Planning with saved sessions lands on the full-pane, scrollable saved-session list with **New session** as its footer. **Sessions** and the mobile back control always return to that list, including from plan review and create retry.
|
Planning Mode opens directly into the composer pane on mobile when no planning sessions exist, avoiding an empty-sidebar dead end. Every viewport uses the same sequential surface: idea, generated initial plan, then optional refinement questions. Plan review offers model-suggested focus choices plus **Write your own focus**, followed by **Refine** and **Validate**; refine asks one question and validate creates the task. Generation remains resumable across refreshes, and answer turns visibly report **Updating plan…** before returning to review. There is no three-pane interview or Question/Running plan/Answered questions tab switcher. On mobile, opening Planning with saved sessions lands on the full-pane, scrollable saved-session list with **New session** as its footer. **Sessions** and the mobile back control always return to that list, including from plan review and create retry.
|
||||||
|
|
||||||
### Chat and Quick Chat mobile scroll/readability behavior
|
### Chat and Quick Chat mobile scroll/readability behavior
|
||||||
|
|
||||||
|
|||||||
@@ -530,8 +530,8 @@ Use **New session** to restart planning with a different idea.
|
|||||||
|
|
||||||
Planning Mode uses the selected workflow's `planning` seam—the same triage template used for newly added tasks—as its quality bar, then layers the user-controlled interview adapter on top. An explicitly configured `planning-system` prompt override replaces that full system prompt.
|
Planning Mode uses the selected workflow's `planning` seam—the same triage template used for newly added tasks—as its quality bar, then layers the user-controlled interview adapter on top. An explicitly configured `planning-system` prompt override replaces that full system prompt.
|
||||||
|
|
||||||
<!-- FNXC:PlanningMode 2026-07-20-15:45: FN-8442 replaces the simultaneous three-pane interview with a sequential question and plan-review loop. -->
|
<!-- FNXC:PlanningMode 2026-07-20-15:45: FN-8442 replaces the simultaneous three-pane interview with a sequential plan-review and question loop. -->
|
||||||
Planning Mode is a single-surface sequence: enter an idea, answer one AI question, then review the evolving plan. Plan review shows the title, description, and deliverables plus an optional **Focus the next question** field. Choose **Refine** to ask another high-impact question; its optional focus can name a topic such as security or real-time behavior, or specify the next question directly. Answering that question returns to plan review rather than automatically starting another question.
|
Planning Mode is a single-surface sequence: enter an idea, wait while Fusion generates a concrete initial plan, then review that evolving work product before deciding whether clarification is needed. Plan review shows the title, description, explicit **What to change** and **Acceptance criteria** sections, and deliverables, plus model-suggested **Focus the next question** choices and **Write your own focus**. Choose **Refine** to ask one high-impact question or **Validate** to accept the current plan. Answering a question shows **Updating plan…** and returns to plan review rather than automatically starting another question. The active generation purpose and running plan are persisted, so refreshing or navigating away during generation restores the correct progress state and eventual review.
|
||||||
|
|
||||||
Choose **Validate** when the plan is ready. Validation is durable and immediately creates the task using the selected workflow and branch settings. If creation is interrupted after validation, Planning restores a create-only retry state; it never validates again or creates a second task. **Sessions** (and mobile Back) remains the escape hatch for browsing, switching, and reviewing session history, with **New session** pinned in the saved-session list. The AI never ends an interview on its own; selection questions still provide alternatives with pros and cons plus an **Other** free-text choice.
|
Choose **Validate** when the plan is ready. Validation is durable and immediately creates the task using the selected workflow and branch settings. If creation is interrupted after validation, Planning restores a create-only retry state; it never validates again or creates a second task. **Sessions** (and mobile Back) remains the escape hatch for browsing, switching, and reviewing session history, with **New session** pinned in the saved-session list. The AI never ends an interview on its own; selection questions still provide alternatives with pros and cons plus an **Other** free-text choice.
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { formatPlanningPlanMd, parsePlanningPlanMd } from "../planning-plan-md.j
|
|||||||
|
|
||||||
describe("Planning Mode plan.md", () => {
|
describe("Planning Mode plan.md", () => {
|
||||||
it("round-trips the lean operator schema without priority", () => {
|
it("round-trips the lean operator schema without priority", () => {
|
||||||
const formatted = formatPlanningPlanMd({ title: "Ship plans", description: "Keep plans durable.", suggestedSize: "M", priority: "high", suggestedDependencies: ["FN-1"], keyDeliverables: ["Format plan", "Store request"] });
|
const formatted = formatPlanningPlanMd({ title: "Ship plans", description: "Keep plans durable.", proposedChanges: ["Persist the plan state"], acceptanceCriteria: ["Refresh restores the active plan"], suggestedSize: "M", priority: "high", suggestedDependencies: ["FN-1"], keyDeliverables: ["Format plan", "Store request"] });
|
||||||
expect(formatted).not.toContain("## Priority");
|
expect(formatted).not.toContain("## Priority");
|
||||||
expect(parsePlanningPlanMd(formatted)).toEqual({ title: "Ship plans", description: "Keep plans durable.", suggestedSize: "M", suggestedDependencies: ["FN-1"], keyDeliverables: ["Format plan", "Store request"] });
|
expect(formatted).toContain("## What to change\n- Persist the plan state");
|
||||||
|
expect(formatted).toContain("## Acceptance criteria\n- Refresh restores the active plan");
|
||||||
|
expect(parsePlanningPlanMd(formatted)).toEqual({ title: "Ship plans", description: "Keep plans durable.", proposedChanges: ["Persist the plan state"], acceptanceCriteria: ["Refresh restores the active plan"], suggestedSize: "M", suggestedDependencies: ["FN-1"], keyDeliverables: ["Format plan", "Store request"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips empty dependencies", () => {
|
it("round-trips empty dependencies", () => {
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ FN-8441 makes plan.md Planning Mode's lean, durable operator product. Triage alo
|
|||||||
expands it into PROMPT.md; priority remains a task-row field and is never serialized here.
|
expands it into PROMPT.md; priority remains a task-row field and is never serialized here.
|
||||||
*/
|
*/
|
||||||
export function formatPlanningPlanMd(summary: PlanningSummary): string {
|
export function formatPlanningPlanMd(summary: PlanningSummary): string {
|
||||||
|
const list = (items: string[] | undefined) => items && items.length > 0
|
||||||
|
? items.map((item) => `- ${item}`).join("\n")
|
||||||
|
: "_None_";
|
||||||
|
const proposedChanges = list(summary.proposedChanges);
|
||||||
|
const acceptanceCriteria = list(summary.acceptanceCriteria);
|
||||||
const dependencies = summary.suggestedDependencies.length > 0
|
const dependencies = summary.suggestedDependencies.length > 0
|
||||||
? summary.suggestedDependencies.map((dependency) => `- ${dependency}`).join("\n")
|
? summary.suggestedDependencies.map((dependency) => `- ${dependency}`).join("\n")
|
||||||
: "_None_";
|
: "_None_";
|
||||||
@@ -13,7 +18,7 @@ export function formatPlanningPlanMd(summary: PlanningSummary): string {
|
|||||||
? summary.keyDeliverables.map((deliverable) => `- ${deliverable}`).join("\n")
|
? summary.keyDeliverables.map((deliverable) => `- ${deliverable}`).join("\n")
|
||||||
: "_None_";
|
: "_None_";
|
||||||
|
|
||||||
return `# ${summary.title}\n\n${summary.description}\n\n## Size\n${summary.suggestedSize}\n\n## Suggested dependencies\n${dependencies}\n\n## Key deliverables\n${deliverables}\n`;
|
return `# ${summary.title}\n\n${summary.description}\n\n## What to change\n${proposedChanges}\n\n## Acceptance criteria\n${acceptanceCriteria}\n\n## Size\n${summary.suggestedSize}\n\n## Suggested dependencies\n${dependencies}\n\n## Key deliverables\n${deliverables}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -23,15 +28,32 @@ canonical section sequence emitted by formatPlanningPlanMd so user prose cannot
|
|||||||
round trips or the fail-soft original-description body.
|
round trips or the fail-soft original-description body.
|
||||||
*/
|
*/
|
||||||
export function parsePlanningPlanMd(text: string): Partial<PlanningSummary> | null {
|
export function parsePlanningPlanMd(text: string): Partial<PlanningSummary> | null {
|
||||||
|
const list = (value: string) => value.trim() === "_None_"
|
||||||
|
? []
|
||||||
|
: value.split("\n").map((line) => line.replace(/^\s*-\s*/, "").trim()).filter(Boolean);
|
||||||
|
const expanded = text.match(
|
||||||
|
/^#\s+(.+)\n\n([\s\S]*)\n\n## What to change\n([\s\S]*?)\n\n## Acceptance criteria\n([\s\S]*?)\n\n## Size\n([SML])\n\n## Suggested dependencies\n([\s\S]*?)\n\n## Key deliverables\n([\s\S]*?)\n?$/,
|
||||||
|
);
|
||||||
|
if (expanded) {
|
||||||
|
const [, rawTitle, description, proposedChanges, acceptanceCriteria, rawSize, dependencies, deliverables] = expanded;
|
||||||
|
return {
|
||||||
|
title: rawTitle!.trim(),
|
||||||
|
description: description!.trim(),
|
||||||
|
proposedChanges: list(proposedChanges!),
|
||||||
|
acceptanceCriteria: list(acceptanceCriteria!),
|
||||||
|
suggestedSize: rawSize as PlanningSummary["suggestedSize"],
|
||||||
|
suggestedDependencies: list(dependencies!),
|
||||||
|
keyDeliverables: list(deliverables!),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility for plan.md artifacts created before the detail sections existed.
|
||||||
const canonical = text.match(
|
const canonical = text.match(
|
||||||
/^#\s+(.+)\n\n([\s\S]*)\n\n## Size\n([SML])\n\n## Suggested dependencies\n([\s\S]*?)\n\n## Key deliverables\n([\s\S]*?)\n?$/,
|
/^#\s+(.+)\n\n([\s\S]*)\n\n## Size\n([SML])\n\n## Suggested dependencies\n([\s\S]*?)\n\n## Key deliverables\n([\s\S]*?)\n?$/,
|
||||||
);
|
);
|
||||||
if (!canonical) return null;
|
if (!canonical) return null;
|
||||||
|
|
||||||
const [, rawTitle, description, rawSize, dependencies, deliverables] = canonical;
|
const [, rawTitle, description, rawSize, dependencies, deliverables] = canonical;
|
||||||
const list = (value: string) => value.trim() === "_None_"
|
|
||||||
? []
|
|
||||||
: value.split("\n").map((line) => line.replace(/^\s*-\s*/, "").trim()).filter(Boolean);
|
|
||||||
return {
|
return {
|
||||||
title: rawTitle!.trim(),
|
title: rawTitle!.trim(),
|
||||||
description: description!.trim(),
|
description: description!.trim(),
|
||||||
|
|||||||
@@ -5663,10 +5663,16 @@ export interface PlanningQuestion {
|
|||||||
export interface PlanningSummary {
|
export interface PlanningSummary {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
/** Concrete product, code, or configuration changes proposed by the model. */
|
||||||
|
proposedChanges?: string[];
|
||||||
|
/** Observable pass/fail conditions for the implementation. */
|
||||||
|
acceptanceCriteria?: string[];
|
||||||
suggestedSize: "S" | "M" | "L";
|
suggestedSize: "S" | "M" | "L";
|
||||||
priority?: TaskPriority;
|
priority?: TaskPriority;
|
||||||
suggestedDependencies: string[];
|
suggestedDependencies: string[];
|
||||||
keyDeliverables: string[];
|
keyDeliverables: string[];
|
||||||
|
/** Model-suggested areas the operator can choose for the next refinement question. */
|
||||||
|
suggestedRefinements?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Response from planning endpoints - either a question or the final summary */
|
/** Response from planning endpoints - either a question or the final summary */
|
||||||
|
|||||||
@@ -1113,6 +1113,20 @@ margins so paragraphs and lists do not create unintended gaps.
|
|||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.planning-refine-focus {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-refine-focus legend {
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.planning-confirm-answer {
|
.planning-confirm-answer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ type ViewState =
|
|||||||
| { type: "breakdown"; sessionId: string; originalSubtasks: SubtaskItem[]; subtasks: SubtaskItem[]; dirty: boolean }
|
| { type: "breakdown"; sessionId: string; originalSubtasks: SubtaskItem[]; subtasks: SubtaskItem[]; dirty: boolean }
|
||||||
| { type: "loading" };
|
| { type: "loading" };
|
||||||
|
|
||||||
|
type PlanningGenerationActivity = "initial_plan" | "plan_update" | "question";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FNXC:PlanningMode 2026-07-20-00:00:
|
* FNXC:PlanningMode 2026-07-20-00:00:
|
||||||
* A persisted planning `result` is an evolving running plan, not proof that the interview ended.
|
* A persisted planning `result` is an evolving running plan, not proof that the interview ended.
|
||||||
@@ -191,10 +193,13 @@ function normalizePlanningSummary(summary: PlanningSummary): PlanningSummary {
|
|||||||
...summary,
|
...summary,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
proposedChanges: normalizeStringArray(raw.proposedChanges),
|
||||||
|
acceptanceCriteria: normalizeStringArray(raw.acceptanceCriteria),
|
||||||
suggestedSize: raw.suggestedSize === "S" || raw.suggestedSize === "M" || raw.suggestedSize === "L" ? raw.suggestedSize : "M",
|
suggestedSize: raw.suggestedSize === "S" || raw.suggestedSize === "M" || raw.suggestedSize === "L" ? raw.suggestedSize : "M",
|
||||||
priority: normalizeTaskPriority(summary.priority),
|
priority: normalizeTaskPriority(summary.priority),
|
||||||
suggestedDependencies: normalizeStringArray(raw.suggestedDependencies),
|
suggestedDependencies: normalizeStringArray(raw.suggestedDependencies),
|
||||||
keyDeliverables: normalizeStringArray(raw.keyDeliverables),
|
keyDeliverables: normalizeStringArray(raw.keyDeliverables),
|
||||||
|
suggestedRefinements: normalizeStringArray(raw.suggestedRefinements).slice(0, 3),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +220,10 @@ function normalizeQuestionOptions(question: PlanningQuestion): PlanningQuestion
|
|||||||
option.id.trim().length > 0 &&
|
option.id.trim().length > 0 &&
|
||||||
typeof option.label === "string" &&
|
typeof option.label === "string" &&
|
||||||
option.label.trim().length > 0 &&
|
option.label.trim().length > 0 &&
|
||||||
(option.description === undefined || typeof option.description === "string"),
|
(option.description === undefined || typeof option.description === "string") &&
|
||||||
|
option.isOther !== true &&
|
||||||
|
option.id !== "other" &&
|
||||||
|
option.id !== PLANNING_OTHER_OPTION_ID,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.map((option) => ({
|
.map((option) => ({
|
||||||
@@ -376,6 +384,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
const [loadedSessionTitle, setLoadedSessionTitle] = useState<string | null>(null);
|
const [loadedSessionTitle, setLoadedSessionTitle] = useState<string | null>(null);
|
||||||
const [isRefiningSummary, setIsRefiningSummary] = useState(false);
|
const [isRefiningSummary, setIsRefiningSummary] = useState(false);
|
||||||
const [generationStartTime, setGenerationStartTime] = useState<number | null>(null);
|
const [generationStartTime, setGenerationStartTime] = useState<number | null>(null);
|
||||||
|
const [generationActivity, setGenerationActivity] = useState<PlanningGenerationActivity>("initial_plan");
|
||||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
// Align long-form planning composers with FN-5146's 640px chat convention so
|
// Align long-form planning composers with FN-5146's 640px chat convention so
|
||||||
@@ -430,6 +439,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
draftSessionIdRef is only populated after createPlanningDraft resolves, so it cannot gate concurrent creates while a request is in flight. This synchronous sentinel flips true before the await and gates all subsequent debounce fires, collapsing the create path to exactly one call. Cleared on failure so a later keystroke can retry.
|
draftSessionIdRef is only populated after createPlanningDraft resolves, so it cannot gate concurrent creates while a request is in flight. This synchronous sentinel flips true before the await and gates all subsequent debounce fires, collapsing the create path to exactly one call. Cleared on failure so a later keystroke can retry.
|
||||||
*/
|
*/
|
||||||
const draftCreateInFlightRef = useRef(false);
|
const draftCreateInFlightRef = useRef(false);
|
||||||
|
const draftCreatePromiseRef = useRef<Promise<{ sessionId: string; title: string }> | null>(null);
|
||||||
const draftDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const draftDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
// Tracks resumeSessionId values the user has explicitly dismissed (via "New
|
// Tracks resumeSessionId values the user has explicitly dismissed (via "New
|
||||||
// Session"). Without this, the resume effect re-fires on every callback
|
// Session"). Without this, the resume effect re-fires on every callback
|
||||||
@@ -549,6 +559,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
// FNXC:PlanningModeMobile 2026-07-20-10:30: Empty mobile state opens the composer because no saved destination exists; once sessions exist, every compact detail surface gets this single Back-to-list escape.
|
// FNXC:PlanningModeMobile 2026-07-20-10:30: Empty mobile state opens the composer because no saved destination exists; once sessions exist, every compact detail surface gets this single Back-to-list escape.
|
||||||
const canReturnToSessionList = isCompactInterview && mobileShowDetail && planningSessions.length > 0;
|
const canReturnToSessionList = isCompactInterview && mobileShowDetail && planningSessions.length > 0;
|
||||||
const [refineFocus, setRefineFocus] = useState("");
|
const [refineFocus, setRefineFocus] = useState("");
|
||||||
|
const [customRefineFocus, setCustomRefineFocus] = useState<string | null>(null);
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const { pushNav } = useNavigationHistoryContext();
|
const { pushNav } = useNavigationHistoryContext();
|
||||||
|
|
||||||
@@ -726,7 +737,22 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
const session = await fetchAiSession(sessionId);
|
const session = await fetchAiSession(sessionId);
|
||||||
if (cancelled || !session) return;
|
if (cancelled || !session) return;
|
||||||
if (currentSessionIdRef.current !== sessionId) return;
|
if (currentSessionIdRef.current !== sessionId) return;
|
||||||
if (session.status === "awaiting_input" && session.currentQuestion) {
|
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.
|
||||||
|
resetPlanningAutoRetryBudget();
|
||||||
|
const history = parseConversationHistory(session.conversationHistory);
|
||||||
|
const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary);
|
||||||
|
conversationHistoryRef.current = history;
|
||||||
|
runningSummaryRef.current = summary;
|
||||||
|
setConversationHistory(history);
|
||||||
|
setResponseHistory(history
|
||||||
|
.map((entry) => entry.response)
|
||||||
|
.filter((response): response is QuestionResponse => Boolean(response && typeof response === "object" && !Array.isArray(response))));
|
||||||
|
setRunningSummary(summary);
|
||||||
|
setView({ type: "plan_review", session: { sessionId, currentQuestion: null, summary }, summary });
|
||||||
|
setStreamingOutput("");
|
||||||
|
} else if (session.status === "awaiting_input" && session.currentQuestion) {
|
||||||
/*
|
/*
|
||||||
FNXC:PlanningTurnReconciliation 2026-07-20-10:36:
|
FNXC:PlanningTurnReconciliation 2026-07-20-10:36:
|
||||||
Missed SSE recovery must hydrate the server's entire interview turn together. Keeping
|
Missed SSE recovery must hydrate the server's entire interview turn together. Keeping
|
||||||
@@ -1225,6 +1251,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
resetPlanningAutoRetryBudget();
|
resetPlanningAutoRetryBudget();
|
||||||
setIsRefiningSummary(false);
|
setIsRefiningSummary(false);
|
||||||
refineSummaryInFlightRef.current = false;
|
refineSummaryInFlightRef.current = false;
|
||||||
|
setGenerationActivity("initial_plan");
|
||||||
|
savePlanningDescription(startedPlan, projectId);
|
||||||
setView({ type: "loading" });
|
setView({ type: "loading" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1234,13 +1262,40 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
? { planningModelProvider, planningModelId, thinkingLevel: planningThinkingLevel || undefined }
|
? { planningModelProvider, planningModelId, thinkingLevel: planningThinkingLevel || undefined }
|
||||||
: (planningThinkingLevel ? { thinkingLevel: planningThinkingLevel } : undefined);
|
: (planningThinkingLevel ? { thinkingLevel: planningThinkingLevel } : undefined);
|
||||||
|
|
||||||
const draftSessionId = draftSessionIdRef.current;
|
let draftSessionId = draftSessionIdRef.current;
|
||||||
|
if (!draftSessionId) {
|
||||||
|
if (draftDebounceRef.current) {
|
||||||
|
clearTimeout(draftDebounceRef.current);
|
||||||
|
draftDebounceRef.current = null;
|
||||||
|
}
|
||||||
|
const draftPromise = draftCreatePromiseRef.current ?? createPlanningDraft(startedPlan, projectId, modelOverride);
|
||||||
|
draftCreatePromiseRef.current = draftPromise;
|
||||||
|
draftCreateInFlightRef.current = true;
|
||||||
|
const draft = await draftPromise;
|
||||||
|
draftSessionId = draft.sessionId;
|
||||||
|
draftSessionIdRef.current = draft.sessionId;
|
||||||
|
draftCreatePromiseRef.current = null;
|
||||||
|
setPlanningSessions((previous) => dedupeSessionsById([{
|
||||||
|
id: draft.sessionId,
|
||||||
|
type: "planning",
|
||||||
|
status: "draft",
|
||||||
|
title: draft.title,
|
||||||
|
preview: startedPlan.length > 80 ? `${startedPlan.slice(0, 79).trimEnd()}…` : startedPlan,
|
||||||
|
projectId: projectId ?? null,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
archived: false,
|
||||||
|
}, ...previous]));
|
||||||
|
setSelectedSessionId(draft.sessionId);
|
||||||
|
}
|
||||||
|
// Persist the durable handle before starting generation so a refresh during
|
||||||
|
// the start request can reopen the draft/generating row in either draft path.
|
||||||
|
savePlanningActiveSession(draftSessionId, projectId);
|
||||||
const { sessionId } = await startPlanningStreaming(
|
const { sessionId } = await startPlanningStreaming(
|
||||||
startedPlan,
|
startedPlan,
|
||||||
projectId,
|
projectId,
|
||||||
modelOverride,
|
modelOverride,
|
||||||
{ clarificationEnabled: true, ...(workflowId ? { workflowId } : {}) },
|
{ clarificationEnabled: true, ...(workflowId ? { workflowId } : {}) },
|
||||||
draftSessionId ?? undefined,
|
draftSessionId,
|
||||||
);
|
);
|
||||||
draftSessionIdRef.current = null;
|
draftSessionIdRef.current = null;
|
||||||
currentSessionIdRef.current = sessionId;
|
currentSessionIdRef.current = sessionId;
|
||||||
@@ -1252,6 +1307,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
connectToPlanningStream(sessionId);
|
connectToPlanningStream(sessionId);
|
||||||
setResponseHistory([]);
|
setResponseHistory([]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
draftCreatePromiseRef.current = null;
|
||||||
|
draftCreateInFlightRef.current = false;
|
||||||
setError(getErrorMessage(err) || t("planning.failedStartSession", "Failed to start planning session"));
|
setError(getErrorMessage(err) || t("planning.failedStartSession", "Failed to start planning session"));
|
||||||
setView({ type: "initial" });
|
setView({ type: "initial" });
|
||||||
currentSessionIdRef.current = null;
|
currentSessionIdRef.current = null;
|
||||||
@@ -1372,6 +1429,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
// An unavailable payload cannot provide a safe copy target.
|
// An unavailable payload cannot provide a safe copy target.
|
||||||
}
|
}
|
||||||
setActivePlanPrompt(typeof inputPayload?.initialPlan === "string" ? inputPayload.initialPlan : "");
|
setActivePlanPrompt(typeof inputPayload?.initialPlan === "string" ? inputPayload.initialPlan : "");
|
||||||
|
if (inputPayload?.generationPurpose === "plan_update" || inputPayload?.generationPurpose === "question" || inputPayload?.generationPurpose === "initial_plan") {
|
||||||
|
setGenerationActivity(inputPayload.generationPurpose);
|
||||||
|
} else if (session.status === "generating") {
|
||||||
|
setGenerationActivity("initial_plan");
|
||||||
|
}
|
||||||
const parsedHistory = parseConversationHistory(session.conversationHistory);
|
const parsedHistory = parseConversationHistory(session.conversationHistory);
|
||||||
setConversationHistory(parsedHistory);
|
setConversationHistory(parsedHistory);
|
||||||
setResponseHistory(
|
setResponseHistory(
|
||||||
@@ -1659,6 +1721,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
streamConnectionRef.current?.close();
|
streamConnectionRef.current?.close();
|
||||||
streamConnectionRef.current = null;
|
streamConnectionRef.current = null;
|
||||||
draftSessionIdRef.current = null;
|
draftSessionIdRef.current = null;
|
||||||
|
draftCreatePromiseRef.current = null;
|
||||||
|
draftCreateInFlightRef.current = false;
|
||||||
if (draftDebounceRef.current) {
|
if (draftDebounceRef.current) {
|
||||||
clearTimeout(draftDebounceRef.current);
|
clearTimeout(draftDebounceRef.current);
|
||||||
draftDebounceRef.current = null;
|
draftDebounceRef.current = null;
|
||||||
@@ -2055,6 +2119,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
conversationHistoryRef.current = optimisticHistory;
|
conversationHistoryRef.current = optimisticHistory;
|
||||||
setConversationHistory(optimisticHistory);
|
setConversationHistory(optimisticHistory);
|
||||||
resetPlanningAutoRetryBudget();
|
resetPlanningAutoRetryBudget();
|
||||||
|
setGenerationActivity("plan_update");
|
||||||
setView({ type: "loading" });
|
setView({ type: "loading" });
|
||||||
setStreamingOutput(""); // Clear old thinking output when entering loading state
|
setStreamingOutput(""); // Clear old thinking output when entering loading state
|
||||||
liveGenerationSessionIdRef.current = sessionId;
|
liveGenerationSessionIdRef.current = sessionId;
|
||||||
@@ -2171,10 +2236,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
const handleRefineFromPlan = useCallback(async () => {
|
const handleRefineFromPlan = useCallback(async () => {
|
||||||
if (view.type !== "plan_review") return;
|
if (view.type !== "plan_review") return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setGenerationActivity("question");
|
||||||
setView({ type: "loading" });
|
setView({ type: "loading" });
|
||||||
try {
|
try {
|
||||||
await respondToPlanning(view.session.sessionId, { refine: true, ...(refineFocus.trim() ? { focus: refineFocus.trim() } : {}) }, projectId);
|
await respondToPlanning(view.session.sessionId, { refine: true, ...(refineFocus.trim() ? { focus: refineFocus.trim() } : {}) }, projectId);
|
||||||
setRefineFocus("");
|
setRefineFocus("");
|
||||||
|
setCustomRefineFocus(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(getErrorMessage(err) || t("planning.failedSubmitResponse", "Failed to refine plan"));
|
setError(getErrorMessage(err) || t("planning.failedSubmitResponse", "Failed to refine plan"));
|
||||||
setView({ type: "plan_review", session: view.session, summary: view.summary });
|
setView({ type: "plan_review", session: view.session, summary: view.summary });
|
||||||
@@ -2536,7 +2603,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
<Sparkles size={32} className="icon-triage-lg" />
|
<Sparkles size={32} className="icon-triage-lg" />
|
||||||
<h4>{t("planning.initialHeading", "Transform your idea into a detailed task")}</h4>
|
<h4>{t("planning.initialHeading", "Transform your idea into a detailed task")}</h4>
|
||||||
<p className="text-muted">
|
<p className="text-muted">
|
||||||
{t("planning.initialSubheading", "Describe what you want to build in plain language. The AI will ask clarifying questions and help you structure a well-defined task.")}
|
{t("planning.initialSubheading", "Describe what you want to build in plain language. The AI will generate an initial plan, then you can refine or validate it.")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2571,8 +2638,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
: (planningThinkingLevel ? { thinkingLevel: planningThinkingLevel } : undefined);
|
: (planningThinkingLevel ? { thinkingLevel: planningThinkingLevel } : undefined);
|
||||||
// FNXC:PlanningMode 2026-07-01-00:00: mark in-flight synchronously so debounce fires during the round-trip don't spawn duplicate drafts.
|
// FNXC:PlanningMode 2026-07-01-00:00: mark in-flight synchronously so debounce fires during the round-trip don't spawn duplicate drafts.
|
||||||
draftCreateInFlightRef.current = true;
|
draftCreateInFlightRef.current = true;
|
||||||
void createPlanningDraft(content, projectId, modelOverride)
|
const draftPromise = createPlanningDraft(content, projectId, modelOverride);
|
||||||
|
draftCreatePromiseRef.current = draftPromise;
|
||||||
|
void draftPromise
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
if (draftCreatePromiseRef.current === draftPromise) {
|
||||||
|
draftCreatePromiseRef.current = null;
|
||||||
|
}
|
||||||
draftSessionIdRef.current = response.sessionId;
|
draftSessionIdRef.current = response.sessionId;
|
||||||
setPlanningSessions((prev) => {
|
setPlanningSessions((prev) => {
|
||||||
const draft: AiSessionSummary = {
|
const draft: AiSessionSummary = {
|
||||||
@@ -2593,6 +2665,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
// best-effort; clear the in-flight sentinel so a
|
// best-effort; clear the in-flight sentinel so a
|
||||||
// later keystroke can retry creating the draft.
|
// later keystroke can retry creating the draft.
|
||||||
draftCreateInFlightRef.current = false;
|
draftCreateInFlightRef.current = false;
|
||||||
|
draftCreatePromiseRef.current = null;
|
||||||
});
|
});
|
||||||
}, 300);
|
}, 300);
|
||||||
}}
|
}}
|
||||||
@@ -2718,9 +2791,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
attempt: autoRetryAttempt,
|
attempt: autoRetryAttempt,
|
||||||
max: MAX_PLANNING_AUTO_RETRIES,
|
max: MAX_PLANNING_AUTO_RETRIES,
|
||||||
})
|
})
|
||||||
: streamingOutput
|
: generationActivity === "plan_update"
|
||||||
? t("planning.aiThinking", "AI is thinking...")
|
? t("planning.updatingPlan", "Updating plan…")
|
||||||
: t("planning.generatingQuestion", "Generating next question...")}
|
: generationActivity === "question"
|
||||||
|
? t("planning.generatingQuestion", "Generating next question…")
|
||||||
|
: t("planning.generatingInitialPlan", "Generating initial plan…")}
|
||||||
</p>
|
</p>
|
||||||
{generationStartTime && (
|
{generationStartTime && (
|
||||||
<div className="planning-elapsed">{t("planning.thinkingElapsed", "Thinking… ({{seconds}}s)", { seconds: elapsedSeconds })}</div>
|
<div className="planning-elapsed">{t("planning.thinkingElapsed", "Thinking… ({{seconds}}s)", { seconds: elapsedSeconds })}</div>
|
||||||
@@ -2803,11 +2878,73 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
<div className="planning-view-scroll planning-summary-scroll">
|
<div className="planning-view-scroll planning-summary-scroll">
|
||||||
<h4>{view.summary.title}</h4>
|
<h4>{view.summary.title}</h4>
|
||||||
<p>{view.summary.description}</p>
|
<p>{view.summary.description}</p>
|
||||||
|
{(view.summary.proposedChanges?.length ?? 0) > 0 && (
|
||||||
|
<section>
|
||||||
|
<h5>{t("planning.proposedChanges", "What to change")}</h5>
|
||||||
|
<ul>{view.summary.proposedChanges!.map((item) => <li key={item}>{item}</li>)}</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{(view.summary.acceptanceCriteria?.length ?? 0) > 0 && (
|
||||||
|
<section>
|
||||||
|
<h5>{t("planning.acceptanceCriteria", "Acceptance criteria")}</h5>
|
||||||
|
<ul>{view.summary.acceptanceCriteria!.map((item) => <li key={item}>{item}</li>)}</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
{view.summary.keyDeliverables.length > 0 && <ul>{view.summary.keyDeliverables.map((item) => <li key={item}>{item}</li>)}</ul>}
|
{view.summary.keyDeliverables.length > 0 && <ul>{view.summary.keyDeliverables.map((item) => <li key={item}>{item}</li>)}</ul>}
|
||||||
<label htmlFor="planning-refine-focus">{t("planning.refineFocus", "Focus the next question (optional)")}</label>
|
<fieldset className="planning-refine-focus" data-testid="planning-refine-focus">
|
||||||
<input id="planning-refine-focus" data-testid="planning-refine-focus" className="input" value={refineFocus} onChange={(event) => setRefineFocus(event.target.value)} placeholder={t("planning.refineFocusPlaceholder", "For example: security, real-time updates, or a specific question")}/>
|
<legend>{t("planning.refineFocus", "Focus the next question")}</legend>
|
||||||
|
<div className="planning-radio-group" role="radiogroup">
|
||||||
|
{(view.summary.suggestedRefinements ?? []).map((focus) => (
|
||||||
|
<label key={focus} className="planning-option planning-option--radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="planning-refine-focus"
|
||||||
|
value={focus}
|
||||||
|
checked={customRefineFocus === null && refineFocus === focus}
|
||||||
|
onChange={() => {
|
||||||
|
setCustomRefineFocus(null);
|
||||||
|
setRefineFocus(focus);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="planning-option-label">{focus}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<label className="planning-option planning-option--radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="planning-refine-focus"
|
||||||
|
value={PLANNING_OTHER_OPTION_ID}
|
||||||
|
checked={customRefineFocus !== null}
|
||||||
|
onChange={() => {
|
||||||
|
setCustomRefineFocus("");
|
||||||
|
setRefineFocus("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="planning-option-label">{t("planning.writeOwnFocus", "Write your own focus")}</span>
|
||||||
|
</label>
|
||||||
|
{customRefineFocus !== null && (
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
autoFocus
|
||||||
|
value={customRefineFocus}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCustomRefineFocus(event.target.value);
|
||||||
|
setRefineFocus(event.target.value);
|
||||||
|
}}
|
||||||
|
placeholder={t("planning.refineFocusPlaceholder", "Describe what the next question should focus on")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
<div className="planning-summary-actions">
|
<div className="planning-summary-actions">
|
||||||
<button type="button" className="btn" onClick={() => void handleRefineFromPlan()}>{t("planning.refine", "Refine")}</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={refineFocus.trim().length === 0}
|
||||||
|
onClick={() => void handleRefineFromPlan()}
|
||||||
|
>
|
||||||
|
{t("planning.refine", "Refine")}
|
||||||
|
</button>
|
||||||
<button type="button" className="btn btn-primary" onClick={() => void handleValidatePlan()}>{t("planning.validatePlan", "Validate")}</button>
|
<button type="button" className="btn btn-primary" onClick={() => void handleValidatePlan()}>{t("planning.validatePlan", "Validate")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { PlanningModeModal } from "../PlanningModeModal";
|
import { PlanningModeModal } from "../PlanningModeModal";
|
||||||
import { mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers";
|
import { mockCreatePlanningDraft, mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockStartPlanningStreaming, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers";
|
||||||
|
|
||||||
|
const mockViewportMode = vi.hoisted(() => vi.fn(() => "desktop" as "desktop" | "mobile"));
|
||||||
|
|
||||||
vi.mock("../../hooks/useToast", () => ({ useOptionalToast: () => null, useToast: () => ({ addToast: vi.fn(), removeToast: vi.fn(), toasts: [] }) }));
|
vi.mock("../../hooks/useToast", () => ({ useOptionalToast: () => null, useToast: () => ({ addToast: vi.fn(), removeToast: vi.fn(), toasts: [] }) }));
|
||||||
vi.mock("../../hooks/useNavigationHistory", () => ({ useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }) }));
|
vi.mock("../../hooks/useNavigationHistory", () => ({ useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }) }));
|
||||||
vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px)", isFullScreenSheetViewport: () => false, isShortViewport: () => false, getViewportMode: () => "desktop", isMobileViewport: () => false, useViewportMode: () => "desktop" }));
|
vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px)", isFullScreenSheetViewport: () => false, isShortViewport: () => false, getViewportMode: () => mockViewportMode(), isMobileViewport: () => mockViewportMode() === "mobile", useViewportMode: () => mockViewportMode() }));
|
||||||
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: () => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false }) }));
|
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: () => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false }) }));
|
||||||
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: vi.fn().mockResolvedValue(true) }) }));
|
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: vi.fn().mockResolvedValue(true) }) }));
|
||||||
vi.mock("../../api", () => {
|
vi.mock("../../api", () => {
|
||||||
@@ -14,32 +16,91 @@ vi.mock("../../api", () => {
|
|||||||
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args), fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
|
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args), fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
|
||||||
respondToPlanning: (...args: unknown[]) => mockRespondToPlanning(...args), validatePlanningSession: (...args: unknown[]) => mockValidatePlanningSession(...args), createTaskFromPlanning: (...args: unknown[]) => mockCreateTaskFromPlanning(...args),
|
respondToPlanning: (...args: unknown[]) => mockRespondToPlanning(...args), validatePlanningSession: (...args: unknown[]) => mockValidatePlanningSession(...args), createTaskFromPlanning: (...args: unknown[]) => mockCreateTaskFromPlanning(...args),
|
||||||
fetchSettings: fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), fetchGlobalSettings: fn().mockResolvedValue({}), fetchModels: fn().mockResolvedValue([]), fetchWorkflowSteps: fn().mockResolvedValue([]), fetchBoardWorkflows: fn().mockResolvedValue({ workflows: [] }),
|
fetchSettings: fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), fetchGlobalSettings: fn().mockResolvedValue({}), fetchModels: fn().mockResolvedValue([]), fetchWorkflowSteps: fn().mockResolvedValue([]), fetchBoardWorkflows: fn().mockResolvedValue({ workflows: [] }),
|
||||||
startPlanning: fn(), startPlanningStreaming: fn(), createPlanningDraft: fn(), connectPlanningStream: fn(), rewindPlanningSession: fn(), retryPlanningSession: fn(), cancelPlanning: fn(), stopPlanningGeneration: fn(), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: () => [], acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message,
|
startPlanning: fn(), startPlanningStreaming: (...args: unknown[]) => mockStartPlanningStreaming(...args), createPlanningDraft: (...args: unknown[]) => mockCreatePlanningDraft(...args), connectPlanningStream: fn(), rewindPlanningSession: fn(), retryPlanningSession: fn(), cancelPlanning: fn(), stopPlanningGeneration: fn(), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: () => [], acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const base = { id: "session-1", title: "Secure plan", projectId: "project-1", updatedAt: new Date().toISOString(), archived: false, conversationHistory: "[]", thinkingOutput: "" };
|
const base = { id: "session-1", title: "Secure plan", projectId: "project-1", updatedAt: new Date().toISOString(), archived: false, conversationHistory: "[]", thinkingOutput: "" };
|
||||||
function renderSession(session: Record<string, unknown>) { return render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />); }
|
function renderSession(session: Record<string, unknown>) { return render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />); }
|
||||||
|
const summaryWithRefinements = {
|
||||||
|
...mockSummary,
|
||||||
|
proposedChanges: ["Change the authentication API", "Add durable session recovery"],
|
||||||
|
acceptanceCriteria: ["Refresh preserves generation", "The plan is reviewable before questions"],
|
||||||
|
suggestedRefinements: ["Security boundaries", "Rollout strategy", "Failure recovery"],
|
||||||
|
};
|
||||||
|
|
||||||
describe("PlanningModeModal sequential flow", () => {
|
describe("PlanningModeModal sequential flow", () => {
|
||||||
beforeEach(() => { vi.clearAllMocks(); mockFetchAiSessions.mockResolvedValue([]); mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true }); mockCreateTaskFromPlanning.mockResolvedValue({ id: "FN-8442" }); });
|
beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); mockViewportMode.mockReturnValue("desktop"); mockFetchAiSessions.mockResolvedValue([]); mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-1", title: "Secure plan" }); mockStartPlanningStreaming.mockResolvedValue({ sessionId: "draft-1" }); mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true }); mockCreateTaskFromPlanning.mockResolvedValue({ id: "FN-8442" }); });
|
||||||
|
it("persists a draft before generation and immediately shows initial-plan progress", async () => {
|
||||||
|
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" />);
|
||||||
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), { target: { value: "Build secure accounts" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Start Planning" }));
|
||||||
|
expect(screen.getByText("Generating initial plan…")).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(mockCreatePlanningDraft).toHaveBeenCalledWith("Build secure accounts", "project-1", undefined));
|
||||||
|
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 () => {
|
it("renders plan review after an answered turn without retired interview panes", async () => {
|
||||||
mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(mockSummary), inputPayload: JSON.stringify({ initialPlan: "Secure accounts" }) });
|
mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: JSON.stringify({ initialPlan: "Secure accounts" }) });
|
||||||
renderSession({});
|
renderSession({});
|
||||||
expect(await screen.findByTestId("planning-plan-review")).toHaveTextContent("Build authentication system");
|
expect(await screen.findByTestId("planning-plan-review")).toHaveTextContent("Build authentication system");
|
||||||
expect(screen.getByTestId("planning-refine-focus")).toBeInTheDocument();
|
expect(screen.getByText("What to change")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Refine" })).toBeInTheDocument();
|
expect(screen.getByText("Change the authentication API")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Acceptance criteria")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Refresh preserves generation")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("radio", { name: "Security boundaries" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("radio", { name: "Rollout strategy" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("radio", { name: "Failure recovery" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("radio", { name: "Write your own focus" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Refine" })).toBeDisabled();
|
||||||
expect(screen.getByRole("button", { name: "Validate" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Validate" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Sessions" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Sessions" })).toBeInTheDocument();
|
||||||
expect(document.querySelector(".planning-running-plan")).toBeNull();
|
expect(document.querySelector(".planning-running-plan")).toBeNull();
|
||||||
expect(document.querySelector(".planning-answered-history")).toBeNull();
|
expect(document.querySelector(".planning-answered-history")).toBeNull();
|
||||||
});
|
});
|
||||||
it("sends trimmed focus only when Refine requests the next question", async () => {
|
it("sends a model-suggested focus when Refine requests the next question", async () => {
|
||||||
mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(mockSummary), inputPayload: "{}" });
|
mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: "{}" });
|
||||||
mockRespondToPlanning.mockResolvedValue({}); renderSession({});
|
mockRespondToPlanning.mockResolvedValue({}); renderSession({});
|
||||||
fireEvent.change(await screen.findByTestId("planning-refine-focus"), { target: { value: " security " } });
|
fireEvent.click(await screen.findByRole("radio", { name: "Security boundaries" }));
|
||||||
|
expect(screen.getByRole("button", { name: "Refine" })).toBeEnabled();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Refine" }));
|
fireEvent.click(screen.getByRole("button", { name: "Refine" }));
|
||||||
await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith("session-1", { refine: true, focus: "security" }, "project-1"));
|
await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith("session-1", { refine: true, focus: "Security boundaries" }, "project-1"));
|
||||||
|
expect(screen.getByText("Generating next question…")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
it("renders exactly one write-your-own choice for normalized select questions", async () => {
|
||||||
|
mockFetchAiSession.mockResolvedValue({
|
||||||
|
...base,
|
||||||
|
status: "awaiting_input",
|
||||||
|
currentQuestion: JSON.stringify({
|
||||||
|
id: "q-1",
|
||||||
|
type: "single_select",
|
||||||
|
question: "What should come next?",
|
||||||
|
options: [
|
||||||
|
{ id: "security", label: "Security" },
|
||||||
|
{ id: "rollout", label: "Rollout" },
|
||||||
|
{ id: "other", label: "Other (write your own)", isOther: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
result: JSON.stringify(summaryWithRefinements),
|
||||||
|
inputPayload: "{}",
|
||||||
|
});
|
||||||
|
renderSession({});
|
||||||
|
expect(await screen.findByText("What should come next?")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Other (write your own)")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
it("keeps detailed plan review and refinement choices available on mobile", async () => {
|
||||||
|
mockViewportMode.mockReturnValue("mobile");
|
||||||
|
mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: "{}" });
|
||||||
|
renderSession({});
|
||||||
|
expect(await screen.findByText("What to change")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Acceptance criteria")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("radio", { name: "Security boundaries" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("radio", { name: "Write your own focus" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
it("restores a validated unlinked session to create-only retry", async () => {
|
it("restores a validated unlinked session to create-only retry", async () => {
|
||||||
mockFetchAiSession.mockResolvedValue({ ...base, status: "complete", currentQuestion: null, result: JSON.stringify(mockSummary), inputPayload: JSON.stringify({ validated: true }) });
|
mockFetchAiSession.mockResolvedValue({ ...base, status: "complete", currentQuestion: null, result: JSON.stringify(mockSummary), inputPayload: JSON.stringify({ validated: true }) });
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ function completePayload(): string {
|
|||||||
data: {
|
data: {
|
||||||
title: "Secure account recovery delivery",
|
title: "Secure account recovery delivery",
|
||||||
description: "Build a reviewed recovery workflow with audit coverage.",
|
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"],
|
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
|
||||||
|
suggestedRefinements: ["Security boundaries", "Rollout strategy", "Failure recovery"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -108,6 +111,21 @@ describe("reactive Planning Mode question contract", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("deduplicates a model-authored Other option before appending the canonical one", () => {
|
||||||
|
const question = normalizePlanningQuestion({
|
||||||
|
id: "security",
|
||||||
|
type: "single_select",
|
||||||
|
question: "What matters most?",
|
||||||
|
options: [
|
||||||
|
{ id: "safe", label: "Safe defaults" },
|
||||||
|
{ id: "fast", label: "Fast delivery" },
|
||||||
|
{ id: "other", label: "Other (write your own)" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(question.options?.filter((option) => option.id === "other" || option.isOther)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("upgrades legacy text questions so every question has alternatives and Other", () => {
|
it("upgrades legacy text questions so every question has alternatives and Other", () => {
|
||||||
const question = normalizePlanningQuestion({ type: "text", question: "What matters next?", options: [{ id: "bad" }] });
|
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).toEqual(expect.objectContaining({ type: "single_select", question: "What matters next?" }));
|
||||||
@@ -122,7 +140,7 @@ describe("reactive Planning Mode question contract", () => {
|
|||||||
Other steering, and explicit-only validation invariant rather than only testing normalization.
|
Other steering, and explicit-only validation invariant rather than only testing normalization.
|
||||||
*/
|
*/
|
||||||
it("delivers planning-clarification metadata that can reopen the exact session", async () => {
|
it("delivers planning-clarification metadata that can reopen the exact session", async () => {
|
||||||
installScriptedAgent([payload(FIRST_QUESTION)]);
|
installScriptedAgent([completePayload(), payload(FIRST_QUESTION)]);
|
||||||
let resolveDelivered: ((message: Record<string, unknown>) => void) | undefined;
|
let resolveDelivered: ((message: Record<string, unknown>) => void) | undefined;
|
||||||
const delivered = new Promise<Record<string, unknown>>((resolve) => {
|
const delivered = new Promise<Record<string, unknown>>((resolve) => {
|
||||||
resolveDelivered = resolve;
|
resolveDelivered = resolve;
|
||||||
@@ -142,7 +160,15 @@ describe("reactive Planning Mode question contract", () => {
|
|||||||
{ clarificationEnabled: true, messageStore: messageStore as never },
|
{ clarificationEnabled: true, messageStore: messageStore as never },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const initialPlanReady = new Promise<void>((resolve) => {
|
||||||
|
planningStreamManager.subscribe(sessionId, (event) => {
|
||||||
|
if (event.type === "summary") resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
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;
|
const message = await delivered;
|
||||||
|
|
||||||
expect(message).toMatchObject({
|
expect(message).toMatchObject({
|
||||||
@@ -156,7 +182,7 @@ describe("reactive Planning Mode question contract", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the streaming agent turn non-terminal after complete, persists its running plan, and validates only on user action", async () => {
|
it("generates a durable initial plan before any question and validates only on user action", async () => {
|
||||||
const prompts = installScriptedAgent([
|
const prompts = installScriptedAgent([
|
||||||
completePayload(),
|
completePayload(),
|
||||||
payload(SECOND_QUESTION),
|
payload(SECOND_QUESTION),
|
||||||
@@ -172,33 +198,31 @@ describe("reactive Planning Mode question contract", () => {
|
|||||||
{ clarificationEnabled: true },
|
{ clarificationEnabled: true },
|
||||||
);
|
);
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
const firstQuestion = new Promise<typeof SECOND_QUESTION>((resolve) => {
|
const initialPlanReady = new Promise<void>((resolve) => {
|
||||||
planningStreamManager.subscribe(sessionId, (event) => {
|
planningStreamManager.subscribe(sessionId, (event) => {
|
||||||
events.push(event.type);
|
events.push(event.type);
|
||||||
if (event.type === "question") resolve(event.data as typeof SECOND_QUESTION);
|
if (event.type === "summary") resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
||||||
const fallbackQuestion = await firstQuestion;
|
await initialPlanReady;
|
||||||
|
|
||||||
// The streamed processAgentTurn seam must coerce generic complete output into a question.
|
|
||||||
expect(fallbackQuestion.id).not.toBe("complete");
|
|
||||||
expect((await getSession(sessionId))?.summary).toMatchObject({
|
expect((await getSession(sessionId))?.summary).toMatchObject({
|
||||||
title: "Secure account recovery delivery",
|
title: "Secure account recovery delivery",
|
||||||
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
|
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
|
||||||
|
suggestedRefinements: ["Security boundaries", "Rollout strategy", "Failure 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);
|
|
||||||
// FNXC:PlanningMode 2026-07-20-20:15: Answers update the plan only; the next
|
|
||||||
// question is user-triggered by refine so a focus can steer it.
|
|
||||||
expect(next).toEqual(expect.objectContaining({ type: "complete" }));
|
|
||||||
expect((await getSession(sessionId))?.currentQuestion).toBeUndefined();
|
expect((await getSession(sessionId))?.currentQuestion).toBeUndefined();
|
||||||
expect(prompts.at(-1)).toContain("Ask about audit-log security before anything else.");
|
expect((await getSession(sessionId))?.validated).toBe(false);
|
||||||
expect(events.filter((type) => type === "summary")).toHaveLength(2);
|
expect(events).not.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);
|
await validateSession(sessionId);
|
||||||
expect(await getSession(sessionId)).toMatchObject({ validated: true, currentQuestion: undefined });
|
expect(await getSession(sessionId)).toMatchObject({ validated: true, currentQuestion: undefined });
|
||||||
@@ -262,7 +286,7 @@ describe("reactive Planning Mode question contract", () => {
|
|||||||
expect(created.summary.description).not.toBe(created.firstQuestion.question);
|
expect(created.summary.description).not.toBe(created.firstQuestion.question);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses a model-authored initial plan on the streaming first turn before its question event", async () => {
|
it("uses a model-authored initial plan on the streaming first turn without exposing its question", async () => {
|
||||||
installScriptedAgent([payload({
|
installScriptedAgent([payload({
|
||||||
...FIRST_QUESTION,
|
...FIRST_QUESTION,
|
||||||
runningPlan: {
|
runningPlan: {
|
||||||
@@ -275,22 +299,22 @@ describe("reactive Planning Mode question contract", () => {
|
|||||||
"127.0.0.15", "Build secure account recovery", "/tmp/project", MOCK_TASK_STORE,
|
"127.0.0.15", "Build secure account recovery", "/tmp/project", MOCK_TASK_STORE,
|
||||||
);
|
);
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
const firstQuestion = new Promise<void>((resolve) => {
|
const initialPlanReady = new Promise<void>((resolve) => {
|
||||||
planningStreamManager.subscribe(sessionId, (event) => {
|
planningStreamManager.subscribe(sessionId, (event) => {
|
||||||
events.push(event.type);
|
events.push(event.type);
|
||||||
if (event.type === "question") resolve();
|
if (event.type === "summary") resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
||||||
await firstQuestion;
|
await initialPlanReady;
|
||||||
|
|
||||||
expect((await getSession(sessionId))?.summary).toMatchObject({
|
expect((await getSession(sessionId))?.summary).toMatchObject({
|
||||||
title: "Streaming account recovery plan",
|
title: "Streaming account recovery plan",
|
||||||
description: "Stage a secure recovery flow with observability.",
|
description: "Stage a secure recovery flow with observability.",
|
||||||
keyDeliverables: ["Design recovery token lifecycle", "Test recovery telemetry"],
|
keyDeliverables: ["Design recovery token lifecycle", "Test recovery telemetry"],
|
||||||
});
|
});
|
||||||
expect(events.indexOf("summary")).toBeLessThan(events.indexOf("question"));
|
expect(events).not.toContain("question");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recovers a plan-shaped streaming first turn when the model omits runningPlan", async () => {
|
it("recovers a plan-shaped streaming first turn when the model omits runningPlan", async () => {
|
||||||
@@ -299,15 +323,15 @@ describe("reactive Planning Mode question contract", () => {
|
|||||||
"127.0.0.16", "Build secure account recovery", "/tmp/project", MOCK_TASK_STORE,
|
"127.0.0.16", "Build secure account recovery", "/tmp/project", MOCK_TASK_STORE,
|
||||||
);
|
);
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
const firstQuestion = new Promise<void>((resolve) => {
|
const initialPlanReady = new Promise<void>((resolve) => {
|
||||||
planningStreamManager.subscribe(sessionId, (event) => {
|
planningStreamManager.subscribe(sessionId, (event) => {
|
||||||
events.push(event.type);
|
events.push(event.type);
|
||||||
if (event.type === "question") resolve();
|
if (event.type === "summary") resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
||||||
await firstQuestion;
|
await initialPlanReady;
|
||||||
|
|
||||||
const session = await getSession(sessionId);
|
const session = await getSession(sessionId);
|
||||||
expect(session?.summary).toMatchObject({
|
expect(session?.summary).toMatchObject({
|
||||||
@@ -316,7 +340,7 @@ describe("reactive Planning Mode question contract", () => {
|
|||||||
});
|
});
|
||||||
expect(session?.summary?.description).not.toBe(FIRST_QUESTION.question);
|
expect(session?.summary?.description).not.toBe(FIRST_QUESTION.question);
|
||||||
expect(session?.summary?.keyDeliverables).not.toEqual([FIRST_QUESTION.question]);
|
expect(session?.summary?.keyDeliverables).not.toEqual([FIRST_QUESTION.question]);
|
||||||
expect(events.indexOf("summary")).toBeLessThan(events.indexOf("question"));
|
expect(events).not.toContain("question");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("merges a partial model running-plan update with the prior work product", async () => {
|
it("merges a partial model running-plan update with the prior work product", async () => {
|
||||||
@@ -346,8 +370,11 @@ describe("reactive Planning Mode question contract", () => {
|
|||||||
description: "Deliver a secure recovery experience with a gradual rollout.",
|
description: "Deliver a secure recovery experience with a gradual rollout.",
|
||||||
suggestedSize: "L",
|
suggestedSize: "L",
|
||||||
priority: "high",
|
priority: "high",
|
||||||
|
proposedChanges: ["Change the affected workflow to support: Build secure account recovery"],
|
||||||
|
acceptanceCriteria: ["The requested outcome works end to end for: Build secure account recovery"],
|
||||||
suggestedDependencies: ["Identity service"],
|
suggestedDependencies: ["Identity service"],
|
||||||
keyDeliverables: ["Add recovery token flow", "Test recovery audit events"],
|
keyDeliverables: ["Add recovery token flow", "Test recovery audit events"],
|
||||||
|
suggestedRefinements: ["Scope and user experience", "Technical approach and integration", "Validation and rollout"],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -790,11 +790,10 @@ describe("Planning Mode Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /planning/start-streaming", () => {
|
describe("POST /planning/start-streaming", () => {
|
||||||
it("broadcasts a mandatory question instead of accepting a first-turn completion", async () => {
|
it("broadcasts a reviewable initial plan without an unsolicited question", async () => {
|
||||||
const messages: Array<{ role: string; content: string }> = [];
|
const messages: Array<{ role: string; content: string }> = [];
|
||||||
const responses = [
|
const responses = [
|
||||||
JSON.stringify({ type: "complete", data: { title: "Too early", description: "A plan", keyDeliverables: [] } }),
|
JSON.stringify({ type: "complete", data: { title: "Reporting workflow plan", description: "A concrete reporting plan", proposedChanges: ["Update report generation"], acceptanceCriteria: ["Reports complete successfully"], keyDeliverables: ["Implement report generation"] } }),
|
||||||
JSON.stringify({ type: "question", data: { id: "q-stream-required", type: "text", question: "What risk should the plan address?" } }),
|
|
||||||
];
|
];
|
||||||
let responseIndex = 0;
|
let responseIndex = 0;
|
||||||
__setCreateFnAgent(async () => ({
|
__setCreateFnAgent(async () => ({
|
||||||
@@ -819,10 +818,9 @@ describe("Planning Mode Routes", () => {
|
|||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
planningStreamManager.consumeInitialTurn(res.body.sessionId)!();
|
planningStreamManager.consumeInitialTurn(res.body.sessionId)!();
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
const questions = planningStreamManager.getBufferedEvents(res.body.sessionId, 0)
|
const events = planningStreamManager.getBufferedEvents(res.body.sessionId, 0);
|
||||||
.filter((event) => event.event === "question");
|
expect(events.filter((event) => event.event === "summary")).toHaveLength(1);
|
||||||
expect(questions).toHaveLength(1);
|
expect(events.filter((event) => event.event === "question")).toHaveLength(0);
|
||||||
expect(JSON.parse(questions[0]!.data)).toMatchObject({ id: expect.any(String), type: expect.any(String) });
|
|
||||||
});
|
});
|
||||||
expect(messages).toHaveLength(2);
|
expect(messages).toHaveLength(2);
|
||||||
expect(messages[0]?.content).toContain("Build a detailed reporting workflow");
|
expect(messages[0]?.content).toContain("Build a detailed reporting workflow");
|
||||||
@@ -1299,7 +1297,8 @@ describe("Planning Mode Routes", () => {
|
|||||||
expect(promptCalls).toHaveLength(1);
|
expect(promptCalls).toHaveLength(1);
|
||||||
expect(streamRes.body).toContain("event: thinking");
|
expect(streamRes.body).toContain("event: thinking");
|
||||||
expect(streamRes.body).toContain("live first-turn reasoning");
|
expect(streamRes.body).toContain("live first-turn reasoning");
|
||||||
expect(streamRes.body).toContain("event: question");
|
expect(streamRes.body).toContain("event: summary");
|
||||||
|
expect(streamRes.body).not.toContain("event: question");
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
}
|
}
|
||||||
@@ -1351,7 +1350,8 @@ describe("Planning Mode Routes", () => {
|
|||||||
expect(promptCalls).toHaveLength(1);
|
expect(promptCalls).toHaveLength(1);
|
||||||
expect(streamRes.body).toContain("event: thinking");
|
expect(streamRes.body).toContain("event: thinking");
|
||||||
expect(streamRes.body).toContain("live first-turn reasoning");
|
expect(streamRes.body).toContain("live first-turn reasoning");
|
||||||
expect(streamRes.body).toContain("event: question");
|
expect(streamRes.body).toContain("event: summary");
|
||||||
|
expect(streamRes.body).not.toContain("event: question");
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,11 +240,11 @@ 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.
|
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.
|
||||||
|
|
||||||
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. Never decide that the interview is complete or emit a terminal/complete response. Only the user can validate the plan.
|
Start by producing a concrete initial plan for review without asking a question. 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.
|
||||||
|
|
||||||
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":"...","suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"]}}}. For an answer-update turn respond only with {"type":"complete","data":{"title":"...","description":"...","suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"]}}.
|
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","next focus 3"]}}}. 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","next focus 3"]}}.
|
||||||
|
|
||||||
Every turn must include the running-plan fields: only title, description, suggestedSize, optional priority, suggestedDependencies, and concrete keyDeliverables informed by the idea and answers so far. 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 Validate. Validate serializes this lean plan as plan.md without priority; 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.`;
|
Every turn must include the running-plan fields: only title, description, concrete proposedChanges, observable acceptanceCriteria, suggestedSize, optional priority, suggestedDependencies, concrete keyDeliverables, and exactly three concise suggestedRefinements informed by the idea and answers so far. 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 Validate. Validate 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.`;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:PlanningMode 2026-07-20-14:30:
|
FNXC:PlanningMode 2026-07-20-14:30:
|
||||||
@@ -303,6 +303,7 @@ export const DRAFT_PLACEHOLDER_TITLE = "New planning session";
|
|||||||
*/
|
*/
|
||||||
export interface DraftInputPayload {
|
export interface DraftInputPayload {
|
||||||
initialPlan?: string;
|
initialPlan?: string;
|
||||||
|
generationPurpose?: "initial_plan" | "plan_update" | "question";
|
||||||
clarificationEnabled?: boolean;
|
clarificationEnabled?: boolean;
|
||||||
lastMailboxNotifiedQuestionKey?: string;
|
lastMailboxNotifiedQuestionKey?: string;
|
||||||
modelProvider?: string;
|
modelProvider?: string;
|
||||||
@@ -399,7 +400,7 @@ interface Session {
|
|||||||
claimOwnerToken?: string;
|
claimOwnerToken?: string;
|
||||||
claimStartedAt?: string;
|
claimStartedAt?: string;
|
||||||
/** Whether the current generation must end at plan review rather than a question. */
|
/** Whether the current generation must end at plan review rather than a question. */
|
||||||
generationPurpose?: "plan_update" | "question";
|
generationPurpose?: "initial_plan" | "plan_update" | "question";
|
||||||
/** Last terminal error for retry UX */
|
/** Last terminal error for retry UX */
|
||||||
error?: string;
|
error?: string;
|
||||||
/** AI agent session for real-time interaction */
|
/** AI agent session for real-time interaction */
|
||||||
@@ -506,12 +507,15 @@ export function normalizePlanningSummaryPayload(
|
|||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
proposedChanges: normalizeStringArray(summary.proposedChanges),
|
||||||
|
acceptanceCriteria: normalizeStringArray(summary.acceptanceCriteria),
|
||||||
suggestedSize: summary.suggestedSize === "S" || summary.suggestedSize === "M" || summary.suggestedSize === "L"
|
suggestedSize: summary.suggestedSize === "S" || summary.suggestedSize === "M" || summary.suggestedSize === "L"
|
||||||
? summary.suggestedSize
|
? summary.suggestedSize
|
||||||
: "M",
|
: "M",
|
||||||
priority: isTaskPriority(summary.priority) ? summary.priority : DEFAULT_TASK_PRIORITY,
|
priority: isTaskPriority(summary.priority) ? summary.priority : DEFAULT_TASK_PRIORITY,
|
||||||
suggestedDependencies: normalizeStringArray(summary.suggestedDependencies),
|
suggestedDependencies: normalizeStringArray(summary.suggestedDependencies),
|
||||||
keyDeliverables: normalizeStringArray(summary.keyDeliverables),
|
keyDeliverables: normalizeStringArray(summary.keyDeliverables),
|
||||||
|
suggestedRefinements: normalizeStringArray(summary.suggestedRefinements).slice(0, 3),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,6 +611,7 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
|
|||||||
...(typeof session.clarificationEnabled === "boolean"
|
...(typeof session.clarificationEnabled === "boolean"
|
||||||
? { clarificationEnabled: session.clarificationEnabled }
|
? { clarificationEnabled: session.clarificationEnabled }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(session.generationPurpose ? { generationPurpose: session.generationPurpose } : {}),
|
||||||
...(session.lastMailboxNotifiedQuestionKey ? { lastMailboxNotifiedQuestionKey: session.lastMailboxNotifiedQuestionKey } : {}),
|
...(session.lastMailboxNotifiedQuestionKey ? { lastMailboxNotifiedQuestionKey: session.lastMailboxNotifiedQuestionKey } : {}),
|
||||||
}),
|
}),
|
||||||
conversationHistory: JSON.stringify(session.history),
|
conversationHistory: JSON.stringify(session.history),
|
||||||
@@ -742,6 +747,11 @@ function buildSessionFromRow(row: AiSessionRow): Session {
|
|||||||
clarificationEnabled: typeof payload.clarificationEnabled === "boolean"
|
clarificationEnabled: typeof payload.clarificationEnabled === "boolean"
|
||||||
? payload.clarificationEnabled
|
? payload.clarificationEnabled
|
||||||
: undefined,
|
: undefined,
|
||||||
|
generationPurpose: payload.generationPurpose === "initial_plan"
|
||||||
|
|| payload.generationPurpose === "plan_update"
|
||||||
|
|| payload.generationPurpose === "question"
|
||||||
|
? payload.generationPurpose
|
||||||
|
: undefined,
|
||||||
lastMailboxNotifiedQuestionKey: typeof payload.lastMailboxNotifiedQuestionKey === "string"
|
lastMailboxNotifiedQuestionKey: typeof payload.lastMailboxNotifiedQuestionKey === "string"
|
||||||
? payload.lastMailboxNotifiedQuestionKey
|
? payload.lastMailboxNotifiedQuestionKey
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -1578,7 +1588,8 @@ export async function startExistingSession(
|
|||||||
session.ntfyConfig = runtimeOptions.ntfyConfig;
|
session.ntfyConfig = runtimeOptions.ntfyConfig;
|
||||||
session.messageStore = runtimeOptions.messageStore;
|
session.messageStore = runtimeOptions.messageStore;
|
||||||
}
|
}
|
||||||
persistSession(session, "generating");
|
session.generationPurpose = "initial_plan";
|
||||||
|
await persistSession(session, "generating");
|
||||||
planningStreamManager.registerInitialTurn(sessionId, () => {
|
planningStreamManager.registerInitialTurn(sessionId, () => {
|
||||||
session.pluginRunner = pluginRunner;
|
session.pluginRunner = pluginRunner;
|
||||||
initializeAgent(session, rootDir, store, modelProvider, modelId, session.draftThinkingLevel, promptOverrides, pluginRunner).catch((err) => {
|
initializeAgent(session, rootDir, store, modelProvider, modelId, session.draftThinkingLevel, promptOverrides, pluginRunner).catch((err) => {
|
||||||
@@ -1661,7 +1672,8 @@ export async function createSessionWithAgent(
|
|||||||
};
|
};
|
||||||
|
|
||||||
sessions.set(sessionId, session);
|
sessions.set(sessionId, session);
|
||||||
persistSession(session, "generating");
|
session.generationPurpose = "initial_plan";
|
||||||
|
await persistSession(session, "generating");
|
||||||
|
|
||||||
planningStreamManager.registerInitialTurn(sessionId, () => {
|
planningStreamManager.registerInitialTurn(sessionId, () => {
|
||||||
initializeAgent(
|
initializeAgent(
|
||||||
@@ -1741,7 +1753,7 @@ async function initializeAgent(
|
|||||||
session.updatedAt = new Date();
|
session.updatedAt = new Date();
|
||||||
});
|
});
|
||||||
|
|
||||||
await continueAgentConversation(session, formatInitialPlanRequestForAgent(session.initialPlan));
|
await continueAgentConversation(session, formatInitialRunningPlanRequestForAgent(session.initialPlan));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.name === "AbortError") {
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
return;
|
return;
|
||||||
@@ -2144,6 +2156,18 @@ export function formatInitialPlanRequestForAgent(initialPlan: string): string {
|
|||||||
].join("\n\n");
|
].join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Streaming Planning Mode starts with a reviewable work product, never an unsolicited question. */
|
||||||
|
export function formatInitialRunningPlanRequestForAgent(initialPlan: string): string {
|
||||||
|
return [
|
||||||
|
"Create a concrete initial implementation plan from this operator idea.",
|
||||||
|
"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 exactly three short suggestedRefinements representing the highest-value unresolved areas the operator could explore next.",
|
||||||
|
"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.",
|
||||||
|
"Operator idea:",
|
||||||
|
initialPlan,
|
||||||
|
].join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
function buildFallbackDeliverables(initialPlan: string): string[] {
|
function buildFallbackDeliverables(initialPlan: string): string[] {
|
||||||
const subject = initialPlan.trim() || "the requested work";
|
const subject = initialPlan.trim() || "the requested work";
|
||||||
return [
|
return [
|
||||||
@@ -2173,10 +2197,19 @@ function buildRunningSummary(
|
|||||||
return normalizePlanningSummaryPayload({
|
return normalizePlanningSummaryPayload({
|
||||||
title: previousSummary?.title || `Plan: ${subject.slice(0, 74)}`,
|
title: previousSummary?.title || `Plan: ${subject.slice(0, 74)}`,
|
||||||
description,
|
description,
|
||||||
|
proposedChanges: previousSummary?.proposedChanges?.length
|
||||||
|
? previousSummary.proposedChanges
|
||||||
|
: [`Change the affected workflow to support: ${subject}`],
|
||||||
|
acceptanceCriteria: previousSummary?.acceptanceCriteria?.length
|
||||||
|
? previousSummary.acceptanceCriteria
|
||||||
|
: [`The requested outcome works end to end for: ${subject}`],
|
||||||
suggestedSize: previousSummary?.suggestedSize ?? "M",
|
suggestedSize: previousSummary?.suggestedSize ?? "M",
|
||||||
priority: previousSummary?.priority,
|
priority: previousSummary?.priority,
|
||||||
suggestedDependencies: previousSummary?.suggestedDependencies ?? [],
|
suggestedDependencies: previousSummary?.suggestedDependencies ?? [],
|
||||||
keyDeliverables: previousSummary?.keyDeliverables?.length ? previousSummary.keyDeliverables : buildFallbackDeliverables(subject),
|
keyDeliverables: previousSummary?.keyDeliverables?.length ? previousSummary.keyDeliverables : buildFallbackDeliverables(subject),
|
||||||
|
suggestedRefinements: previousSummary?.suggestedRefinements?.length
|
||||||
|
? previousSummary.suggestedRefinements
|
||||||
|
: ["Scope and user experience", "Technical approach and integration", "Validation and rollout"],
|
||||||
}, { title: `Plan: ${subject}`, description: initialDescription });
|
}, { title: `Plan: ${subject}`, description: initialDescription });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2238,7 +2271,11 @@ export function normalizePlanningQuestion(input: unknown, userInput = ""): Plann
|
|||||||
const normalized: PlanningQuestion = { id: typeof source.id === "string" && source.id.trim() ? source.id : randomUUID(), type, 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() } : {}) };
|
...(typeof source.description === "string" && source.description.trim() ? { description: source.description.trim() } : {}) };
|
||||||
const raw = Array.isArray(source.options) ? source.options : [];
|
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)
|
const alternatives = raw.filter((item): item is Record<string, unknown> => {
|
||||||
|
if (!item || typeof item !== "object") return false;
|
||||||
|
const option = item as Record<string, unknown>;
|
||||||
|
return option.isOther !== true && option.id !== "other" && option.id !== "__other__";
|
||||||
|
})
|
||||||
.slice(0, 2).map((item, index) => ({
|
.slice(0, 2).map((item, index) => ({
|
||||||
id: typeof item.id === "string" && item.id.trim() ? item.id : `option-${index + 1}`,
|
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),
|
label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : fallback.option(index + 1),
|
||||||
@@ -2441,7 +2478,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
|||||||
session.error = undefined;
|
session.error = undefined;
|
||||||
session.lastGeneratedThinking = session.thinkingOutput;
|
session.lastGeneratedThinking = session.thinkingOutput;
|
||||||
session.updatedAt = new Date();
|
session.updatedAt = new Date();
|
||||||
const planUpdate = session.generationPurpose === "plan_update";
|
const planUpdate = session.generationPurpose === "plan_update" || session.generationPurpose === "initial_plan";
|
||||||
session.generationPurpose = undefined;
|
session.generationPurpose = undefined;
|
||||||
/*
|
/*
|
||||||
FNXC:PlanningMode 2026-07-20-15:45:
|
FNXC:PlanningMode 2026-07-20-15:45:
|
||||||
@@ -2450,7 +2487,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
|||||||
triggered by the plan-review Refine action, optionally with operator focus text.
|
triggered by the plan-review Refine action, optionally with operator focus text.
|
||||||
*/
|
*/
|
||||||
session.currentQuestion = planUpdate ? undefined : coerceQuestionResponse(parsed, session);
|
session.currentQuestion = planUpdate ? undefined : coerceQuestionResponse(parsed, session);
|
||||||
persistSession(session, "awaiting_input");
|
await persistSession(session, "awaiting_input");
|
||||||
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
|
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
|
||||||
if (session.currentQuestion) {
|
if (session.currentQuestion) {
|
||||||
void maybeNotifyPlanningAwaitingInput(session, session.currentQuestion, true);
|
void maybeNotifyPlanningAwaitingInput(session, session.currentQuestion, true);
|
||||||
@@ -2806,11 +2843,11 @@ export async function submitResponse(
|
|||||||
throw new InvalidSessionStateError("No active question in session");
|
throw new InvalidSessionStateError("No active question in session");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session.generationPurpose = "question";
|
||||||
session.error = undefined;
|
session.error = undefined;
|
||||||
persistSession(session, "generating");
|
await persistSession(session, "generating");
|
||||||
|
|
||||||
await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store);
|
await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store);
|
||||||
session.generationPurpose = "question";
|
|
||||||
const focus = typeof responses.focus === "string" ? responses.focus.trim() : undefined;
|
const focus = typeof responses.focus === "string" ? responses.focus.trim() : undefined;
|
||||||
const refineMessage = formatRefineRequestForAgent(session.summary, focus);
|
const refineMessage = formatRefineRequestForAgent(session.summary, focus);
|
||||||
await continueAgentConversation(session, refineMessage);
|
await continueAgentConversation(session, refineMessage);
|
||||||
@@ -2851,7 +2888,7 @@ export async function submitResponse(
|
|||||||
*/
|
*/
|
||||||
session.currentQuestion = undefined;
|
session.currentQuestion = undefined;
|
||||||
session.generationPurpose = "plan_update";
|
session.generationPurpose = "plan_update";
|
||||||
persistSession(session, "generating");
|
await persistSession(session, "generating");
|
||||||
if (!session.agent) {
|
if (!session.agent) {
|
||||||
// An edited older answer must be replayed in its original position with every
|
// 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.
|
// later answer retained; only a newly appended answer is sent after replay.
|
||||||
@@ -2934,11 +2971,12 @@ export async function retrySession(
|
|||||||
*/
|
*/
|
||||||
session.currentQuestion = undefined;
|
session.currentQuestion = undefined;
|
||||||
session.updatedAt = new Date();
|
session.updatedAt = new Date();
|
||||||
persistSession(session, "generating");
|
session.generationPurpose = session.history.length === 0 ? "initial_plan" : "plan_update";
|
||||||
|
await persistSession(session, "generating");
|
||||||
|
|
||||||
if (session.history.length === 0) {
|
if (session.history.length === 0) {
|
||||||
await ensureSessionAgent(session, rootDir, [], promptOverrides, store);
|
await ensureSessionAgent(session, rootDir, [], promptOverrides, store);
|
||||||
await continueAgentConversation(session, formatInitialPlanRequestForAgent(session.initialPlan));
|
await continueAgentConversation(session, formatInitialRunningPlanRequestForAgent(session.initialPlan));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2946,10 +2984,10 @@ export async function retrySession(
|
|||||||
const lastEntry = session.history[session.history.length - 1];
|
const lastEntry = session.history[session.history.length - 1];
|
||||||
|
|
||||||
await ensureSessionAgent(session, rootDir, replayHistory, promptOverrides, store);
|
await ensureSessionAgent(session, rootDir, replayHistory, promptOverrides, store);
|
||||||
const replayMessage = formatResponseForAgent(
|
const replayMessage = `${formatResponseForAgent(
|
||||||
lastEntry.question,
|
lastEntry.question,
|
||||||
coerceResponseRecord(lastEntry.question, lastEntry.response),
|
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);
|
await continueAgentConversation(session, replayMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3108,7 +3146,7 @@ export function formatResponseForAgent(
|
|||||||
System prompts can be displaced by long tool/context turns. Repeat the per-answer contract at the invocation boundary
|
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.
|
so every submitted answer steers the following high-impact question instead of inviting a model-generated completion.
|
||||||
*/
|
*/
|
||||||
return `${answerContext}\n\nUpdate only the lean runningPlan fields (title, description, suggestedSize, optional priority, suggestedDependencies, and concrete keyDeliverables) informed by this answer; never list interview questions as deliverables or PROMPT.md sections such as Mission, Steps, File Scope, Review Level, Completion Criteria, or Do NOT. Then ask exactly one new, high-impact question that does not repeat a prior question. Offer alternatives with pros and cons. Do not complete or validate the plan; only the user can validate it.`;
|
return `${answerContext}\n\nUpdate only the runningPlan fields (title, description, concrete proposedChanges, observable acceptanceCriteria, suggestedSize, optional priority, suggestedDependencies, concrete keyDeliverables, and three suggestedRefinements) informed by this answer; never list interview questions as deliverables or PROMPT.md sections such as Mission, Steps, File Scope, Review Level, Completion Criteria, or Do NOT. Then ask exactly one new, high-impact question that does not repeat a prior question. Offer alternatives with pros and cons. Do not validate the plan; only the user can validate it.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {
|
function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {
|
||||||
|
|||||||
Reference in New Issue
Block a user