diff --git a/.changeset/planning-regenerate-question-on-refine.md b/.changeset/planning-regenerate-question-on-refine.md new file mode 100644 index 0000000000..b2b5205049 --- /dev/null +++ b/.changeset/planning-regenerate-question-on-refine.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Refining a plan with no active question now generates a fresh question instead of erroring. +category: fix +dev: submitResponse no longer throws "No active question in session" — refine/comments fall back to a rebuilt running summary and a new question-regeneration reprompt (`formatQuestionRegenerationForAgent`) continues the interview; the Planning modal forwards no-question submissions instead of dead-ending locally. diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 047180a721..cf0a36499b 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -2664,7 +2664,36 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const sessionId = session.sessionId; const activeQuestion = session.currentQuestion; if (!activeQuestion) { - setError(t("planning.noActiveQuestion", "No active question in session")); + /* + FNXC:PlanningQuestionRegeneration 2026-07-23-21:40: + Submitting with no active question is no longer a dead-end "No active question in + session" error. The server regenerates a fresh interview question from the accumulated + context, so forward the input and enter the loading state; the SSE question event (or + the HTTP payload for restored sessions without a live stream) restores the view. No + optimistic history entry is recorded because there is no question to pair it with. + */ + setError(null); + resetPlanningAutoRetryBudget(); + setGenerationActivity("question"); + setGenerationStartTime(Date.now()); + setView({ type: "loading" }); + setStreamingOutput(""); + currentSessionIdRef.current = sessionId; + if (!streamConnectionRef.current?.isConnected()) { + connectToPlanningStream(sessionId); + } + try { + const response = await respondToPlanning(sessionId, responses, projectId); + const responseQuestion = "type" in response ? response.data : response.currentQuestion; + if (responseQuestion) { + const nextQuestion = normalizeQuestionOptions(responseQuestion); + setWorkspaceQuestion(nextQuestion); + setView({ type: "question", session: { sessionId, currentQuestion: nextQuestion, summary: runningSummaryRef.current } }); + } + } catch (err) { + setError(getErrorMessage(err) || t("planning.failedSubmitResponse", "Failed to submit response")); + setView({ type: "question", session: { sessionId, currentQuestion: null, summary: runningSummaryRef.current } }); + } return; } @@ -2790,7 +2819,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setView({ type: "question", session: { ...session, summary: runningSummaryRef.current } }); } }, - [connectToPlanningStream, conversationHistory, editingQuestionId, projectId, resetPlanningAutoRetryBudget, view] + [connectToPlanningStream, conversationHistory, editingQuestionId, projectId, resetPlanningAutoRetryBudget, t, view] ); const handleStopGeneration = useCallback(async () => { diff --git a/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts b/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts new file mode 100644 index 0000000000..c67820f70b --- /dev/null +++ b/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts @@ -0,0 +1,148 @@ +// @vitest-environment node + +/* +FNXC:PlanningQuestionRegeneration 2026-07-23-21:40: +Regression tests for the reported dead-end: refining a plan (or submitting any input) while +the session had no active question — e.g. after a failed retry cleared summary/currentQuestion +— surfaced "No active question in session" to the operator. Requirement: never surface that +error for a live session; instead reprompt the agent to continue the interview and generate a +fresh option-driven question from the accumulated context. + +## Symptom Verification +- Original symptom: Refine on a plan with no active question returned 400 + InvalidSessionStateError("No active question in session"). +- Exact reproduction: submitResponse with {refine:true} (and with a plain answer) on a session + whose summary/currentQuestion are cleared. +- Assertion it is gone: submitResponse resolves with a regenerated type:"question" response, + session.error stays unset, and no "No active question" error is thrown. +*/ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import type { TaskStore } from "@fusion/core"; + +vi.mock("@fusion/engine", () => ({ + listCliAdapterDescriptors: () => [], + resolveMcpServersForStore: async () => ({ servers: [] }), + buildSessionSkillContextSync: () => ({ + skillSelectionContext: undefined, + resolvedSkillNames: ["fusion"], + skillSource: "role-fallback" as const, + }), + createFnAgent: vi.fn(), + createWorkflowAuthoringTools: () => [], + createChatTaskDocumentTools: () => [], + createChatTaskLogsReadTool: () => ({}), +})); + +import { + __resetPlanningState, + __setCreateFnAgent, + createSessionWithAgent, + getSession, + planningStreamManager, + setAiSessionStore, + submitResponse, +} from "../planning.js"; + +const MOCK_TASK_STORE = { + listTasks: vi.fn(async () => []), + getSettings: vi.fn(async () => ({})), + getTask: vi.fn(async () => { + throw new Error("not found"); + }), +} as unknown as TaskStore; + +const QUESTION_JSON = JSON.stringify({ + type: "question", + data: { id: "q-regenerated", type: "single_select", question: "Which direction next?" }, +}); + +function createScriptedAgent() { + const messages: Array<{ role: string; content: string }> = []; + const prompt = vi.fn(async (..._args: unknown[]) => { + messages.push({ role: "assistant", content: QUESTION_JSON }); + }); + return { agent: { session: { state: { messages }, prompt, dispose: vi.fn() } }, prompt }; +} + +async function waitFor(predicate: () => Promise | boolean, attempts = 50): Promise { + for (let i = 0; i < attempts; i++) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("condition not reached"); +} + +async function startSessionAwaitingInput(ip: string) { + const scripted = createScriptedAgent(); + __setCreateFnAgent(vi.fn(async () => scripted.agent) as never); + const sessionId = await createSessionWithAgent(ip, "Plan something small", "/tmp/project", MOCK_TASK_STORE); + planningStreamManager.consumeInitialTurn(sessionId)?.(); + await waitFor(async () => Boolean((await getSession(sessionId))?.currentQuestion)); + return { sessionId, scripted }; +} + +describe("planning question regeneration instead of no-active-question errors", () => { + beforeEach(() => { + __resetPlanningState(); + setAiSessionStore(Object.assign(new EventEmitter(), { + upsert: vi.fn(async () => {}), + get: vi.fn(async () => null), + updateThinking: vi.fn(), + }) as never); + }); + + it("refine with no summary and no active question regenerates a question", async () => { + const { sessionId } = await startSessionAwaitingInput("10.2.0.1"); + const session = (await getSession(sessionId))!; + // A retry that failed mid-regeneration leaves the session in exactly this shape. + session.summary = undefined; + session.currentQuestion = undefined; + + const result = await submitResponse(sessionId, { refine: true, focus: "tighten scope" }, "/tmp/project", undefined, MOCK_TASK_STORE); + + expect(result.type).toBe("question"); + const after = (await getSession(sessionId))!; + expect(after.error).toBeUndefined(); + expect(after.currentQuestion).toBeDefined(); + }); + + it("a plain submission with no active question reprompts for a fresh question instead of throwing", async () => { + const { sessionId, scripted } = await startSessionAwaitingInput("10.2.0.2"); + const session = (await getSession(sessionId))!; + session.currentQuestion = undefined; + const historyLengthBefore = session.history.length; + + const result = await submitResponse(sessionId, { "q-stale": "my answer" }, "/tmp/project", undefined, MOCK_TASK_STORE); + + expect(result.type).toBe("question"); + const after = (await getSession(sessionId))!; + expect(after.error).toBeUndefined(); + expect(after.currentQuestion?.id).toBe("q-regenerated"); + // No history entry is fabricated — there was no question to pair the response with. + expect(after.history.length).toBe(historyLengthBefore); + // The reprompt instructs the agent to continue the interview and carries the operator input. + const lastPrompt = scripted.prompt.mock.calls[scripted.prompt.mock.calls.length - 1]?.[0] as string; + expect(lastPrompt).toContain("no active interview question"); + expect(lastPrompt).toContain("my answer"); + }); + + it("contextual comments with no summary still apply via the rebuilt running summary", async () => { + const { sessionId } = await startSessionAwaitingInput("10.2.0.3"); + const session = (await getSession(sessionId))!; + session.summary = undefined; + session.currentQuestion = undefined; + + const result = await submitResponse( + sessionId, + { contextualComments: [{ quote: "the plan", suggestion: "make it smaller" }] }, + "/tmp/project", + undefined, + MOCK_TASK_STORE, + ); + + expect(result.type).toBe("question"); + expect((await getSession(sessionId))!.error).toBeUndefined(); + }); +}); diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 01453c877f..74b2b76046 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -3085,6 +3085,35 @@ function formatRefineRequestForAgent(summary: PlanningSummary, focus?: string): ].join("\n\n"); } +/* +FNXC:PlanningQuestionRegeneration 2026-07-23-21:40: +A submission that arrives while the session has no active question (the previous question was +already answered, cleared by a retry, or lost with a cleared summary) must not surface +"No active question in session" to the operator. Instead the turn reprompts the agent to +continue the interview and generate a fresh option-driven question from the accumulated +context, honoring any submitted operator input as context rather than dropping it. +*/ +export function formatQuestionRegenerationForAgent( + summary: PlanningSummary, + responses: Record, +): string { + const operatorInput = Object.entries(responses) + .filter(([key, value]) => + key !== "refine" && key !== "focus" && key !== "contextualComments" + && value !== undefined && value !== null && value !== "") + .map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`); + return [ + "The planning session currently has no active interview question; continue the interview instead of treating this as an error.", + "Rebuild affected plan fields around every accumulated selection and Other answer, then ask exactly one new high-impact, option-driven question that narrows the current direction, with at least two useful alternatives (pros and cons) plus one write-your-own option.", + 'Return only type:"question" JSON with the complete runningPlan. Do not return a completion response: only the user can validate a plan.', + ...(operatorInput.length + ? ["The operator submitted this input while no question was active; honor it as context for the plan and the next question:", operatorInput.join("\n")] + : []), + "Current summary:", + JSON.stringify(summary), + ].join("\n\n"); +} + /* FNXC:PlanningRetry 2026-07-14-00:00: currentQuestion is cleared once an answer is accepted, so a duplicate re-submit during the @@ -3170,7 +3199,16 @@ export async function submitResponse( try { const contextualComments = getContextualComments(responses); - if (contextualComments && session.summary) { + /* + FNXC:PlanningQuestionRegeneration 2026-07-23-21:40: + Refine, contextual comments, and the no-active-question fallback must never depend on + session.summary being set: a retry that failed mid-regeneration clears it, and the old + `&& session.summary` guards dropped those submissions into the terminal + "No active question in session" error. Rebuild the running summary from persisted + history instead so the interview always continues. + */ + const effectiveSummary = session.summary ?? buildRunningSummary(session.initialPlan, session.history); + if (contextualComments) { /* FNXC:PlanningComments 2026-07-23-12:00: Comment batches deliberately reuse the existing session, active-turn reservation, SSE, and @@ -3183,8 +3221,8 @@ export async function submitResponse( await persistSession(session, "generating"); enteredGenerating = true; await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store); - await continueAgentConversation(session, formatContextualCommentsForAgent(session.summary, contextualComments)); - } else if (isRefineRequest(responses) && session.summary) { + await continueAgentConversation(session, formatContextualCommentsForAgent(effectiveSummary, contextualComments)); + } else if (isRefineRequest(responses)) { // Refinement steers which question comes next; it is never an answer to the // currently displayed question and therefore must not create a history entry. beginPlanningGeneration(session, "question"); @@ -3195,10 +3233,24 @@ export async function submitResponse( await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store); const focus = typeof responses.focus === "string" ? responses.focus.trim() : undefined; - const refineMessage = formatRefineRequestForAgent(session.summary, focus); + const refineMessage = formatRefineRequestForAgent(effectiveSummary, focus); await continueAgentConversation(session, refineMessage); } else if (!session.currentQuestion) { - throw new InvalidSessionStateError("No active question in session"); + /* + FNXC:PlanningQuestionRegeneration 2026-07-23-21:40: + A submission with no active question used to throw InvalidSessionStateError + ("No active question in session") to the operator. Requirement: regenerate instead — + reprompt the agent to continue the interview and produce a fresh question, carrying any + submitted operator input along as context. No history entry is recorded because there is + no question to pair the response with. + */ + beginPlanningGeneration(session, "question"); + session.error = undefined; + await persistSession(session, "generating"); + enteredGenerating = true; + + await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store); + await continueAgentConversation(session, formatQuestionRegenerationForAgent(effectiveSummary, responses)); } else { const currentQuestion = captureOtherCustomText(session.currentQuestion, responses); const historyEntry = {