FN-8434: preserve evolving running plans

Keep Planning Mode's running plan as an evolving work product alongside every interview question.

- Carry model-authored running-plan updates through question responses without ending the interview
- Merge partial plan updates with prior title, deliverables, dependencies, size, and priority
- Derive safe fallback summaries without turning interview questions into deliverables
- Cover running-plan persistence and document the contract

Files changed:
 .changeset/fn-8434-running-plan-content.md         |   7 +
 docs/dashboard-guide.md                            |   2 +-
 packages/core/src/types.ts                         |   6 +
 .../PlanningModeModal.planning-flow.test.tsx       |  11 +-
 .../__tests__/planning-e2e-plan-creation.test.ts   |  17 ++-
 .../__tests__/planning-infinite-interview.test.ts  |  91 +++++++++++-
 packages/dashboard/src/planning.ts                 | 163 ++++++++++++++-------
 7 files changed, 232 insertions(+), 65 deletions(-)

Fusion-Task-Id: FN-8434

Fusion-Task-Lineage: cdf1c4ff-fd33-4e4a-84f4-b54a5e0a9cf8

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-20 10:33:59 -07:00
parent 9db0ffc1f9
commit e43dffad52
7 changed files with 232 additions and 65 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Planning Mode running plan shows an evolving plan, not repeated interview questions.
category: fix
dev: Fix buildRunningSummary + agent turn merge so summary keyDeliverables/description are plan-quality; preserve model plan fields when coercing complete payloads.

View File

@@ -529,7 +529,7 @@ When an active Planning AI generation appears stuck, Planning Mode automatically
Use **New session** to restart planning with a different idea. Use **New session** to restart planning with a different idea.
<!-- FNXC:PlanningMode 2026-07-18-16:00: Planning Mode is an infinite, user-controlled interview. Each answer updates the running plan and produces another context-aware high-impact question; only Validate plan finalizes it. --> <!-- FNXC:PlanningMode 2026-07-18-16:00: Planning Mode is an infinite, user-controlled interview. Each answer updates the running plan and produces another context-aware high-impact question; only Validate plan finalizes it. -->
Planning Mode keeps the running plan visible beside answered-question history and the current question on desktop; you can rename a session and keep asking high-impact, context-aware questions until you choose **Validate plan**. On tablet, mobile, and phone-class short landscape, the interview switches between labeled **Question**, **Running plan**, and **Answered questions** surfaces so the current question stays usable instead of competing with three columns. On mobile, Planning opens to the full-pane, scrollable saved-session list when sessions exist; **Running plan** appears only after you intentionally open a session and choose its tab. **Sessions** (and mobile Back) return to that list with **New session** pinned as its footer. This escape remains available from interview, summary, breakdown, and a new-session composer whenever saved sessions exist, while **Validate plan** remains available on the Running plan surface. The running title, description, and deliverables are available throughout the interview—including while the next question is generating or a recoverable error is shown. The AI never ends an interview on its own. Selection questions provide alternatives with pros and cons plus an **Other** free-text choice, whose wording follows your input language and whose answer steers the next question. You may edit an earlier answer by question ID without losing later answers; Planning re-derives the running plan and appends a fresh next question. Planning Mode keeps the running plan visible beside answered-question history and the current question on desktop; its title, description, and deliverables are the evolving work product synthesized from the idea and answers, not a transcript or list of interview questions. You can rename a session and keep asking high-impact, context-aware questions until you choose **Validate plan**. On tablet, mobile, and phone-class short landscape, the interview switches between labeled **Question**, **Running plan**, and **Answered questions** surfaces so the current question stays usable instead of competing with three columns. On mobile, Planning opens to the full-pane, scrollable saved-session list when sessions exist; **Running plan** appears only after you intentionally open a session and choose its tab. **Sessions** (and mobile Back) return to that list with **New session** pinned as its footer. This escape remains available from interview, summary, breakdown, and a new-session composer whenever saved sessions exist, while **Validate plan** remains available on the Running plan surface. The running title, description, and deliverables are available throughout the interview—including while the next question is generating or a recoverable error is shown. The AI never ends an interview on its own. Selection questions provide alternatives with pros and cons plus an **Other** free-text choice, whose wording follows your input language and whose answer steers the next question. You may edit an earlier answer by question ID without losing later answers; Planning re-derives the running plan and appends a fresh next question.
Choose **Validate plan** when the running plan is ready for task creation. Validation is durable and is required before **Create task**, **Create tasks**, or **Start breakdown**; those actions reject unvalidated sessions. Choose **Validate plan** when the running plan is ready for task creation. Validation is durable and is required before **Create task**, **Create tasks**, or **Start breakdown**; those actions reject unvalidated sessions.

