feat(FN-1132): support resuming completed planning sessions

- Include complete planning sessions in active session queries and background tracking
- Add create-task fallback to recover completed planning data from persisted ai session records
- Validate and reconstruct summary/initial plan data from stored JSON before creating tasks
- Expand tests for modal resume flow, session-store filtering, and route fallback behavior
This commit is contained in:
gsxdsm
2026-04-08 02:06:37 -07:00
parent c549a5e4f0
commit 6e00061684
6 changed files with 353 additions and 11 deletions

View File

@@ -0,0 +1,82 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Database } from "@fusion/core";
import { AiSessionStore, type AiSessionRow, type AiSessionStatus } from "./ai-session-store.js";
describe("AiSessionStore.listActive", () => {
let tmpRoot: string;
let db: Database;
let store: AiSessionStore;
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), "kb-ai-session-store-"));
db = new Database(join(tmpRoot, ".fusion"));
db.init();
store = new AiSessionStore(db);
});
afterEach(async () => {
try {
db.close();
} catch {
// no-op
}
await rm(tmpRoot, { recursive: true, force: true });
});
function createSession(id: string, status: AiSessionStatus, projectId: string | null = null): AiSessionRow {
const now = new Date().toISOString();
return {
id,
type: "planning",
status,
title: `Session ${id}`,
inputPayload: JSON.stringify({ plan: `plan-${id}` }),
conversationHistory: "[]",
currentQuestion: null,
result: status === "complete" ? JSON.stringify({ title: "Done" }) : null,
thinkingOutput: "",
error: status === "error" ? "boom" : null,
projectId,
createdAt: now,
updatedAt: now,
};
}
it("returns generating, awaiting_input, and complete sessions", () => {
store.upsert(createSession("S-1", "generating"));
store.upsert(createSession("S-2", "awaiting_input"));
store.upsert(createSession("S-3", "complete"));
store.upsert(createSession("S-4", "error"));
const active = store.listActive();
const statuses = active.map((session) => session.status).sort();
expect(statuses).toEqual(["awaiting_input", "complete", "generating"]);
expect(active.map((session) => session.id)).toEqual(expect.arrayContaining(["S-1", "S-2", "S-3"]));
});
it("excludes sessions with error status", () => {
store.upsert(createSession("S-err", "error"));
const active = store.listActive();
expect(active).toEqual([]);
});
it("filters active sessions by projectId", () => {
store.upsert(createSession("S-a1", "generating", "project-a"));
store.upsert(createSession("S-a2", "complete", "project-a"));
store.upsert(createSession("S-b1", "awaiting_input", "project-b"));
store.upsert(createSession("S-none", "complete", null));
const projectA = store.listActive("project-a");
expect(projectA).toHaveLength(2);
expect(projectA.map((session) => session.id).sort()).toEqual(["S-a1", "S-a2"]);
expect(projectA.every((session) => session.projectId === "project-a")).toBe(true);
});
});

View File

