From e43dffad521cfc637623ff591e817357e5e8ed7e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 20 Jul 2026 10:33:59 -0700 Subject: [PATCH] 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) --- .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 +- .../planning-e2e-plan-creation.test.ts | 17 +- .../planning-infinite-interview.test.ts | 91 +++++++++- packages/dashboard/src/planning.ts | 163 ++++++++++++------ 7 files changed, 232 insertions(+), 65 deletions(-) create mode 100644 .changeset/fn-8434-running-plan-content.md diff --git a/.changeset/fn-8434-running-plan-content.md b/.changeset/fn-8434-running-plan-content.md new file mode 100644 index 0000000000..485e247820 --- /dev/null +++ b/.changeset/fn-8434-running-plan-content.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index e63d61cec0..5ef08b889e 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. -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. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b96ec40f15..4883441ae0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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 */ diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index 2f01e7f428..9d98a5ba9c 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -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" })); diff --git a/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts b/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts index c780226f2c..3dcf26f750 100644 --- a/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts +++ b/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts @@ -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"); diff --git a/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts b/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts index 94dd366d5b..896fd59031 100644 --- a/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts +++ b/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts @@ -38,7 +38,11 @@ function payload(data: Record): 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"); }); }); diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 98cdc62885..fca039079f 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -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> { 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(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 + : {}; + 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; + 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), + }, { + 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 Promise)( "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 {