View File

@@ -5639,6 +5639,12 @@ export interface PlanningQuestion {
question: string; question: string;
description?: string; description?: string;
options?: Array<{ id: string; label: string; description?: string; pros?: string[]; cons?: string[]; isOther?: boolean; customText?: string }>; options?: Array<{ id: string; label: string; description?: string; pros?: string[]; cons?: string[]; isOther?: boolean; customText?: string }>;
/*
FNXC:PlanningMode 2026-07-20-00:00:
FN-8434 carries the evolving plan beside the next interview question. This field is additive:
it must never be interpreted as model authority to complete a user-controlled Planning Mode session.
*/
runningPlan?: PlanningSummary;
} }
/** The final summary generated after planning conversation completes */ /** The final summary generated after planning conversation completes */

View File

@@ -344,7 +344,11 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByText("Start Planning")); fireEvent.click(screen.getByText("Start Planning"));
expect(await screen.findByText(mockQuestion.question)).toBeDefined(); expect(await screen.findByText(mockQuestion.question)).toBeDefined();
expect(screen.getByText("Before question running plan")).toBeDefined(); const runningPlan = screen.getByRole("complementary", { name: "Running plan" });
expect(within(runningPlan).getByText("Before question running plan")).toBeDefined();
expect(within(runningPlan).getByText(mockSummary.description)).toBeDefined();
expect(within(runningPlan).getByText("Login page")).toBeDefined();
expect(within(runningPlan).queryByText(mockQuestion.question)).toBeNull();
expect(screen.queryByText("Planning Complete!")).toBeNull(); expect(screen.queryByText("Planning Complete!")).toBeNull();
await act(async () => { await act(async () => {
@@ -3640,6 +3644,11 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByRole("button", { name: "Running plan" })); fireEvent.click(screen.getByRole("button", { name: "Running plan" }));
expect(rendered.container.querySelector(".planning-modal-body")).toHaveClass("planning-modal-body--compact-plan"); expect(rendered.container.querySelector(".planning-modal-body")).toHaveClass("planning-modal-body--compact-plan");
const runningPlan = screen.getByRole("complementary", { name: "Running plan" });
expect(within(runningPlan).getByText(mockSummary.title)).toBeVisible();
expect(within(runningPlan).getByText(mockSummary.description)).toBeVisible();
expect(within(runningPlan).getByText("Login page")).toBeVisible();
expect(within(runningPlan).queryByText(mockQuestion.question)).toBeNull();
expect(screen.getByRole("button", { name: "Validate plan" })).toBeVisible(); expect(screen.getByRole("button", { name: "Validate plan" })).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "Answered questions" })); fireEvent.click(screen.getByRole("button", { name: "Answered questions" }));

View File

