FN-5880: recover plan-ready mission interview drafts
Keep completed mission interview summaries resumable until they are approved or discarded. - include mission interview sessions in dashboard, CLI, and extension draft listings - relabel complete drafts as plan-ready review items and reopen them at the summary approval step - add regression coverage and docs/changeset updates for resumable complete drafts Files changed: .../fn-5880-mission-interview-complete-draft.md | 5 ++ docs/missions.md | 8 +-- packages/cli/src/__tests__/extension.test.ts | 13 +++- .../cli/src/commands/__tests__/mission.test.ts | 11 ++- packages/cli/src/commands/mission.ts | 17 ++++- packages/cli/src/extension.ts | 7 +- .../dashboard/app/components/MissionManager.tsx | 24 ++++--- .../components/__tests__/MissionManager.test.tsx | 70 ++++++++++++++++++ packages/dashboard/app/components/mission-types.ts | 2 +- .../mission-interview-drafts-routes.test.ts | 18 ++++ - .../src/__tests__/mission-interview.test.ts | 82 ++++++++++++++++++++++ packages/dashboard/src/mission-interview.ts | 36 +++++----- 12 files changed, 252 insertions(+), 41 deletions(-) Fusion-Task-Id: FN-5880 Fusion-Task-Lineage: 18a9c25c-3f59-4179-9e9c-63f0747acb7f
This commit is contained in:
@@ -112,16 +112,28 @@ describe("mission interview draft routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("GET /interview/drafts excludes complete and archived mission interview rows", async () => {
|
||||
it("GET /interview/drafts includes complete mission interview rows but excludes archived rows", async () => {
|
||||
aiSessionStore.upsert(makeRow({ id: "draft-live", title: "Live draft", status: "awaiting_input" }));
|
||||
aiSessionStore.upsert(makeRow({ id: "draft-complete", title: "Complete draft", status: "complete" }));
|
||||
aiSessionStore.upsert(
|
||||
makeRow({
|
||||
id: "draft-complete",
|
||||
title: "Complete draft",
|
||||
status: "complete",
|
||||
result: JSON.stringify({ missionTitle: "Complete draft", missionDescription: "desc", milestones: [] }),
|
||||
currentQuestion: null,
|
||||
}),
|
||||
);
|
||||
aiSessionStore.upsert(makeRow({ id: "draft-archived", title: "Archived draft", status: "error" }));
|
||||
aiSessionStore.upsert(makeRow({ id: "draft-other-type", title: "Planning row", type: "planning", status: "complete" }));
|
||||
db.prepare("UPDATE ai_sessions SET archived = 1 WHERE id = ?").run("draft-archived");
|
||||
|
||||
const res = await request(app, "GET", "/api/missions/interview/drafts");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { drafts: Array<{ id: string }> }).drafts.map((draft) => draft.id)).toEqual(["draft-live"]);
|
||||
expect((res.body as { drafts: Array<{ id: string; status: string }> }).drafts).toEqual([
|
||||
expect.objectContaining({ id: "draft-complete", status: "complete" }),
|
||||
expect.objectContaining({ id: "draft-live", status: "awaiting_input" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("POST /interview/drafts/:sessionId/discard removes a hot in-memory session", async () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getMissionInterviewSession,
|
||||
getMissionInterviewSummary,
|
||||
getRateLimitResetTime,
|
||||
listMissionInterviewDrafts,
|
||||
InvalidSessionStateError,
|
||||
missionInterviewStreamManager,
|
||||
parseMissionAgentResponse,
|
||||
@@ -136,6 +137,13 @@ class MockAiSessionStore extends EventEmitter {
|
||||
);
|
||||
}
|
||||
|
||||
listAll(projectId?: string): AiSessionRow[] {
|
||||
return [...this.rows.values()]
|
||||
.filter((row) => row.archived !== 1)
|
||||
.filter((row) => (projectId ? row.projectId === projectId : row.projectId == null))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
on(event: "ai_session:deleted", listener: (sessionId: string) => void): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
@@ -238,6 +246,80 @@ describe("mission-interview module", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("draft listing", () => {
|
||||
it("includes complete mission interview drafts and filters by type/project", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
store.rows.set(
|
||||
"draft-complete",
|
||||
buildMissionRow({
|
||||
id: "draft-complete",
|
||||
status: "complete",
|
||||
title: "Plan ready",
|
||||
result: JSON.stringify({ missionTitle: "Plan ready", missionDescription: "desc", milestones: [] }),
|
||||
currentQuestion: null,
|
||||
projectId: "project-a",
|
||||
createdAt: "2026-05-12T00:00:00.000Z",
|
||||
updatedAt: "2026-05-12T00:05:00.000Z",
|
||||
}),
|
||||
);
|
||||
store.rows.set(
|
||||
"draft-other-type",
|
||||
buildMissionRow({
|
||||
id: "draft-other-type",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Not a mission interview",
|
||||
projectId: "project-a",
|
||||
}),
|
||||
);
|
||||
store.rows.set(
|
||||
"draft-other-project",
|
||||
buildMissionRow({
|
||||
id: "draft-other-project",
|
||||
status: "complete",
|
||||
title: "Other project",
|
||||
projectId: "project-b",
|
||||
}),
|
||||
);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
expect(listMissionInterviewDrafts("project-a")).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "draft-complete",
|
||||
title: "Plan ready",
|
||||
status: "complete",
|
||||
projectId: "project-a",
|
||||
createdAt: "2026-05-12T00:00:00.000Z",
|
||||
updatedAt: "2026-05-12T00:05:00.000Z",
|
||||
hasConversation: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes converted interviews from the draft list after cleanup", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
store.rows.set(
|
||||
"draft-complete",
|
||||
buildMissionRow({
|
||||
id: "draft-complete",
|
||||
status: "complete",
|
||||
title: "Ready to create",
|
||||
result: JSON.stringify({ missionTitle: "Ready to create", missionDescription: "desc", milestones: [] }),
|
||||
currentQuestion: null,
|
||||
}),
|
||||
);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
expect(listMissionInterviewDrafts()).toEqual([
|
||||
expect.objectContaining({ id: "draft-complete", status: "complete" }),
|
||||
]);
|
||||
|
||||
cleanupMissionInterviewSession("draft-complete");
|
||||
|
||||
expect(listMissionInterviewDrafts()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rehydration and session lookup", () => {
|
||||
it("rehydrates mission interview sessions from recoverable rows", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { PlanningQuestion, PromptOverrideMap, TaskStore } from "@fusion/cor
|
||||
import { resolvePrompt } from "@fusion/core";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { AiSessionStore, AiSessionRow, AiSessionStatus } from "./ai-session-store.js";
|
||||
import type { AiSessionStore, AiSessionRow, AiSessionStatus, AiSessionSummary } from "./ai-session-store.js";
|
||||
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
|
||||
import {
|
||||
createSessionDiagnostics,
|
||||
@@ -98,7 +98,7 @@ export const GENERATION_TIMEOUT_MS = 180_000;
|
||||
|
||||
const generationGuard = new GenerationGuard();
|
||||
|
||||
const MISSION_INTERVIEW_DRAFT_STATUSES = ["generating", "awaiting_input", "error"] as const;
|
||||
const MISSION_INTERVIEW_DRAFT_STATUSES = ["generating", "awaiting_input", "error", "complete"] as const;
|
||||
|
||||
function isMissionInterviewDraftStatus(
|
||||
status: AiSessionStatus,
|
||||
@@ -1314,26 +1314,30 @@ export function listMissionInterviewDrafts(projectId?: string): MissionInterview
|
||||
}
|
||||
|
||||
return _aiSessionStore
|
||||
.listActive(projectId)
|
||||
.filter((session) => {
|
||||
if (session.type !== "mission_interview") {
|
||||
return false;
|
||||
}
|
||||
if (!isMissionInterviewDraftStatus(session.status)) {
|
||||
return false;
|
||||
}
|
||||
if (projectId) {
|
||||
return session.projectId === projectId;
|
||||
}
|
||||
return session.projectId == null;
|
||||
})
|
||||
.listAll(projectId)
|
||||
.filter(
|
||||
(
|
||||
session,
|
||||
): session is AiSessionSummary & { type: "mission_interview"; status: MissionInterviewDraftSummary["status"] } => {
|
||||
if (session.type !== "mission_interview") {
|
||||
return false;
|
||||
}
|
||||
if (!isMissionInterviewDraftStatus(session.status)) {
|
||||
return false;
|
||||
}
|
||||
if (projectId) {
|
||||
return session.projectId === projectId;
|
||||
}
|
||||
return session.projectId == null;
|
||||
},
|
||||
)
|
||||
.map((session) => {
|
||||
const row = _aiSessionStore?.get(session.id);
|
||||
const conversation = row ? safeParseJson<unknown[]>(row.conversationHistory, []) : [];
|
||||
return {
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
status: session.status as MissionInterviewDraftSummary["status"],
|
||||
status: session.status,
|
||||
projectId: session.projectId,
|
||||
createdAt: row?.createdAt ?? session.updatedAt,
|
||||
updatedAt: session.updatedAt,
|
||||
|
||||
Reference in New Issue
Block a user