fix(dashboard): regenerate interview questions in mission/milestone/onboarding too

Extends the Planning Mode no-active-question fix to the other three
interview lanes: a LIVE session (no summary yet) that receives a
submission with no active question now reprompts the agent to continue
the interview and ask a fresh question — carrying the submitted input as
context — instead of throwing "No active question in session". Completed
interviews (summary present) still reject late submissions, preserving
the existing contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-23 18:39:10 -07:00
parent 64b20c8be3
commit 7cb87e7687
6 changed files with 277 additions and 39 deletions

View File

@@ -2,6 +2,6 @@
"@runfusion/fusion": patch
---
summary: Refining a plan with no active question now generates a fresh question instead of erroring.
summary: Planning, mission, milestone, and onboarding interviews regenerate a question instead of "No active question" errors.
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.
dev: submitResponse no longer throws "No active question in session" — refine/comments fall back to a rebuilt running summary and a question-regeneration reprompt continues the interview. Mission/milestone/onboarding interviews mirror the same recovery for live sessions (completed sessions still reject); the Planning modal forwards no-question submissions instead of dead-ending locally.

View File

@@ -889,4 +889,37 @@ describe("agent-onboarding", () => {
expect(getAgentOnboardingSession(sessionId)).toBeUndefined();
await expect(respondToAgentOnboarding(sessionId, { q1: "x" })).rejects.toBeInstanceOf(SessionNotFoundError);
});
/*
FNXC:PlanningQuestionRegeneration 2026-07-23-22:20:
A live onboarding session with no active question must regenerate the next question instead
of dead-ending with "No active question in session"; a completed session still rejects.
*/
it("regenerates a question when responding with no active question on a live session", async () => {
mockCreateFnAgent.mockResolvedValueOnce(
createMockAgent([
JSON.stringify({ type: "question", data: { id: "goal", type: "text", question: "What is the primary goal?" } }),
JSON.stringify({ type: "question", data: { id: "regenerated", type: "text", question: "What should this agent own?" } }),
]),
);
const sessionId = await startAgentOnboardingSession(
"127.0.0.1",
{ intent: "hygiene agent", existingAgents: [], templates: [] },
process.cwd(),
);
await waitFor(() => Boolean(getAgentOnboardingSession(sessionId)?.currentQuestion));
const session = getAgentOnboardingSession(sessionId)!;
const historyLengthBefore = session.history.length;
// A failed generation leaves the session live with no active question.
session.currentQuestion = undefined;
const result = await respondToAgentOnboarding(sessionId, { goal: "Keep CI green" });
expect(result.type).toBe("question");
expect((result as { data: { id: string } }).data.id).toBe("regenerated");
expect(session.history.length).toBe(historyLengthBefore);
expect(session.error).toBeUndefined();
});
});

View File

