FN-8759: preserve planning interview context in created tasks

Retain complete Planning Mode decisions throughout task creation.

- Add a canonical handoff formatter that embeds ordered interview Q&A without duplicates.
- Apply the handoff to single-task, API, and multi-task planning creation paths.
- Cover selected, custom, and commented answers; document the retained context and add a patch changeset.

Files changed:
 .changeset/fn-8759-planning-session-qa.md          |  7 ++++
 docs/dashboard-guide.md                            |  3 +-
 .../planning-interview-formatters.test.ts          | 45 +++++++++++++++++++++-
 .../planning-question-regeneration.test.ts         | 35 +++++++++++++++++
 .../src/__tests__/routes-planning.test.ts          | 28 +++++++++++++-
 packages/dashboard/src/planning.ts                 | 42 ++++++++++++++------
 .../src/routes/register-planning-subtask-routes.ts | 10 ++---
 7 files changed, 150 insertions(+), 20 deletions(-)

Fusion-Task-Id: FN-8759
Fusion-Task-Lineage: c07e1714-cd0e-4701-9448-b77db773211d
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-03 03:23:14 -07:00
parent 71ba437cfe
commit f1ed5ac613
7 changed files with 150 additions and 20 deletions

View File

@@ -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.

View File

@@ -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. 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.
<!-- FNXC:PlanningMode 2026-07-20-12:00: FN-8441 separates the lean Planning Mode artifact from triage's executor specification. --> <!-- FNXC:PlanningMode 2026-07-20-12:00: FN-8441 separates the lean Planning Mode artifact from triage's executor specification. -->
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. <!-- FNXC:PlanningMode 2026-08-03-10:15: FN-8759 requires Planning Mode task handoff to retain the full ordered interview, including selected answers, custom Other text, and comments, without replacing the verbatim original request. -->
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: - **Branch strategy** options mirror Subtask Breakdown semantics:
- `Use project/default branch` - `Use project/default branch`

View File

@@ -1,9 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { PlanningQuestion } from "@fusion/core"; import { parsePlanningPlanMd, type PlanningQuestion, type PlanningSummary } from "@fusion/core";
import { import {
formatInitialPlanRequestForAgent, formatInitialPlanRequestForAgent,
formatInitialRunningPlanRequestForAgent, formatInitialRunningPlanRequestForAgent,
formatInterviewQA, formatInterviewQA,
formatPlanningTaskHandoff,
formatResponseForAgent, formatResponseForAgent,
normalizePlanningSummaryPayload, normalizePlanningSummaryPayload,
} from "../planning"; } from "../planning";
@@ -122,3 +123,45 @@ describe("planning interview formatter Other answers", () => {
expect(prompt).toMatch(/every accumulated decision/i); 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 });
});
});

View File

@@ -266,8 +266,43 @@ describe("planning question regeneration instead of no-active-question errors",
createTask, createTask,
} as unknown as TaskStore; } 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); const first = await createTaskFromPlanSession(sessionId, taskStore);
expect(first.alreadyCreated).toBe(false); 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).toHaveBeenCalledTimes(1);
expect(createTask.mock.calls[0][0].proposalClaimId).toBe(`planning-session:${sessionId}`); expect(createTask.mock.calls[0][0].proposalClaimId).toBe(`planning-session:${sessionId}`);
expect((await getSession(sessionId))?.validated).toBe(true); expect((await getSession(sessionId))?.validated).toBe(true);

View File

