FN-8438: generate and refine running plans

Planning Mode now generates an initial work-product plan and refines it after every answer.

- Prompt both initial agent paths to create a running plan before asking questions
- Provide meaningful fallback summaries and deliverables when model plan fields are absent
- Cover streaming and non-streaming initial-plan behavior and document the refinement contract

Files changed:
 .changeset/fn-8438-running-plan-generate-refine.md |  7 ++
 docs/dashboard-guide.md                            |  3 +-
 .../__tests__/planning-infinite-interview.test.ts  | 76 ++++++++++++++++++++--
 packages/dashboard/src/planning.ts                 | 58 ++++++++++++-----
 4 files changed, 122 insertions(+), 22 deletions(-)

Fusion-Task-Id: FN-8438

Fusion-Task-Lineage: 29e155ec-480e-462a-be3d-07ac02d7236b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-20 12:50:06 -07:00
parent df488f25ea
commit f9c1620c1e
4 changed files with 122 additions and 22 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Planning Mode now drafts an initial running plan from your idea and refines it after each answer.
category: fix
dev: Strengthens first-turn and per-answer runningPlan generation and recovers when a turn omits plan fields.

View File

@@ -529,7 +529,8 @@ 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; 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. <!-- FNXC:PlanningMode 2026-07-20-12:42: FN-8438 requires the first AI turn to draft the running plan from the operator idea, then refine that work product after every answer instead of showing an interview transcript. -->
Planning Mode keeps the running plan visible beside answered-question history and the current question on desktop; the AI drafts its title, description, and concrete deliverables from your idea, then refines them after each answer. It is an evolving work product, 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

