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