@@ -0,0 +1,112 @@
// @vitest-environment node
/*
FNXC:PlanningQuestionRegeneration 2026-07-23-22:20:
Mission and milestone/slice interviews share Planning Mode's invariant: submitting while a
LIVE session has no active question (e.g. cleared by a failed generation) must regenerate a
fresh question instead of throwing "No active question in session". A completed interview
(summary present) still rejects late submissions.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@fusion/engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/engine")>();
return {
...actual,
buildSessionSkillContextSync: vi.fn(() => ({ skillSelectionContext: undefined })),
resolveMcpServersForStore: vi.fn(async () => ({ servers: [] })),
createFnAgent: vi.fn(async () => ({
session: {
state: { messages: [] as Array<{ role: string; content: string }> },
prompt: vi.fn(async function (this: { state: { messages: Array<{ role: string; content: string }> } }) {
this.state.messages.push({
role: "assistant",
content: JSON.stringify({
type: "question",
data: { id: "q-regenerated", type: "text", question: "What should we cover next?" },
}),
});
}),
dispose: vi.fn(),
},
})),
};
});
vi.mock("../planning-board-tools.js", () => ({
createPlanningBoardTools: vi.fn(() => []),
}));
import {
__resetMissionInterviewState,
createMissionInterviewSession,
getMissionInterviewSession,
submitMissionInterviewResponse,
} from "../mission-interview.js";
import {
__resetMilestoneSliceInterviewState,
createTargetInterviewSession,
getTargetInterviewSession,
submitTargetInterviewResponse,
} from "../milestone-slice-interview.js";
const store = {} as never;
describe("interview question regeneration instead of no-active-question errors", () => {
beforeEach(() => {
__resetMissionInterviewState();
__resetMilestoneSliceInterviewState();
});
it("mission interview regenerates a question for a live session with no active question", async () => {
const sessionId = await createMissionInterviewSession(
"127.0.0.1",
"Plan reliable interviews",
"/tmp/fusion-dashboard-test",
store,
);
await vi.waitFor(async () => {
expect((await getMissionInterviewSession(sessionId))?.currentQuestion).toBeDefined();
});
const session = (await getMissionInterviewSession(sessionId))!;
session.currentQuestion = undefined;
session.summary = undefined;
const historyLengthBefore = session.history.length;
const result = await submitMissionInterviewResponse(sessionId, { "q-stale": "my answer" }, "/tmp/fusion-dashboard-test", store);
expect(result.type).toBe("question");
expect(session.error).toBeUndefined();
expect(session.currentQuestion).toBeDefined();
expect(session.history.length).toBe(historyLengthBefore);
});
it("milestone/slice interview regenerates a question for a live session with no active question", async () => {
const sessionId = await createTargetInterviewSession(
"127.0.0.1",
"milestone",
"milestone-1",
"Ship the importer",
undefined,
"/tmp/fusion-dashboard-test",
store,
);
await vi.waitFor(async () => {
expect((await getTargetInterviewSession(sessionId))?.currentQuestion).toBeDefined();
});
const session = (await getTargetInterviewSession(sessionId))!;
session.currentQuestion = undefined;
session.summary = undefined;
const historyLengthBefore = session.history.length;
const result = await submitTargetInterviewResponse(sessionId, { "q-stale": "my answer" }, "/tmp/fusion-dashboard-test", store);
expect(result.type).toBe("question");
expect(session.error).toBeUndefined();
expect(session.currentQuestion).toBeDefined();
expect(session.history.length).toBe(historyLengthBefore);
});
});

View File

@@ -537,7 +537,30 @@ export async function respondToAgentOnboarding(
): Promise<AgentOnboardingRespondResult> {
const session = sessions.get(sessionId);
if (!session) throw new SessionNotFoundError(`Agent onboarding session ${sessionId} not found or expired`);
if (!session.currentQuestion) throw new InvalidSessionStateError("No active question in session");
if (!session.currentQuestion) {
/*
FNXC:PlanningQuestionRegeneration 2026-07-23-22:20:
A completed onboarding (summary present) still rejects late submissions, but a live session
with no active question (e.g. cleared by a failed generation) must not dead-end with
"No active question in session". Mirror Planning Mode: reprompt the agent to continue and
ask the next onboarding question, carrying the submitted input along as context. No history
entry is recorded because there is no question to pair the response with.
*/
if (session.summary) throw new InvalidSessionStateError("No active question in session");
const operatorInput = Object.entries(responses)
.filter(([, value]) => value !== undefined && value !== null && value !== "")
.map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
await continueConversation(session, [
"The onboarding interview currently has no active question; continue instead of treating this as an error.",
"Ask the next best onboarding question, following the established response contract.",
...(operatorInput.length
? ["The operator submitted this input while no question was active; honor it as context:", operatorInput.join("\n")]
: []),
].join("\n\n"));
if (session.summary) return { type: "complete", data: session.summary };
if (session.currentQuestion) return { type: "question", data: session.currentQuestion };
throw new InvalidSessionStateError("AI agent did not return a question or summary");
}
const answeredQuestion = session.currentQuestion;
session.history.push({ question: answeredQuestion, response: responses });
/*

View File

@@ -656,6 +656,25 @@ export function getRateLimitResetTime(ip: string): Date | null {
// ── Response Formatting ──────────────────────────────────────────────────────
/*
FNXC:PlanningQuestionRegeneration 2026-07-23-22:20:
Reprompt used when a submission arrives while the interview has no active question — the
agent continues the interview and asks a fresh question instead of the operator seeing
"No active question in session". Submitted input is preserved as context, never dropped.
*/
function formatNoActiveQuestionReprompt(responses: Record<string, unknown>): string {
const operatorInput = Object.entries(responses)
.filter(([, value]) => value !== undefined && value !== null && value !== "")
.map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
return [
"The interview currently has no active question; continue instead of treating this as an error.",
"Use the accumulated interview context and ask exactly one focused next question, following the established response contract.",
...(operatorInput.length
? ["The operator submitted this input while no question was active; honor it as context:", operatorInput.join("\n")]
: []),
].join("\n\n");
}
/**
* Format user response as a message for the AI agent.
*/
@@ -1240,26 +1259,42 @@ export async function submitTargetInterviewResponse(
}
if (!session.currentQuestion) {
throw new TargetInvalidSessionStateError("No active question in session");
/*
FNXC:PlanningQuestionRegeneration 2026-07-23-22:20:
A completed interview (summary present) still rejects late submissions, but a live session
with no active question (e.g. cleared by a failed generation) must not dead-end with
"No active question in session". Mirror Planning Mode: reprompt the agent to continue the
interview and generate a fresh question, carrying the submitted input along as context.
No history entry is recorded because there is no question to pair the response with.
*/
if (session.summary) {
throw new TargetInvalidSessionStateError("No active question in session");
}
session.error = undefined;
persistSession(session, "generating");
if (!session.agent) {
await ensureInterviewAgent(session, rootDir, store, session.history, pluginRunner);
}
await continueAgentConversation(session, formatNoActiveQuestionReprompt(responses));
} else {
// Record the response
session.history.push({
question: session.currentQuestion,
response: responses,
thinkingOutput: session.lastGeneratedThinking || "",
});
session.error = undefined;
persistSession(session, "generating");
if (!session.agent) {
const replayHistory = session.history.slice(0, -1);
await ensureInterviewAgent(session, rootDir, store, replayHistory, pluginRunner);
}
const message = formatResponseForAgent(session.currentQuestion, responses);
await continueAgentConversation(session, message);
}
// Record the response
session.history.push({
question: session.currentQuestion,
response: responses,
thinkingOutput: session.lastGeneratedThinking || "",
});
session.error = undefined;
persistSession(session, "generating");
if (!session.agent) {
const replayHistory = session.history.slice(0, -1);
await ensureInterviewAgent(session, rootDir, store, replayHistory, pluginRunner);
}
const message = formatResponseForAgent(session.currentQuestion, responses);
await continueAgentConversation(session, message);
if (session.summary) {
return { type: "complete", data: session.summary };
}

