diff --git a/.changeset/fn-8438-running-plan-generate-refine.md b/.changeset/fn-8438-running-plan-generate-refine.md new file mode 100644 index 0000000000..c136e1c672 --- /dev/null +++ b/.changeset/fn-8438-running-plan-generate-refine.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 57b2fdca56..2d2820e353 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. -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. + +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. diff --git a/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts b/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts index 896fd59031..83ec43204c 100644 --- a/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts +++ b/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts @@ -241,8 +241,8 @@ describe("reactive Planning Mode question contract", () => { expect(await getSession(created.sessionId)).toMatchObject({ validated: true, currentQuestion: undefined }); }); - it("uses a model runningPlan attached to a continuing question", async () => { - installScriptedAgent([payload({ + it("uses a model-authored initial plan on the non-streaming first turn", async () => { + const prompts = installScriptedAgent([payload({ ...FIRST_QUESTION, runningPlan: { title: "Account recovery implementation plan", @@ -258,6 +258,66 @@ describe("reactive Planning Mode question contract", () => { description: "Deliver a secure, observable recovery experience.", 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((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((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 () => { @@ -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"); expect(created.summary).toMatchObject({ - title: "Build secure account recovery", - description: "Build secure account recovery", - keyDeliverables: [], + title: "Plan: Build secure account recovery", + description: expect.stringContaining("Plan and deliver Build secure account recovery"), + 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); @@ -307,7 +369,7 @@ describe("reactive Planning Mode question contract", () => { 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).toContain("Define scope and acceptance criteria for Build secure account recovery"); expect(session?.summary?.keyDeliverables).not.toEqual(askedQuestions); 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?.currentQuestion?.id).toBe("fresh-after-edit"); 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); }); }); diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index fca039079f..031428f89e 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -1049,8 +1049,7 @@ export async function createSession( session.agent = agentResult; session.updatedAt = new Date(); - // Send initial plan to get first question from AI - const firstResponse = await getFirstQuestionFromAgent(session, initialPlan); + const firstResponse = await getFirstQuestionFromAgent(session, formatInitialPlanRequestForAgent(initialPlan)); const firstQuestion = firstResponse.data; session.currentQuestion = firstQuestion; @@ -1665,8 +1664,7 @@ async function initializeAgent( session.updatedAt = new Date(); }); - // Send initial message to get first question - await continueAgentConversation(session, session.initialPlan); + await continueAgentConversation(session, formatInitialPlanRequestForAgent(session.initialPlan)); } catch (err) { if (err instanceof Error && err.name === "AbortError") { return; @@ -2055,26 +2053,56 @@ function describePlanningAnswer(entry: PlanningHistoryEntry): string { 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( 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 + const subject = initialPlan.trim() || "the requested work"; + const initialDescription = `Plan and deliver ${subject}. Establish scope, implementation approach, and acceptance criteria through the planning interview.`; + 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 - : 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("; ")}`; + : priorDescription + ? `${priorDescription}\n\nPlanning decisions incorporated: ${incorporatedDecisions}.` + : `Plan and deliver ${subject}. Refine scope and implementation around the confirmed planning decisions: ${incorporatedDecisions}.`; return normalizePlanningSummaryPayload({ - title: previousSummary?.title || initialPlan.slice(0, 80), + title: previousSummary?.title || `Plan: ${subject.slice(0, 74)}`, description, suggestedSize: previousSummary?.suggestedSize ?? "M", priority: previousSummary?.priority, suggestedDependencies: previousSummary?.suggestedDependencies ?? [], - keyDeliverables: previousSummary?.keyDeliverables ?? [], - }, { title: initialPlan, description: initialDescription }); + keyDeliverables: previousSummary?.keyDeliverables?.length ? previousSummary.keyDeliverables : buildFallbackDeliverables(subject), + }, { title: `Plan: ${subject}`, description: initialDescription }); } function hasPlanContent(value: unknown): boolean { @@ -2816,7 +2844,7 @@ export async function retrySession( if (session.history.length === 0) { await ensureSessionAgent(session, rootDir, [], promptOverrides, store); - await continueAgentConversation(session, session.initialPlan); + await continueAgentConversation(session, formatInitialPlanRequestForAgent(session.initialPlan)); 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 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 {