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.
|
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
|
## New Task Modal Branch Strategy
|
||||||
|
|
||||||
The **New Task** dialog uses the same four-option **Branch strategy** selector and `branchSelection` payload as Planning Mode:
|
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);
|
onTaskCreated(task);
|
||||||
// The server cleans up the planning session after task creation. Drop
|
// Single-task creation should preserve completed planning history, so
|
||||||
// the local selection so a future reopen doesn't try to fetch a deleted
|
// only clear the active selection before closing; keep the sidebar row
|
||||||
// id (which would otherwise show "Session not found"). Also broadcast
|
// in local state to match persisted server truth.
|
||||||
// completion so the footer's useBackgroundSessions prunes its count.
|
|
||||||
setSelectedSessionId(null);
|
setSelectedSessionId(null);
|
||||||
setPlanningSessions((prev) => prev.filter((s) => s.id !== completedSessionId));
|
|
||||||
broadcastCompleted({
|
broadcastCompleted({
|
||||||
sessionId: completedSessionId,
|
sessionId: completedSessionId,
|
||||||
status: "complete",
|
status: "complete",
|
||||||
@@ -2501,8 +2499,9 @@ function SummaryView({
|
|||||||
|
|
||||||
<div className="task-detail-section">
|
<div className="task-detail-section">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Branch strategy</label>
|
<label htmlFor="planning-branch-strategy">Branch strategy</label>
|
||||||
<select
|
<select
|
||||||
|
id="planning-branch-strategy"
|
||||||
value={branchMode}
|
value={branchMode}
|
||||||
onChange={(event) => onBranchModeChange(event.target.value as "project-default" | "auto-new" | "existing" | "custom-new")}
|
onChange={(event) => onBranchModeChange(event.target.value as "project-default" | "auto-new" | "existing" | "custom-new")}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -2515,8 +2514,9 @@ function SummaryView({
|
|||||||
</div>
|
</div>
|
||||||
{isBranchNameRequired && (
|
{isBranchNameRequired && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Branch name</label>
|
<label htmlFor="planning-branch-name">Branch name</label>
|
||||||
<input
|
<input
|
||||||
|
id="planning-branch-name"
|
||||||
value={branchName}
|
value={branchName}
|
||||||
onChange={(event) => onBranchNameChange(event.target.value)}
|
onChange={(event) => onBranchNameChange(event.target.value)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -2524,8 +2524,9 @@ function SummaryView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Merge target / base branch (optional)</label>
|
<label htmlFor="planning-base-branch">Merge target / base branch (optional)</label>
|
||||||
<input
|
<input
|
||||||
|
id="planning-base-branch"
|
||||||
value={baseBranch}
|
value={baseBranch}
|
||||||
onChange={(event) => onBaseBranchChange(event.target.value)}
|
onChange={(event) => onBaseBranchChange(event.target.value)}
|
||||||
disabled={isLoading}
|
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 () => {
|
it("shows retry panel when resuming an errored session", async () => {
|
||||||
mockFetchAiSession.mockResolvedValueOnce({
|
mockFetchAiSession.mockResolvedValueOnce({
|
||||||
id: "session-error-1",
|
id: "session-error-1",
|
||||||
@@ -965,7 +1066,7 @@ describe("PlanningModeModal", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
|
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 = {
|
const resumedSummary: PlanningSummary = {
|
||||||
title: "Resume-to-task",
|
title: "Resume-to-task",
|
||||||
description: "Recovered summary for task creation",
|
description: "Recovered summary for task creation",
|
||||||
@@ -974,6 +1075,18 @@ describe("PlanningModeModal", () => {
|
|||||||
keyDeliverables: ["Implement", "Verify"],
|
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({
|
mockFetchAiSession.mockResolvedValueOnce({
|
||||||
id: "session-complete-2",
|
id: "session-complete-2",
|
||||||
type: "planning",
|
type: "planning",
|
||||||
@@ -1016,6 +1129,7 @@ describe("PlanningModeModal", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Create Single Task")).toBeDefined();
|
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" });
|
const createSingleTaskButton = screen.getByRole("button", { name: "Create Single Task" });
|
||||||
@@ -1034,6 +1148,8 @@ describe("PlanningModeModal", () => {
|
|||||||
expect.objectContaining({ branchSelection: { mode: "project-default" } }),
|
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 () => {
|
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" });
|
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({
|
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
id: "FN-043",
|
id: "FN-043",
|
||||||
description: "Build a resumable planning flow",
|
description: "Build a resumable planning flow",
|
||||||
@@ -1683,29 +1683,50 @@ describe("Planning Mode Routes", () => {
|
|||||||
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
|
|
||||||
const sessionId = "session-from-sqlite";
|
const sessionId = "session-from-sqlite";
|
||||||
const mockAiSessionStore = {
|
const persistedSession = {
|
||||||
get: vi.fn().mockReturnValue({
|
id: sessionId,
|
||||||
id: sessionId,
|
type: "planning",
|
||||||
type: "planning",
|
status: "complete",
|
||||||
status: "complete",
|
title: "Build resumable planning",
|
||||||
title: "Build resumable planning",
|
inputPayload: JSON.stringify({ initialPlan: "Build resumable planning sessions" }),
|
||||||
inputPayload: JSON.stringify({ initialPlan: "Build resumable planning sessions" }),
|
conversationHistory: "[]",
|
||||||
conversationHistory: "[]",
|
currentQuestion: null,
|
||||||
currentQuestion: null,
|
result: JSON.stringify({
|
||||||
result: JSON.stringify({
|
title: "Build resumable planning flow",
|
||||||
title: "Build resumable planning flow",
|
description: "Persist planning results so users can create tasks later",
|
||||||
description: "Persist planning results so users can create tasks later",
|
suggestedSize: "M",
|
||||||
suggestedSize: "M",
|
suggestedDependencies: ["FN-100"],
|
||||||
suggestedDependencies: ["FN-100"],
|
keyDeliverables: ["Persist sessions", "Support resume"],
|
||||||
keyDeliverables: ["Persist sessions", "Support resume"],
|
}),
|
||||||
}),
|
thinkingOutput: "",
|
||||||
thinkingOutput: "",
|
error: null,
|
||||||
error: null,
|
projectId: null,
|
||||||
projectId: null,
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
updatedAt: "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();
|
const appWithAiSessionStore = express();
|
||||||
@@ -1735,7 +1756,26 @@ describe("Planning Mode Routes", () => {
|
|||||||
"Created via Planning Mode",
|
"Created via Planning Mode",
|
||||||
expect.stringContaining("Initial plan: Build resumable planning sessions"),
|
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 () => {
|
it("creates task with explicit summary priority", async () => {
|
||||||
|
|||||||
@@ -460,6 +460,11 @@ function unpersistSession(sessionId: string): void {
|
|||||||
_aiSessionStore.delete(sessionId);
|
_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 {
|
function buildSessionFromRow(row: AiSessionRow): Session {
|
||||||
const payload = safeParseJson<DraftInputPayload & { ip?: string }>(
|
const payload = safeParseJson<DraftInputPayload & { ip?: string }>(
|
||||||
row.inputPayload,
|
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 {
|
export function cleanupSession(sessionId: string): void {
|
||||||
cleanupInMemorySession(sessionId);
|
cleanupInMemorySession(sessionId);
|
||||||
|
|||||||
@@ -1006,12 +1006,11 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
const summaryOverride = parsePlanningSummaryOverride(summaryInput);
|
const summaryOverride = parsePlanningSummaryOverride(summaryInput);
|
||||||
|
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
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);
|
const session = getSession(sessionId);
|
||||||
let summary = summaryOverride ?? getSummary(sessionId);
|
let summary = summaryOverride ?? getSummary(sessionId);
|
||||||
let initialPlan = session?.initialPlan;
|
let initialPlan = session?.initialPlan;
|
||||||
let usedPersistedFallback = false;
|
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
if (!aiSessionStore) {
|
if (!aiSessionStore) {
|
||||||
@@ -1083,7 +1082,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
initialPlan = persistedSession.title;
|
initialPlan = persistedSession.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
usedPersistedFallback = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!summary) {
|
if (!summary) {
|
||||||
@@ -1113,12 +1111,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
// Log the planning mode creation
|
// Log the planning mode creation
|
||||||
await scopedStore.logEntry(task.id, "Created via Planning Mode", `Initial plan: ${(initialPlan ?? "").slice(0, 200)}`);
|
await scopedStore.logEntry(task.id, "Created via Planning Mode", `Initial plan: ${(initialPlan ?? "").slice(0, 200)}`);
|
||||||
|
|
||||||
// Cleanup the session
|
// Release any live in-memory planning runtime for this session, but
|
||||||
if (usedPersistedFallback) {
|
// keep the persisted completed row so planning history can still list
|
||||||
aiSessionStore?.delete(sessionId);
|
// and restore the summary after single-task creation.
|
||||||
} else {
|
releaseSession(sessionId);
|
||||||
cleanupSession(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(201).json(task);
|
res.status(201).json(task);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
Reference in New Issue
Block a user