From 7cb87e7687249cfe356f9cd0662d7d38e9f2c871 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 23 Jul 2026 18:39:10 -0700 Subject: [PATCH] fix(dashboard): regenerate interview questions in mission/milestone/onboarding too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../planning-regenerate-question-on-refine.md | 4 +- .../src/__tests__/agent-onboarding.test.ts | 33 ++++++ .../interview-question-regeneration.test.ts | 112 ++++++++++++++++++ packages/dashboard/src/agent-onboarding.ts | 25 +++- .../src/milestone-slice-interview.ts | 71 ++++++++--- packages/dashboard/src/mission-interview.ts | 71 ++++++++--- 6 files changed, 277 insertions(+), 39 deletions(-) create mode 100644 packages/dashboard/src/__tests__/interview-question-regeneration.test.ts diff --git a/.changeset/planning-regenerate-question-on-refine.md b/.changeset/planning-regenerate-question-on-refine.md index b2b5205049..60f89b4e73 100644 --- a/.changeset/planning-regenerate-question-on-refine.md +++ b/.changeset/planning-regenerate-question-on-refine.md @@ -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. diff --git a/packages/dashboard/src/__tests__/agent-onboarding.test.ts b/packages/dashboard/src/__tests__/agent-onboarding.test.ts index c994c9f1fa..98de38dae5 100644 --- a/packages/dashboard/src/__tests__/agent-onboarding.test.ts +++ b/packages/dashboard/src/__tests__/agent-onboarding.test.ts @@ -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(); + }); }); diff --git a/packages/dashboard/src/__tests__/interview-question-regeneration.test.ts b/packages/dashboard/src/__tests__/interview-question-regeneration.test.ts new file mode 100644 index 0000000000..e3b4534821 --- /dev/null +++ b/packages/dashboard/src/__tests__/interview-question-regeneration.test.ts @@ -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(); + 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); + }); +}); diff --git a/packages/dashboard/src/agent-onboarding.ts b/packages/dashboard/src/agent-onboarding.ts index 79b516c8b9..24f7968c33 100644 --- a/packages/dashboard/src/agent-onboarding.ts +++ b/packages/dashboard/src/agent-onboarding.ts @@ -537,7 +537,30 @@ export async function respondToAgentOnboarding( ): Promise { 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 }); /* diff --git a/packages/dashboard/src/milestone-slice-interview.ts b/packages/dashboard/src/milestone-slice-interview.ts index 292dd7f1d3..a5701849c8 100644 --- a/packages/dashboard/src/milestone-slice-interview.ts +++ b/packages/dashboard/src/milestone-slice-interview.ts @@ -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 { + 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 }; } diff --git a/packages/dashboard/src/mission-interview.ts b/packages/dashboard/src/mission-interview.ts index 78accf819a..fed1e770c2 100644 --- a/packages/dashboard/src/mission-interview.ts +++ b/packages/dashboard/src/mission-interview.ts @@ -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 { + 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 @@ -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 }; }