fix(dashboard): regenerate a planning question instead of "No active question in session"
Refining a plan (or submitting any input) while the session had no active
question — e.g. after a failed retry cleared summary/currentQuestion —
threw InvalidSessionStateError("No active question in session") at the
operator. Now the interview continues instead:
- The refine and contextual-comment branches no longer require
session.summary; they fall back to a running summary rebuilt from
persisted history.
- A submission with no active question reprompts the agent via
formatQuestionRegenerationForAgent to produce a fresh option-driven
question, carrying the submitted operator input along as context.
- The Planning modal forwards no-question submissions to the server
(loading view + SSE) instead of dead-ending with a local error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/planning-regenerate-question-on-refine.md
Normal file
7
.changeset/planning-regenerate-question-on-refine.md
Normal file
@@ -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.
|
||||||
@@ -2664,7 +2664,36 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
const sessionId = session.sessionId;
|
const sessionId = session.sessionId;
|
||||||
const activeQuestion = session.currentQuestion;
|
const activeQuestion = session.currentQuestion;
|
||||||
if (!activeQuestion) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2790,7 +2819,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
setView({ type: "question", session: { ...session, summary: runningSummaryRef.current } });
|
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 () => {
|
const handleStopGeneration = useCallback(async () => {
|
||||||
|
|||||||
@@ -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> | boolean, attempts = 50): Promise<void> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3085,6 +3085,35 @@ function formatRefineRequestForAgent(summary: PlanningSummary, focus?: string):
|
|||||||
].join("\n\n");
|
].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, unknown>,
|
||||||
|
): 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:
|
FNXC:PlanningRetry 2026-07-14-00:00:
|
||||||
currentQuestion is cleared once an answer is accepted, so a duplicate re-submit during the
|
currentQuestion is cleared once an answer is accepted, so a duplicate re-submit during the
|
||||||
@@ -3170,7 +3199,16 @@ export async function submitResponse(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const contextualComments = getContextualComments(responses);
|
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:
|
FNXC:PlanningComments 2026-07-23-12:00:
|
||||||
Comment batches deliberately reuse the existing session, active-turn reservation, SSE, and
|
Comment batches deliberately reuse the existing session, active-turn reservation, SSE, and
|
||||||
@@ -3183,8 +3221,8 @@ export async function submitResponse(
|
|||||||
await persistSession(session, "generating");
|
await persistSession(session, "generating");
|
||||||
enteredGenerating = true;
|
enteredGenerating = true;
|
||||||
await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store);
|
await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store);
|
||||||
await continueAgentConversation(session, formatContextualCommentsForAgent(session.summary, contextualComments));
|
await continueAgentConversation(session, formatContextualCommentsForAgent(effectiveSummary, contextualComments));
|
||||||
} else if (isRefineRequest(responses) && session.summary) {
|
} else if (isRefineRequest(responses)) {
|
||||||
// Refinement steers which question comes next; it is never an answer to the
|
// Refinement steers which question comes next; it is never an answer to the
|
||||||
// currently displayed question and therefore must not create a history entry.
|
// currently displayed question and therefore must not create a history entry.
|
||||||
beginPlanningGeneration(session, "question");
|
beginPlanningGeneration(session, "question");
|
||||||
@@ -3195,10 +3233,24 @@ export async function submitResponse(
|
|||||||
|
|
||||||
await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store);
|
await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store);
|
||||||
const focus = typeof responses.focus === "string" ? responses.focus.trim() : undefined;
|
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);
|
await continueAgentConversation(session, refineMessage);
|
||||||
} else if (!session.currentQuestion) {
|
} 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 {
|
} else {
|
||||||
const currentQuestion = captureOtherCustomText(session.currentQuestion, responses);
|
const currentQuestion = captureOtherCustomText(session.currentQuestion, responses);
|
||||||
const historyEntry = {
|
const historyEntry = {
|
||||||
|
|||||||
Reference in New Issue
Block a user