@@ -144,7 +144,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
}
/**
* List active sessions (generating or awaiting_input).
* List active sessions (generating, awaiting_input, or complete).
* Optionally filtered by projectId.
*/
listActive(projectId?: string): AiSessionSummary[] {
@@ -152,7 +152,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
return this.db
.prepare(
`SELECT id, type, status, title, projectId, updatedAt FROM ai_sessions
WHERE status IN ('generating', 'awaiting_input') AND projectId = ?
WHERE status IN ('generating', 'awaiting_input', 'complete') AND projectId = ?
ORDER BY updatedAt DESC`,
)
.all(projectId) as unknown as AiSessionSummary[];
@@ -160,7 +160,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
return this.db
.prepare(
`SELECT id, type, status, title, projectId, updatedAt FROM ai_sessions
WHERE status IN ('generating', 'awaiting_input')
WHERE status IN ('generating', 'awaiting_input', 'complete')
ORDER BY updatedAt DESC`,
)
.all() as unknown as AiSessionSummary[];

View File

@@ -5970,6 +5970,74 @@ describe("Git Management endpoints", () => {
expect(store.createTask).toHaveBeenCalled();
});
it("creates a task from a persisted complete session when in-memory session is missing", async () => {
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "FN-043",
description: "Build a resumable planning flow",
column: "triage",
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({});
(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",
}),
delete: vi.fn(),
};
const appWithAiSessionStore = express();
appWithAiSessionStore.use(express.json());
appWithAiSessionStore.use(
"/api",
createApiRoutes(store, { aiSessionStore: mockAiSessionStore as any }),
);
const res = await REQUEST(
appWithAiSessionStore,
"POST",
"/api/planning/create-task",
JSON.stringify({ sessionId }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: "Build resumable planning flow",
dependencies: ["FN-100"],
}),
);
expect(store.logEntry).toHaveBeenCalledWith(
"FN-043",
"Created via Planning Mode",
expect.stringContaining("Initial plan: Build resumable planning sessions"),
);
expect(mockAiSessionStore.delete).toHaveBeenCalledWith(sessionId);
});
it("returns 400 if session is not complete", async () => {
// Create a session but don't complete it
const startRes = await REQUEST(

View File

@@ -5661,15 +5661,87 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
const { getSession, getSummary, cleanupSession, SessionNotFoundError } = await import("./planning.js");
const { getSession, getSummary, cleanupSession } = await import("./planning.js");
const session = getSession(sessionId);
let summary = getSummary(sessionId);
let initialPlan = session?.initialPlan;
let usedPersistedFallback = false;
if (!session) {
res.status(404).json({ error: `Planning session ${sessionId} not found or expired` });
return;
if (!aiSessionStore) {
res.status(404).json({ error: `Planning session ${sessionId} not found or expired` });
return;
}
const persistedSession = aiSessionStore.get(sessionId);
if (!persistedSession || persistedSession.type !== "planning") {
res.status(404).json({ error: `Planning session ${sessionId} not found or expired` });
return;
}
if (persistedSession.status !== "complete") {
res.status(400).json({ error: "Planning session is not complete" });
return;
}
if (!persistedSession.result) {
res.status(400).json({ error: "Planning session result is not available" });
return;
}
try {
const parsedSummary = JSON.parse(persistedSession.result) as {
title?: unknown;
description?: unknown;
suggestedSize?: unknown;
suggestedDependencies?: unknown;
keyDeliverables?: unknown;
};
summary = {
title:
typeof parsedSummary.title === "string" && parsedSummary.title.trim().length > 0
? parsedSummary.title
: persistedSession.title,
description:
typeof parsedSummary.description === "string" && parsedSummary.description.trim().length > 0
? parsedSummary.description
: persistedSession.title,
suggestedSize:
parsedSummary.suggestedSize === "S" ||
parsedSummary.suggestedSize === "M" ||
parsedSummary.suggestedSize === "L"
? parsedSummary.suggestedSize
: "M",
suggestedDependencies: Array.isArray(parsedSummary.suggestedDependencies)
? parsedSummary.suggestedDependencies.filter((dep): dep is string => typeof dep === "string")
: [],
keyDeliverables: Array.isArray(parsedSummary.keyDeliverables)
? parsedSummary.keyDeliverables.filter((item): item is string => typeof item === "string")
: [],
};
} catch {
res.status(400).json({ error: "Planning session result is invalid" });
return;
}
try {
const parsedInput = JSON.parse(persistedSession.inputPayload) as { initialPlan?: unknown };
if (typeof parsedInput.initialPlan === "string" && parsedInput.initialPlan.trim().length > 0) {
initialPlan = parsedInput.initialPlan;
}
} catch {
// Keep fallback value below
}
if (!initialPlan) {
initialPlan = persistedSession.title;
}
usedPersistedFallback = true;
}
const summary = getSummary(sessionId);
if (!summary) {
res.status(400).json({ error: "Planning session is not complete" });
return;
@@ -5689,10 +5761,14 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
// Log the planning mode creation
await store.logEntry(task.id, "Created via Planning Mode", `Initial plan: ${session.initialPlan.slice(0, 200)}`);
await store.logEntry(task.id, "Created via Planning Mode", `Initial plan: ${(initialPlan ?? "").slice(0, 200)}`);
// Cleanup the session
cleanupSession(sessionId);
if (usedPersistedFallback) {
aiSessionStore?.delete(sessionId);
} else {
cleanupSession(sessionId);
}
res.status(201).json(task);
} catch (err: any) {