FN-8434: preserve evolving running plans
Keep Planning Mode's running plan as an evolving work product alongside every interview question. - Carry model-authored running-plan updates through question responses without ending the interview - Merge partial plan updates with prior title, deliverables, dependencies, size, and priority - Derive safe fallback summaries without turning interview questions into deliverables - Cover running-plan persistence and document the contract Files changed: .changeset/fn-8434-running-plan-content.md | 7 + docs/dashboard-guide.md | 2 +- packages/core/src/types.ts | 6 + .../PlanningModeModal.planning-flow.test.tsx | 11 +- .../__tests__/planning-e2e-plan-creation.test.ts | 17 ++- .../__tests__/planning-infinite-interview.test.ts | 91 +++++++++++- packages/dashboard/src/planning.ts | 163 ++++++++++++++------- 7 files changed, 232 insertions(+), 65 deletions(-) Fusion-Task-Id: FN-8434 Fusion-Task-Lineage: cdf1c4ff-fd33-4e4a-84f4-b54a5e0a9cf8 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8434-running-plan-content.md
Normal file
7
.changeset/fn-8434-running-plan-content.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Planning Mode running plan shows an evolving plan, not repeated interview questions.
|
||||
category: fix
|
||||
dev: Fix buildRunningSummary + agent turn merge so summary keyDeliverables/description are plan-quality; preserve model plan fields when coercing complete payloads.
|
||||
@@ -529,7 +529,7 @@ When an active Planning AI generation appears stuck, Planning Mode automatically
|
||||
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. -->
|
||||
Planning Mode keeps the running plan visible beside answered-question history and the current question on desktop; 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; 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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -5639,6 +5639,12 @@ export interface PlanningQuestion {
|
||||
question: string;
|
||||
description?: string;
|
||||
options?: Array<{ id: string; label: string; description?: string; pros?: string[]; cons?: string[]; isOther?: boolean; customText?: string }>;
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-20-00:00:
|
||||
FN-8434 carries the evolving plan beside the next interview question. This field is additive:
|
||||
it must never be interpreted as model authority to complete a user-controlled Planning Mode session.
|
||||
*/
|
||||
runningPlan?: PlanningSummary;
|
||||
}
|
||||
|
||||
/** The final summary generated after planning conversation completes */
|
||||
|
||||
@@ -344,7 +344,11 @@ describe("PlanningModeModal", () => {
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
expect(await screen.findByText(mockQuestion.question)).toBeDefined();
|
||||
expect(screen.getByText("Before question running plan")).toBeDefined();
|
||||
const runningPlan = screen.getByRole("complementary", { name: "Running plan" });
|
||||
expect(within(runningPlan).getByText("Before question running plan")).toBeDefined();
|
||||
expect(within(runningPlan).getByText(mockSummary.description)).toBeDefined();
|
||||
expect(within(runningPlan).getByText("Login page")).toBeDefined();
|
||||
expect(within(runningPlan).queryByText(mockQuestion.question)).toBeNull();
|
||||
expect(screen.queryByText("Planning Complete!")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
@@ -3640,6 +3644,11 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Running plan" }));
|
||||
expect(rendered.container.querySelector(".planning-modal-body")).toHaveClass("planning-modal-body--compact-plan");
|
||||
const runningPlan = screen.getByRole("complementary", { name: "Running plan" });
|
||||
expect(within(runningPlan).getByText(mockSummary.title)).toBeVisible();
|
||||
expect(within(runningPlan).getByText(mockSummary.description)).toBeVisible();
|
||||
expect(within(runningPlan).getByText("Login page")).toBeVisible();
|
||||
expect(within(runningPlan).queryByText(mockQuestion.question)).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Validate plan" })).toBeVisible();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Answered questions" }));
|
||||
|
||||
@@ -157,7 +157,13 @@ function buildApp(store: TaskStore): express.Express {
|
||||
}
|
||||
|
||||
function expectRunningPlan(body: any) {
|
||||
expect(body.summary).toEqual(expect.objectContaining({ title: expect.any(String), description: expect.any(String) }));
|
||||
expect(body.summary).toEqual(expect.objectContaining({
|
||||
title: expect.any(String),
|
||||
description: expect.any(String),
|
||||
keyDeliverables: expect.any(Array),
|
||||
}));
|
||||
expect(body.summary.description).not.toBe(body.firstQuestion?.question);
|
||||
expect(body.summary.keyDeliverables).not.toContain(body.firstQuestion?.question);
|
||||
expect(body.validated).toBe(false);
|
||||
}
|
||||
|
||||
@@ -216,8 +222,8 @@ describe("Planning Mode plan creation E2E", () => {
|
||||
expectOpenQuestion(prematureComplete.body);
|
||||
expect(getCompleteResponseCount()).toBe(1);
|
||||
const afterPrematureComplete = await getRunningPlan(app, sessionId);
|
||||
expect(afterPrematureComplete).toMatchObject({ title: "Build secure account recovery" });
|
||||
expect(afterPrematureComplete.description).toContain('"scope":"secure"');
|
||||
expect(afterPrematureComplete).toMatchObject({ title: "Premature plan", description: "Must not finalize" });
|
||||
expect(afterPrematureComplete.keyDeliverables).not.toContain("Which outcome matters most?");
|
||||
|
||||
const midInterview = await post(app, "/api/planning/respond", {
|
||||
sessionId,
|
||||
@@ -227,7 +233,7 @@ describe("Planning Mode plan creation E2E", () => {
|
||||
expectOpenQuestion(midInterview.body);
|
||||
expect(midInterview.body.data.id).not.toBe(prematureComplete.body.data.id);
|
||||
const afterMidInterview = await getRunningPlan(app, sessionId);
|
||||
expect(afterMidInterview.description).toContain('"scope":"secure"');
|
||||
expect(afterMidInterview.description).toContain("Must not finalize");
|
||||
expect(afterMidInterview.description).toContain("controlled");
|
||||
|
||||
const otherSteer = await post(app, "/api/planning/respond", {
|
||||
@@ -254,8 +260,7 @@ describe("Planning Mode plan creation E2E", () => {
|
||||
expectOpenQuestion(edited.body);
|
||||
expect(edited.body.data).toMatchObject({ id: "riesgo-reeditado" });
|
||||
const afterEdit = await getRunningPlan(app, sessionId);
|
||||
expect(afterEdit.description).toContain('"scope":"fast"');
|
||||
expect(afterEdit.description).not.toContain('"scope":"secure"');
|
||||
expect(afterEdit.description).toContain("fast");
|
||||
expect(afterEdit.description).toContain("controlled");
|
||||
expect(afterEdit.description).toContain("priorizar controles de privacidad");
|
||||
|
||||
|
||||
@@ -38,7 +38,11 @@ function payload(data: Record<string, unknown>): string {
|
||||
function completePayload(): string {
|
||||
return JSON.stringify({
|
||||
type: "complete",
|
||||
data: { title: "The model tried to end the interview", description: "must be ignored" },
|
||||
data: {
|
||||
title: "Secure account recovery delivery",
|
||||
description: "Build a reviewed recovery workflow with audit coverage.",
|
||||
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -177,7 +181,10 @@ describe("reactive Planning Mode question contract", () => {
|
||||
|
||||
// The streamed processAgentTurn seam must coerce generic complete output into a question.
|
||||
expect(fallbackQuestion.id).not.toBe("complete");
|
||||
expect((await getSession(sessionId))?.summary?.description).toContain("Build secure account recovery");
|
||||
expect((await getSession(sessionId))?.summary).toMatchObject({
|
||||
title: "Secure account recovery delivery",
|
||||
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
|
||||
});
|
||||
expect((await getSession(sessionId))?.validated).toBe(false);
|
||||
|
||||
const next = await submitResponse(sessionId, {
|
||||
@@ -217,7 +224,10 @@ describe("reactive Planning Mode question contract", () => {
|
||||
const afterCompletion = await getSession(created.sessionId);
|
||||
expect(afterCompletion?.validated).toBe(false);
|
||||
expect(afterCompletion).not.toHaveProperty("pendingSummary");
|
||||
expect(afterCompletion?.summary?.description).toContain("audit logging security");
|
||||
expect(afterCompletion?.summary).toMatchObject({
|
||||
title: "Secure account recovery delivery",
|
||||
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
|
||||
});
|
||||
expect(afterCompletion?.currentQuestion).toBeDefined();
|
||||
|
||||
const secondQuestion = afterCompletion!.currentQuestion!;
|
||||
@@ -227,10 +237,81 @@ describe("reactive Planning Mode question contract", () => {
|
||||
expect((await getSession(created.sessionId))?.validated).toBe(false);
|
||||
|
||||
const finalPlan = await validateSession(created.sessionId);
|
||||
expect(finalPlan.description).toContain("Build secure account recovery");
|
||||
expect(finalPlan.description).toContain("Build a reviewed recovery workflow with audit coverage.");
|
||||
expect(await getSession(created.sessionId)).toMatchObject({ validated: true, currentQuestion: undefined });
|
||||
});
|
||||
|
||||
it("uses a model runningPlan attached to a continuing question", async () => {
|
||||
installScriptedAgent([payload({
|
||||
...FIRST_QUESTION,
|
||||
runningPlan: {
|
||||
title: "Account recovery implementation plan",
|
||||
description: "Deliver a secure, observable recovery experience.",
|
||||
keyDeliverables: ["Add recovery token flow", "Test recovery audit events"],
|
||||
},
|
||||
})]);
|
||||
|
||||
const created = await createSession("127.0.0.13", "Build secure account recovery", MOCK_TASK_STORE, "/tmp/project");
|
||||
expect(created.validated).toBe(false);
|
||||
expect(created.summary).toMatchObject({
|
||||
title: "Account recovery implementation plan",
|
||||
description: "Deliver a secure, observable recovery experience.",
|
||||
keyDeliverables: ["Add recovery token flow", "Test recovery audit events"],
|
||||
});
|
||||
});
|
||||
|
||||
it("merges a partial model running-plan update with the prior work product", async () => {
|
||||
installScriptedAgent([
|
||||
payload({
|
||||
...FIRST_QUESTION,
|
||||
runningPlan: {
|
||||
title: "Account recovery implementation plan",
|
||||
description: "Deliver a secure, observable recovery experience.",
|
||||
suggestedSize: "L",
|
||||
priority: "high",
|
||||
suggestedDependencies: ["Identity service"],
|
||||
keyDeliverables: ["Add recovery token flow", "Test recovery audit events"],
|
||||
},
|
||||
}),
|
||||
payload({
|
||||
...SECOND_QUESTION,
|
||||
runningPlan: { description: "Deliver a secure recovery experience with a gradual rollout." },
|
||||
}),
|
||||
]);
|
||||
|
||||
const created = await createSession("127.0.0.14", "Build secure account recovery", MOCK_TASK_STORE, "/tmp/project");
|
||||
await submitResponse(created.sessionId, { scope: "secure" }, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
|
||||
expect((await getSession(created.sessionId))?.summary).toEqual({
|
||||
title: "Account recovery implementation plan",
|
||||
description: "Deliver a secure recovery experience with a gradual rollout.",
|
||||
suggestedSize: "L",
|
||||
priority: "high",
|
||||
suggestedDependencies: ["Identity service"],
|
||||
keyDeliverables: ["Add recovery token flow", "Test recovery audit events"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps fallback running plans answer-aware without turning questions into deliverables", async () => {
|
||||
installScriptedAgent([payload(FIRST_QUESTION), payload(SECOND_QUESTION)]);
|
||||
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: [],
|
||||
});
|
||||
|
||||
await submitResponse(created.sessionId, { scope: "secure" }, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
const session = await getSession(created.sessionId);
|
||||
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).not.toEqual(askedQuestions);
|
||||
expect(session?.validated).toBe(false);
|
||||
});
|
||||
|
||||
it("replays an edited historical answer while retaining later answers and appending a fresh question", async () => {
|
||||
installScriptedAgent([
|
||||
payload(FIRST_QUESTION),
|
||||
@@ -253,6 +334,6 @@ describe("reactive Planning Mode question contract", () => {
|
||||
expect(edited?.history[0]?.response).toEqual({ scope: "fast" });
|
||||
expect(edited?.history[1]?.response).toEqual({ [second.id]: "gradual" });
|
||||
expect(edited?.currentQuestion?.id).toBe("fresh-after-edit");
|
||||
expect(edited?.summary?.description).toContain("fast");
|
||||
expect(edited?.summary?.description).toContain("Fast delivery");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,9 +232,9 @@ export const PLANNING_SYSTEM_PROMPT = `You are a planning assistant for the fn t
|
||||
|
||||
Ask exactly one next, high-impact question on every turn. Use every prior answer as context, avoid repeated questions, and never decide that the interview is complete or emit a terminal/complete response. The user alone validates the plan.
|
||||
|
||||
Respond only with JSON: {"type":"question","data":{"id":"unique-id","type":"single_select|multi_select","question":"...","description":"...","options":[{"id":"option-a","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"option-b","label":"...","description":"...","pros":["..."],"cons":["...]},{"id":"other","label":"...","isOther":true}]}}.
|
||||
Respond only with JSON: {"type":"question","data":{"id":"unique-id","type":"single_select|multi_select","question":"...","description":"...","options":[{"id":"option-a","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"option-b","label":"...","description":"...","pros":["..."],"cons":["...]},{"id":"other","label":"...","isOther":true}],"runningPlan":{"title":"...","description":"...","suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"]}}}.
|
||||
|
||||
Every question must provide at least two alternatives, each with non-empty pros and cons, plus exactly one Other/write-your-own option. Write every label, option, and Other label in the language of the user's original input. Incorporate free-text Other answers verbatim as steering context for the following question.`;
|
||||
Every turn must include runningPlan: a concise work-product title, description, and concrete deliverables informed by the idea and answers so far. Never use interview question text as a deliverable. Every question must provide at least two alternatives, each with non-empty pros and cons, plus exactly one Other/write-your-own option. Write every label, option, and Other label in the language of the user's original input. Incorporate free-text Other answers verbatim as steering context for the following question.`;
|
||||
|
||||
|
||||
|
||||
@@ -1054,11 +1054,11 @@ export async function createSession(
|
||||
|
||||
const firstQuestion = firstResponse.data;
|
||||
session.currentQuestion = firstQuestion;
|
||||
session.summary = mergeRunningSummary(session, firstResponse);
|
||||
session.updatedAt = new Date();
|
||||
await persistSession(session, "awaiting_input");
|
||||
void maybeNotifyPlanningAwaitingInput(session, firstQuestion, true);
|
||||
|
||||
session.summary = buildRunningSummary(initialPlan, session.history);
|
||||
return { sessionId, firstQuestion, summary: session.summary, validated: false };
|
||||
}
|
||||
|
||||
@@ -1070,7 +1070,7 @@ export async function createSession(
|
||||
async function getFirstQuestionFromAgent(
|
||||
session: Session,
|
||||
message: string,
|
||||
): Promise<{ type: "question"; data: PlanningQuestion }> {
|
||||
): Promise<Extract<PlanningResponse, { type: "question" }>> {
|
||||
if (!session.agent) {
|
||||
throw new InvalidSessionStateError("AI agent not initialized");
|
||||
}
|
||||
@@ -1142,7 +1142,7 @@ async function getFirstQuestionFromAgent(
|
||||
try {
|
||||
await session.agent.session.prompt(
|
||||
"Your previous response could not be parsed as JSON. " +
|
||||
'Please respond with ONLY a valid JSON object: {"type":"question","data":{...}}. ' +
|
||||
'Please respond with ONLY a valid JSON object: {"type":"question","data":{"runningPlan":{...},...}}. ' +
|
||||
"No markdown, no explanation, just the JSON."
|
||||
);
|
||||
|
||||
@@ -1194,16 +1194,22 @@ async function getFirstQuestionFromAgent(
|
||||
}
|
||||
|
||||
if (parsed.type === "question") {
|
||||
return { type: "question", data: normalizePlanningQuestion(parsed.data, session.initialPlan) };
|
||||
return {
|
||||
type: "question",
|
||||
data: {
|
||||
...normalizePlanningQuestion(parsed.data, session.initialPlan),
|
||||
...(parsed.data.runningPlan ? { runningPlan: parsed.data.runningPlan } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-18-11:36:
|
||||
FN-8331 makes the first planning turn an interview invariant: a completion cannot become a
|
||||
deepening checkpoint until the user has answered a real clarifying question. Re-prompt once
|
||||
for the required protocol shape, then use a safe local question if the model still refuses.
|
||||
FNXC:PlanningMode 2026-07-20-00:00:
|
||||
FN-8434 preserves a legacy complete payload's plan as a running-plan update while still
|
||||
coercing its control flow into a question. Only the user Validate action may terminalize.
|
||||
*/
|
||||
return requestMandatoryFirstPlanningQuestion(session);
|
||||
const mandatoryQuestion = await requestMandatoryFirstPlanningQuestion(session);
|
||||
return { type: "question", data: { ...mandatoryQuestion.data, runningPlan: parsed.data } };
|
||||
}
|
||||
|
||||
function buildMandatoryFirstPlanningQuestion(userInput = ""): PlanningQuestion {
|
||||
@@ -2029,21 +2035,80 @@ async function runGenerationWithTimeout<T>(session: Session, operation: (abortSi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:PlanningMode 2026-07-18-16:00:
|
||||
* Planning is an infinite, user-gated interview. A running plan is derived after every
|
||||
* turn and only validateSession may mark it final; model completion payloads are coerced
|
||||
* into another question rather than terminating the session.
|
||||
*/
|
||||
function buildRunningSummary(initialPlan: string, history: PlanningHistoryEntry[]): PlanningSummary {
|
||||
const answers = history.map((entry) => `${entry.question.question}: ${JSON.stringify(entry.response)}`);
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-20-00:00:
|
||||
FN-8434 makes the Running plan an evolving work product, not an interview transcript.
|
||||
Fallback text may acknowledge answer choices, but interview questions must never become
|
||||
key deliverables because task creation and breakdown consume those as implementation work.
|
||||
*/
|
||||
function describePlanningAnswer(entry: PlanningHistoryEntry): string {
|
||||
const response = entry.response && typeof entry.response === "object" && !Array.isArray(entry.response)
|
||||
? entry.response as Record<string, unknown>
|
||||
: {};
|
||||
const optionLabels = new Map((entry.question.options ?? []).map((option) => [option.id, option.label]));
|
||||
const values = Object.entries(response)
|
||||
.filter(([key]) => key !== "_comment")
|
||||
.flatMap(([, value]) => Array.isArray(value) ? value : [value])
|
||||
.filter((value): value is string | number | boolean => typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
||||
.map((value) => typeof value === "string" ? optionLabels.get(value) ?? value : String(value));
|
||||
const comment = typeof response._comment === "string" ? response._comment.trim() : "";
|
||||
return [...values, ...(comment ? [comment] : [])].join(", ") || "a response";
|
||||
}
|
||||
|
||||
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
|
||||
? 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("; ")}`;
|
||||
return normalizePlanningSummaryPayload({
|
||||
title: initialPlan.slice(0, 80) || "Untitled planning task",
|
||||
description: [initialPlan, ...answers].filter(Boolean).join("\n\n"),
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: history.map((entry) => entry.question.question),
|
||||
}, { title: initialPlan, description: initialPlan });
|
||||
title: previousSummary?.title || initialPlan.slice(0, 80),
|
||||
description,
|
||||
suggestedSize: previousSummary?.suggestedSize ?? "M",
|
||||
priority: previousSummary?.priority,
|
||||
suggestedDependencies: previousSummary?.suggestedDependencies ?? [],
|
||||
keyDeliverables: previousSummary?.keyDeliverables ?? [],
|
||||
}, { title: initialPlan, description: initialDescription });
|
||||
}
|
||||
|
||||
function hasPlanContent(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const plan = value as Record<string, unknown>;
|
||||
return (typeof plan.title === "string" && plan.title.trim().length > 0)
|
||||
|| (typeof plan.description === "string" && plan.description.trim().length > 0)
|
||||
|| (Array.isArray(plan.keyDeliverables) && plan.keyDeliverables.some((item) => typeof item === "string" && item.trim().length > 0));
|
||||
}
|
||||
|
||||
function getModelRunningPlan(response: PlanningResponse): unknown {
|
||||
if (response.type === "complete") return response.data;
|
||||
return response.data.runningPlan;
|
||||
}
|
||||
|
||||
function mergeRunningSummary(session: Session, response?: PlanningResponse): PlanningSummary {
|
||||
const modelPlan = response ? getModelRunningPlan(response) : undefined;
|
||||
if (hasPlanContent(modelPlan)) {
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-20-00:00:
|
||||
FN-8434 permits a question to carry a partial running-plan update. Merge that patch over
|
||||
the prior work product before normalization so an update to one field cannot reset the
|
||||
model's previously established title, deliverables, dependencies, size, or priority.
|
||||
*/
|
||||
const priorPlan = session.summary ?? buildRunningSummary(session.initialPlan, session.history);
|
||||
return normalizePlanningSummaryPayload({
|
||||
...priorPlan,
|
||||
...(modelPlan as Record<string, unknown>),
|
||||
}, {
|
||||
title: session.initialPlan,
|
||||
description: session.initialPlan,
|
||||
});
|
||||
}
|
||||
return buildRunningSummary(session.initialPlan, session.history, session.summary);
|
||||
}
|
||||
|
||||
function planningFallbackCopy(input: string): { question: string; option: (n: number) => string; pro: string; con: string; other: string } {
|
||||
@@ -2107,7 +2172,8 @@ export async function validateSession(sessionId: string): Promise<PlanningSummar
|
||||
activeGenerations.delete(session.id);
|
||||
}
|
||||
|
||||
session.summary = buildRunningSummary(session.initialPlan, session.history);
|
||||
// Keep the last model-authored running plan intact; validation only finalizes it.
|
||||
session.summary = session.summary ?? buildRunningSummary(session.initialPlan, session.history);
|
||||
session.currentQuestion = undefined;
|
||||
session.editingQuestionId = undefined;
|
||||
session.validated = true;
|
||||
@@ -2212,8 +2278,8 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
}
|
||||
await (session.agent.session.prompt as (input: string, options?: { signal?: AbortSignal }) => Promise<void>)(
|
||||
"Your previous response could not be parsed as JSON. " +
|
||||
'Please respond with ONLY a valid JSON object: either {"type":"question","data":{...}} ' +
|
||||
'or {"type":"complete","data":{...}}. No markdown, no explanation, just the JSON.',
|
||||
'Please respond with ONLY a valid JSON object: {"type":"question","data":{"runningPlan":{...},...}}. ' +
|
||||
'No markdown, no explanation, just the JSON.',
|
||||
{ signal: abortSignal },
|
||||
);
|
||||
if (abortSignal.aborted) {
|
||||
@@ -2268,28 +2334,18 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "question") {
|
||||
session.currentQuestion = coerceQuestionResponse(parsed, session);
|
||||
session.summary = buildRunningSummary(session.initialPlan, session.history);
|
||||
session.error = undefined;
|
||||
session.lastGeneratedThinking = session.thinkingOutput;
|
||||
session.updatedAt = new Date();
|
||||
// Persist after deriving the plan: reloads must see the running summary on every turn.
|
||||
persistSession(session, "awaiting_input");
|
||||
void maybeNotifyPlanningAwaitingInput(session, session.currentQuestion, true);
|
||||
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
|
||||
planningStreamManager.broadcast(session.id, { type: "question", data: session.currentQuestion });
|
||||
} else {
|
||||
// A generic engine completion is never terminal in Planning Mode.
|
||||
session.currentQuestion = coerceQuestionResponse(parsed, session);
|
||||
session.summary = buildRunningSummary(session.initialPlan, session.history);
|
||||
session.error = undefined;
|
||||
session.lastGeneratedThinking = session.thinkingOutput;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "awaiting_input");
|
||||
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
|
||||
planningStreamManager.broadcast(session.id, { type: "question", data: session.currentQuestion });
|
||||
}
|
||||
// A generic engine completion is never terminal in Planning Mode. Its data remains
|
||||
// eligible as a model-authored running plan while the fallback question keeps interviewing.
|
||||
session.currentQuestion = coerceQuestionResponse(parsed, session);
|
||||
session.summary = mergeRunningSummary(session, parsed);
|
||||
session.error = undefined;
|
||||
session.lastGeneratedThinking = session.thinkingOutput;
|
||||
session.updatedAt = new Date();
|
||||
// Persist after deriving the plan: reloads must see the running summary on every turn.
|
||||
persistSession(session, "awaiting_input");
|
||||
void maybeNotifyPlanningAwaitingInput(session, session.currentQuestion, true);
|
||||
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
|
||||
planningStreamManager.broadcast(session.id, { type: "question", data: session.currentQuestion });
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
@@ -2665,6 +2721,8 @@ export async function submitResponse(
|
||||
if (isEditingPriorAnswer) {
|
||||
session.history[editIndex] = historyEntry;
|
||||
session.editingQuestionId = undefined;
|
||||
// Rebuild from history before the next turn so stale pre-edit plan prose cannot survive.
|
||||
session.summary = buildRunningSummary(session.initialPlan, session.history);
|
||||
// Existing agent context contains the old answer; rebuild it from the preserved history.
|
||||
disposeSessionAgentForRetry(session);
|
||||
} else {
|
||||
@@ -2814,7 +2872,7 @@ export async function rewindSession(
|
||||
|
||||
session.currentQuestion = rewindEntry.question;
|
||||
session.editingQuestionId = questionId ? questionId : undefined;
|
||||
// Keep the plan available while the user edits; it is re-derived after submit.
|
||||
// Re-derive from retained answers so an edit cannot revive a prior question as a deliverable.
|
||||
session.summary = buildRunningSummary(session.initialPlan, session.history);
|
||||
session.error = undefined;
|
||||
session.lastGeneratedThinking = session.history[session.history.length - 1]?.thinkingOutput ?? "";
|
||||
@@ -2826,6 +2884,7 @@ export async function rewindSession(
|
||||
}
|
||||
|
||||
persistSession(session, "awaiting_input");
|
||||
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
|
||||
planningStreamManager.broadcast(session.id, { type: "question", data: rewindEntry.question });
|
||||
|
||||
return {
|
||||
@@ -2927,7 +2986,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\nIncorporate this answer into the running plan, 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\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.`;
|
||||
}
|
||||
|
||||
function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {
|
||||
|
||||
Reference in New Issue
Block a user