FN-6976: keep planning JSON failures retryable

Persist malformed Planning Mode AI responses as retryable errors while improving JSON response selection.

- Preserve failed initial-turn planning sessions with actionable retry errors instead of deleting them.
- Prefer valid planning-shaped JSON candidates over unrelated embedded JSON blobs.
- Cover streaming, non-streaming, retry recovery, and AI merge test mock compatibility.
- Add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-6976-planning-json-recovery.md       |   7 +
 .../cli/src/commands/__tests__/dashboard.test.ts   |   1 +
 packages/cli/src/commands/__tests__/task.test.ts   |   9 +-
 .../src/__tests__/session-error-recovery.test.ts   | 144 +++++++++++++++++++++
 packages/dashboard/src/planning.ts                 | 123 ++++++++++--------
 5 files changed, 229 insertions(+), 55 deletions(-)

Fusion-Task-Id: FN-6976

Fusion-Task-Lineage: e1cb59cb-1d7d-4aff-b17f-9633568f823f
This commit is contained in:
gsxdsm
2026-06-24 22:57:40 -07:00
parent eb3833a542
commit 3ae053e744
5 changed files with 229 additions and 55 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep Planning Mode malformed AI responses retryable instead of stranding sessions.
category: fix
dev: Hardens planning JSON candidate selection and persists bounded parse failures as retryable AI-session errors.

View File

