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:
7
.changeset/fn-8438-running-plan-generate-refine.md
Normal file
7
.changeset/fn-8438-running-plan-generate-refine.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user