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:
gsxdsm
2026-07-23 18:29:59 -07:00
parent b462978e92
commit 64b20c8be3
4 changed files with 243 additions and 7 deletions

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

View File

@@ -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 () => {

View File

@@ -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();
});
});

View File

@@ -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 = {