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

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

View File

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

View File

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