@@ -157,7 +157,13 @@ function buildApp(store: TaskStore): express.Express {
} }
function expectRunningPlan(body: any) { function expectRunningPlan(body: any) {
expect(body.summary).toEqual(expect.objectContaining({ title: expect.any(String), description: expect.any(String) })); expect(body.summary).toEqual(expect.objectContaining({
title: expect.any(String),
description: expect.any(String),
keyDeliverables: expect.any(Array),
}));
expect(body.summary.description).not.toBe(body.firstQuestion?.question);
expect(body.summary.keyDeliverables).not.toContain(body.firstQuestion?.question);
expect(body.validated).toBe(false); expect(body.validated).toBe(false);
} }
@@ -216,8 +222,8 @@ describe("Planning Mode plan creation E2E", () => {
expectOpenQuestion(prematureComplete.body); expectOpenQuestion(prematureComplete.body);
expect(getCompleteResponseCount()).toBe(1); expect(getCompleteResponseCount()).toBe(1);
const afterPrematureComplete = await getRunningPlan(app, sessionId); const afterPrematureComplete = await getRunningPlan(app, sessionId);
expect(afterPrematureComplete).toMatchObject({ title: "Build secure account recovery" }); expect(afterPrematureComplete).toMatchObject({ title: "Premature plan", description: "Must not finalize" });
expect(afterPrematureComplete.description).toContain('"scope":"secure"'); expect(afterPrematureComplete.keyDeliverables).not.toContain("Which outcome matters most?");
const midInterview = await post(app, "/api/planning/respond", { const midInterview = await post(app, "/api/planning/respond", {
sessionId, sessionId,
@@ -227,7 +233,7 @@ describe("Planning Mode plan creation E2E", () => {
expectOpenQuestion(midInterview.body); expectOpenQuestion(midInterview.body);
expect(midInterview.body.data.id).not.toBe(prematureComplete.body.data.id); expect(midInterview.body.data.id).not.toBe(prematureComplete.body.data.id);
const afterMidInterview = await getRunningPlan(app, sessionId); const afterMidInterview = await getRunningPlan(app, sessionId);
expect(afterMidInterview.description).toContain('"scope":"secure"'); expect(afterMidInterview.description).toContain("Must not finalize");
expect(afterMidInterview.description).toContain("controlled"); expect(afterMidInterview.description).toContain("controlled");
const otherSteer = await post(app, "/api/planning/respond", { const otherSteer = await post(app, "/api/planning/respond", {
@@ -254,8 +260,7 @@ describe("Planning Mode plan creation E2E", () => {
expectOpenQuestion(edited.body); expectOpenQuestion(edited.body);
expect(edited.body.data).toMatchObject({ id: "riesgo-reeditado" }); expect(edited.body.data).toMatchObject({ id: "riesgo-reeditado" });
const afterEdit = await getRunningPlan(app, sessionId); const afterEdit = await getRunningPlan(app, sessionId);
expect(afterEdit.description).toContain('"scope":"fast"'); expect(afterEdit.description).toContain("fast");
expect(afterEdit.description).not.toContain('"scope":"secure"');
expect(afterEdit.description).toContain("controlled"); expect(afterEdit.description).toContain("controlled");
expect(afterEdit.description).toContain("priorizar controles de privacidad"); expect(afterEdit.description).toContain("priorizar controles de privacidad");

View File

@@ -38,7 +38,11 @@ function payload(data: Record<string, unknown>): string {
function completePayload(): string { function completePayload(): string {
return JSON.stringify({ return JSON.stringify({
type: "complete", type: "complete",
data: { title: "The model tried to end the interview", description: "must be ignored" }, data: {
title: "Secure account recovery delivery",
description: "Build a reviewed recovery workflow with audit coverage.",
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
},
}); });
} }
@@ -177,7 +181,10 @@ describe("reactive Planning Mode question contract", () => {
// The streamed processAgentTurn seam must coerce generic complete output into a question. // The streamed processAgentTurn seam must coerce generic complete output into a question.
expect(fallbackQuestion.id).not.toBe("complete"); expect(fallbackQuestion.id).not.toBe("complete");
expect((await getSession(sessionId))?.summary?.description).toContain("Build secure account recovery"); expect((await getSession(sessionId))?.summary).toMatchObject({
title: "Secure account recovery delivery",
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
});
expect((await getSession(sessionId))?.validated).toBe(false); expect((await getSession(sessionId))?.validated).toBe(false);
const next = await submitResponse(sessionId, { const next = await submitResponse(sessionId, {
@@ -217,7 +224,10 @@ describe("reactive Planning Mode question contract", () => {
const afterCompletion = await getSession(created.sessionId); const afterCompletion = await getSession(created.sessionId);
expect(afterCompletion?.validated).toBe(false); expect(afterCompletion?.validated).toBe(false);
expect(afterCompletion).not.toHaveProperty("pendingSummary"); expect(afterCompletion).not.toHaveProperty("pendingSummary");
expect(afterCompletion?.summary?.description).toContain("audit logging security"); expect(afterCompletion?.summary).toMatchObject({
title: "Secure account recovery delivery",
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
});
expect(afterCompletion?.currentQuestion).toBeDefined(); expect(afterCompletion?.currentQuestion).toBeDefined();
const secondQuestion = afterCompletion!.currentQuestion!; const secondQuestion = afterCompletion!.currentQuestion!;
@@ -227,10 +237,81 @@ describe("reactive Planning Mode question contract", () => {
expect((await getSession(created.sessionId))?.validated).toBe(false); expect((await getSession(created.sessionId))?.validated).toBe(false);
const finalPlan = await validateSession(created.sessionId); const finalPlan = await validateSession(created.sessionId);
expect(finalPlan.description).toContain("Build secure account recovery"); expect(finalPlan.description).toContain("Build a reviewed recovery workflow with audit coverage.");
expect(await getSession(created.sessionId)).toMatchObject({ validated: true, currentQuestion: undefined }); expect(await getSession(created.sessionId)).toMatchObject({ validated: true, currentQuestion: undefined });
}); });
it("uses a model runningPlan attached to a continuing question", async () => {
installScriptedAgent([payload({
...FIRST_QUESTION,
runningPlan: {
title: "Account recovery implementation plan",
description: "Deliver a secure, observable recovery experience.",
keyDeliverables: ["Add recovery token flow", "Test recovery audit events"],
},
})]);
const created = await createSession("127.0.0.13", "Build secure account recovery", MOCK_TASK_STORE, "/tmp/project");
expect(created.validated).toBe(false);
expect(created.summary).toMatchObject({
title: "Account recovery implementation plan",
description: "Deliver a secure, observable recovery experience.",
keyDeliverables: ["Add recovery token flow", "Test recovery audit events"],
});
});
it("merges a partial model running-plan update with the prior work product", async () => {
installScriptedAgent([
payload({
...FIRST_QUESTION,
runningPlan: {
title: "Account recovery implementation plan",
description: "Deliver a secure, observable recovery experience.",
suggestedSize: "L",
priority: "high",
suggestedDependencies: ["Identity service"],
keyDeliverables: ["Add recovery token flow", "Test recovery audit events"],
},
}),
payload({
...SECOND_QUESTION,
runningPlan: { description: "Deliver a secure recovery experience with a gradual rollout." },
}),
]);
const created = await createSession("127.0.0.14", "Build secure account recovery", MOCK_TASK_STORE, "/tmp/project");
await submitResponse(created.sessionId, { scope: "secure" }, "/tmp/project", undefined, MOCK_TASK_STORE);
expect((await getSession(created.sessionId))?.summary).toEqual({
title: "Account recovery implementation plan",
description: "Deliver a secure recovery experience with a gradual rollout.",
suggestedSize: "L",
priority: "high",
suggestedDependencies: ["Identity service"],
keyDeliverables: ["Add recovery token flow", "Test recovery audit events"],
});
});
it("keeps fallback running plans answer-aware without turning questions into deliverables", async () => {
installScriptedAgent([payload(FIRST_QUESTION), payload(SECOND_QUESTION)]);
const created = await createSession("127.0.0.12", "Build secure account recovery", MOCK_TASK_STORE, "/tmp/project");
expect(created.summary).toMatchObject({
title: "Build secure account recovery",
description: "Build secure account recovery",
keyDeliverables: [],
});
await submitResponse(created.sessionId, { scope: "secure" }, "/tmp/project", undefined, MOCK_TASK_STORE);
const session = await getSession(created.sessionId);
const askedQuestions = session!.history.map((entry) => entry.question.question);
expect(session?.summary?.description).toContain("Secure defaults");
expect(session?.summary?.description).not.toBe(session?.currentQuestion?.question);
expect(session?.summary?.keyDeliverables).toEqual([]);
expect(session?.summary?.keyDeliverables).not.toEqual(askedQuestions);
expect(session?.validated).toBe(false);
});
it("replays an edited historical answer while retaining later answers and appending a fresh question", async () => { it("replays an edited historical answer while retaining later answers and appending a fresh question", async () => {
installScriptedAgent([ installScriptedAgent([
payload(FIRST_QUESTION), payload(FIRST_QUESTION),
@@ -253,6 +334,6 @@ describe("reactive Planning Mode question contract", () => {
expect(edited?.history[0]?.response).toEqual({ scope: "fast" }); expect(edited?.history[0]?.response).toEqual({ scope: "fast" });
expect(edited?.history[1]?.response).toEqual({ [second.id]: "gradual" }); expect(edited?.history[1]?.response).toEqual({ [second.id]: "gradual" });
expect(edited?.currentQuestion?.id).toBe("fresh-after-edit"); expect(edited?.currentQuestion?.id).toBe("fresh-after-edit");
expect(edited?.summary?.description).toContain("fast"); expect(edited?.summary?.description).toContain("Fast delivery");
}); });
}); });

View File

@@ -232,9 +232,9 @@ export const PLANNING_SYSTEM_PROMPT = `You are a planning assistant for the fn t
Ask exactly one next, high-impact question on every turn. Use every prior answer as context, avoid repeated questions, and never decide that the interview is complete or emit a terminal/complete response. The user alone validates the plan. Ask exactly one next, high-impact question on every turn. Use every prior answer as context, avoid repeated questions, and never decide that the interview is complete or emit a terminal/complete response. The user alone validates the plan.
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}]}}. 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"]}}}.
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 runningPlan: a concise work-product title, description, and concrete deliverables informed by the idea and answers so far. Never use interview question text as a deliverable. 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.`;
@@ -1054,11 +1054,11 @@ export async function createSession(
const firstQuestion = firstResponse.data; const firstQuestion = firstResponse.data;
session.currentQuestion = firstQuestion; session.currentQuestion = firstQuestion;
session.summary = mergeRunningSummary(session, firstResponse);
session.updatedAt = new Date(); session.updatedAt = new Date();
await persistSession(session, "awaiting_input"); await persistSession(session, "awaiting_input");
void maybeNotifyPlanningAwaitingInput(session, firstQuestion, true); void maybeNotifyPlanningAwaitingInput(session, firstQuestion, true);
session.summary = buildRunningSummary(initialPlan, session.history);
return { sessionId, firstQuestion, summary: session.summary, validated: false }; return { sessionId, firstQuestion, summary: session.summary, validated: false };
} }
@@ -1070,7 +1070,7 @@ export async function createSession(
async function getFirstQuestionFromAgent( async function getFirstQuestionFromAgent(
session: Session, session: Session,
message: string, message: string,
): Promise<{ type: "question"; data: PlanningQuestion }> { ): Promise<Extract<PlanningResponse, { type: "question" }>> {
if (!session.agent) { if (!session.agent) {
throw new InvalidSessionStateError("AI agent not initialized"); throw new InvalidSessionStateError("AI agent not initialized");
} }
@@ -1142,7 +1142,7 @@ async function getFirstQuestionFromAgent(
try { try {
await session.agent.session.prompt( await session.agent.session.prompt(
"Your previous response could not be parsed as JSON. " + "Your previous response could not be parsed as JSON. " +
'Please respond with ONLY a valid JSON object: {"type":"question","data":{...}}. ' + 'Please respond with ONLY a valid JSON object: {"type":"question","data":{"runningPlan":{...},...}}. ' +
"No markdown, no explanation, just the JSON." "No markdown, no explanation, just the JSON."
); );
@@ -1194,16 +1194,22 @@ async function getFirstQuestionFromAgent(
} }
if (parsed.type === "question") { if (parsed.type === "question") {
return { type: "question", data: normalizePlanningQuestion(parsed.data, session.initialPlan) }; return {
type: "question",
data: {
...normalizePlanningQuestion(parsed.data, session.initialPlan),
...(parsed.data.runningPlan ? { runningPlan: parsed.data.runningPlan } : {}),
},
};
} }
/* /*
FNXC:PlanningMode 2026-07-18-11:36: FNXC:PlanningMode 2026-07-20-00:00:
FN-8331 makes the first planning turn an interview invariant: a completion cannot become a FN-8434 preserves a legacy complete payload's plan as a running-plan update while still
deepening checkpoint until the user has answered a real clarifying question. Re-prompt once coercing its control flow into a question. Only the user Validate action may terminalize.
for the required protocol shape, then use a safe local question if the model still refuses.
*/ */
return requestMandatoryFirstPlanningQuestion(session); const mandatoryQuestion = await requestMandatoryFirstPlanningQuestion(session);
return { type: "question", data: { ...mandatoryQuestion.data, runningPlan: parsed.data } };
} }
function buildMandatoryFirstPlanningQuestion(userInput = ""): PlanningQuestion { function buildMandatoryFirstPlanningQuestion(userInput = ""): PlanningQuestion {
@@ -2029,21 +2035,80 @@ async function runGenerationWithTimeout<T>(session: Session, operation: (abortSi
} }
} }
/** /*
* FNXC:PlanningMode 2026-07-18-16:00: FNXC:PlanningMode 2026-07-20-00:00:
* Planning is an infinite, user-gated interview. A running plan is derived after every FN-8434 makes the Running plan an evolving work product, not an interview transcript.
* turn and only validateSession may mark it final; model completion payloads are coerced Fallback text may acknowledge answer choices, but interview questions must never become
* into another question rather than terminating the session. key deliverables because task creation and breakdown consume those as implementation work.
*/ */
function buildRunningSummary(initialPlan: string, history: PlanningHistoryEntry[]): PlanningSummary { function describePlanningAnswer(entry: PlanningHistoryEntry): string {
const answers = history.map((entry) => `${entry.question.question}: ${JSON.stringify(entry.response)}`); const response = entry.response && typeof entry.response === "object" && !Array.isArray(entry.response)
? entry.response as Record<string, unknown>
: {};
const optionLabels = new Map((entry.question.options ?? []).map((option) => [option.id, option.label]));
const values = Object.entries(response)
.filter(([key]) => key !== "_comment")
.flatMap(([, value]) => Array.isArray(value) ? value : [value])
.filter((value): value is string | number | boolean => typeof value === "string" || typeof value === "number" || typeof value === "boolean")
.map((value) => typeof value === "string" ? optionLabels.get(value) ?? value : String(value));
const comment = typeof response._comment === "string" ? response._comment.trim() : "";
return [...values, ...(comment ? [comment] : [])].join(", ") || "a response";
}
function buildRunningSummary(
initialPlan: string,
history: PlanningHistoryEntry[],
previousSummary?: PlanningSummary,
): PlanningSummary {
const initialDescription = initialPlan.trim() || "Plan details will be refined during the interview.";
const latestAnswer = history.length > 0 ? describePlanningAnswer(history[history.length - 1]!) : "";
const description = history.length === 0
? initialDescription
: previousSummary?.description
? `${previousSummary.description}\n\nLatest planning input: ${latestAnswer}`
: `${initialDescription}\n\nRefined with ${history.length} planning answer${history.length === 1 ? "" : "s"}: ${history.map(describePlanningAnswer).join("; ")}`;
return normalizePlanningSummaryPayload({ return normalizePlanningSummaryPayload({
title: initialPlan.slice(0, 80) || "Untitled planning task", title: previousSummary?.title || initialPlan.slice(0, 80),
description: [initialPlan, ...answers].filter(Boolean).join("\n\n"), description,
suggestedSize: "M", suggestedSize: previousSummary?.suggestedSize ?? "M",
suggestedDependencies: [], priority: previousSummary?.priority,
keyDeliverables: history.map((entry) => entry.question.question), suggestedDependencies: previousSummary?.suggestedDependencies ?? [],
}, { title: initialPlan, description: initialPlan }); keyDeliverables: previousSummary?.keyDeliverables ?? [],
}, { title: initialPlan, description: initialDescription });
}
function hasPlanContent(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const plan = value as Record<string, unknown>;
return (typeof plan.title === "string" && plan.title.trim().length > 0)
|| (typeof plan.description === "string" && plan.description.trim().length > 0)
|| (Array.isArray(plan.keyDeliverables) && plan.keyDeliverables.some((item) => typeof item === "string" && item.trim().length > 0));
}
function getModelRunningPlan(response: PlanningResponse): unknown {
if (response.type === "complete") return response.data;
return response.data.runningPlan;
}
function mergeRunningSummary(session: Session, response?: PlanningResponse): PlanningSummary {
const modelPlan = response ? getModelRunningPlan(response) : undefined;
if (hasPlanContent(modelPlan)) {
/*
FNXC:PlanningMode 2026-07-20-00:00:
FN-8434 permits a question to carry a partial running-plan update. Merge that patch over
the prior work product before normalization so an update to one field cannot reset the
model's previously established title, deliverables, dependencies, size, or priority.
*/
const priorPlan = session.summary ?? buildRunningSummary(session.initialPlan, session.history);
return normalizePlanningSummaryPayload({
...priorPlan,
...(modelPlan as Record<string, unknown>),
}, {
title: session.initialPlan,
description: session.initialPlan,
});
}
return buildRunningSummary(session.initialPlan, session.history, session.summary);
} }
function planningFallbackCopy(input: string): { question: string; option: (n: number) => string; pro: string; con: string; other: string } { function planningFallbackCopy(input: string): { question: string; option: (n: number) => string; pro: string; con: string; other: string } {
@@ -2107,7 +2172,8 @@ export async function validateSession(sessionId: string): Promise<PlanningSummar
activeGenerations.delete(session.id); activeGenerations.delete(session.id);
} }
session.summary = buildRunningSummary(session.initialPlan, session.history); // Keep the last model-authored running plan intact; validation only finalizes it.
session.summary = session.summary ?? buildRunningSummary(session.initialPlan, session.history);
session.currentQuestion = undefined; session.currentQuestion = undefined;
session.editingQuestionId = undefined; session.editingQuestionId = undefined;
session.validated = true; session.validated = true;
@@ -2212,8 +2278,8 @@ async function continueAgentConversation(session: Session, message: string): Pro
} }
await (session.agent.session.prompt as (input: string, options?: { signal?: AbortSignal }) => Promise<void>)( await (session.agent.session.prompt as (input: string, options?: { signal?: AbortSignal }) => Promise<void>)(
"Your previous response could not be parsed as JSON. " + "Your previous response could not be parsed as JSON. " +
'Please respond with ONLY a valid JSON object: either {"type":"question","data":{...}} ' + 'Please respond with ONLY a valid JSON object: {"type":"question","data":{"runningPlan":{...},...}}. ' +
'or {"type":"complete","data":{...}}. No markdown, no explanation, just the JSON.', 'No markdown, no explanation, just the JSON.',
{ signal: abortSignal }, { signal: abortSignal },
); );
if (abortSignal.aborted) { if (abortSignal.aborted) {
@@ -2268,28 +2334,18 @@ async function continueAgentConversation(session: Session, message: string): Pro
return; return;
} }
if (parsed.type === "question") { // A generic engine completion is never terminal in Planning Mode. Its data remains
session.currentQuestion = coerceQuestionResponse(parsed, session); // eligible as a model-authored running plan while the fallback question keeps interviewing.
session.summary = buildRunningSummary(session.initialPlan, session.history); session.currentQuestion = coerceQuestionResponse(parsed, session);
session.error = undefined; session.summary = mergeRunningSummary(session, parsed);
session.lastGeneratedThinking = session.thinkingOutput; session.error = undefined;
session.updatedAt = new Date(); session.lastGeneratedThinking = session.thinkingOutput;
// Persist after deriving the plan: reloads must see the running summary on every turn. session.updatedAt = new Date();
persistSession(session, "awaiting_input"); // Persist after deriving the plan: reloads must see the running summary on every turn.
void maybeNotifyPlanningAwaitingInput(session, session.currentQuestion, true); persistSession(session, "awaiting_input");
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary }); void maybeNotifyPlanningAwaitingInput(session, session.currentQuestion, true);
planningStreamManager.broadcast(session.id, { type: "question", data: session.currentQuestion }); planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
} else { planningStreamManager.broadcast(session.id, { type: "question", data: session.currentQuestion });
// A generic engine completion is never terminal in Planning Mode.
session.currentQuestion = coerceQuestionResponse(parsed, session);
session.summary = buildRunningSummary(session.initialPlan, session.history);
session.error = undefined;
session.lastGeneratedThinking = session.thinkingOutput;
session.updatedAt = new Date();
persistSession(session, "awaiting_input");
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
planningStreamManager.broadcast(session.id, { type: "question", data: session.currentQuestion });
}
}); });
} catch (err) { } catch (err) {
if (err instanceof Error && err.name === "AbortError") { if (err instanceof Error && err.name === "AbortError") {
@@ -2665,6 +2721,8 @@ export async function submitResponse(
if (isEditingPriorAnswer) { if (isEditingPriorAnswer) {
session.history[editIndex] = historyEntry; session.history[editIndex] = historyEntry;
session.editingQuestionId = undefined; session.editingQuestionId = undefined;
// Rebuild from history before the next turn so stale pre-edit plan prose cannot survive.
session.summary = buildRunningSummary(session.initialPlan, session.history);
// Existing agent context contains the old answer; rebuild it from the preserved history. // Existing agent context contains the old answer; rebuild it from the preserved history.
disposeSessionAgentForRetry(session); disposeSessionAgentForRetry(session);
} else { } else {
@@ -2814,7 +2872,7 @@ export async function rewindSession(
session.currentQuestion = rewindEntry.question; session.currentQuestion = rewindEntry.question;
session.editingQuestionId = questionId ? questionId : undefined; session.editingQuestionId = questionId ? questionId : undefined;
// Keep the plan available while the user edits; it is re-derived after submit. // Re-derive from retained answers so an edit cannot revive a prior question as a deliverable.
session.summary = buildRunningSummary(session.initialPlan, session.history); session.summary = buildRunningSummary(session.initialPlan, session.history);
session.error = undefined; session.error = undefined;
session.lastGeneratedThinking = session.history[session.history.length - 1]?.thinkingOutput ?? ""; session.lastGeneratedThinking = session.history[session.history.length - 1]?.thinkingOutput ?? "";
@@ -2826,6 +2884,7 @@ export async function rewindSession(
} }
persistSession(session, "awaiting_input"); persistSession(session, "awaiting_input");
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
planningStreamManager.broadcast(session.id, { type: "question", data: rewindEntry.question }); planningStreamManager.broadcast(session.id, { type: "question", data: rewindEntry.question });
return { return {
@@ -2927,7 +2986,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\nIncorporate this answer into the running plan, 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 the runningPlan object with a concise title, description, and concrete work-item deliverables informed by this answer; never list interview questions as deliverables. 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.`;
} }
function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> { function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {