From 2bd25b060544da0c74926395887042a860ba3566 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 01:36:38 -0700 Subject: [PATCH] FN-8260: prevent truncated planning completions Prevent incomplete AI responses from reaching the Planning Complete checkpoint. - Reject completion payloads repaired from truncated JSON and retry once - Preserve tolerant question parsing and add regression coverage - Document the retryable incomplete-response behavior and add a patch changeset Files changed: .../fn-8260-planning-truncated-completion.md | 7 ++ docs/dashboard-guide.md | 3 +- .../planning-truncated-completion.test.ts | 35 +++++++++ packages/dashboard/src/planning.ts | 85 +++++++++++++++++++--- 4 files changed, 117 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-8260 Fusion-Task-Lineage: aff1a1de-7b01-4cad-83a1-06b9684ed9de Co-authored-by: Fusion (runfusion.ai) --- .../fn-8260-planning-truncated-completion.md | 7 ++ docs/dashboard-guide.md | 3 +- .../planning-truncated-completion.test.ts | 35 ++++++++ packages/dashboard/src/planning.ts | 85 ++++++++++++++++--- 4 files changed, 117 insertions(+), 13 deletions(-) create mode 100644 .changeset/fn-8260-planning-truncated-completion.md create mode 100644 packages/dashboard/src/__tests__/planning-truncated-completion.test.ts diff --git a/.changeset/fn-8260-planning-truncated-completion.md b/.changeset/fn-8260-planning-truncated-completion.md new file mode 100644 index 0000000000..75ee15e756 --- /dev/null +++ b/.changeset/fn-8260-planning-truncated-completion.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Planning Mode no longer accepts a truncated final plan with empty deliverables. +category: fix +dev: parseAgentResponse now rejects truncation-repaired completions; acceptance paths retry or surface a retryable error instead of showing an incomplete checkpoint summary. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index df715d3b40..2523727785 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -507,7 +507,8 @@ Use **Copy prompt** in the error panel or an active interview question to copy t -Before Planning Mode shows **Planning Complete!** or the final plan summary, it first asks **Would you like to go deeper?**. A read-only preview of the generated plan appears above the refinement options so you can review its title, formatted description, and key deliverables before deciding. The suggested themes are plan-specific: the planning AI proposes topics tailored to your plan's title, description, and deliverables as part of its completion response, surfacing angles you may not have anticipated. When the AI does not supply any themes, Planning Mode falls back to a generic, regex-derived set (scope, edge cases, UX, dependencies, testing, rollout) inferred from the interview text. Either way, select one or more suggested themes to continue the interview, use **Other** to add a custom topic, or choose **Proceed to final plan** to reveal the pending summary and task-creation actions. + +Before Planning Mode shows **Planning Complete!** or the final plan summary, it first asks **Would you like to go deeper?**. If the final AI response is incomplete, Planning Mode requests a clean complete response once; if that fails, it shows a retryable session error rather than presenting a partial plan. A read-only preview of the generated plan appears above the refinement options so you can review its title, formatted description, and key deliverables before deciding. The suggested themes are plan-specific: the planning AI proposes topics tailored to your plan's title, description, and deliverables as part of its completion response, surfacing angles you may not have anticipated. When the AI does not supply any themes, Planning Mode falls back to a generic, regex-derived set (scope, edge cases, UX, dependencies, testing, rollout) inferred from the interview text. Either way, select one or more suggested themes to continue the interview, use **Other** to add a custom topic, or choose **Proceed to final plan** to reveal the pending summary and task-creation actions. - **Branch strategy** options mirror Subtask Breakdown semantics: - `Use project/default branch` diff --git a/packages/dashboard/src/__tests__/planning-truncated-completion.test.ts b/packages/dashboard/src/__tests__/planning-truncated-completion.test.ts new file mode 100644 index 0000000000..982f2e318f --- /dev/null +++ b/packages/dashboard/src/__tests__/planning-truncated-completion.test.ts @@ -0,0 +1,35 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { parseAgentResponse } from "../planning.js"; + +const completePrefix = '{"type":"complete","data":{"title":"Plan","description":"Complete description","keyDeliverables":['; + +describe("parseAgentResponse truncated completion recovery", () => { + it("accepts a clean complete payload, including legitimately empty deliverables", () => { + const response = parseAgentResponse(JSON.stringify({ + type: "complete", + data: { title: "Plan", description: "Complete description", keyDeliverables: [] }, + })); + + expect(response).toEqual({ + type: "complete", + data: { title: "Plan", description: "Complete description", keyDeliverables: [] }, + }); + }); + + it.each([ + ['{"type":"complete","data":{"title":"Plan","description":"chopped', "unclosed string"], + [`${completePrefix}{"title":"first"}`, "unclosed array"], + ['{"type":"complete","data":{"title":"Plan"', "unclosed object"], + ])("rejects a %s completion instead of accepting repair as final", (response) => { + expect(() => parseAgentResponse(response)).toThrow("truncated completion"); + }); + + it("keeps trailing-comma question recovery tolerant", () => { + expect(parseAgentResponse('{"type":"question","data":{"question":"What next?",}}')).toEqual({ + type: "question", + data: { question: "What next?" }, + }); + }); +}); diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 0770a7c471..5022698635 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -1496,7 +1496,7 @@ async function continueToSummaryAfterSuppressedQuestion( const followUp = (session.agent.session.state.messages as AgentMessage[]) .filter((message) => message.role === "assistant") .pop(); - const followUpText = typeof followUp?.content === "string" + let followUpText = typeof followUp?.content === "string" ? followUp.content : Array.isArray(followUp?.content) ? followUp.content @@ -1504,9 +1504,34 @@ async function continueToSummaryAfterSuppressedQuestion( .map((block) => block.text) .join("") : ""; - const complete = parseAgentResponse(followUpText); - if (complete.type !== "complete") { - throw new Error("Clarification-disabled follow-up did not produce a summary"); + let complete: PlanningResponse | undefined; + let lastError: Error | undefined; + for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) { + try { + complete = parseAgentResponse(followUpText); + break; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + if (attempt === MAX_PARSE_RETRIES) break; + await (session.agent.session.prompt as (input: string, options?: { signal?: AbortSignal }) => Promise)( + 'Your previous response was incomplete or invalid. Return ONLY a complete valid JSON object: {"type":"complete","data":{...}}. No markdown or explanation.', + { signal: abortSignal }, + ); + const retry = (session.agent.session.state.messages as AgentMessage[]) + .filter((message) => message.role === "assistant") + .pop(); + followUpText = typeof retry?.content === "string" + ? retry.content + : Array.isArray(retry?.content) + ? retry.content + .filter((block): block is { type: "text"; text: string } => block.type === "text" && typeof block.text === "string") + .map((block) => block.text) + .join("") + : ""; + } + } + if (!complete || complete.type !== "complete") { + throw new Error(buildRetryableParseErrorMessage(lastError ?? new Error("Clarification-disabled follow-up did not produce a summary"))); } return setPendingSummaryCheckpoint(session, normalizePlanningSummaryPayload(complete.data, { title: session.title || session.initialPlan, @@ -2545,7 +2570,7 @@ function parseJsonCandidateForShape(candidate: string): unknown | undefined { return JSON.parse(candidate); } catch { try { - return JSON.parse(repairJson(candidate)); + return JSON.parse(repairJson(candidate).repaired); } catch { return undefined; } @@ -2602,6 +2627,16 @@ function extractJsonCandidate(text: string): string | null { const planningCandidate = candidates.find((candidate) => isPlanningResponseShape(candidate.parsed)); if (planningCandidate) return planningCandidate.text; + /* + FNXC:PlanningJsonRecovery 2026-07-28-12:00: + FN-8260 must prefer a truncated top-level completion over a complete nested + deliverable object, so a cutoff cannot silently select that nested object. + */ + const trimmed = text.trim(); + if (trimmed.startsWith("{") && isPlanningResponseShape(parseJsonCandidateForShape(trimmed))) { + return trimmed; + } + // Pick the largest valid JSON candidate only after planning-shaped candidates are ruled out. const validCandidates = candidates.filter((candidate) => candidate.parsed !== undefined); if (validCandidates.length > 0) { @@ -2610,7 +2645,6 @@ function extractJsonCandidate(text: string): string | null { } // 3. Last resort: try the full trimmed text so repairJson can close truncated objects. - const trimmed = text.trim(); if (trimmed.startsWith("{")) return trimmed; return null; @@ -2624,8 +2658,9 @@ function extractJsonCandidate(text: string): string | null { * * Returns the repaired string, or the original if no repair was possible. */ -function repairJson(text: string): string { +function repairJson(text: string): { repaired: string; wasTruncationRepaired: boolean } { let repaired = text; + let wasTruncationRepaired = false; // Fix trailing commas before } or ] repaired = repaired.replace(/,\s*([}\]])/g, "$1"); @@ -2649,6 +2684,7 @@ function repairJson(text: string): string { // If we're in an unclosed string, close it if (inString) { repaired += '"'; + wasTruncationRepaired = true; } // Re-count after potential string fix @@ -2668,10 +2704,15 @@ function repairJson(text: string): string { } // Close unclosed brackets and braces - repaired += "]".repeat(Math.max(0, openBrackets)); - repaired += "}".repeat(Math.max(0, openBraces)); + const missingBrackets = Math.max(0, openBrackets); + const missingBraces = Math.max(0, openBraces); + if (missingBrackets > 0 || missingBraces > 0) { + wasTruncationRepaired = true; + } + repaired += "]".repeat(missingBrackets); + repaired += "}".repeat(missingBraces); - return repaired; + return { repaired, wasTruncationRepaired }; } /** @@ -2683,6 +2724,13 @@ function repairJson(text: string): string { * 3. If parse fails, attempt repair (truncated JSON, trailing commas) * 4. Validate the resulting structure */ +class TruncatedPlanningCompletionError extends Error { + constructor() { + super("AI returned a truncated completion response"); + this.name = "TruncatedPlanningCompletionError"; + } +} + export function parseAgentResponse(text: string): PlanningResponse { const candidate = extractJsonCandidate(text); @@ -2692,13 +2740,15 @@ export function parseAgentResponse(text: string): PlanningResponse { } let parsed: unknown; + let wasTruncationRepaired = false; try { parsed = JSON.parse(candidate); } catch { // Attempt repair for truncated/malformed JSON try { - const repaired = repairJson(candidate); - parsed = JSON.parse(repaired); + const repair = repairJson(candidate); + wasTruncationRepaired = repair.wasTruncationRepaired; + parsed = JSON.parse(repair.repaired); } catch (repairErr) { diagnostics.error( "Failed to parse agent response (repair also failed)", @@ -2712,6 +2762,17 @@ export function parseAgentResponse(text: string): PlanningResponse { // Validate structure if (isPlanningResponseShape(parsed)) { + /* + FNXC:PlanningJsonRecovery 2026-07-28-12:00: + FN-8260 / GitHub #2240 requires Planning Mode to treat a completion whose + structure was closed by recovery as incomplete generation. Retrying here + prevents a token-cutoff plan from reaching the checkpoint as an empty or + chopped summary, while clean payloads and benign question repairs remain + accepted. + */ + if (parsed.type === "complete" && wasTruncationRepaired) { + throw new TruncatedPlanningCompletionError(); + } return parsed; }