FN-5883: restore planning-mode single-task history
Keep completed planning sessions restorable after single-task creation. - preserve completed planning-session rows after create-task by releasing only in-memory runtime state - keep completed sessions in the Planning Mode history UI and add coverage for history restoration flows - document the restored Planning Mode history behavior and add a patch changeset for @runfusion/fusion Files changed: .changeset/fn-5883-planning-restore.md | 5 + docs/dashboard-guide.md | 2 + .../dashboard/app/components/PlanningModeModal.tsx | 17 +-- .../PlanningModeModal.planning-flow.test.tsx | 118 ++++++++++++++++++++- .../src/__tests__/routes-planning.test.ts | 86 +++++++++++---- packages/dashboard/src/planning.ts | 7 +- .../src/routes/register-planning-subtask-routes.ts | 14 +-- 7 files changed, 207 insertions(+), 42 deletions(-) Fusion-Task-Id: FN-5883 Fusion-Task-Lineage: 47824bac-3083-45a4-a0d5-b8c7540966c1
This commit is contained in:
5
.changeset/fn-5883-planning-restore.md
Normal file
5
.changeset/fn-5883-planning-restore.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix Planning Mode single-task session history so completed sessions remain restorable from the summary view after task creation.
|
||||
@@ -113,6 +113,8 @@ Planning Mode now includes branch controls on the summary screen before you crea
|
||||
|
||||
These values are sent with the Planning Mode create-task request as `branchSelection`, so created tasks persist branch/base-branch settings consistently with other branch-aware task creation flows.
|
||||
|
||||
Completed single-task planning sessions remain in the Planning Mode history after you create the task, and selecting one restores the completed summary instead of restarting the composer.
|
||||
|
||||
## New Task Modal Branch Strategy
|
||||
|
||||
The **New Task** dialog uses the same four-option **Branch strategy** selector and `branchSelection` payload as Planning Mode:
|
||||
|
||||
@@ -1587,12 +1587,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
},
|
||||
});
|
||||
onTaskCreated(task);
|
||||
// The server cleans up the planning session after task creation. Drop
|
||||
// the local selection so a future reopen doesn't try to fetch a deleted
|
||||
// id (which would otherwise show "Session not found"). Also broadcast
|
||||
// completion so the footer's useBackgroundSessions prunes its count.
|
||||
// Single-task creation should preserve completed planning history, so
|
||||
// only clear the active selection before closing; keep the sidebar row
|
||||
// in local state to match persisted server truth.
|
||||
setSelectedSessionId(null);
|
||||
setPlanningSessions((prev) => prev.filter((s) => s.id !== completedSessionId));
|
||||
broadcastCompleted({
|
||||
sessionId: completedSessionId,
|
||||
status: "complete",
|
||||
@@ -2501,8 +2499,9 @@ function SummaryView({
|
||||
|
||||
<div className="task-detail-section">
|
||||
<div className="form-group">
|
||||
<label>Branch strategy</label>
|
||||
<label htmlFor="planning-branch-strategy">Branch strategy</label>
|
||||
<select
|
||||
id="planning-branch-strategy"
|
||||
value={branchMode}
|
||||
onChange={(event) => onBranchModeChange(event.target.value as "project-default" | "auto-new" | "existing" | "custom-new")}
|
||||
disabled={isLoading}
|
||||
@@ -2515,8 +2514,9 @@ function SummaryView({
|
||||
</div>
|
||||
{isBranchNameRequired && (
|
||||
<div className="form-group">
|
||||
<label>Branch name</label>
|
||||
<label htmlFor="planning-branch-name">Branch name</label>
|
||||
<input
|
||||
id="planning-branch-name"
|
||||
value={branchName}
|
||||
onChange={(event) => onBranchNameChange(event.target.value)}
|
||||
disabled={isLoading}
|
||||
@@ -2524,8 +2524,9 @@ function SummaryView({
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>Merge target / base branch (optional)</label>
|
||||
<label htmlFor="planning-base-branch">Merge target / base branch (optional)</label>
|
||||
<input
|
||||
id="planning-base-branch"
|
||||
value={baseBranch}
|
||||
onChange={(event) => onBaseBranchChange(event.target.value)}
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -931,6 +931,107 @@ describe("PlanningModeModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lists planning history rows and restores the selected session to the correct view", async () => {
|
||||
const completedSummary: PlanningSummary = {
|
||||
title: "Completed planning session",
|
||||
description: "Recovered summary from history",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Implement"],
|
||||
};
|
||||
|
||||
mockFetchAiSessions.mockResolvedValueOnce([
|
||||
{
|
||||
id: "session-history-complete",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Completed planning session",
|
||||
projectId: null,
|
||||
lockedByTab: null,
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
id: "session-history-draft",
|
||||
type: "planning",
|
||||
status: "draft",
|
||||
title: "New planning session",
|
||||
preview: "Draft plan from history",
|
||||
projectId: null,
|
||||
lockedByTab: null,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
archived: false,
|
||||
},
|
||||
]);
|
||||
mockFetchAiSession.mockImplementation(async (sessionId: string) => {
|
||||
if (sessionId === "session-history-complete") {
|
||||
return {
|
||||
id: "session-history-complete",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: completedSummary.title,
|
||||
inputPayload: JSON.stringify({ initialPlan: "Recover completed session" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(completedSummary),
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: "session-history-draft",
|
||||
type: "planning",
|
||||
status: "draft",
|
||||
title: "New planning session",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Draft plan from history" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: null,
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Completed planning session/i })).toBeDefined();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /Draft plan from history/i })).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Completed planning session/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSession).toHaveBeenCalledWith("session-history-complete");
|
||||
expect(screen.getByText("Planning Complete!")).toBeDefined();
|
||||
});
|
||||
expect(screen.queryByPlaceholderText(/e.g., Build a user authentication/)).toBeNull();
|
||||
expect(screen.getByDisplayValue("Recovered summary from history")).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Draft plan from history/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSession).toHaveBeenCalledWith("session-history-draft");
|
||||
});
|
||||
expect(screen.getByDisplayValue("Draft plan from history")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Start Planning" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows retry panel when resuming an errored session", async () => {
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-error-1",
|
||||
@@ -965,7 +1066,7 @@ describe("PlanningModeModal", () => {
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("creates a task from a resumed complete session", async () => {
|
||||
it("creates a task from a resumed complete session and keeps the completed session in local history", async () => {
|
||||
const resumedSummary: PlanningSummary = {
|
||||
title: "Resume-to-task",
|
||||
description: "Recovered summary for task creation",
|
||||
@@ -974,6 +1075,18 @@ describe("PlanningModeModal", () => {
|
||||
keyDeliverables: ["Implement", "Verify"],
|
||||
};
|
||||
|
||||
mockFetchAiSessions.mockResolvedValueOnce([
|
||||
{
|
||||
id: "session-complete-2",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Resume-to-task",
|
||||
projectId: null,
|
||||
lockedByTab: null,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
archived: false,
|
||||
},
|
||||
]);
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-complete-2",
|
||||
type: "planning",
|
||||
@@ -1016,6 +1129,7 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Single Task")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: /Resume-to-task/i })).toBeDefined();
|
||||
});
|
||||
|
||||
const createSingleTaskButton = screen.getByRole("button", { name: "Create Single Task" });
|
||||
@@ -1034,6 +1148,8 @@ describe("PlanningModeModal", () => {
|
||||
expect.objectContaining({ branchSelection: { mode: "project-default" } }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: /Resume-to-task/i })).toBeDefined();
|
||||
});
|
||||
|
||||
it("submits selected summary priority when creating a single task", async () => {
|
||||
|
||||
@@ -1670,7 +1670,7 @@ describe("Planning Mode Routes", () => {
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-099", { size: "S" });
|
||||
});
|
||||
|
||||
it("creates a task from a persisted complete session when in-memory session is missing", async () => {
|
||||
it("creates a task from a persisted complete session when in-memory session is missing and keeps the completed session fetchable", async () => {
|
||||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "FN-043",
|
||||
description: "Build a resumable planning flow",
|
||||
@@ -1683,29 +1683,50 @@ describe("Planning Mode Routes", () => {
|
||||
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
|
||||
const sessionId = "session-from-sqlite";
|
||||
const mockAiSessionStore = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
id: sessionId,
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Build resumable planning",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Build resumable planning sessions" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify({
|
||||
title: "Build resumable planning flow",
|
||||
description: "Persist planning results so users can create tasks later",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: ["FN-100"],
|
||||
keyDeliverables: ["Persist sessions", "Support resume"],
|
||||
}),
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
const persistedSession = {
|
||||
id: sessionId,
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Build resumable planning",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Build resumable planning sessions" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify({
|
||||
title: "Build resumable planning flow",
|
||||
description: "Persist planning results so users can create tasks later",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: ["FN-100"],
|
||||
keyDeliverables: ["Persist sessions", "Support resume"],
|
||||
}),
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
archived: 0,
|
||||
};
|
||||
let storedSession: typeof persistedSession | undefined = persistedSession;
|
||||
const mockAiSessionStore = {
|
||||
get: vi.fn((id: string) => (id === sessionId ? storedSession ?? null : null)),
|
||||
listAll: vi.fn(() =>
|
||||
storedSession
|
||||
? [
|
||||
{
|
||||
id: storedSession.id,
|
||||
type: storedSession.type,
|
||||
status: storedSession.status,
|
||||
title: storedSession.title,
|
||||
projectId: storedSession.projectId,
|
||||
lockedByTab: null,
|
||||
updatedAt: storedSession.updatedAt,
|
||||
archived: false,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
delete: vi.fn(() => {
|
||||
storedSession = undefined;
|
||||
}),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
const appWithAiSessionStore = express();
|
||||
@@ -1735,7 +1756,26 @@ describe("Planning Mode Routes", () => {
|
||||
"Created via Planning Mode",
|
||||
expect.stringContaining("Initial plan: Build resumable planning sessions"),
|
||||
);
|
||||
expect(mockAiSessionStore.delete).toHaveBeenCalledWith(sessionId);
|
||||
|
||||
const listRes = await REQUEST(appWithAiSessionStore, "GET", "/api/ai-sessions?includeCompleted=1");
|
||||
expect(listRes.status).toBe(200);
|
||||
expect(listRes.body.sessions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: sessionId,
|
||||
status: "complete",
|
||||
title: "Build resumable planning",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const sessionRes = await REQUEST(appWithAiSessionStore, "GET", `/api/ai-sessions/${sessionId}`);
|
||||
expect(sessionRes.status).toBe(200);
|
||||
expect(sessionRes.body).toMatchObject({
|
||||
id: sessionId,
|
||||
status: "complete",
|
||||
result: storedSession?.result,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates task with explicit summary priority", async () => {
|
||||
|
||||
@@ -460,6 +460,11 @@ function unpersistSession(sessionId: string): void {
|
||||
_aiSessionStore.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Release in-memory planning runtime state while keeping persisted history. */
|
||||
export function releaseSession(sessionId: string): void {
|
||||
cleanupInMemorySession(sessionId);
|
||||
}
|
||||
|
||||
function buildSessionFromRow(row: AiSessionRow): Session {
|
||||
const payload = safeParseJson<DraftInputPayload & { ip?: string }>(
|
||||
row.inputPayload,
|
||||
@@ -2479,7 +2484,7 @@ export function mergePlanningSubtaskDrafts(
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup a session (used after task creation).
|
||||
* Cleanup a session and remove its persisted row.
|
||||
*/
|
||||
export function cleanupSession(sessionId: string): void {
|
||||
cleanupInMemorySession(sessionId);
|
||||
|
||||
@@ -1006,12 +1006,11 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
const summaryOverride = parsePlanningSummaryOverride(summaryInput);
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { getSession, getSummary, cleanupSession } = await import("../planning.js");
|
||||
const { getSession, getSummary, releaseSession } = await import("../planning.js");
|
||||
|
||||
const session = getSession(sessionId);
|
||||
let summary = summaryOverride ?? getSummary(sessionId);
|
||||
let initialPlan = session?.initialPlan;
|
||||
let usedPersistedFallback = false;
|
||||
|
||||
if (!session) {
|
||||
if (!aiSessionStore) {
|
||||
@@ -1083,7 +1082,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
initialPlan = persistedSession.title;
|
||||
}
|
||||
|
||||
usedPersistedFallback = true;
|
||||
}
|
||||
|
||||
if (!summary) {
|
||||
@@ -1113,12 +1111,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
// Log the planning mode creation
|
||||
await scopedStore.logEntry(task.id, "Created via Planning Mode", `Initial plan: ${(initialPlan ?? "").slice(0, 200)}`);
|
||||
|
||||
// Cleanup the session
|
||||
if (usedPersistedFallback) {
|
||||
aiSessionStore?.delete(sessionId);
|
||||
} else {
|
||||
cleanupSession(sessionId);
|
||||
}
|
||||
// Release any live in-memory planning runtime for this session, but
|
||||
// keep the persisted completed row so planning history can still list
|
||||
// and restore the summary after single-task creation.
|
||||
releaseSession(sessionId);
|
||||
|
||||
res.status(201).json(task);
|
||||
} catch (err: unknown) {
|
||||
|
||||
Reference in New Issue
Block a user