feat(FN-1147): rehydrate AI sessions across server restarts
- Add recoverable-session querying in AiSessionStore and cover it with targeted store tests - Persist resume context (ip, initial plan, mission metadata) and rebuild planning/subtask/mission sessions from SQLite rows - Rehydrate recoverable sessions at server startup and resume planning/mission interviews by recreating agents with replayed conversation context - Update API flows to pass project root context and clean in-memory sessions when persisted sessions are deleted, with comprehensive regression tests
This commit is contained in:
@@ -204,4 +204,63 @@ describe("AiSessionStore", () => {
|
||||
expect(projectA.map((session) => session.id).sort()).toEqual(["S-a1", "S-a2"]);
|
||||
expect(projectA.every((session) => session.projectId === "project-a")).toBe(true);
|
||||
});
|
||||
|
||||
it("listRecoverable returns awaiting_input and generating sessions", () => {
|
||||
seedSession({ id: "S-generating", status: "generating", ageMs: 3_000 });
|
||||
seedSession({ id: "S-awaiting", status: "awaiting_input", ageMs: 1_000 });
|
||||
seedSession({ id: "S-complete", status: "complete" });
|
||||
|
||||
const recoverable = store.listRecoverable();
|
||||
|
||||
expect(recoverable.map((session) => session.id)).toEqual(["S-awaiting", "S-generating"]);
|
||||
expect(recoverable.map((session) => session.status).sort()).toEqual(["awaiting_input", "generating"]);
|
||||
});
|
||||
|
||||
it("listRecoverable excludes complete and error sessions", () => {
|
||||
seedSession({ id: "S-complete", status: "complete" });
|
||||
seedSession({ id: "S-error", status: "error" });
|
||||
|
||||
const recoverable = store.listRecoverable();
|
||||
|
||||
expect(recoverable).toEqual([]);
|
||||
});
|
||||
|
||||
it("listRecoverable filters by projectId", () => {
|
||||
seedSession({ id: "S-a1", status: "generating", projectId: "project-a" });
|
||||
seedSession({ id: "S-a2", status: "awaiting_input", projectId: "project-a" });
|
||||
seedSession({ id: "S-b1", status: "awaiting_input", projectId: "project-b" });
|
||||
|
||||
const projectA = store.listRecoverable("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);
|
||||
});
|
||||
|
||||
it("listRecoverable returns full AiSessionRow objects", () => {
|
||||
seedSession({
|
||||
id: "S-full",
|
||||
status: "awaiting_input",
|
||||
projectId: "project-a",
|
||||
currentQuestion: { id: "q-1", type: "text", question: "Next?" },
|
||||
});
|
||||
|
||||
const [row] = store.listRecoverable();
|
||||
|
||||
expect(row).toMatchObject({
|
||||
id: "S-full",
|
||||
type: "planning",
|
||||
status: "awaiting_input",
|
||||
title: "Session S-full",
|
||||
inputPayload: expect.any(String),
|
||||
conversationHistory: expect.any(String),
|
||||
currentQuestion: expect.any(String),
|
||||
result: null,
|
||||
thinkingOutput: expect.any(String),
|
||||
error: null,
|
||||
projectId: "project-a",
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,6 +168,30 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
.all() as unknown as AiSessionSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
* List recoverable sessions for in-memory rehydration.
|
||||
* Returns full rows for sessions still in progress.
|
||||
*/
|
||||
listRecoverable(projectId?: string): AiSessionRow[] {
|
||||
if (projectId) {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM ai_sessions
|
||||
WHERE status IN ('generating', 'awaiting_input') AND projectId = ?
|
||||
ORDER BY updatedAt DESC`,
|
||||
)
|
||||
.all(projectId) as unknown as AiSessionRow[];
|
||||
}
|
||||
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM ai_sessions
|
||||
WHERE status IN ('generating', 'awaiting_input')
|
||||
ORDER BY updatedAt DESC`,
|
||||
)
|
||||
.all() as unknown as AiSessionRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session by ID. Emits `ai_session:deleted`.
|
||||
*/
|
||||
|
||||
@@ -22,10 +22,14 @@ import {
|
||||
InvalidSessionStateError,
|
||||
missionInterviewStreamManager,
|
||||
parseMissionAgentResponse,
|
||||
rehydrateFromStore,
|
||||
setAiSessionStore,
|
||||
RateLimitError,
|
||||
SessionNotFoundError,
|
||||
submitMissionInterviewResponse,
|
||||
} from "./mission-interview.js";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { AiSessionRow } from "./ai-session-store.js";
|
||||
|
||||
function createQuestionJson(id = "q-1"): string {
|
||||
return JSON.stringify({
|
||||
@@ -88,6 +92,85 @@ async function waitForCurrentQuestion(sessionId: string): Promise<void> {
|
||||
throw new Error("Timed out waiting for currentQuestion");
|
||||
}
|
||||
|
||||
class MockAiSessionStore extends EventEmitter {
|
||||
rows = new Map<string, AiSessionRow>();
|
||||
|
||||
upsert(row: AiSessionRow): void {
|
||||
this.rows.set(row.id, row);
|
||||
}
|
||||
|
||||
updateThinking(id: string, thinkingOutput: string): void {
|
||||
const row = this.rows.get(id);
|
||||
if (!row) return;
|
||||
this.rows.set(id, { ...row, thinkingOutput, updatedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.rows.delete(id);
|
||||
this.emit("ai_session:deleted", id);
|
||||
}
|
||||
|
||||
get(id: string): AiSessionRow | null {
|
||||
return this.rows.get(id) ?? null;
|
||||
}
|
||||
|
||||
listRecoverable(): AiSessionRow[] {
|
||||
return [...this.rows.values()].filter(
|
||||
(row) => row.status === "awaiting_input" || row.status === "generating",
|
||||
);
|
||||
}
|
||||
|
||||
on(event: "ai_session:deleted", listener: (sessionId: string) => void): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
off(event: "ai_session:deleted", listener: (sessionId: string) => void): this {
|
||||
return super.off(event, listener);
|
||||
}
|
||||
}
|
||||
|
||||
function buildMissionRow(
|
||||
overrides: Partial<AiSessionRow> & Pick<AiSessionRow, "id" | "status">,
|
||||
): AiSessionRow {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: overrides.id,
|
||||
type: overrides.type ?? "mission_interview",
|
||||
status: overrides.status,
|
||||
title: overrides.title ?? "Mission planning",
|
||||
inputPayload:
|
||||
overrides.inputPayload ??
|
||||
JSON.stringify({ ip: "127.0.0.1", missionId: "mission-123", missionTitle: "Mission planning" }),
|
||||
conversationHistory:
|
||||
overrides.conversationHistory ??
|
||||
JSON.stringify([
|
||||
{
|
||||
question: {
|
||||
id: "q-1",
|
||||
type: "text",
|
||||
question: "What is your goal?",
|
||||
description: "scope",
|
||||
},
|
||||
response: { "q-1": "Ship a dashboard" },
|
||||
},
|
||||
]),
|
||||
currentQuestion:
|
||||
overrides.currentQuestion ??
|
||||
JSON.stringify({
|
||||
id: "q-2",
|
||||
type: "text",
|
||||
question: "Any constraints?",
|
||||
description: "details",
|
||||
}),
|
||||
result: overrides.result ?? null,
|
||||
thinkingOutput: overrides.thinkingOutput ?? "thinking",
|
||||
error: overrides.error ?? null,
|
||||
projectId: overrides.projectId ?? null,
|
||||
createdAt: overrides.createdAt ?? now,
|
||||
updatedAt: overrides.updatedAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mission-interview module", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -139,6 +222,96 @@ describe("mission-interview module", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("rehydration and session lookup", () => {
|
||||
it("rehydrates mission interview sessions from recoverable rows", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const missionRow = buildMissionRow({ id: "mission-rehydrate-1", status: "awaiting_input" });
|
||||
const planningRow = buildMissionRow({ id: "planning-rehydrate-1", status: "awaiting_input", type: "planning" });
|
||||
store.rows.set(missionRow.id, missionRow);
|
||||
store.rows.set(planningRow.id, planningRow);
|
||||
|
||||
const rehydrated = rehydrateFromStore(store as any);
|
||||
|
||||
expect(rehydrated).toBe(1);
|
||||
const session = getMissionInterviewSession(missionRow.id);
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.id).toBe(missionRow.id);
|
||||
expect(session?.ip).toBe("127.0.0.1");
|
||||
expect(session?.missionId).toBe("mission-123");
|
||||
expect(session?.currentQuestion?.id).toBe("q-2");
|
||||
expect(session?.agent).toBeUndefined();
|
||||
expect(getMissionInterviewSession(planningRow.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips corrupted rows and continues with valid rows", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const goodRow = buildMissionRow({ id: "mission-good", status: "awaiting_input" });
|
||||
const badRow = buildMissionRow({
|
||||
id: "mission-bad",
|
||||
status: "awaiting_input",
|
||||
conversationHistory: "{bad-json",
|
||||
});
|
||||
store.rows.set(goodRow.id, goodRow);
|
||||
store.rows.set(badRow.id, badRow);
|
||||
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
const rehydrated = rehydrateFromStore(store as any);
|
||||
|
||||
expect(rehydrated).toBe(1);
|
||||
expect(getMissionInterviewSession(goodRow.id)).toBeDefined();
|
||||
expect(getMissionInterviewSession(badRow.id)).toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
`[mission-interview] Failed to rehydrate session ${badRow.id}:`,
|
||||
expect.any(Error),
|
||||
);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("falls through to SQLite when in-memory session is missing", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildMissionRow({ id: "mission-fallthrough", status: "awaiting_input" });
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
const session = getMissionInterviewSession(row.id);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.missionTitle).toBe("Mission planning");
|
||||
expect(session?.agent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns in-memory session before SQLite fallback", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildMissionRow({ id: "mission-memory-first", status: "awaiting_input" });
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
rehydrateFromStore(store as any);
|
||||
|
||||
store.rows.set(
|
||||
row.id,
|
||||
buildMissionRow({
|
||||
id: row.id,
|
||||
status: "awaiting_input",
|
||||
inputPayload: JSON.stringify({ ip: "10.0.0.5", missionId: "mission-xyz", missionTitle: "SQLite title" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const getSpy = vi.spyOn(store, "get");
|
||||
const session = getMissionInterviewSession(row.id);
|
||||
|
||||
expect(session?.missionTitle).toBe("Mission planning");
|
||||
expect(getSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined when session exists nowhere", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
expect(getMissionInterviewSession("missing-session")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("submitMissionInterviewResponse", () => {
|
||||
it("processes response and returns completed summary", async () => {
|
||||
mockCreateKbAgent.mockImplementationOnce(async () =>
|
||||
@@ -160,6 +333,51 @@ describe("mission-interview module", () => {
|
||||
expect(getMissionInterviewSummary(sessionId)?.missionTitle).toBe("Mission Ready");
|
||||
});
|
||||
|
||||
it("reconstructs agent for a rehydrated session and continues conversation", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildMissionRow({ id: "mission-rehydrated-1", status: "awaiting_input" });
|
||||
store.rows.set(row.id, row);
|
||||
|
||||
setAiSessionStore(store as any);
|
||||
expect(rehydrateFromStore(store as any)).toBe(1);
|
||||
|
||||
const resumedAgent = createMockAgent([
|
||||
createQuestionJson("q-context"),
|
||||
JSON.stringify({
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-3",
|
||||
type: "text",
|
||||
question: "What timeline do you have?",
|
||||
description: "delivery",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const createKbAgentSpy = vi.fn(async () => resumedAgent);
|
||||
mockCreateKbAgent.mockImplementation(createKbAgentSpy);
|
||||
|
||||
const result = await submitMissionInterviewResponse(
|
||||
row.id,
|
||||
{ "q-2": "Need launch in 4 weeks" },
|
||||
"/tmp/project",
|
||||
);
|
||||
|
||||
expect(result.type).toBe("question");
|
||||
if (result.type === "question") {
|
||||
expect(result.data.id).toBe("q-3");
|
||||
}
|
||||
expect(createKbAgentSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/project",
|
||||
systemPrompt: expect.stringContaining("mission planning assistant"),
|
||||
}),
|
||||
);
|
||||
expect(resumedAgent.session.prompt).toHaveBeenCalledTimes(2);
|
||||
expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain("Previous conversation summary");
|
||||
expect(resumedAgent.session.prompt.mock.calls[1]?.[0]).toContain("Any constraints?");
|
||||
expect(getMissionInterviewSession(row.id)?.agent).toBeDefined();
|
||||
});
|
||||
|
||||
it("throws SessionNotFoundError for unknown session", async () => {
|
||||
await expect(submitMissionInterviewResponse("missing", {})).rejects.toBeInstanceOf(SessionNotFoundError);
|
||||
});
|
||||
@@ -174,6 +392,18 @@ describe("mission-interview module", () => {
|
||||
|
||||
await expect(submitMissionInterviewResponse(sessionId, {})).rejects.toBeInstanceOf(InvalidSessionStateError);
|
||||
});
|
||||
|
||||
it("throws InvalidSessionStateError when rootDir is missing for a rehydrated session", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildMissionRow({ id: "mission-rehydrated-2", status: "awaiting_input" });
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
rehydrateFromStore(store as any);
|
||||
|
||||
await expect(submitMissionInterviewResponse(row.id, { "q-2": "answer" })).rejects.toThrow(
|
||||
"cannot be resumed without project context",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stream manager", () => {
|
||||
|
||||
@@ -184,6 +184,26 @@ const rateLimits = new Map<string, RateLimitEntry>();
|
||||
let _aiSessionStore: AiSessionStore | undefined;
|
||||
let _aiSessionDeletedListener: ((sessionId: string) => void) | undefined;
|
||||
|
||||
function safeParseJson<T>(
|
||||
text: string | null,
|
||||
fallback: T,
|
||||
options?: { throwOnError?: boolean; fieldName?: string },
|
||||
): T {
|
||||
if (!text) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch (error) {
|
||||
if (options?.throwOnError) {
|
||||
const fieldSuffix = options.fieldName ? ` in ${options.fieldName}` : "";
|
||||
throw new Error(`Invalid JSON${fieldSuffix}: ${(error as Error).message}`);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAiSessionStore(store: AiSessionStore): void {
|
||||
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||||
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||||
@@ -219,7 +239,11 @@ function persistMissionSession(session: MissionInterviewSession, status: "genera
|
||||
type: "mission_interview",
|
||||
status,
|
||||
title: session.missionTitle.slice(0, 120),
|
||||
inputPayload: JSON.stringify({ missionTitle: session.missionTitle }),
|
||||
inputPayload: JSON.stringify({
|
||||
ip: session.ip,
|
||||
missionTitle: session.missionTitle,
|
||||
missionId: session.missionId,
|
||||
}),
|
||||
conversationHistory: JSON.stringify(session.history),
|
||||
currentQuestion: session.currentQuestion ? JSON.stringify(session.currentQuestion) : null,
|
||||
result: session.summary ? JSON.stringify(session.summary) : null,
|
||||
@@ -242,6 +266,73 @@ function unpersistMissionSession(sessionId: string): void {
|
||||
_aiSessionStore.delete(sessionId);
|
||||
}
|
||||
|
||||
function buildMissionInterviewSessionFromRow(row: AiSessionRow): MissionInterviewSession {
|
||||
const payload = safeParseJson<{ ip?: string; missionId?: string; missionTitle?: string }>(
|
||||
row.inputPayload,
|
||||
{},
|
||||
{ throwOnError: true, fieldName: "inputPayload" },
|
||||
);
|
||||
|
||||
const createdAt = new Date(row.createdAt);
|
||||
const updatedAt = new Date(row.updatedAt);
|
||||
|
||||
if (Number.isNaN(createdAt.getTime()) || Number.isNaN(updatedAt.getTime())) {
|
||||
throw new Error("Invalid session timestamps");
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
ip: payload.ip ?? "",
|
||||
missionId: payload.missionId ?? "",
|
||||
missionTitle: payload.missionTitle ?? row.title,
|
||||
history: safeParseJson<Array<{ question: PlanningQuestion; response: unknown }>>(
|
||||
row.conversationHistory,
|
||||
[],
|
||||
{ throwOnError: true, fieldName: "conversationHistory" },
|
||||
),
|
||||
currentQuestion: row.currentQuestion
|
||||
? (safeParseJson<PlanningQuestion | null>(row.currentQuestion, null, {
|
||||
throwOnError: true,
|
||||
fieldName: "currentQuestion",
|
||||
}) ?? undefined)
|
||||
: undefined,
|
||||
summary: row.result
|
||||
? (safeParseJson<MissionPlanSummary | null>(row.result, null, {
|
||||
throwOnError: true,
|
||||
fieldName: "result",
|
||||
}) ?? undefined)
|
||||
: undefined,
|
||||
thinkingOutput: row.thinkingOutput,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
agent: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function rehydrateFromStore(store: AiSessionStore): number {
|
||||
let rows: AiSessionRow[] = [];
|
||||
|
||||
try {
|
||||
rows = store.listRecoverable().filter((row) => row.type === "mission_interview");
|
||||
} catch (error) {
|
||||
console.error("[mission-interview] Failed to list recoverable sessions:", error);
|
||||
return 0;
|
||||
}
|
||||
|
||||
let rehydrated = 0;
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const session = buildMissionInterviewSessionFromRow(row);
|
||||
sessions.set(session.id, session);
|
||||
rehydrated += 1;
|
||||
} catch (error) {
|
||||
console.error(`[mission-interview] Failed to rehydrate session ${row.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return rehydrated;
|
||||
}
|
||||
|
||||
// ── Cleanup Interval ────────────────────────────────────────────────────────
|
||||
|
||||
function cleanupExpiredSessions(): void {
|
||||
@@ -561,32 +652,13 @@ function formatResponseForAgent(
|
||||
*/
|
||||
async function initializeAgent(session: MissionInterviewSession, rootDir: string): Promise<void> {
|
||||
try {
|
||||
await engineReady;
|
||||
|
||||
const agentResult = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: MISSION_INTERVIEW_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
onThinking: (delta: string) => {
|
||||
session.thinkingOutput += delta;
|
||||
persistMissionThinking(session.id, session.thinkingOutput);
|
||||
missionInterviewStreamManager.broadcast(session.id, {
|
||||
type: "thinking",
|
||||
data: delta,
|
||||
});
|
||||
},
|
||||
onText: (delta: string) => {
|
||||
session.thinkingOutput += delta;
|
||||
},
|
||||
});
|
||||
|
||||
session.agent = agentResult;
|
||||
session.agent = await createMissionInterviewAgent(session, rootDir);
|
||||
session.updatedAt = new Date();
|
||||
|
||||
// Send initial message to get first question
|
||||
await continueAgentConversation(
|
||||
session,
|
||||
`I want to plan a mission: "${session.missionTitle}". Interview me to understand what I need, then produce a structured plan.`
|
||||
`I want to plan a mission: "${session.missionTitle}". Interview me to understand what I need, then produce a structured plan.`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`[mission-interview] Agent initialization error for session ${session.id}:`, err);
|
||||
@@ -597,6 +669,87 @@ async function initializeAgent(session: MissionInterviewSession, rootDir: string
|
||||
}
|
||||
}
|
||||
|
||||
async function createMissionInterviewAgent(
|
||||
session: MissionInterviewSession,
|
||||
rootDir: string,
|
||||
): Promise<AgentResult> {
|
||||
await engineReady;
|
||||
|
||||
return createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: MISSION_INTERVIEW_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
onThinking: (delta: string) => {
|
||||
session.thinkingOutput += delta;
|
||||
persistMissionThinking(session.id, session.thinkingOutput);
|
||||
missionInterviewStreamManager.broadcast(session.id, {
|
||||
type: "thinking",
|
||||
data: delta,
|
||||
});
|
||||
},
|
||||
onText: (delta: string) => {
|
||||
session.thinkingOutput += delta;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function formatMissionInterviewHistory(
|
||||
history: Array<{ question: PlanningQuestion; response: unknown }>,
|
||||
): string {
|
||||
if (history.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return history
|
||||
.map(({ question, response }) => {
|
||||
const responseValue =
|
||||
response && typeof response === "object" && !Array.isArray(response)
|
||||
? (response as Record<string, unknown>)[question.id]
|
||||
: response;
|
||||
|
||||
return [
|
||||
`Q: ${question.question}`,
|
||||
`A: ${typeof responseValue === "string" ? responseValue : JSON.stringify(responseValue ?? null)}`,
|
||||
].join("\n");
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
async function ensureMissionInterviewAgent(
|
||||
session: MissionInterviewSession,
|
||||
rootDir: string | undefined,
|
||||
historyForReplay: Array<{ question: PlanningQuestion; response: unknown }>,
|
||||
): Promise<void> {
|
||||
if (session.agent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rootDir) {
|
||||
throw new InvalidSessionStateError(
|
||||
"AI agent not available for this session and cannot be resumed without project context",
|
||||
);
|
||||
}
|
||||
|
||||
session.agent = await createMissionInterviewAgent(session, rootDir);
|
||||
|
||||
if (historyForReplay.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const historySummary = formatMissionInterviewHistory(historyForReplay);
|
||||
if (!historySummary) {
|
||||
return;
|
||||
}
|
||||
|
||||
await session.agent.session.prompt(
|
||||
[
|
||||
"Previous conversation summary:",
|
||||
historySummary,
|
||||
"Use this context when handling the next user response.",
|
||||
].join("\n\n"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue the AI conversation with a user message.
|
||||
* Includes bounded recovery: one retry on parse failure.
|
||||
@@ -773,9 +926,10 @@ export async function createMissionInterviewSession(
|
||||
*/
|
||||
export async function submitMissionInterviewResponse(
|
||||
sessionId: string,
|
||||
responses: Record<string, unknown>
|
||||
responses: Record<string, unknown>,
|
||||
rootDir?: string,
|
||||
): Promise<MissionInterviewResponse> {
|
||||
const session = sessions.get(sessionId);
|
||||
const session = getMissionInterviewSession(sessionId);
|
||||
if (!session) {
|
||||
throw new SessionNotFoundError(`Mission interview session ${sessionId} not found or expired`);
|
||||
}
|
||||
@@ -791,31 +945,30 @@ export async function submitMissionInterviewResponse(
|
||||
});
|
||||
persistMissionSession(session, "generating");
|
||||
|
||||
// If AI agent is active, use it for next question
|
||||
if (session.agent) {
|
||||
const message = formatResponseForAgent(session.currentQuestion, responses);
|
||||
await continueAgentConversation(session, message);
|
||||
|
||||
if (session.summary) {
|
||||
return { type: "complete", data: session.summary };
|
||||
}
|
||||
if (session.currentQuestion) {
|
||||
return { type: "question", data: session.currentQuestion };
|
||||
}
|
||||
// Fallback — should not happen with a working agent
|
||||
return {
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-fallback",
|
||||
type: "text",
|
||||
question: "Could you tell me more about what you want to build?",
|
||||
description: "The AI is processing your response. Please provide more details.",
|
||||
},
|
||||
};
|
||||
if (!session.agent) {
|
||||
const replayHistory = session.history.slice(0, -1);
|
||||
await ensureMissionInterviewAgent(session, rootDir, replayHistory);
|
||||
}
|
||||
|
||||
// No agent — should not happen in normal flow
|
||||
throw new InvalidSessionStateError("AI agent not available for this session");
|
||||
const message = formatResponseForAgent(session.currentQuestion, responses);
|
||||
await continueAgentConversation(session, message);
|
||||
|
||||
if (session.summary) {
|
||||
return { type: "complete", data: session.summary };
|
||||
}
|
||||
if (session.currentQuestion) {
|
||||
return { type: "question", data: session.currentQuestion };
|
||||
}
|
||||
// Fallback — should not happen with a working agent
|
||||
return {
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-fallback",
|
||||
type: "text",
|
||||
question: "Could you tell me more about what you want to build?",
|
||||
description: "The AI is processing your response. Please provide more details.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function cancelMissionInterviewSession(sessionId: string): Promise<void> {
|
||||
@@ -828,11 +981,32 @@ export async function cancelMissionInterviewSession(sessionId: string): Promise<
|
||||
}
|
||||
|
||||
export function getMissionInterviewSession(sessionId: string): MissionInterviewSession | undefined {
|
||||
return sessions.get(sessionId);
|
||||
const inMemory = sessions.get(sessionId);
|
||||
if (inMemory) {
|
||||
return inMemory;
|
||||
}
|
||||
|
||||
if (!_aiSessionStore) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const row = _aiSessionStore.get(sessionId);
|
||||
if (!row || row.type !== "mission_interview") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const restored = buildMissionInterviewSessionFromRow(row);
|
||||
sessions.set(restored.id, restored);
|
||||
return restored;
|
||||
} catch (error) {
|
||||
console.error(`[mission-interview] Failed to restore session ${sessionId} from SQLite:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMissionInterviewSummary(sessionId: string): MissionPlanSummary | undefined {
|
||||
return sessions.get(sessionId)?.summary;
|
||||
return getMissionInterviewSession(sessionId)?.summary;
|
||||
}
|
||||
|
||||
export function cleanupMissionInterviewSession(sessionId: string): void {
|
||||
|
||||
@@ -360,7 +360,8 @@ export function createMissionRouter(
|
||||
InvalidSessionStateError,
|
||||
} = await import("./mission-interview.js");
|
||||
|
||||
const result = await submitMissionInterviewResponse(sessionId, responses);
|
||||
const rootDir = await getRootDirForRequest(req);
|
||||
const result = await submitMissionInterviewResponse(sessionId, responses, rootDir);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err.name === "SessionNotFoundError") {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import {
|
||||
createSession,
|
||||
createSessionWithAgent,
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
getRateLimitResetTime,
|
||||
__resetPlanningState,
|
||||
__setCreateKbAgent,
|
||||
rehydrateFromStore,
|
||||
setAiSessionStore,
|
||||
RateLimitError,
|
||||
SessionNotFoundError,
|
||||
InvalidSessionStateError,
|
||||
@@ -22,6 +25,7 @@ import {
|
||||
SESSION_TTL_MS,
|
||||
} from "./planning.js";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
import type { AiSessionRow } from "./ai-session-store.js";
|
||||
|
||||
// ── Mock Agent Factory ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -111,6 +115,92 @@ function setupMockAgent(responses?: string[]) {
|
||||
return agent;
|
||||
}
|
||||
|
||||
class MockAiSessionStore extends EventEmitter {
|
||||
rows = new Map<string, AiSessionRow>();
|
||||
|
||||
upsert(row: AiSessionRow): void {
|
||||
this.rows.set(row.id, row);
|
||||
}
|
||||
|
||||
updateThinking(id: string, thinkingOutput: string): void {
|
||||
const row = this.rows.get(id);
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rows.set(id, {
|
||||
...row,
|
||||
thinkingOutput,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.rows.delete(id);
|
||||
this.emit("ai_session:deleted", id);
|
||||
}
|
||||
|
||||
get(id: string): AiSessionRow | null {
|
||||
return this.rows.get(id) ?? null;
|
||||
}
|
||||
|
||||
listRecoverable(): AiSessionRow[] {
|
||||
return [...this.rows.values()].filter(
|
||||
(row) => row.status === "awaiting_input" || row.status === "generating",
|
||||
);
|
||||
}
|
||||
|
||||
on(event: "ai_session:deleted", listener: (sessionId: string) => void): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
off(event: "ai_session:deleted", listener: (sessionId: string) => void): this {
|
||||
return super.off(event, listener);
|
||||
}
|
||||
}
|
||||
|
||||
function buildPlanningRow(
|
||||
overrides: Partial<AiSessionRow> & Pick<AiSessionRow, "id" | "status">,
|
||||
): AiSessionRow {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: overrides.id,
|
||||
type: "planning",
|
||||
status: overrides.status,
|
||||
title: overrides.title ?? "Recovered planning session",
|
||||
inputPayload:
|
||||
overrides.inputPayload ??
|
||||
JSON.stringify({ ip: "127.0.0.1", initialPlan: "Recovered planning session" }),
|
||||
conversationHistory:
|
||||
overrides.conversationHistory ??
|
||||
JSON.stringify([
|
||||
{
|
||||
question: {
|
||||
id: "q-existing",
|
||||
type: "text",
|
||||
question: "What should we build?",
|
||||
description: "baseline",
|
||||
},
|
||||
response: { "q-existing": "A useful feature" },
|
||||
},
|
||||
]),
|
||||
currentQuestion:
|
||||
overrides.currentQuestion ??
|
||||
JSON.stringify({
|
||||
id: "q-next",
|
||||
type: "text",
|
||||
question: "Any constraints?",
|
||||
description: "detail",
|
||||
}),
|
||||
result: overrides.result ?? null,
|
||||
thinkingOutput: overrides.thinkingOutput ?? "thinking",
|
||||
error: overrides.error ?? null,
|
||||
projectId: overrides.projectId ?? null,
|
||||
createdAt: overrides.createdAt ?? now,
|
||||
updatedAt: overrides.updatedAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
describe("planning module", () => {
|
||||
const initialPlan = "Build a user authentication system";
|
||||
|
||||
@@ -343,26 +433,80 @@ describe("planning module", () => {
|
||||
await expect(submitResponse(sessionId, {})).rejects.toThrow(InvalidSessionStateError);
|
||||
});
|
||||
|
||||
it("throws InvalidSessionStateError if session has no AI agent", async () => {
|
||||
// Create a session without an agent by directly manipulating state
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
it("reconstructs agent for a rehydrated session and continues conversation", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildPlanningRow({
|
||||
id: "planning-rehydrated-1",
|
||||
status: "awaiting_input",
|
||||
conversationHistory: JSON.stringify([
|
||||
{
|
||||
question: {
|
||||
id: "q-1",
|
||||
type: "text",
|
||||
question: "What should we build?",
|
||||
description: "scope",
|
||||
},
|
||||
response: { "q-1": "Authentication" },
|
||||
},
|
||||
]),
|
||||
currentQuestion: JSON.stringify({
|
||||
id: "q-2",
|
||||
type: "text",
|
||||
question: "Any constraints?",
|
||||
description: "details",
|
||||
}),
|
||||
});
|
||||
store.rows.set(row.id, row);
|
||||
|
||||
// Manually remove the agent to simulate a corrupted session
|
||||
const session = getSession(sessionId);
|
||||
expect(session).toBeDefined();
|
||||
if (session) {
|
||||
session.agent = undefined;
|
||||
}
|
||||
setAiSessionStore(store as any);
|
||||
expect(rehydrateFromStore(store as any)).toBe(1);
|
||||
|
||||
await expect(submitResponse(sessionId, { answer: "test" })).rejects.toThrow(
|
||||
InvalidSessionStateError
|
||||
const resumedAgent = createMockAgent([
|
||||
JSON.stringify({
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-3",
|
||||
type: "text",
|
||||
question: "Do you need tests?",
|
||||
description: "quality",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const createKbAgentSpy = vi.fn(async () => resumedAgent);
|
||||
__setCreateKbAgent(createKbAgentSpy as any);
|
||||
|
||||
const response = await submitResponse(
|
||||
row.id,
|
||||
{ "q-2": "Must run on mobile" },
|
||||
TEST_ROOT_DIR,
|
||||
);
|
||||
try {
|
||||
await submitResponse(sessionId, { answer: "test" });
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toBe("Planning session has no AI agent");
|
||||
|
||||
expect(response.type).toBe("question");
|
||||
if (response.type === "question") {
|
||||
expect(response.data.id).toBe("q-3");
|
||||
}
|
||||
expect(createKbAgentSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: TEST_ROOT_DIR,
|
||||
systemPrompt: expect.stringContaining("planning assistant"),
|
||||
}),
|
||||
);
|
||||
expect(resumedAgent.session.prompt).toHaveBeenCalledTimes(2);
|
||||
expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain("Previous conversation summary");
|
||||
expect(resumedAgent.session.prompt.mock.calls[1]?.[0]).toContain("Any constraints?");
|
||||
expect(getSession(row.id)?.agent).toBeDefined();
|
||||
});
|
||||
|
||||
it("throws InvalidSessionStateError when resuming without project context", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildPlanningRow({ id: "planning-rehydrated-2", status: "awaiting_input" });
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
rehydrateFromStore(store as any);
|
||||
|
||||
await expect(submitResponse(row.id, { "q-next": "answer" })).rejects.toThrow(
|
||||
"cannot be resumed without project context",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -382,6 +526,54 @@ describe("planning module", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("rehydrateFromStore", () => {
|
||||
it("rehydrates planning sessions from SQLite rows", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const planningRow = buildPlanningRow({ id: "planning-row-1", status: "awaiting_input" });
|
||||
const subtaskRow: AiSessionRow = {
|
||||
...buildPlanningRow({ id: "subtask-row-1", status: "awaiting_input" }),
|
||||
type: "subtask",
|
||||
};
|
||||
store.rows.set(planningRow.id, planningRow);
|
||||
store.rows.set(subtaskRow.id, subtaskRow);
|
||||
|
||||
const rehydrated = rehydrateFromStore(store as any);
|
||||
|
||||
expect(rehydrated).toBe(1);
|
||||
const session = getSession(planningRow.id);
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.id).toBe(planningRow.id);
|
||||
expect(session?.ip).toBe("127.0.0.1");
|
||||
expect(session?.currentQuestion?.id).toBe("q-next");
|
||||
expect(session?.thinkingOutput).toBe("thinking");
|
||||
});
|
||||
|
||||
it("skips corrupted rows and continues rehydrating valid sessions", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const goodRow = buildPlanningRow({ id: "planning-good", status: "awaiting_input" });
|
||||
const badRow = buildPlanningRow({
|
||||
id: "planning-bad",
|
||||
status: "awaiting_input",
|
||||
conversationHistory: "{bad-json",
|
||||
});
|
||||
store.rows.set(goodRow.id, goodRow);
|
||||
store.rows.set(badRow.id, badRow);
|
||||
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
const rehydrated = rehydrateFromStore(store as any);
|
||||
|
||||
expect(rehydrated).toBe(1);
|
||||
expect(getSession(goodRow.id)).toBeDefined();
|
||||
expect(getSession(badRow.id)).toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
`[planning] Failed to rehydrate session ${badRow.id}:`,
|
||||
expect.any(Error),
|
||||
);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSession", () => {
|
||||
it("returns session for valid ID", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
@@ -394,7 +586,47 @@ describe("planning module", () => {
|
||||
expect(session?.ip).toBe(mockIp);
|
||||
});
|
||||
|
||||
it("returns undefined for invalid ID", () => {
|
||||
it("returns session from memory before SQLite", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const getSpy = vi.spyOn(store, "get");
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
store.rows.set(
|
||||
sessionId,
|
||||
buildPlanningRow({
|
||||
id: sessionId,
|
||||
status: "awaiting_input",
|
||||
inputPayload: JSON.stringify({ ip: "10.0.0.1", initialPlan: "sqlite-plan" }),
|
||||
}),
|
||||
);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
const session = getSession(sessionId);
|
||||
|
||||
expect(session?.initialPlan).toBe(initialPlan);
|
||||
expect(session?.ip).toBe(mockIp);
|
||||
expect(getSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls through to SQLite when session is missing in memory", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildPlanningRow({ id: "planning-fallthrough", status: "awaiting_input" });
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
const session = getSession(row.id);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.id).toBe(row.id);
|
||||
expect(session?.initialPlan).toBe("Recovered planning session");
|
||||
expect(session?.agent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when session exists nowhere", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
expect(getSession("invalid-id")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,6 +156,26 @@ const rateLimits = new Map<string, RateLimitEntry>();
|
||||
let _aiSessionStore: AiSessionStore | undefined;
|
||||
let _aiSessionDeletedListener: ((sessionId: string) => void) | undefined;
|
||||
|
||||
function safeParseJson<T>(
|
||||
text: string | null,
|
||||
fallback: T,
|
||||
options?: { throwOnError?: boolean; fieldName?: string },
|
||||
): T {
|
||||
if (!text) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch (error) {
|
||||
if (options?.throwOnError) {
|
||||
const fieldSuffix = options.fieldName ? ` in ${options.fieldName}` : "";
|
||||
throw new Error(`Invalid JSON${fieldSuffix}: ${(error as Error).message}`);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire up the AI session persistence store. Called once from server.ts. */
|
||||
export function setAiSessionStore(store: AiSessionStore): void {
|
||||
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||||
@@ -197,7 +217,7 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
|
||||
type: "planning",
|
||||
status,
|
||||
title: session.initialPlan.slice(0, 120),
|
||||
inputPayload: JSON.stringify({ initialPlan: session.initialPlan }),
|
||||
inputPayload: JSON.stringify({ ip: session.ip, initialPlan: session.initialPlan }),
|
||||
conversationHistory: JSON.stringify(session.history),
|
||||
currentQuestion: session.currentQuestion ? JSON.stringify(session.currentQuestion) : null,
|
||||
result: session.summary ? JSON.stringify(session.summary) : null,
|
||||
@@ -222,6 +242,72 @@ function unpersistSession(sessionId: string): void {
|
||||
_aiSessionStore.delete(sessionId);
|
||||
}
|
||||
|
||||
function buildSessionFromRow(row: AiSessionRow): Session {
|
||||
const payload = safeParseJson<{ ip?: string; initialPlan?: string }>(
|
||||
row.inputPayload,
|
||||
{},
|
||||
{ throwOnError: true, fieldName: "inputPayload" },
|
||||
);
|
||||
|
||||
const createdAt = new Date(row.createdAt);
|
||||
const updatedAt = new Date(row.updatedAt);
|
||||
|
||||
if (Number.isNaN(createdAt.getTime()) || Number.isNaN(updatedAt.getTime())) {
|
||||
throw new Error("Invalid session timestamps");
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
ip: payload.ip ?? "",
|
||||
initialPlan: payload.initialPlan ?? row.title,
|
||||
history: safeParseJson<Array<{ question: PlanningQuestion; response: unknown }>>(
|
||||
row.conversationHistory,
|
||||
[],
|
||||
{ throwOnError: true, fieldName: "conversationHistory" },
|
||||
),
|
||||
currentQuestion: row.currentQuestion
|
||||
? (safeParseJson<PlanningQuestion | null>(row.currentQuestion, null, {
|
||||
throwOnError: true,
|
||||
fieldName: "currentQuestion",
|
||||
}) ?? undefined)
|
||||
: undefined,
|
||||
summary: row.result
|
||||
? (safeParseJson<PlanningSummary | null>(row.result, null, {
|
||||
throwOnError: true,
|
||||
fieldName: "result",
|
||||
}) ?? undefined)
|
||||
: undefined,
|
||||
thinkingOutput: row.thinkingOutput,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
agent: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function rehydrateFromStore(store: AiSessionStore): number {
|
||||
let rows: AiSessionRow[] = [];
|
||||
|
||||
try {
|
||||
rows = store.listRecoverable().filter((row) => row.type === "planning");
|
||||
} catch (error) {
|
||||
console.error("[planning] Failed to list recoverable sessions:", error);
|
||||
return 0;
|
||||
}
|
||||
|
||||
let rehydrated = 0;
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const session = buildSessionFromRow(row);
|
||||
sessions.set(session.id, session);
|
||||
rehydrated += 1;
|
||||
} catch (error) {
|
||||
console.error(`[planning] Failed to rehydrate session ${row.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return rehydrated;
|
||||
}
|
||||
|
||||
// ── Cleanup Interval ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -664,34 +750,7 @@ async function initializeAgent(
|
||||
modelId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Ensure engine is loaded before using createKbAgent
|
||||
await engineReady;
|
||||
|
||||
const agentResult = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: PLANNING_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId
|
||||
? {
|
||||
defaultProvider: modelProvider,
|
||||
defaultModelId: modelId,
|
||||
}
|
||||
: {}),
|
||||
onThinking: (delta: string) => {
|
||||
session.thinkingOutput += delta;
|
||||
persistThinking(session.id, session.thinkingOutput);
|
||||
planningStreamManager.broadcast(session.id, {
|
||||
type: "thinking",
|
||||
data: delta,
|
||||
});
|
||||
},
|
||||
onText: (delta: string) => {
|
||||
// Capture AI response text - will be parsed at end of turn
|
||||
session.thinkingOutput += delta;
|
||||
},
|
||||
});
|
||||
|
||||
session.agent = agentResult;
|
||||
session.agent = await createPlanningAgent(session, rootDir, modelProvider, modelId);
|
||||
session.updatedAt = new Date();
|
||||
|
||||
// Send initial message to get first question
|
||||
@@ -705,6 +764,80 @@ async function initializeAgent(
|
||||
}
|
||||
}
|
||||
|
||||
async function createPlanningAgent(
|
||||
session: Session,
|
||||
rootDir: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
): Promise<AgentResult> {
|
||||
// Ensure engine is loaded before using createKbAgent
|
||||
await engineReady;
|
||||
|
||||
return createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: PLANNING_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId
|
||||
? {
|
||||
defaultProvider: modelProvider,
|
||||
defaultModelId: modelId,
|
||||
}
|
||||
: {}),
|
||||
onThinking: (delta: string) => {
|
||||
session.thinkingOutput += delta;
|
||||
persistThinking(session.id, session.thinkingOutput);
|
||||
planningStreamManager.broadcast(session.id, {
|
||||
type: "thinking",
|
||||
data: delta,
|
||||
});
|
||||
},
|
||||
onText: (delta: string) => {
|
||||
// Capture AI response text - will be parsed at end of turn
|
||||
session.thinkingOutput += delta;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildHistoryReplayPrompt(
|
||||
history: Array<{ question: PlanningQuestion; response: unknown }>,
|
||||
): string {
|
||||
const interviewSummary = formatInterviewQA(history);
|
||||
if (!interviewSummary) {
|
||||
return "No prior planning interview context is available.";
|
||||
}
|
||||
|
||||
return [
|
||||
"Previous conversation summary:",
|
||||
interviewSummary,
|
||||
"Use this as context for the next response. Do not repeat prior questions unless necessary.",
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
async function ensureSessionAgent(
|
||||
session: Session,
|
||||
rootDir: string | undefined,
|
||||
historyForReplay: Array<{ question: PlanningQuestion; response: unknown }>,
|
||||
): Promise<void> {
|
||||
if (session.agent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rootDir) {
|
||||
throw new InvalidSessionStateError(
|
||||
"Planning session has no AI agent and cannot be resumed without project context",
|
||||
);
|
||||
}
|
||||
|
||||
session.agent = await createPlanningAgent(session, rootDir);
|
||||
|
||||
if (historyForReplay.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contextMessage = buildHistoryReplayPrompt(historyForReplay);
|
||||
await session.agent.session.prompt(contextMessage);
|
||||
}
|
||||
|
||||
/** Max number of retry attempts when AI returns unparseable output */
|
||||
const MAX_PARSE_RETRIES = 1;
|
||||
|
||||
@@ -1039,9 +1172,10 @@ export function parseAgentResponse(text: string): PlanningResponse {
|
||||
*/
|
||||
export async function submitResponse(
|
||||
sessionId: string,
|
||||
responses: Record<string, unknown>
|
||||
responses: Record<string, unknown>,
|
||||
rootDir?: string,
|
||||
): Promise<PlanningResponse> {
|
||||
const session = sessions.get(sessionId);
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
||||
}
|
||||
@@ -1057,9 +1191,9 @@ export async function submitResponse(
|
||||
});
|
||||
persistSession(session, "generating");
|
||||
|
||||
// If AI agent is active, use it for next question
|
||||
if (!session.agent) {
|
||||
throw new InvalidSessionStateError("Planning session has no AI agent");
|
||||
const replayHistory = session.history.slice(0, -1);
|
||||
await ensureSessionAgent(session, rootDir, replayHistory);
|
||||
}
|
||||
|
||||
const message = formatResponseForAgent(session.currentQuestion, responses);
|
||||
@@ -1186,7 +1320,28 @@ export async function cancelSession(sessionId: string): Promise<void> {
|
||||
* Get session details.
|
||||
*/
|
||||
export function getSession(sessionId: string): Session | undefined {
|
||||
return sessions.get(sessionId);
|
||||
const inMemory = sessions.get(sessionId);
|
||||
if (inMemory) {
|
||||
return inMemory;
|
||||
}
|
||||
|
||||
if (!_aiSessionStore) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const row = _aiSessionStore.get(sessionId);
|
||||
if (!row || row.type !== "planning") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const restored = buildSessionFromRow(row);
|
||||
sessions.set(restored.id, restored);
|
||||
return restored;
|
||||
} catch (error) {
|
||||
console.error(`[planning] Failed to restore session ${sessionId} from SQLite:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5735,7 +5735,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
|
||||
const { submitResponse, SessionNotFoundError, InvalidSessionStateError } = await import("./planning.js");
|
||||
const result = await submitResponse(sessionId, responses);
|
||||
const result = await submitResponse(sessionId, responses, store.getRootDir());
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err.name === "SessionNotFoundError") {
|
||||
@@ -8486,24 +8486,26 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up the in-memory agent based on session type
|
||||
aiSessionStore.delete(id);
|
||||
|
||||
try {
|
||||
switch (session.type) {
|
||||
case "planning":
|
||||
if (getPlanningSession(id)) cleanupPlanningSession(id);
|
||||
break;
|
||||
case "subtask":
|
||||
if (getSubtaskSession(id)) cleanupSubtaskSession(id);
|
||||
break;
|
||||
case "mission_interview":
|
||||
if (getMissionInterviewSession(id)) cleanupMissionInterviewSession(id);
|
||||
break;
|
||||
}
|
||||
if (getPlanningSession(id)) cleanupPlanningSession(id);
|
||||
} catch {
|
||||
// Agent may already be cleaned up — that's fine
|
||||
// Session may not belong to planning or may already be cleaned up.
|
||||
}
|
||||
|
||||
try {
|
||||
if (getSubtaskSession(id)) cleanupSubtaskSession(id);
|
||||
} catch {
|
||||
// Session may not belong to subtask breakdown or may already be cleaned up.
|
||||
}
|
||||
|
||||
try {
|
||||
if (getMissionInterviewSession(id)) cleanupMissionInterviewSession(id);
|
||||
} catch {
|
||||
// Session may not belong to mission interview or may already be cleaned up.
|
||||
}
|
||||
|
||||
aiSessionStore.delete(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -17,9 +17,18 @@ import { WebSocketManager, type BadgeSnapshot } from "./websocket.js";
|
||||
import type { BadgePubSub } from "./badge-pubsub.js";
|
||||
import { createBadgePubSub, type BadgePubSubMessage } from "./badge-pubsub.js";
|
||||
import { AiSessionStore } from "./ai-session-store.js";
|
||||
import { setAiSessionStore as setPlanningAiSessionStore } from "./planning.js";
|
||||
import { setAiSessionStore as setSubtaskAiSessionStore } from "./subtask-breakdown.js";
|
||||
import { setAiSessionStore as setMissionAiSessionStore } from "./mission-interview.js";
|
||||
import {
|
||||
setAiSessionStore as setPlanningAiSessionStore,
|
||||
rehydrateFromStore as rehydratePlanningSessions,
|
||||
} from "./planning.js";
|
||||
import {
|
||||
setAiSessionStore as setSubtaskAiSessionStore,
|
||||
rehydrateFromStore as rehydrateSubtaskSessions,
|
||||
} from "./subtask-breakdown.js";
|
||||
import {
|
||||
setAiSessionStore as setMissionAiSessionStore,
|
||||
rehydrateFromStore as rehydrateMissionSessions,
|
||||
} from "./mission-interview.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -322,6 +331,17 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
setSubtaskAiSessionStore(aiSessionStore);
|
||||
setMissionAiSessionStore(aiSessionStore);
|
||||
|
||||
const planningRehydratedCount = rehydratePlanningSessions(aiSessionStore);
|
||||
const subtaskRehydratedCount = rehydrateSubtaskSessions(aiSessionStore);
|
||||
const missionRehydratedCount = rehydrateMissionSessions(aiSessionStore);
|
||||
const totalRehydrated =
|
||||
planningRehydratedCount + subtaskRehydratedCount + missionRehydratedCount;
|
||||
if (totalRehydrated > 0) {
|
||||
console.log(
|
||||
`[server] Rehydrated ${planningRehydratedCount} planning, ${subtaskRehydratedCount} subtask, ${missionRehydratedCount} mission sessions from SQLite`,
|
||||
);
|
||||
}
|
||||
|
||||
const loadSettings = (store as { getSettings?: () => Promise<{ aiSessionTtlMs?: number; aiSessionCleanupIntervalMs?: number }> }).getSettings;
|
||||
if (typeof loadSettings === "function") {
|
||||
void loadSettings
|
||||
|
||||
@@ -1,10 +1,69 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import {
|
||||
__resetSubtaskBreakdownState,
|
||||
getSubtaskSession,
|
||||
rehydrateFromStore,
|
||||
setAiSessionStore,
|
||||
subtaskStreamManager,
|
||||
} from "./subtask-breakdown.js";
|
||||
import type { AiSessionRow } from "./ai-session-store.js";
|
||||
|
||||
class MockAiSessionStore extends EventEmitter {
|
||||
rows = new Map<string, AiSessionRow>();
|
||||
|
||||
get(id: string): AiSessionRow | null {
|
||||
return this.rows.get(id) ?? null;
|
||||
}
|
||||
|
||||
listRecoverable(): AiSessionRow[] {
|
||||
return [...this.rows.values()].filter(
|
||||
(row) => row.status === "awaiting_input" || row.status === "generating",
|
||||
);
|
||||
}
|
||||
|
||||
on(event: "ai_session:deleted", listener: (sessionId: string) => void): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
off(event: "ai_session:deleted", listener: (sessionId: string) => void): this {
|
||||
return super.off(event, listener);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSubtaskRow(
|
||||
overrides: Partial<AiSessionRow> & Pick<AiSessionRow, "id" | "status">,
|
||||
): AiSessionRow {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: overrides.id,
|
||||
type: overrides.type ?? "subtask",
|
||||
status: overrides.status,
|
||||
title: overrides.title ?? "Subtask breakdown",
|
||||
inputPayload:
|
||||
overrides.inputPayload ?? JSON.stringify({ initialDescription: "Break this task down" }),
|
||||
conversationHistory: overrides.conversationHistory ?? "[]",
|
||||
currentQuestion: overrides.currentQuestion ?? null,
|
||||
result:
|
||||
overrides.result ??
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "subtask-1",
|
||||
title: "Define scope",
|
||||
description: "Plan the work",
|
||||
suggestedSize: "S",
|
||||
dependsOn: [],
|
||||
},
|
||||
]),
|
||||
thinkingOutput: overrides.thinkingOutput ?? "thinking",
|
||||
error: overrides.error ?? null,
|
||||
projectId: overrides.projectId ?? null,
|
||||
createdAt: overrides.createdAt ?? now,
|
||||
updatedAt: overrides.updatedAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
describe("subtask-breakdown stream buffering", () => {
|
||||
beforeEach(() => {
|
||||
@@ -71,3 +130,100 @@ describe("subtask-breakdown stream buffering", () => {
|
||||
expect(subtaskStreamManager.getBufferedEvents(sessionId, 0)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subtask-breakdown rehydration", () => {
|
||||
beforeEach(() => {
|
||||
__resetSubtaskBreakdownState();
|
||||
});
|
||||
|
||||
it("rehydrates recoverable subtask sessions from SQLite rows", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const subtaskRow = buildSubtaskRow({ id: "subtask-rehydrate-1", status: "generating" });
|
||||
const planningRow = buildSubtaskRow({ id: "planning-rehydrate-1", status: "awaiting_input", type: "planning" });
|
||||
|
||||
store.rows.set(subtaskRow.id, subtaskRow);
|
||||
store.rows.set(planningRow.id, planningRow);
|
||||
|
||||
const rehydrated = rehydrateFromStore(store as any);
|
||||
|
||||
expect(rehydrated).toBe(1);
|
||||
const session = getSubtaskSession(subtaskRow.id);
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.sessionId).toBe(subtaskRow.id);
|
||||
expect(session?.initialDescription).toBe("Break this task down");
|
||||
expect(session?.status).toBe("generating");
|
||||
expect(session?.subtasks).toHaveLength(1);
|
||||
expect(getSubtaskSession(planningRow.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips corrupted rows and continues with valid rows", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const goodRow = buildSubtaskRow({ id: "subtask-good", status: "generating" });
|
||||
const badRow = buildSubtaskRow({
|
||||
id: "subtask-bad",
|
||||
status: "generating",
|
||||
inputPayload: "{bad-json",
|
||||
});
|
||||
|
||||
store.rows.set(goodRow.id, goodRow);
|
||||
store.rows.set(badRow.id, badRow);
|
||||
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
const rehydrated = rehydrateFromStore(store as any);
|
||||
|
||||
expect(rehydrated).toBe(1);
|
||||
expect(getSubtaskSession(goodRow.id)).toBeDefined();
|
||||
expect(getSubtaskSession(badRow.id)).toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
`[subtask-breakdown] Failed to rehydrate session ${badRow.id}:`,
|
||||
expect.any(Error),
|
||||
);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("falls through to SQLite when session is missing in memory", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildSubtaskRow({ id: "subtask-fallthrough", status: "generating" });
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
const session = getSubtaskSession(row.id);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.sessionId).toBe(row.id);
|
||||
expect(session?.initialDescription).toBe("Break this task down");
|
||||
expect(session?.status).toBe("generating");
|
||||
});
|
||||
|
||||
it("returns in-memory session before SQLite fallback", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildSubtaskRow({ id: "subtask-memory-first", status: "generating" });
|
||||
store.rows.set(row.id, row);
|
||||
|
||||
setAiSessionStore(store as any);
|
||||
rehydrateFromStore(store as any);
|
||||
|
||||
store.rows.set(
|
||||
row.id,
|
||||
buildSubtaskRow({
|
||||
id: row.id,
|
||||
status: "generating",
|
||||
inputPayload: JSON.stringify({ initialDescription: "SQLite version" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const getSpy = vi.spyOn(store, "get");
|
||||
const session = getSubtaskSession(row.id);
|
||||
|
||||
expect(session?.initialDescription).toBe("Break this task down");
|
||||
expect(getSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined when session exists nowhere", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
expect(getSubtaskSession("missing-subtask-session")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,99 @@ export function setAiSessionStore(store: AiSessionStore): void {
|
||||
|
||||
type SubtaskInternalSession = SubtaskSession & { updatedAt: Date; agent?: any; thinkingOutput: string };
|
||||
|
||||
function safeParseJson<T>(
|
||||
text: string | null,
|
||||
fallback: T,
|
||||
options?: { throwOnError?: boolean; fieldName?: string },
|
||||
): T {
|
||||
if (!text) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch (error) {
|
||||
if (options?.throwOnError) {
|
||||
const fieldSuffix = options.fieldName ? ` in ${options.fieldName}` : "";
|
||||
throw new Error(`Invalid JSON${fieldSuffix}: ${(error as Error).message}`);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function buildSubtaskSessionFromRow(row: AiSessionRow): SubtaskInternalSession {
|
||||
const payload = safeParseJson<{ initialDescription?: string }>(
|
||||
row.inputPayload,
|
||||
{},
|
||||
{ throwOnError: true, fieldName: "inputPayload" },
|
||||
);
|
||||
|
||||
const createdAt = new Date(row.createdAt);
|
||||
const updatedAt = new Date(row.updatedAt);
|
||||
|
||||
if (Number.isNaN(createdAt.getTime()) || Number.isNaN(updatedAt.getTime())) {
|
||||
throw new Error("Invalid session timestamps");
|
||||
}
|
||||
|
||||
const rawStatus = row.status === "awaiting_input" ? "generating" : row.status;
|
||||
const status: SubtaskSession["status"] =
|
||||
rawStatus === "generating" || rawStatus === "complete" || rawStatus === "error"
|
||||
? rawStatus
|
||||
: "error";
|
||||
|
||||
return {
|
||||
sessionId: row.id,
|
||||
initialDescription: payload.initialDescription ?? row.title,
|
||||
subtasks: row.result
|
||||
? safeParseJson<SubtaskItem[]>(row.result, [], {
|
||||
throwOnError: true,
|
||||
fieldName: "result",
|
||||
})
|
||||
: [],
|
||||
status,
|
||||
error: row.error ?? undefined,
|
||||
thinkingOutput: row.thinkingOutput,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
agent: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toPublicSubtaskSession(session: SubtaskInternalSession): SubtaskSession {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
initialDescription: session.initialDescription,
|
||||
subtasks: session.subtasks,
|
||||
status: session.status,
|
||||
error: session.error,
|
||||
createdAt: session.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function rehydrateFromStore(store: AiSessionStore): number {
|
||||
let rows: AiSessionRow[] = [];
|
||||
|
||||
try {
|
||||
rows = store.listRecoverable().filter((row) => row.type === "subtask");
|
||||
} catch (error) {
|
||||
console.error("[subtask-breakdown] Failed to list recoverable sessions:", error);
|
||||
return 0;
|
||||
}
|
||||
|
||||
let rehydrated = 0;
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const session = buildSubtaskSessionFromRow(row);
|
||||
sessions.set(session.sessionId, session);
|
||||
rehydrated += 1;
|
||||
} catch (error) {
|
||||
console.error(`[subtask-breakdown] Failed to rehydrate session ${row.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return rehydrated;
|
||||
}
|
||||
|
||||
function cleanupInMemorySubtaskSession(sessionId: string): boolean {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
@@ -374,16 +467,28 @@ function completeSession(sessionId: string, subtasks: SubtaskItem[]): void {
|
||||
}
|
||||
|
||||
export function getSubtaskSession(sessionId: string): SubtaskSession | undefined {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return undefined;
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
initialDescription: session.initialDescription,
|
||||
subtasks: session.subtasks,
|
||||
status: session.status,
|
||||
error: session.error,
|
||||
createdAt: session.createdAt,
|
||||
};
|
||||
const inMemory = sessions.get(sessionId);
|
||||
if (inMemory) {
|
||||
return toPublicSubtaskSession(inMemory);
|
||||
}
|
||||
|
||||
if (!_aiSessionStore) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const row = _aiSessionStore.get(sessionId);
|
||||
if (!row || row.type !== "subtask") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const restored = buildSubtaskSessionFromRow(row);
|
||||
sessions.set(restored.sessionId, restored);
|
||||
return toPublicSubtaskSession(restored);
|
||||
} catch (error) {
|
||||
console.error(`[subtask-breakdown] Failed to restore session ${sessionId} from SQLite:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelSubtaskSession(sessionId: string): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user