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:
gsxdsm
2026-07-20 10:33:59 -07:00
parent 9db0ffc1f9
commit e43dffad52
7 changed files with 232 additions and 65 deletions

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

View File

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

View File

@@ -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 */

View File

@@ -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" }));

View File

@@ -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");

View File

@@ -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");
});
});

View File

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