View File

@@ -746,6 +746,25 @@ export function parseMissionAgentResponse(text: string): MissionInterviewRespons
/**
* Format user response as a message for the AI agent.
*/
/*
FNXC:PlanningQuestionRegeneration 2026-07-23-22:20:
Reprompt used when a submission arrives while the interview has no active question — the
agent continues the interview and asks a fresh question instead of the operator seeing
"No active question in session". Submitted input is preserved as context, never dropped.
*/
function formatNoActiveQuestionReprompt(responses: Record<string, unknown>): string {
const operatorInput = Object.entries(responses)
.filter(([, value]) => value !== undefined && value !== null && value !== "")
.map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
return [
"The interview currently has no active question; continue instead of treating this as an error.",
"Use the accumulated interview context and ask exactly one focused next question, following the established response contract.",
...(operatorInput.length
? ["The operator submitted this input while no question was active; honor it as context:", operatorInput.join("\n")]
: []),
].join("\n\n");
}
export function formatResponseForAgent(
question: PlanningQuestion,
responses: Record<string, unknown>
@@ -1376,26 +1395,42 @@ export async function submitMissionInterviewResponse(
}
if (!session.currentQuestion) {
throw new InvalidSessionStateError("No active question in session");
/*
FNXC:PlanningQuestionRegeneration 2026-07-23-22:20:
A completed interview (summary present) still rejects late submissions, but a live session
with no active question (e.g. cleared by a failed generation) must not dead-end with
"No active question in session". Mirror Planning Mode: reprompt the agent to continue the
interview and generate a fresh question, carrying the submitted input along as context.
No history entry is recorded because there is no question to pair the response with.
*/
if (session.summary) {
throw new InvalidSessionStateError("No active question in session");
}
session.error = undefined;
persistMissionSession(session, "generating");
if (!session.agent) {
await ensureMissionInterviewAgent(session, rootDir, store, session.history, promptOverrides);
}
await continueAgentConversation(session, formatNoActiveQuestionReprompt(responses));
} else {
// Record the response
session.history.push({
question: session.currentQuestion,
response: responses,
thinkingOutput: session.lastGeneratedThinking || "",
});
session.error = undefined;
persistMissionSession(session, "generating");
if (!session.agent) {
const replayHistory = session.history.slice(0, -1);
await ensureMissionInterviewAgent(session, rootDir, store, replayHistory, promptOverrides);
}
const message = formatResponseForAgent(session.currentQuestion, responses);
await continueAgentConversation(session, message);
}
// Record the response
session.history.push({
question: session.currentQuestion,
response: responses,
thinkingOutput: session.lastGeneratedThinking || "",
});
session.error = undefined;
persistMissionSession(session, "generating");
if (!session.agent) {
const replayHistory = session.history.slice(0, -1);
await ensureMissionInterviewAgent(session, rootDir, store, replayHistory, promptOverrides);
}
const message = formatResponseForAgent(session.currentQuestion, responses);
await continueAgentConversation(session, message);
if (session.summary) {
return { type: "complete", data: session.summary };
}