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:
82
packages/dashboard/src/ai-session-store.test.ts
Normal file
82
packages/dashboard/src/ai-session-store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user