diff --git a/.changeset/fn-8759-planning-session-qa.md b/.changeset/fn-8759-planning-session-qa.md new file mode 100644 index 0000000000..c5ac1d6308 --- /dev/null +++ b/.changeset/fn-8759-planning-session-qa.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Retain complete Planning Mode interview decisions in every created task. +category: fix +dev: Centralizes Planning Mode task handoff formatting across single, CLI, and multi-task creation. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 219e8542d2..e386ab5e28 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -606,7 +606,8 @@ After five completed answers, mobile interviews show **Next question** and **Rev Choose **Validate** when the plan is ready. Validation is durable and immediately creates the task using the selected workflow and branch settings. If creation is interrupted after validation, Planning restores a create-only retry state; it never validates again or creates a second task. **Sessions** (and mobile Back) remains the escape hatch for browsing, switching, and reviewing session history, with **New session** pinned in the saved-session list. The AI never ends an interview on its own; selection questions still provide alternatives with pros and cons plus an **Other** free-text choice. -On creation, the validated running plan becomes **plan.md**: its title, description, size, suggested dependencies, and key deliverables are stored as the task description and task document `plan` (priority remains a task field, not a plan.md section). The original request that started the session is stored separately as `original-description`. The task planning agent later expands plan.md into the executor-ready **PROMPT.md**; PROMPT.md's **Original Description** preserves that original request verbatim. + +On creation, the validated running plan becomes **plan.md**: its title, description, size, suggested dependencies, and key deliverables are stored as the task description and task document `plan` (priority remains a task field, not a plan.md section). The full ordered Planning Mode interview Q&A, including selected answers, custom **Other** text, and comments, is retained with that plan for triage and executors. The original request that started the session is stored separately as `original-description`. The task planning agent later expands plan.md into the executor-ready **PROMPT.md**; PROMPT.md's **Original Description** preserves that original request verbatim. - **Branch strategy** options mirror Subtask Breakdown semantics: - `Use project/default branch` diff --git a/packages/dashboard/src/__tests__/planning-interview-formatters.test.ts b/packages/dashboard/src/__tests__/planning-interview-formatters.test.ts index fc1a98c786..b87536d412 100644 --- a/packages/dashboard/src/__tests__/planning-interview-formatters.test.ts +++ b/packages/dashboard/src/__tests__/planning-interview-formatters.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; -import type { PlanningQuestion } from "@fusion/core"; +import { parsePlanningPlanMd, type PlanningQuestion, type PlanningSummary } from "@fusion/core"; import { formatInitialPlanRequestForAgent, formatInitialRunningPlanRequestForAgent, formatInterviewQA, + formatPlanningTaskHandoff, formatResponseForAgent, normalizePlanningSummaryPayload, } from "../planning"; @@ -122,3 +123,45 @@ describe("planning interview formatter Other answers", () => { expect(prompt).toMatch(/every accumulated decision/i); }); }); + +describe("planning task handoff formatter", () => { + const summary: PlanningSummary = { + title: "Ship planning handoff", + description: "Preserve the lean plan.", + suggestedSize: "M", + suggestedDependencies: [], + keyDeliverables: ["Persist decisions"], + }; + + it("includes every ordered answer once without mutating the lean summary", () => { + const longOther = "Long operator decision ".repeat(20); + const history = [ + { question: singleSelectQuestion, response: { scope: "mvp", _other: longOther, _comment: "Keep this comment" } }, + { question: multiSelectQuestion, response: { priorities: ["speed", "quality"] } }, + { question: confirmQuestion, response: { proceed: true } }, + ]; + + const handoff = formatPlanningTaskHandoff(summary, history); + const qa = formatInterviewQA(history); + + expect(handoff).toContain(qa); + expect(handoff.match(/## Planning Interview Context/g)).toHaveLength(1); + expect(handoff).toContain("MVP"); + expect(handoff).toContain("Speed, Quality"); + expect(handoff).toContain("Yes"); + expect(handoff).toContain(longOther); + expect(handoff).toContain("Keep this comment"); + expect(summary.description).toBe("Preserve the lean plan."); + expect(parsePlanningPlanMd(handoff)).toMatchObject({ + title: summary.title, + description: expect.stringContaining(longOther), + }); + expect(formatPlanningTaskHandoff({ ...summary, description: `${summary.description}\n\n${qa}` }, history).match(/## Planning Interview Context/g)).toHaveLength(1); + }); + + it("keeps empty interview handoffs canonical without an empty context shell", () => { + const handoff = formatPlanningTaskHandoff(summary, []); + expect(handoff).not.toContain("Planning Interview Context"); + expect(parsePlanningPlanMd(handoff)).toMatchObject({ description: summary.description }); + }); +}); diff --git a/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts b/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts index 54a2ded1cb..4cbe25a489 100644 --- a/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts +++ b/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts @@ -266,8 +266,43 @@ describe("planning question regeneration instead of no-active-question errors", createTask, } as unknown as TaskStore; + const session = await getSession(sessionId); + const longOther = "Lossless custom decision ".repeat(20); + session!.history = [ + { + question: { + id: "direction", + type: "single_select", + question: "Which direction?", + options: [{ id: "safe", label: "Safe rollout" }], + }, + response: { direction: "safe", _other: longOther, _comment: "Keep the audit trail" }, + }, + { + question: { + id: "priorities", + type: "multi_select", + question: "Which priorities?", + options: [{ id: "quality", label: "Quality" }, { id: "speed", label: "Speed" }], + }, + response: { priorities: ["quality", "speed"] }, + }, + { + question: { id: "confirm", type: "confirm", question: "Proceed?" }, + response: { confirm: true }, + }, + ]; + const first = await createTaskFromPlanSession(sessionId, taskStore); expect(first.alreadyCreated).toBe(false); + const firstDescription = createTask.mock.calls[0]![0].description; + expect(firstDescription).toContain("## Planning Interview Context"); + expect(firstDescription).toContain("Safe rollout"); + expect(firstDescription).toContain("Quality, Speed"); + expect(firstDescription).toContain("Yes"); + expect(firstDescription).toContain(longOther); + expect(firstDescription).toContain("Keep the audit trail"); + expect(firstDescription.match(/## Planning Interview Context/g)).toHaveLength(1); expect(createTask).toHaveBeenCalledTimes(1); expect(createTask.mock.calls[0][0].proposalClaimId).toBe(`planning-session:${sessionId}`); expect((await getSession(sessionId))?.validated).toBe(true); diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index 3a4c7989cc..3e87471f39 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -2619,6 +2619,11 @@ describe("Planning Mode Routes", () => { responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] }, }), { "Content-Type": "application/json" }); await REQUEST(buildApp(), "POST", `/api/planning/${sessionId}/validate`, undefined, { "Content-Type": "application/json" }); + const completedSession = await planningModule.getSession(sessionId); + completedSession!.history = [{ + question: { id: "handoff", type: "text", question: "What must remain durable?" }, + response: "Must have login", + }]; // Create task from planning const res = await REQUEST( @@ -2631,6 +2636,10 @@ describe("Planning Mode Routes", () => { expect(res.status).toBe(201); expect(store.createTask).toHaveBeenCalled(); + const [createInput] = (store.createTask as ReturnType).mock.calls[0]!; + expect(createInput.description).toContain("## Planning Interview Context"); + expect(createInput.description).toContain("Must have login"); + expect(createInput.description.match(/## Planning Interview Context/g)).toHaveLength(1); }); it("terminalizes a not-yet-validated session when Proceed with plan creates its task", async () => { @@ -3266,6 +3275,16 @@ describe("Planning Mode Routes", () => { (store.logEntry as ReturnType).mockResolvedValue(undefined); const planningSessionId = await createCompletedPlanningSession(); + const planningSession = await planningModule.getSession(planningSessionId); + planningSession!.history = [{ + question: { + id: "retention", + type: "single_select", + question: "Which retention policy?", + options: [{ id: "full", label: "Keep full interview context" }], + }, + response: { retention: "full", _other: "Preserve every custom answer", _comment: "No truncation" }, + }]; const breakdownRes = await REQUEST( buildApp(), "POST", @@ -3309,9 +3328,16 @@ describe("Planning Mode Routes", () => { expect(res.status).toBe(201); expect(res.body.tasks).toHaveLength(2); + for (const [input] of (store.createTask as ReturnType).mock.calls) { + expect(input.description).toContain("## Planning Interview Context"); + expect(input.description).toContain("Keep full interview context"); + expect(input.description).toContain("Preserve every custom answer"); + expect(input.description).toContain("No truncation"); + expect(input.description.match(/## Planning Interview Context/g)).toHaveLength(1); + } }); - it("keeps the completed planning session in history after multi-task creation", async () => { + it("keeps the completed planning session in history after multi-task creation", async () => { // Bug C: /planning/create-tasks used cleanupSession() which deleted the // persisted ai_sessions row, so a session that ran to completion AND // created tasks vanished from the saved-sessions history. It must instead diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 04ae321a83..8bdeecd7cf 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -3874,15 +3874,12 @@ function formatInterviewAnswer(question: PlanningQuestion, responseValue: unknow case "text": return typeof responseValue === "string" ? responseValue : String(responseValue ?? ""); - case "single_select": - if (other.length > 0) { - return `${other} (user's own answer)`; - } - if (typeof responseValue === "string") { - const option = question.options?.find((candidate) => candidate.id === responseValue); - return option?.label || responseValue; - } - return String(responseValue ?? ""); + case "single_select": { + const selected = typeof responseValue === "string" + ? question.options?.find((candidate) => candidate.id === responseValue)?.label || responseValue + : String(responseValue ?? ""); + return [selected, other.length > 0 ? `${other} (user's own answer)` : ""].filter(Boolean).join(", "); + } case "multi_select": { const selected = Array.isArray(responseValue) ? responseValue.map((id) => { @@ -3898,8 +3895,10 @@ function formatInterviewAnswer(question: PlanningQuestion, responseValue: unknow return selected.length > 0 ? selected.join(", ") : String(responseValue ?? ""); } - case "confirm": - return other.length > 0 ? `${other} (user's own answer)` : responseValue === true ? "Yes" : "No"; + case "confirm": { + const selected = responseValue === true ? "Yes" : "No"; + return [selected, other.length > 0 ? `${other} (user's own answer)` : ""].filter(Boolean).join(", "); + } default: return JSON.stringify(responseValue); @@ -3932,6 +3931,25 @@ export function formatInterviewQA( return `## Planning Interview Context\n\n${entries.join("\n\n")}`; } +/* +FNXC:PlanningMode 2026-08-03-10:03: +Every task created from Planning Mode must retain the ordered interview decisions that shaped its +lean plan. Compose a copy for the task handoff so the authoritative running summary stays lean, +empty sessions add no shell, and replaying an already-composed child cannot duplicate Q&A. +*/ +export function formatPlanningTaskHandoff( + summary: PlanningSummary, + history: Array<{ question: PlanningQuestion; response: unknown }>, +): string { + const qaSection = formatInterviewQA(history); + const description = summary.description.trim(); + const handoffDescription = qaSection && !description.includes(qaSection) + ? `${description}\n\n${qaSection}` + : description; + + return formatPlanningPlanMd({ ...summary, description: handoffDescription }); +} + /** * Cancel and cleanup a planning session. */ @@ -4191,7 +4209,7 @@ export async function createTaskFromPlanSession( // FNXC:PlanningMultiTask 2026-07-24-03:40: review finding — a post-insert failure (e.g. finalize) lands in the raced-insert catch; without this marker the task WE created was mislabeled alreadyCreated:true. let insertedTask: Task | undefined; try { - const planMd = formatPlanningPlanMd(summary); + const planMd = formatPlanningTaskHandoff(summary, session.history); const originalRequest = session.initialPlan?.trim() || summary.description.trim(); const task = await store.createTask({ title: summary.title, diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index 7bde554048..f8faa69cec 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -1,6 +1,5 @@ import { DEFAULT_TASK_PRIORITY, - formatPlanningPlanMd, resolveEffectiveSettingsDetailedById, resolvePlanningSettingsModel, TASK_PRIORITIES, @@ -1203,6 +1202,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann releasePlanningTaskCreation, validateSession, planningProposalClaimId, + formatPlanningTaskHandoff, } = await import("../planning.js"); let session = await getSession(sessionId); @@ -1433,7 +1433,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann FN-8441 hands the current lean plan to triage as task description plus a plan document. The raw session request remains a separate original-description document. */ - const planMd = formatPlanningPlanMd(summary); + const planMd = formatPlanningTaskHandoff(summary, session?.history ?? []); // Persisted legacy sessions can lack initialPlan; retain the pre-format plan body, // never the session title, as the only fail-soft operator-request substitute. const originalRequest = typeof initialPlan === "string" && initialPlan.trim() @@ -1598,7 +1598,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann } const { store: scopedStore } = await getProjectContext(req); - const { getSession, releaseSession, formatInterviewQA, mergePlanningSubtaskDrafts } = await import("../planning.js"); + const { getSession, releaseSession, formatInterviewQA, formatPlanningTaskHandoff, mergePlanningSubtaskDrafts } = await import("../planning.js"); const session = await getSession(planningSessionId); if (!session) { @@ -1695,13 +1695,13 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann for (const item of mergedSubtasks) { const itemDescription = typeof item.description === "string" ? item.description.trim() : item.title.trim(); - const planMd = formatPlanningPlanMd({ + const planMd = formatPlanningTaskHandoff({ title: item.title.trim(), description: itemDescription, suggestedSize: item.suggestedSize ?? session.summary.suggestedSize, suggestedDependencies: item.dependsOn ?? [], keyDeliverables: [itemDescription], - }); + }, session.history); const { workingBranch: taskBranch } = resolveEntryPointBranchAssignment({ assignmentMode: branchMode, resolvedBranch,