@@ -241,8 +241,8 @@ describe("reactive Planning Mode question contract", () => {
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 () => { it("uses a model-authored initial plan on the non-streaming first turn", async () => {
installScriptedAgent([payload({ const prompts = installScriptedAgent([payload({
...FIRST_QUESTION, ...FIRST_QUESTION,
runningPlan: { runningPlan: {
title: "Account recovery implementation plan", title: "Account recovery implementation plan",
@@ -258,6 +258,66 @@ describe("reactive Planning Mode question contract", () => {
description: "Deliver a secure, observable recovery experience.", description: "Deliver a secure, observable recovery experience.",
keyDeliverables: ["Add recovery token flow", "Test recovery audit events"], keyDeliverables: ["Add recovery token flow", "Test recovery audit events"],
}); });
expect(prompts[0]).toContain("Create the initial running plan");
expect(prompts[0]).toContain("Build secure account recovery");
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 () => {
installScriptedAgent([payload({
...FIRST_QUESTION,
runningPlan: {
title: "Streaming account recovery plan",
description: "Stage a secure recovery flow with observability.",
keyDeliverables: ["Design recovery token lifecycle", "Test recovery telemetry"],
},
})]);
const sessionId = await createSessionWithAgent(
"127.0.0.15", "Build secure account recovery", "/tmp/project", MOCK_TASK_STORE,
);
const events: string[] = [];
const firstQuestion = new Promise<void>((resolve) => {
planningStreamManager.subscribe(sessionId, (event) => {
events.push(event.type);
if (event.type === "question") resolve();
});
});
planningStreamManager.consumeInitialTurn(sessionId)?.();
await firstQuestion;
expect((await getSession(sessionId))?.summary).toMatchObject({
title: "Streaming account recovery plan",
description: "Stage a secure recovery flow with observability.",
keyDeliverables: ["Design recovery token lifecycle", "Test recovery telemetry"],
});
expect(events.indexOf("summary")).toBeLessThan(events.indexOf("question"));
});
it("recovers a plan-shaped streaming first turn when the model omits runningPlan", async () => {
installScriptedAgent([payload(FIRST_QUESTION)]);
const sessionId = await createSessionWithAgent(
"127.0.0.16", "Build secure account recovery", "/tmp/project", MOCK_TASK_STORE,
);
const events: string[] = [];
const firstQuestion = new Promise<void>((resolve) => {
planningStreamManager.subscribe(sessionId, (event) => {
events.push(event.type);
if (event.type === "question") resolve();
});
});
planningStreamManager.consumeInitialTurn(sessionId)?.();
await firstQuestion;
const session = await getSession(sessionId);
expect(session?.summary).toMatchObject({
title: "Plan: Build secure account recovery",
description: expect.stringContaining("Plan and deliver Build secure account recovery"),
});
expect(session?.summary?.description).not.toBe(FIRST_QUESTION.question);
expect(session?.summary?.keyDeliverables).not.toEqual([FIRST_QUESTION.question]);
expect(events.indexOf("summary")).toBeLessThan(events.indexOf("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 () => {
@@ -297,9 +357,11 @@ describe("reactive Planning Mode question contract", () => {
const created = await createSession("127.0.0.12", "Build secure account recovery", MOCK_TASK_STORE, "/tmp/project"); const created = await createSession("127.0.0.12", "Build secure account recovery", MOCK_TASK_STORE, "/tmp/project");
expect(created.summary).toMatchObject({ expect(created.summary).toMatchObject({
title: "Build secure account recovery", title: "Plan: Build secure account recovery",
description: "Build secure account recovery", description: expect.stringContaining("Plan and deliver Build secure account recovery"),
keyDeliverables: [], keyDeliverables: expect.arrayContaining([
"Define scope and acceptance criteria for Build secure account recovery",
]),
}); });
await submitResponse(created.sessionId, { scope: "secure" }, "/tmp/project", undefined, MOCK_TASK_STORE); await submitResponse(created.sessionId, { scope: "secure" }, "/tmp/project", undefined, MOCK_TASK_STORE);
@@ -307,7 +369,7 @@ describe("reactive Planning Mode question contract", () => {
const askedQuestions = session!.history.map((entry) => entry.question.question); const askedQuestions = session!.history.map((entry) => entry.question.question);
expect(session?.summary?.description).toContain("Secure defaults"); expect(session?.summary?.description).toContain("Secure defaults");
expect(session?.summary?.description).not.toBe(session?.currentQuestion?.question); expect(session?.summary?.description).not.toBe(session?.currentQuestion?.question);
expect(session?.summary?.keyDeliverables).toEqual([]); expect(session?.summary?.keyDeliverables).toContain("Define scope and acceptance criteria for Build secure account recovery");
expect(session?.summary?.keyDeliverables).not.toEqual(askedQuestions); expect(session?.summary?.keyDeliverables).not.toEqual(askedQuestions);
expect(session?.validated).toBe(false); expect(session?.validated).toBe(false);
}); });
@@ -335,5 +397,7 @@ describe("reactive Planning Mode question contract", () => {
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 delivery"); expect(edited?.summary?.description).toContain("Fast delivery");
expect(edited?.summary?.description.match(/Gradual rollout/g)).toHaveLength(1);
expect(edited?.summary?.keyDeliverables).not.toContain(edited?.currentQuestion?.question);
}); });
}); });

View File

@@ -1049,8 +1049,7 @@ export async function createSession(
session.agent = agentResult; session.agent = agentResult;
session.updatedAt = new Date(); session.updatedAt = new Date();
// Send initial plan to get first question from AI const firstResponse = await getFirstQuestionFromAgent(session, formatInitialPlanRequestForAgent(initialPlan));
const firstResponse = await getFirstQuestionFromAgent(session, initialPlan);
const firstQuestion = firstResponse.data; const firstQuestion = firstResponse.data;
session.currentQuestion = firstQuestion; session.currentQuestion = firstQuestion;
@@ -1665,8 +1664,7 @@ async function initializeAgent(
session.updatedAt = new Date(); session.updatedAt = new Date();
}); });
// Send initial message to get first question await continueAgentConversation(session, formatInitialPlanRequestForAgent(session.initialPlan));
await continueAgentConversation(session, session.initialPlan);
} catch (err) { } catch (err) {
if (err instanceof Error && err.name === "AbortError") { if (err instanceof Error && err.name === "AbortError") {
return; return;
@@ -2055,26 +2053,56 @@ function describePlanningAnswer(entry: PlanningHistoryEntry): string {
return [...values, ...(comment ? [comment] : [])].join(", ") || "a response"; return [...values, ...(comment ? [comment] : [])].join(", ") || "a response";
} }
/*
FNXC:PlanningMode 2026-07-20-14:30:
FN-8438 requires the first Planning Mode turn to author a plan from the operator idea, and every
following answer to refine that work product. Repeat this contract at the user-message boundary
for both agent entry points because system instructions alone can be displaced by tool context.
*/
export function formatInitialPlanRequestForAgent(initialPlan: string): string {
return [
"Create the initial running plan from this operator idea before asking the first interview question.",
"Return only type:\"question\" JSON with a full runningPlan: a work-product title, a concise implementation description, and concrete work-item keyDeliverables derived from the idea.",
"Then ask exactly one high-impact clarifying question with alternatives and pros/cons. Never use that question text as a deliverable. Do not complete or validate the plan; only the user can validate it.",
"Operator idea:",
initialPlan,
].join("\n\n");
}
function buildFallbackDeliverables(initialPlan: string): string[] {
const subject = initialPlan.trim() || "the requested work";
return [
`Define scope and acceptance criteria for ${subject}`,
`Implement the agreed approach for ${subject}`,
`Verify delivery and operational readiness for ${subject}`,
];
}
function buildRunningSummary( function buildRunningSummary(
initialPlan: string, initialPlan: string,
history: PlanningHistoryEntry[], history: PlanningHistoryEntry[],
previousSummary?: PlanningSummary, previousSummary?: PlanningSummary,
): PlanningSummary { ): PlanningSummary {
const initialDescription = initialPlan.trim() || "Plan details will be refined during the interview."; const subject = initialPlan.trim() || "the requested work";
const latestAnswer = history.length > 0 ? describePlanningAnswer(history[history.length - 1]!) : ""; const initialDescription = `Plan and deliver ${subject}. Establish scope, implementation approach, and acceptance criteria through the planning interview.`;
const description = history.length === 0 const decisions = history.map(describePlanningAnswer).filter(Boolean);
const incorporatedDecisions = decisions.join("; ");
const priorDescription = previousSummary?.description
?.replace(/\n\nPlanning decisions incorporated: [\s\S]*$/, "")
.replace(/\. Refine scope and implementation around the confirmed planning decisions: [\s\S]*$/, ".");
const description = decisions.length === 0
? initialDescription ? initialDescription
: previousSummary?.description : priorDescription
? `${previousSummary.description}\n\nLatest planning input: ${latestAnswer}` ? `${priorDescription}\n\nPlanning decisions incorporated: ${incorporatedDecisions}.`
: `${initialDescription}\n\nRefined with ${history.length} planning answer${history.length === 1 ? "" : "s"}: ${history.map(describePlanningAnswer).join("; ")}`; : `Plan and deliver ${subject}. Refine scope and implementation around the confirmed planning decisions: ${incorporatedDecisions}.`;
return normalizePlanningSummaryPayload({ return normalizePlanningSummaryPayload({
title: previousSummary?.title || initialPlan.slice(0, 80), title: previousSummary?.title || `Plan: ${subject.slice(0, 74)}`,
description, description,
suggestedSize: previousSummary?.suggestedSize ?? "M", suggestedSize: previousSummary?.suggestedSize ?? "M",
priority: previousSummary?.priority, priority: previousSummary?.priority,
suggestedDependencies: previousSummary?.suggestedDependencies ?? [], suggestedDependencies: previousSummary?.suggestedDependencies ?? [],
keyDeliverables: previousSummary?.keyDeliverables ?? [], keyDeliverables: previousSummary?.keyDeliverables?.length ? previousSummary.keyDeliverables : buildFallbackDeliverables(subject),
}, { title: initialPlan, description: initialDescription }); }, { title: `Plan: ${subject}`, description: initialDescription });
} }
function hasPlanContent(value: unknown): boolean { function hasPlanContent(value: unknown): boolean {
@@ -2816,7 +2844,7 @@ export async function retrySession(
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, session.initialPlan); await continueAgentConversation(session, formatInitialPlanRequestForAgent(session.initialPlan));
return; return;
} }
@@ -2986,7 +3014,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 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.`; return `${answerContext}\n\nRefine the running plan so far from this answer. Update the runningPlan object with a concise title, description, and concrete work-item deliverables; 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> {