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)
|
- 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
|
- 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
|
- 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
|
### 3) Todo item → Plan Mode
|
||||||
|
|
||||||
|
|||||||
@@ -1307,6 +1307,33 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
[projectId, sessionTabId, view]
|
[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 handleStopGeneration = useCallback(async () => {
|
||||||
const sessionId = currentSessionIdRef.current;
|
const sessionId = currentSessionIdRef.current;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -1935,8 +1962,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
onCreateTask={handleCreateTask}
|
onCreateTask={handleCreateTask}
|
||||||
onBreakIntoTasks={handleStartBreakdown}
|
onBreakIntoTasks={handleStartBreakdown}
|
||||||
onRefine={() => {
|
onRefine={() => {
|
||||||
// Reset to question mode for more refinement
|
void handleRefineFurther();
|
||||||
setView({ type: "question", session: view.session });
|
|
||||||
}}
|
}}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -809,6 +809,84 @@ describe("PlanningModeModal", () => {
|
|||||||
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith("session-complete-2", resumedSummary, undefined);
|
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", () => {
|
describe("Conversation history", () => {
|
||||||
|
|||||||
@@ -839,7 +839,7 @@ describe("planning module", () => {
|
|||||||
await expect(submitResponse("invalid-session-id", {})).rejects.toThrow(SessionNotFoundError);
|
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 mockIp = getUniqueIp();
|
||||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||||
|
|
||||||
@@ -852,6 +852,88 @@ describe("planning module", () => {
|
|||||||
await expect(submitResponse(sessionId, {})).rejects.toThrow(InvalidSessionStateError);
|
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 () => {
|
it("reconstructs agent for a rehydrated session and continues conversation", async () => {
|
||||||
const store = new MockAiSessionStore();
|
const store = new MockAiSessionStore();
|
||||||
const row = buildPlanningRow({
|
const row = buildPlanningRow({
|
||||||
|
|||||||
@@ -991,6 +991,50 @@ describe("Planning Mode Routes", () => {
|
|||||||
expect(finalRes.body.data.keyDeliverables).toBeInstanceOf(Array);
|
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 () => {
|
it("returns 404 for invalid session ID", async () => {
|
||||||
const res = await REQUEST(
|
const res = await REQUEST(
|
||||||
buildApp(),
|
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
|
// hit the real engine. Mirror the same fake into the resolved-session slot
|
||||||
// so existing test setups that only call `__setCreateFnAgent` continue to
|
// so existing test setups that only call `__setCreateFnAgent` continue to
|
||||||
// work.
|
// 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") {
|
if (parsed.type === "question") {
|
||||||
session.currentQuestion = parsed.data;
|
session.currentQuestion = parsed.data;
|
||||||
|
session.summary = undefined;
|
||||||
session.error = undefined;
|
session.error = undefined;
|
||||||
session.lastGeneratedThinking = session.thinkingOutput;
|
session.lastGeneratedThinking = session.thinkingOutput;
|
||||||
session.updatedAt = new Date();
|
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.
|
* Submit a response to the current question and get the next question or summary.
|
||||||
* Supports both stubbed mode and AI agent mode.
|
* 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(
|
export async function submitResponse(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
responses: Record<string, unknown>,
|
responses: Record<string, unknown>,
|
||||||
@@ -1847,9 +1862,17 @@ export async function submitResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!session.currentQuestion) {
|
if (!session.currentQuestion) {
|
||||||
|
if (!isRefineRequest(responses) || !session.summary) {
|
||||||
throw new InvalidSessionStateError("No active question in session");
|
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
|
// Record the response
|
||||||
session.history.push({
|
session.history.push({
|
||||||
question: session.currentQuestion,
|
question: session.currentQuestion,
|
||||||
@@ -1866,6 +1889,7 @@ export async function submitResponse(
|
|||||||
|
|
||||||
const message = formatResponseForAgent(session.currentQuestion, responses);
|
const message = formatResponseForAgent(session.currentQuestion, responses);
|
||||||
await continueAgentConversation(session, message);
|
await continueAgentConversation(session, message);
|
||||||
|
}
|
||||||
|
|
||||||
// Return the current state (will be updated via SSE)
|
// Return the current state (will be updated via SSE)
|
||||||
if (session.summary) {
|
if (session.summary) {
|
||||||
|
|||||||
Reference in New Issue
Block a user