@@ -2619,6 +2619,11 @@ describe("Planning Mode Routes", () => {
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] }, responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" }); }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", `/api/planning/${sessionId}/validate`, undefined, { "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 // Create task from planning
const res = await REQUEST( const res = await REQUEST(
@@ -2631,6 +2636,10 @@ describe("Planning Mode Routes", () => {
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalled(); expect(store.createTask).toHaveBeenCalled();
const [createInput] = (store.createTask as ReturnType<typeof vi.fn>).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 () => { 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<typeof vi.fn>).mockResolvedValue(undefined); (store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const planningSessionId = await createCompletedPlanningSession(); 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( const breakdownRes = await REQUEST(
buildApp(), buildApp(),
"POST", "POST",
@@ -3309,9 +3328,16 @@ describe("Planning Mode Routes", () => {
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(res.body.tasks).toHaveLength(2); expect(res.body.tasks).toHaveLength(2);
for (const [input] of (store.createTask as ReturnType<typeof vi.fn>).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 // Bug C: /planning/create-tasks used cleanupSession() which deleted the
// persisted ai_sessions row, so a session that ran to completion AND // persisted ai_sessions row, so a session that ran to completion AND
// created tasks vanished from the saved-sessions history. It must instead // created tasks vanished from the saved-sessions history. It must instead

View File

@@ -3874,15 +3874,12 @@ function formatInterviewAnswer(question: PlanningQuestion, responseValue: unknow
case "text": case "text":
return typeof responseValue === "string" ? responseValue : String(responseValue ?? ""); return typeof responseValue === "string" ? responseValue : String(responseValue ?? "");
case "single_select": case "single_select": {
if (other.length > 0) { const selected = typeof responseValue === "string"
return `${other} (user's own answer)`; ? question.options?.find((candidate) => candidate.id === responseValue)?.label || responseValue
} : String(responseValue ?? "");
if (typeof responseValue === "string") { return [selected, other.length > 0 ? `${other} (user's own answer)` : ""].filter(Boolean).join(", ");
const option = question.options?.find((candidate) => candidate.id === responseValue); }
return option?.label || responseValue;
}
return String(responseValue ?? "");
case "multi_select": { case "multi_select": {
const selected = Array.isArray(responseValue) ? responseValue.map((id) => { 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 ?? ""); return selected.length > 0 ? selected.join(", ") : String(responseValue ?? "");
} }
case "confirm": case "confirm": {
return other.length > 0 ? `${other} (user's own answer)` : responseValue === true ? "Yes" : "No"; const selected = responseValue === true ? "Yes" : "No";
return [selected, other.length > 0 ? `${other} (user's own answer)` : ""].filter(Boolean).join(", ");
}
default: default:
return JSON.stringify(responseValue); return JSON.stringify(responseValue);
@@ -3932,6 +3931,25 @@ export function formatInterviewQA(
return `## Planning Interview Context\n\n${entries.join("\n\n")}`; 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. * 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. // 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; let insertedTask: Task | undefined;
try { try {
const planMd = formatPlanningPlanMd(summary); const planMd = formatPlanningTaskHandoff(summary, session.history);
const originalRequest = session.initialPlan?.trim() || summary.description.trim(); const originalRequest = session.initialPlan?.trim() || summary.description.trim();
const task = await store.createTask({ const task = await store.createTask({
title: summary.title, title: summary.title,

View File

@@ -1,6 +1,5 @@
import { import {
DEFAULT_TASK_PRIORITY, DEFAULT_TASK_PRIORITY,
formatPlanningPlanMd,
resolveEffectiveSettingsDetailedById, resolveEffectiveSettingsDetailedById,
resolvePlanningSettingsModel, resolvePlanningSettingsModel,
TASK_PRIORITIES, TASK_PRIORITIES,
@@ -1203,6 +1202,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
releasePlanningTaskCreation, releasePlanningTaskCreation,
validateSession, validateSession,
planningProposalClaimId, planningProposalClaimId,
formatPlanningTaskHandoff,
} = await import("../planning.js"); } = await import("../planning.js");
let session = await getSession(sessionId); 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 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. 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, // Persisted legacy sessions can lack initialPlan; retain the pre-format plan body,
// never the session title, as the only fail-soft operator-request substitute. // never the session title, as the only fail-soft operator-request substitute.
const originalRequest = typeof initialPlan === "string" && initialPlan.trim() 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 { 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); const session = await getSession(planningSessionId);
if (!session) { if (!session) {
@@ -1695,13 +1695,13 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
for (const item of mergedSubtasks) { for (const item of mergedSubtasks) {
const itemDescription = typeof item.description === "string" ? item.description.trim() : item.title.trim(); const itemDescription = typeof item.description === "string" ? item.description.trim() : item.title.trim();
const planMd = formatPlanningPlanMd({ const planMd = formatPlanningTaskHandoff({
title: item.title.trim(), title: item.title.trim(),
description: itemDescription, description: itemDescription,
suggestedSize: item.suggestedSize ?? session.summary.suggestedSize, suggestedSize: item.suggestedSize ?? session.summary.suggestedSize,
suggestedDependencies: item.dependsOn ?? [], suggestedDependencies: item.dependsOn ?? [],
keyDeliverables: [itemDescription], keyDeliverables: [itemDescription],
}); }, session.history);
const { workingBranch: taskBranch } = resolveEntryPointBranchAssignment({ const { workingBranch: taskBranch } = resolveEntryPointBranchAssignment({
assignmentMode: branchMode, assignmentMode: branchMode,
resolvedBranch, resolvedBranch,