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:
gsxdsm
2026-06-02 10:07:08 -07:00
parent cc5e61dbc4
commit a66b128089
7 changed files with 208 additions and 43 deletions

View 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.

View File

@@ -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:

View File

@@ -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}

View File

@@ -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 () => {

View File

@@ -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 () => {

View File

@@ -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);

View File

@@ -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) {