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:
7
.changeset/fn-8759-planning-session-qa.md
Normal file
7
.changeset/fn-8759-planning-session-qa.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
<!-- 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:
|
||||
- `Use project/default branch`
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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 () => {
|
||||
@@ -3266,6 +3275,16 @@ describe("Planning Mode Routes", () => {
|
||||
(store.logEntry as ReturnType<typeof vi.fn>).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<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
|
||||
// persisted ai_sessions row, so a session that ran to completion AND
|
||||
// created tasks vanished from the saved-sessions history. It must instead
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user