@@ -756,6 +756,7 @@ vi.mock("@fusion/engine", async (importOriginal) => {
PrMonitor, PrMonitor,
PrCommentHandler, PrCommentHandler,
aiMergeTask, aiMergeTask,
runAiMerge: aiMergeTask,
CronRunner, CronRunner,
createAiPromptExecutor, createAiPromptExecutor,
SelfHealingManager, SelfHealingManager,

View File

@@ -96,7 +96,8 @@ vi.mock("@fusion/core", async (importActual) => {
}); });
// Mock @fusion/engine // Mock @fusion/engine
vi.mock("@fusion/engine", () => ({ aiMergeTask: vi.fn() })); const { runAiMergeMock } = vi.hoisted(() => ({ runAiMergeMock: vi.fn() }));
vi.mock("@fusion/engine", () => ({ runAiMerge: runAiMergeMock, aiMergeTask: runAiMergeMock }));
// Mock @fusion/dashboard // Mock @fusion/dashboard
vi.mock("@fusion/dashboard", () => ({ vi.mock("@fusion/dashboard", () => ({
@@ -155,7 +156,7 @@ import {
import { GitHubClient, generatePrMetadata } from "@fusion/dashboard"; import { GitHubClient, generatePrMetadata } from "@fusion/dashboard";
import { createSession, submitResponse } from "@fusion/dashboard/planning"; import { createSession, submitResponse } from "@fusion/dashboard/planning";
import { resolveProject } from "../../project-context.js"; import { resolveProject } from "../../project-context.js";
import { aiMergeTask } from "@fusion/engine"; import { runAiMerge } from "@fusion/engine";
const mockedExec = vi.mocked(exec); const mockedExec = vi.mocked(exec);
@@ -1228,7 +1229,7 @@ describe("project-aware task command behavior", () => {
isRegistered: true, isRegistered: true,
store: resolvedStore, store: resolvedStore,
}); });
vi.mocked(aiMergeTask).mockResolvedValue({ vi.mocked(runAiMerge).mockResolvedValue({
merged: true, merged: true,
task: makeTask({ id: "FN-123" }), task: makeTask({ id: "FN-123" }),
branch: "fusion/fn-123", branch: "fusion/fn-123",
@@ -1244,7 +1245,7 @@ describe("project-aware task command behavior", () => {
expect(updateStep).toHaveBeenCalled(); expect(updateStep).toHaveBeenCalled();
expect(logEntry).toHaveBeenCalled(); expect(logEntry).toHaveBeenCalled();
expect(aiMergeTask).toHaveBeenCalledWith(resolvedStore, "/test", "FN-123", expect.any(Object)); expect(runAiMerge).toHaveBeenCalledWith(resolvedStore, "/test", "FN-123", expect.any(Object));
expect(duplicateTask).toHaveBeenCalledWith("FN-123"); expect(duplicateTask).toHaveBeenCalledWith("FN-123");
expect(refineTask).toHaveBeenCalledWith("FN-123", "more tests"); expect(refineTask).toHaveBeenCalledWith("FN-123", "more tests");
}); });

View File

@@ -20,6 +20,7 @@ import {
createSessionWithAgent, createSessionWithAgent,
GENERATION_TIMEOUT_MS as PLANNING_GENERATION_TIMEOUT_MS, GENERATION_TIMEOUT_MS as PLANNING_GENERATION_TIMEOUT_MS,
getSession, getSession,
parseAgentResponse,
planningStreamManager, planningStreamManager,
retrySession, retrySession,
setAiSessionStore as setPlanningAiSessionStore, setAiSessionStore as setPlanningAiSessionStore,
@@ -138,6 +139,149 @@ describe("session error recovery", () => {
await rm(tmpDir, { recursive: true, force: true }); await rm(tmpDir, { recursive: true, force: true });
}); });
it("recovers a streaming initial-turn prose response when the bounded reformat succeeds", async () => {
const errorEvents: string[] = [];
const questionEvents: string[] = [];
__setCreateFnAgent(
async () =>
createMockAgent([
"I should ask a question next, but I forgot the JSON wrapper.",
JSON.stringify({
type: "question",
data: { id: "q-reformatted", type: "text", question: "Recovered initial question" },
}),
]),
);
const sessionId = await createSessionWithAgent(
"127.0.0.102",
"Streaming malformed first turn",
"/tmp/project",
taskStore,
);
const unsubscribe = planningStreamManager.subscribe(sessionId, (event) => {
if (event.type === "error") errorEvents.push(String(event.data));
if (event.type === "question") questionEvents.push(String((event.data as { id?: string }).id));
});
planningStreamManager.consumeInitialTurn(sessionId)?.();
await waitFor(() => aiSessionStore.get(sessionId)?.status === "awaiting_input");
const persisted = aiSessionStore.get(sessionId);
expect(persisted?.status).toBe("awaiting_input");
expect(persisted?.error).toBeNull();
expect(getSession(sessionId)?.currentQuestion?.id).toBe("q-reformatted");
expect(questionEvents).toContain("q-reformatted");
expect(errorEvents).toEqual([]);
unsubscribe();
});
it("keeps a streaming initial-turn parse failure retryable and recovers on retry", async () => {
const errorEvents: string[] = [];
__setCreateFnAgent(
async () =>
createMockAgent([
"I can help plan this, but this response is prose only.",
"Still prose only after the bounded reformat request.",
]),
);
const sessionId = await createSessionWithAgent(
"127.0.0.103",
"Streaming unrecoverable first turn",
"/tmp/project",
taskStore,
);
const unsubscribe = planningStreamManager.subscribe(sessionId, (event) => {
if (event.type === "error") {
errorEvents.push(String(event.data));
}
});
planningStreamManager.consumeInitialTurn(sessionId)?.();
await waitFor(() => aiSessionStore.get(sessionId)?.status === "error");
const persistedError = aiSessionStore.get(sessionId);
expect(persistedError?.status).toBe("error");
expect(persistedError?.error).toContain("AI returned no valid JSON");
expect(JSON.parse(persistedError?.conversationHistory ?? "[]")).toHaveLength(0);
expect(errorEvents).toContainEqual(expect.stringContaining("AI returned no valid JSON"));
expect(getSession(sessionId)).toBeDefined();
__setCreateFnAgent(
async () =>
createMockAgent([
JSON.stringify({
type: "question",
data: { id: "q-retry-initial", type: "text", question: "Recovered retry question" },
}),
]),
);
await retrySession(sessionId, "/tmp/project", undefined, taskStore);
const persistedRecovered = aiSessionStore.get(sessionId);
expect(persistedRecovered?.status).toBe("awaiting_input");
expect(persistedRecovered?.error).toBeNull();
expect(getSession(sessionId)?.currentQuestion?.id).toBe("q-retry-initial");
unsubscribe();
});
it("keeps a non-streaming initial-turn parse failure persisted for retry", async () => {
__setCreateFnAgent(
async () =>
createMockAgent([
"This non-streaming first response is prose only.",
"Still not JSON after the bounded reformat request.",
]),
);
await expect(
createSession("127.0.0.104", "Non-streaming unrecoverable first turn", taskStore, "/tmp/project"),
).rejects.toThrow("Failed to get first question from AI");
const failedSession = aiSessionStore.listActive().find((session) => session.type === "planning");
expect(failedSession?.status).toBe("error");
expect(failedSession?.id).toBeTruthy();
const sessionId = failedSession?.id as string;
expect(aiSessionStore.get(sessionId)?.error).toContain("AI returned no valid JSON");
expect(getSession(sessionId)).toBeDefined();
__setCreateFnAgent(
async () =>
createMockAgent([
JSON.stringify({
type: "question",
data: { id: "q-nonstream-retry", type: "text", question: "Recovered non-streaming retry" },
}),
]),
);
await retrySession(sessionId, "/tmp/project", undefined, taskStore);
expect(aiSessionStore.get(sessionId)?.status).toBe("awaiting_input");
expect(aiSessionStore.get(sessionId)?.error).toBeNull();
expect(getSession(sessionId)?.currentQuestion?.id).toBe("q-nonstream-retry");
});
it("selects a valid planning JSON object over a larger unrelated JSON candidate", () => {
const parsed = parseAgentResponse(`Here is an unrelated object first:
{"metadata":{"items":[{"label":"not planning","details":"${"x".repeat(200)}"}]}}
The actual planning response is:
{"type":"question","data":{"id":"q-small","type":"text","question":"What should we build?"}}`);
expect(parsed.type).toBe("question");
if (parsed.type === "question") {
expect(parsed.data.id).toBe("q-small");
}
});
it("captures planning parse failures as error state, preserves history, and allows retry", async () => { it("captures planning parse failures as error state, preserves history, and allows retry", async () => {
const errorEvents: string[] = []; const errorEvents: string[] = [];
const unsubscribe = planningStreamManager.subscribe("pending", () => { const unsubscribe = planningStreamManager.subscribe("pending", () => {

View File

@@ -1009,11 +1009,8 @@ async function getFirstQuestionFromAgent(
} }
if (!parsed) { if (!parsed) {
// Clean up the failed startup session and release the underlying agent so const errorMessage = buildRetryableParseErrorMessage(lastError);
// an unparsable first response cannot leave model/transport handles behind setSessionError(session, errorMessage);
// in the long-running dashboard process.
sessions.delete(session.id);
unpersistSession(session.id);
try { try {
await session.agent.session.dispose?.(); await session.agent.session.dispose?.();
} catch (disposeErr) { } catch (disposeErr) {
@@ -1024,9 +1021,7 @@ async function getFirstQuestionFromAgent(
}); });
} }
session.agent = undefined; session.agent = undefined;
throw new Error( throw new Error(`Failed to get first question from AI: ${errorMessage}`);
`Failed to get first question from AI: ${lastError?.message || "Unknown error"}`
);
} }
if (parsed.type === "complete") { if (parsed.type === "complete") {
@@ -1598,6 +1593,18 @@ async function maybeNotifyPlanningAwaitingInput(session: Session, question: Plan
/** Max number of retry attempts when AI returns unparseable output */ /** Max number of retry attempts when AI returns unparseable output */
const MAX_PARSE_RETRIES = 1; const MAX_PARSE_RETRIES = 1;
/*
FNXC:PlanningJsonRecovery 2026-06-24-20:58:
Planning Mode malformed AI output must either recover through the bounded reformat prompt to a valid planning response or persist a retryable session error. Parser candidate selection therefore prefers valid planning-shaped JSON over unrelated larger JSON blobs embedded in model prose.
*/
function buildRetryableParseErrorMessage(error: Error | undefined): string {
const baseMessage = (error?.message || "Failed to parse AI response")
.replace(/\s*Please try again\.?\s*$/i, "")
.trim();
return `${baseMessage}. Retry this planning session or start a new one.`;
}
/** /**
* Continue the AI conversation with a user message. * Continue the AI conversation with a user message.
* *
@@ -1784,19 +1791,13 @@ async function continueAgentConversation(session: Session, message: string): Pro
} }
if (!parsed) { if (!parsed) {
// All attempts exhausted — emit actionable error // All attempts exhausted — emit actionable, retryable error without duplicated "Please try again" suffixes.
const errorMsg = `${lastError?.message || "Failed to parse AI response"} You can try responding again or start a new planning session.`; const errorMsg = buildRetryableParseErrorMessage(lastError);
diagnostics.error( diagnostics.error(
"All parse attempts exhausted for session", "All parse attempts exhausted for session",
{ sessionId: session.id, message: errorMsg, operation: "parse-exhausted" } { sessionId: session.id, message: errorMsg, operation: "parse-exhausted" }
); );
session.error = errorMsg; setSessionError(session, errorMsg);
session.updatedAt = new Date();
persistSession(session, "error", errorMsg);
planningStreamManager.broadcast(session.id, {
type: "error",
data: errorMsg,
});
return; return;
} }
@@ -1846,18 +1847,52 @@ async function continueAgentConversation(session: Session, message: string): Pro
* *
* Returns the extracted JSON string or null if nothing usable is found. * Returns the extracted JSON string or null if nothing usable is found.
*/ */
function isPlanningResponseShape(parsed: unknown): parsed is PlanningResponse {
if (
typeof parsed !== "object" ||
parsed === null ||
!("type" in parsed) ||
!("data" in parsed)
) {
return false;
}
const typed = parsed as { type: string; data: unknown };
return (
(typed.type === "question" || typed.type === "complete") &&
typed.data !== null &&
typed.data !== undefined
);
}
function parseJsonCandidateForShape(candidate: string): unknown | undefined {
try {
return JSON.parse(candidate);
} catch {
try {
return JSON.parse(repairJson(candidate));
} catch {
return undefined;
}
}
}
function extractJsonCandidate(text: string): string | null { function extractJsonCandidate(text: string): string | null {
if (!text || !text.trim()) return null; if (!text || !text.trim()) return null;
// 1. Try markdown code blocks first (most reliable) // 1. Try markdown code blocks first (most reliable when they contain a planning response).
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/); const codeBlockMatches = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)\s*```/g)];
if (codeBlockMatch?.[1]) { const codeBlockCandidates = codeBlockMatches
const candidate = codeBlockMatch[1].trim(); .map((match) => match[1]?.trim())
if (candidate.startsWith("{")) return candidate; .filter((candidate): candidate is string => Boolean(candidate?.startsWith("{")));
} const planningCodeBlock = codeBlockCandidates.find((candidate) =>
isPlanningResponseShape(parseJsonCandidateForShape(candidate)),
);
if (planningCodeBlock) return planningCodeBlock;
if (codeBlockCandidates.length > 0) return codeBlockCandidates[0];
// 2. Find all top-level brace-delimited objects using balanced brace counting // 2. Find all top-level brace-delimited objects using balanced brace counting.
const candidates: Array<{ start: number; end: number; text: string }> = []; const candidates: Array<{ start: number; end: number; text: string; parsed?: unknown }> = [];
for (let i = 0; i < text.length; i++) { for (let i = 0; i < text.length; i++) {
if (text[i] === "{") { if (text[i] === "{") {
let depth = 0; let depth = 0;
@@ -1882,26 +1917,24 @@ function extractJsonCandidate(text: string): string | null {
if (ch === "}") depth--; if (ch === "}") depth--;
if (depth === 0) { if (depth === 0) {
const candidate = text.slice(i, j + 1).trim(); const candidate = text.slice(i, j + 1).trim();
// Only accept candidates that parse as valid JSON candidates.push({ start: i, end: j, text: candidate, parsed: parseJsonCandidateForShape(candidate) });
try {
JSON.parse(candidate);
candidates.push({ start: i, end: j, text: candidate });
} catch {
// Not valid JSON, skip
}
break; break;
} }
} }
} }
} }
// Pick the largest valid candidate (most likely the full response) const planningCandidate = candidates.find((candidate) => isPlanningResponseShape(candidate.parsed));
if (candidates.length > 0) { if (planningCandidate) return planningCandidate.text;
candidates.sort((a, b) => b.text.length - a.text.length);
return candidates[0].text; // 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) {
validCandidates.sort((a, b) => b.text.length - a.text.length || a.start - b.start);
return validCandidates[0].text;
} }
// 3. Last resort: try the full trimmed text // 3. Last resort: try the full trimmed text so repairJson can close truncated objects.
const trimmed = text.trim(); const trimmed = text.trim();
if (trimmed.startsWith("{")) return trimmed; if (trimmed.startsWith("{")) return trimmed;
@@ -2003,20 +2036,8 @@ export function parseAgentResponse(text: string): PlanningResponse {
} }
// Validate structure // Validate structure
if ( if (isPlanningResponseShape(parsed)) {
typeof parsed === "object" && return parsed;
parsed !== null &&
"type" in parsed &&
"data" in parsed
) {
const typed = parsed as { type: string; data: unknown };
if (
(typed.type === "question" || typed.type === "complete") &&
typed.data !== null &&
typed.data !== undefined
) {
return parsed as PlanningResponse;
}
} }
diagnostics.error("Invalid response structure from AI", { parsedSnippet: JSON.stringify(parsed).slice(0, 500), operation: "parse-validate" }); diagnostics.error("Invalid response structure from AI", { parsedSnippet: JSON.stringify(parsed).slice(0, 500), operation: "parse-validate" });