feat(FN-3209): fix refine continuation flow in planning mode and add local
Merges fixes for the planning refine continuation flow (FN-3209) alongside a new local startup script for development environments. The changes include updates to `PlanningModeModal.tsx`, new and updated tests for the planning system, route handler improvements in `chat.ts` and `planning.ts`, and do Fusion-Task-Id: FN-3209
This commit is contained in:
5
.changeset/fn-3209-planning-refine-fix.md
Normal file
5
.changeset/fn-3209-planning-refine-fix.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix Planning Mode summary refinement so "Refine Further" reliably continues completed/resumed sessions through the backend interview flow instead of showing a blank question screen.
|
||||
@@ -25,6 +25,7 @@ Use the 💡 button to open planning mode:
|
||||
- Break-into-tasks descriptions are structured with subtask-specific guidance first, then a separate larger-plan context section (plus `## Planning Interview Context` when interview history exists)
|
||||
- Sessions persist when the modal is closed — resume from the sidebar list at any time; reasoning context is restored automatically
|
||||
- Back navigation rewinds the server-side planning session to the previous answered question so you can revise earlier answers and continue from the corrected turn
|
||||
- On the summary screen, **Refine Further** continues through the backend planning session (including resumed completed sessions) and waits for a real follow-up question or updated summary; it does not switch to an empty question view
|
||||
|
||||
### 3) Todo item → Plan Mode
|
||||
|
||||
|
||||
@@ -1307,6 +1307,33 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
[projectId, sessionTabId, view]
|
||||
);
|
||||
|
||||
const handleRefineFurther = useCallback(async () => {
|
||||
if (view.type !== "summary") {
|
||||
return;
|
||||
}
|
||||
|
||||
const { session, summary } = view;
|
||||
const sessionId = session.sessionId;
|
||||
currentSessionIdRef.current = sessionId;
|
||||
setLockSessionId(sessionId);
|
||||
|
||||
setError(null);
|
||||
setIsRetrying(false);
|
||||
setStreamingOutput("");
|
||||
setView({ type: "loading" });
|
||||
|
||||
connectToPlanningStream(sessionId);
|
||||
|
||||
try {
|
||||
await respondToPlanning(sessionId, { refine: true }, projectId, sessionTabId);
|
||||
} catch (err) {
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
setError(getErrorMessage(err) || "Failed to refine plan");
|
||||
setView({ type: "summary", session, summary: editedSummary ?? summary });
|
||||
}
|
||||
}, [connectToPlanningStream, editedSummary, projectId, sessionTabId, view]);
|
||||
|
||||
const handleStopGeneration = useCallback(async () => {
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
if (!sessionId) {
|
||||
@@ -1935,8 +1962,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
onCreateTask={handleCreateTask}
|
||||
onBreakIntoTasks={handleStartBreakdown}
|
||||
onRefine={() => {
|
||||
// Reset to question mode for more refinement
|
||||
setView({ type: "question", session: view.session });
|
||||
void handleRefineFurther();
|
||||
}}
|
||||
isLoading={false}
|
||||
/>
|
||||
|
||||
@@ -809,6 +809,84 @@ describe("PlanningModeModal", () => {
|
||||
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith("session-complete-2", resumedSummary, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("refines a resumed complete session without blank question view", async () => {
|
||||
const resumedSummary: PlanningSummary = {
|
||||
title: "Resume-and-refine",
|
||||
description: "Recovered summary for refine",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Implement", "Verify"],
|
||||
};
|
||||
const refinedQuestion: PlanningQuestion = {
|
||||
id: "q-refine",
|
||||
type: "text",
|
||||
question: "Which part should we refine?",
|
||||
description: "Refine follow-up",
|
||||
};
|
||||
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-complete-refine",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Resume-and-refine",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Recover and refine" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(resumedSummary),
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
let streamHandlers: any;
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
streamHandlers = handlers;
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
mockRespondToPlanning.mockImplementationOnce(async () => {
|
||||
setTimeout(() => {
|
||||
streamHandlers?.onQuestion?.(refinedQuestion);
|
||||
}, 10);
|
||||
return { type: "question", data: refinedQuestion };
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId="session-complete-refine"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Refine Further" })).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refine Further" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRespondToPlanning).toHaveBeenCalledWith(
|
||||
"session-complete-refine",
|
||||
{ refine: true },
|
||||
undefined,
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Which part should we refine?")).toBeDefined();
|
||||
});
|
||||
expect(screen.queryByText("No active question in session")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Conversation history", () => {
|
||||
|
||||
@@ -839,7 +839,7 @@ describe("planning module", () => {
|
||||
await expect(submitResponse("invalid-session-id", {})).rejects.toThrow(SessionNotFoundError);
|
||||
});
|
||||
|
||||
it("throws InvalidSessionStateError when no active question", async () => {
|
||||
it("throws InvalidSessionStateError when no active question and not refining", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
@@ -852,6 +852,88 @@ describe("planning module", () => {
|
||||
await expect(submitResponse(sessionId, {})).rejects.toThrow(InvalidSessionStateError);
|
||||
});
|
||||
|
||||
it("continues from summary when refine is requested", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
setupMockAgent([
|
||||
...STANDARD_QUESTION_RESPONSES,
|
||||
JSON.stringify({
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-refine",
|
||||
type: "text",
|
||||
question: "What should we tighten in this plan?",
|
||||
description: "Refine follow-up",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
await submitResponse(sessionId, { scope: "small" }, TEST_ROOT_DIR);
|
||||
await submitResponse(sessionId, { requirements: "test" }, TEST_ROOT_DIR);
|
||||
await submitResponse(sessionId, { confirm: true }, TEST_ROOT_DIR);
|
||||
|
||||
const response = await submitResponse(sessionId, { refine: true }, TEST_ROOT_DIR);
|
||||
expect(response.type).toBe("question");
|
||||
if (response.type === "question") {
|
||||
expect(response.data.id).toBe("q-refine");
|
||||
}
|
||||
expect(getSummary(sessionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rehydrates a completed persisted session and refines from summary", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const summary = {
|
||||
title: "Recovered summary",
|
||||
description: "Recovered summary description",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Deliverable"],
|
||||
};
|
||||
const row = buildPlanningRow({
|
||||
id: "planning-complete-refine",
|
||||
status: "complete",
|
||||
conversationHistory: JSON.stringify([
|
||||
{
|
||||
question: {
|
||||
id: "q-existing",
|
||||
type: "text",
|
||||
question: "What should we build?",
|
||||
description: "baseline",
|
||||
},
|
||||
response: { "q-existing": "A useful feature" },
|
||||
},
|
||||
]),
|
||||
currentQuestion: "null",
|
||||
result: JSON.stringify(summary),
|
||||
});
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
const resumedAgent = createMockAgent([
|
||||
JSON.stringify({
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-refine-rehydrated",
|
||||
type: "text",
|
||||
question: "Any additional constraints?",
|
||||
description: "Refine resumed",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const createFnAgentSpy = vi.fn(async () => resumedAgent);
|
||||
__setCreateFnAgent(createFnAgentSpy as any);
|
||||
|
||||
const response = await submitResponse(row.id, { refine: true }, TEST_ROOT_DIR);
|
||||
expect(response.type).toBe("question");
|
||||
if (response.type === "question") {
|
||||
expect(response.data.id).toBe("q-refine-rehydrated");
|
||||
}
|
||||
expect(createFnAgentSpy).toHaveBeenCalledTimes(1);
|
||||
expect(resumedAgent.session.prompt).toHaveBeenCalledTimes(2);
|
||||
expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain("Previous conversation summary");
|
||||
expect(resumedAgent.session.prompt.mock.calls[1]?.[0]).toContain("Refine Further");
|
||||
});
|
||||
|
||||
it("reconstructs agent for a rehydrated session and continues conversation", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildPlanningRow({
|
||||
|
||||
@@ -991,6 +991,50 @@ describe("Planning Mode Routes", () => {
|
||||
expect(finalRes.body.data.keyDeliverables).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
it("allows refine requests from completed sessions", async () => {
|
||||
const startRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/start",
|
||||
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
const sessionId = startRes.body.sessionId;
|
||||
|
||||
await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { scope: "medium" } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { confirm: true } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
const refineRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { refine: true } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(refineRes.status).toBe(200);
|
||||
expect(["question", "complete"]).toContain(refineRes.body.type);
|
||||
});
|
||||
|
||||
it("returns 404 for invalid session ID", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
|
||||
@@ -1231,7 +1231,7 @@ export function __setCreateFnAgent(mock: typeof createFnAgent): void {
|
||||
// hit the real engine. Mirror the same fake into the resolved-session slot
|
||||
// so existing test setups that only call `__setCreateFnAgent` continue to
|
||||
// work.
|
||||
createResolvedAgentSession = (async (options: any) => mock(options)) as typeof createResolvedAgentSession;
|
||||
createResolvedAgentSession = (async (options: unknown) => mock(options)) as typeof createResolvedAgentSession;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1611,6 +1611,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
|
||||
if (parsed.type === "question") {
|
||||
session.currentQuestion = parsed.data;
|
||||
session.summary = undefined;
|
||||
session.error = undefined;
|
||||
session.lastGeneratedThinking = session.thinkingOutput;
|
||||
session.updatedAt = new Date();
|
||||
@@ -1835,6 +1836,20 @@ export function parseAgentResponse(text: string): PlanningResponse {
|
||||
* Submit a response to the current question and get the next question or summary.
|
||||
* Supports both stubbed mode and AI agent mode.
|
||||
*/
|
||||
function isRefineRequest(responses: Record<string, unknown>): boolean {
|
||||
return responses.refine === true;
|
||||
}
|
||||
|
||||
function formatRefineRequestForAgent(summary: PlanningSummary): string {
|
||||
return [
|
||||
"The user clicked Refine Further on the planning summary.",
|
||||
"Continue the planning interview from the existing context.",
|
||||
"Either ask one focused follow-up question or return an updated completion summary if sufficient.",
|
||||
"Current summary:",
|
||||
JSON.stringify(summary),
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
export async function submitResponse(
|
||||
sessionId: string,
|
||||
responses: Record<string, unknown>,
|
||||
@@ -1847,26 +1862,35 @@ export async function submitResponse(
|
||||
}
|
||||
|
||||
if (!session.currentQuestion) {
|
||||
throw new InvalidSessionStateError("No active question in session");
|
||||
if (!isRefineRequest(responses) || !session.summary) {
|
||||
throw new InvalidSessionStateError("No active question in session");
|
||||
}
|
||||
|
||||
session.error = undefined;
|
||||
persistSession(session, "generating");
|
||||
|
||||
await ensureSessionAgent(session, rootDir, session.history, promptOverrides);
|
||||
const refineMessage = formatRefineRequestForAgent(session.summary);
|
||||
await continueAgentConversation(session, refineMessage);
|
||||
} 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 ensureSessionAgent(session, rootDir, 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;
|
||||
persistSession(session, "generating");
|
||||
|
||||
if (!session.agent) {
|
||||
const replayHistory = session.history.slice(0, -1);
|
||||
await ensureSessionAgent(session, rootDir, replayHistory, promptOverrides);
|
||||
}
|
||||
|
||||
const message = formatResponseForAgent(session.currentQuestion, responses);
|
||||
await continueAgentConversation(session, message);
|
||||
|
||||
// Return the current state (will be updated via SSE)
|
||||
if (session.summary) {
|
||||
return { type: "complete", data: session.summary };
|
||||
|
||||
Reference in New Issue
Block a user