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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 01:36:38 -07:00
parent 377cb9c90a
commit 2bd25b0605
4 changed files with 117 additions and 13 deletions

View File

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

View File

@@ -507,7 +507,8 @@ Use **Copy prompt** in the error panel or an active interview question to copy t
<!-- FNXC:PlanningMode 2026-07-05-00:25: The planning AI now proposes plan-specific deepening themes (deepeningThemes on the completion payload) so this checkpoint surfaces topics tailored to the user's actual plan, including angles they had not anticipated, instead of a fixed generic set. The regex-derived generic themes remain the fallback whenever the AI supplies none (FN-7616 / issue #1912). --> <!-- FNXC:PlanningMode 2026-07-05-00:25: The planning AI now proposes plan-specific deepening themes (deepeningThemes on the completion payload) so this checkpoint surfaces topics tailored to the user's actual plan, including angles they had not anticipated, instead of a fixed generic set. The regex-derived generic themes remain the fallback whenever the AI supplies none (FN-7616 / issue #1912). -->
<!-- FNXC:PlanningMode 2026-07-16-00:00: FN-8065 / GitHub #2150 requires the deepening checkpoint to show the generated plan read-only above its refinement choices, so users can review the title, Markdown description, and deliverables before proceeding. --> <!-- FNXC:PlanningMode 2026-07-16-00:00: FN-8065 / GitHub #2150 requires the deepening checkpoint to show the generated plan read-only above its refinement choices, so users can review the title, Markdown description, and deliverables before proceeding. -->
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. <!-- FNXC:PlanningJsonRecovery 2026-07-28-12:00: FN-8260 / GitHub #2240 requires the final checkpoint to be reached only with a complete AI payload; incomplete JSON retries once and then surfaces the existing retryable session error instead of showing a chopped plan. -->
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: - **Branch strategy** options mirror Subtask Breakdown semantics:
- `Use project/default branch` - `Use project/default branch`

View File

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

View File

@@ -1496,7 +1496,7 @@ async function continueToSummaryAfterSuppressedQuestion(
const followUp = (session.agent.session.state.messages as AgentMessage[]) const followUp = (session.agent.session.state.messages as AgentMessage[])
.filter((message) => message.role === "assistant") .filter((message) => message.role === "assistant")
.pop(); .pop();
const followUpText = typeof followUp?.content === "string" let followUpText = typeof followUp?.content === "string"
? followUp.content ? followUp.content
: Array.isArray(followUp?.content) : Array.isArray(followUp?.content)
? followUp.content ? followUp.content
@@ -1504,9 +1504,34 @@ async function continueToSummaryAfterSuppressedQuestion(
.map((block) => block.text) .map((block) => block.text)
.join("") .join("")
: ""; : "";
const complete = parseAgentResponse(followUpText); let complete: PlanningResponse | undefined;
if (complete.type !== "complete") { let lastError: Error | undefined;
throw new Error("Clarification-disabled follow-up did not produce a summary"); 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<void>)(
'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, { return setPendingSummaryCheckpoint(session, normalizePlanningSummaryPayload(complete.data, {
title: session.title || session.initialPlan, title: session.title || session.initialPlan,
@@ -2545,7 +2570,7 @@ function parseJsonCandidateForShape(candidate: string): unknown | undefined {
return JSON.parse(candidate); return JSON.parse(candidate);
} catch { } catch {
try { try {
return JSON.parse(repairJson(candidate)); return JSON.parse(repairJson(candidate).repaired);
} catch { } catch {
return undefined; return undefined;
} }
@@ -2602,6 +2627,16 @@ function extractJsonCandidate(text: string): string | null {
const planningCandidate = candidates.find((candidate) => isPlanningResponseShape(candidate.parsed)); const planningCandidate = candidates.find((candidate) => isPlanningResponseShape(candidate.parsed));
if (planningCandidate) return planningCandidate.text; 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. // Pick the largest valid JSON candidate only after planning-shaped candidates are ruled out.
const validCandidates = candidates.filter((candidate) => candidate.parsed !== undefined); const validCandidates = candidates.filter((candidate) => candidate.parsed !== undefined);
if (validCandidates.length > 0) { 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. // 3. Last resort: try the full trimmed text so repairJson can close truncated objects.
const trimmed = text.trim();
if (trimmed.startsWith("{")) return trimmed; if (trimmed.startsWith("{")) return trimmed;
return null; return null;
@@ -2624,8 +2658,9 @@ function extractJsonCandidate(text: string): string | null {
* *
* Returns the repaired string, or the original if no repair was possible. * 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 repaired = text;
let wasTruncationRepaired = false;
// Fix trailing commas before } or ] // Fix trailing commas before } or ]
repaired = repaired.replace(/,\s*([}\]])/g, "$1"); 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 we're in an unclosed string, close it
if (inString) { if (inString) {
repaired += '"'; repaired += '"';
wasTruncationRepaired = true;
} }
// Re-count after potential string fix // Re-count after potential string fix
@@ -2668,10 +2704,15 @@ function repairJson(text: string): string {
} }
// Close unclosed brackets and braces // Close unclosed brackets and braces
repaired += "]".repeat(Math.max(0, openBrackets)); const missingBrackets = Math.max(0, openBrackets);
repaired += "}".repeat(Math.max(0, openBraces)); 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) * 3. If parse fails, attempt repair (truncated JSON, trailing commas)
* 4. Validate the resulting structure * 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 { export function parseAgentResponse(text: string): PlanningResponse {
const candidate = extractJsonCandidate(text); const candidate = extractJsonCandidate(text);
@@ -2692,13 +2740,15 @@ export function parseAgentResponse(text: string): PlanningResponse {
} }
let parsed: unknown; let parsed: unknown;
let wasTruncationRepaired = false;
try { try {
parsed = JSON.parse(candidate); parsed = JSON.parse(candidate);
} catch { } catch {
// Attempt repair for truncated/malformed JSON // Attempt repair for truncated/malformed JSON
try { try {
const repaired = repairJson(candidate); const repair = repairJson(candidate);
parsed = JSON.parse(repaired); wasTruncationRepaired = repair.wasTruncationRepaired;
parsed = JSON.parse(repair.repaired);
} catch (repairErr) { } catch (repairErr) {
diagnostics.error( diagnostics.error(
"Failed to parse agent response (repair also failed)", "Failed to parse agent response (repair also failed)",
@@ -2712,6 +2762,17 @@ export function parseAgentResponse(text: string): PlanningResponse {
// Validate structure // Validate structure
if (isPlanningResponseShape(parsed)) { 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; return parsed;
} }