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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
/*
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user