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:
gsxdsm
2026-06-02 09:02:48 -07:00
parent f20c37471d
commit 48e08c07eb
12 changed files with 252 additions and 41 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Recover mission interview drafts that were sent to the background from the final summary step. Plan-ready `complete` mission interview sessions now remain resumable across the dashboard, `fn mission list`, and `fn_mission_list` until they are approved into a mission or discarded.

View File

@@ -158,7 +158,7 @@ The dashboard supports mission planning workflows where you can:
- Associate features to executable tasks - Associate features to executable tasks
- Track progress at each layer - Track progress at each layer
- Persisted missions with `interviewState: "in_progress"` remain visible as interview-styled mission cards in the main mission list so planning work does not disappear after reloads - Persisted missions with `interviewState: "in_progress"` remain visible as interview-styled mission cards in the main mission list so planning work does not disappear after reloads
- Resume in-progress mission interview sessions directly from separate transient session rows in the main missions list (`mission_interview` sessions in `generating`, `awaiting_input`, or `error`) before a mission record is created - Resume in-progress mission interview sessions directly from separate transient session rows in the main missions list (`mission_interview` sessions in `generating`, `awaiting_input`, `error`, or `complete`) before a mission record is created; `complete` means the plan summary is ready for review/approval but has not been converted into a mission yet
- Banner-driven mission interview resumes are one-shot: if you close or send the interview to background, Missions re-fetches project-scoped `mission_interview` sessions and re-surfaces the transient row (including on the mobile stacked Missions view) so resume/retry remains discoverable without losing persisted `interviewState: "in_progress"` mission cards - Banner-driven mission interview resumes are one-shot: if you close or send the interview to background, Missions re-fetches project-scoped `mission_interview` sessions and re-surfaces the transient row (including on the mobile stacked Missions view) so resume/retry remains discoverable without losing persisted `interviewState: "in_progress"` mission cards
- Mission interview, milestone interview, and slice interview agents have read-only board visibility via `fn_task_list` and `fn_task_get`, so they can reference active backlog context and avoid duplicating in-flight tasks while asking planning questions - Mission interview, milestone interview, and slice interview agents have read-only board visibility via `fn_task_list` and `fn_task_get`, so they can reference active backlog context and avoid duplicating in-flight tasks while asking planning questions
@@ -166,9 +166,9 @@ The dashboard supports mission planning workflows where you can:
Mission interview sessions are persisted in `ai_sessions` before a mission row exists, so unfinished drafts stay recoverable across reloads and restarts. Mission interview sessions are persisted in `ai_sessions` before a mission row exists, so unfinished drafts stay recoverable across reloads and restarts.
- **Dashboard:** the Missions view shows a **Drafts** section for in-flight `mission_interview` sessions with **Resume** and **Discard** actions. - **Dashboard:** the Missions view shows a **Drafts** section for in-flight `mission_interview` sessions with **Resume**/**Review** and **Discard** actions. A `complete` draft is a generated-but-unapproved plan parked at the summary step.
- **CLI:** `fn mission list` shows drafts by default before normal mission status sections. Pass `--no-drafts` to hide them. - **CLI:** `fn mission list` shows drafts by default before normal mission status sections, including `complete` plan-ready drafts. Pass `--no-drafts` to hide them.
- **pi extension:** `fn_mission_list` includes drafts by default and accepts `includeDrafts: false` to suppress them. - **pi extension:** `fn_mission_list` includes drafts by default and accepts `includeDrafts: false` to suppress them; `complete` mission interview drafts are returned here too.
- **Discarding drafts:** discarding removes the `ai_sessions` row even for cold drafts after a server restart. - **Discarding drafts:** discarding removes the `ai_sessions` row even for cold drafts after a server restart.
Mission interview draft endpoints: Mission interview draft endpoints:

View File

@@ -1088,13 +1088,24 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
`INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt, lockedByTab, lockedAt) `INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt, lockedByTab, lockedAt)
VALUES (?, 'mission_interview', 'awaiting_input', ?, '{}', '[]', NULL, NULL, '', NULL, NULL, ?, ?, NULL, NULL)`, VALUES (?, 'mission_interview', 'awaiting_input', ?, '{}', '[]', NULL, NULL, '', NULL, NULL, ?, ?, NULL, NULL)`,
).run("draft-1", "Draft Mission", "2026-05-12T00:00:00.000Z", "2026-05-12T00:00:00.000Z"); ).run("draft-1", "Draft Mission", "2026-05-12T00:00:00.000Z", "2026-05-12T00:00:00.000Z");
store.getDatabase().prepare(
`INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt, lockedByTab, lockedAt)
VALUES (?, 'mission_interview', 'complete', ?, '{}', '[]', NULL, '{}', '', NULL, NULL, ?, ?, NULL, NULL)`,
).run("draft-2", "Ready Mission", "2026-05-12T00:01:00.000Z", "2026-05-12T00:01:00.000Z");
const listTool = api.tools.get("fn_mission_list")!; const listTool = api.tools.get("fn_mission_list")!;
const result = await listTool.execute("call-1", {}, undefined, undefined, makeCtx(tmpDir)); const result = await listTool.execute("call-1", {}, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("Drafts (1)"); expect(result.content[0].text).toContain("Drafts (2)");
expect(result.content[0].text).toContain("draft-1: Draft Mission (draft · interview awaiting_input)"); expect(result.content[0].text).toContain("draft-1: Draft Mission (draft · interview awaiting_input)");
expect(result.content[0].text).toContain("draft-2: Ready Mission (draft · interview plan ready)");
expect(result.details.drafts).toEqual([ expect(result.details.drafts).toEqual([
{
id: "draft-2",
title: "Ready Mission",
status: "complete",
updatedAt: "2026-05-12T00:01:00.000Z",
},
{ {
id: "draft-1", id: "draft-1",
title: "Draft Mission", title: "Draft Mission",

View File

@@ -377,6 +377,12 @@ describe("mission commands", () => {
status: "awaiting_input", status: "awaiting_input",
updatedAt: "2026-05-12T00:00:00.000Z", updatedAt: "2026-05-12T00:00:00.000Z",
}, },
{
id: "draft-2",
title: "Ready draft",
status: "complete",
updatedAt: "2026-05-12T00:01:00.000Z",
},
]), ]),
}); });
@@ -394,9 +400,10 @@ describe("mission commands", () => {
} }
const joined = consoleCapture.logs.join("\n"); const joined = consoleCapture.logs.join("\n");
expect(joined).toContain("◌ Drafts (1)"); expect(joined).toContain("◌ Drafts (2)");
expect(joined).toContain("draft-1 Draft mission — (draft · interview awaiting_input)"); expect(joined).toContain("draft-1 Draft mission — (draft · interview awaiting_input)");
expect(joined.indexOf("◌ Drafts (1)")).toBeLessThan(joined.indexOf("● Active (1)")); expect(joined).toContain("draft-2 Ready draft — (draft · interview plan ready)");
expect(joined.indexOf("◌ Drafts (2)")).toBeLessThan(joined.indexOf("● Active (1)"));
mockExit.mockRestore(); mockExit.mockRestore();
} finally { } finally {

View File

@@ -103,6 +103,17 @@ interface RunMissionListOptions {
includeDrafts?: boolean; includeDrafts?: boolean;
} }
function formatMissionInterviewDraftStatus(
status: "generating" | "awaiting_input" | "error" | "complete",
): string {
switch (status) {
case "complete":
return "plan ready";
default:
return status;
}
}
/** /**
* List all missions with status summary. * List all missions with status summary.
*/ */
@@ -118,11 +129,11 @@ export async function runMissionList(projectName?: string, options: RunMissionLi
`SELECT id, title, status, updatedAt `SELECT id, title, status, updatedAt
FROM ai_sessions FROM ai_sessions
WHERE type = 'mission_interview' WHERE type = 'mission_interview'
AND status IN ('generating', 'awaiting_input', 'error') AND status IN ('generating', 'awaiting_input', 'error', 'complete')
AND COALESCE(archived, 0) = 0 AND COALESCE(archived, 0) = 0
ORDER BY updatedAt DESC`, ORDER BY updatedAt DESC`,
) )
.all() as Array<{ id: string; title: string; status: "generating" | "awaiting_input" | "error"; updatedAt: string }>) .all() as Array<{ id: string; title: string; status: "generating" | "awaiting_input" | "error" | "complete"; updatedAt: string }>)
: []; : [];
if (missions.length === 0 && drafts.length === 0) { if (missions.length === 0 && drafts.length === 0) {
@@ -135,7 +146,7 @@ export async function runMissionList(projectName?: string, options: RunMissionLi
if (drafts.length > 0) { if (drafts.length > 0) {
console.log(` ◌ Drafts (${drafts.length})`); console.log(` ◌ Drafts (${drafts.length})`);
for (const draft of drafts) { for (const draft of drafts) {
console.log(` ◌ ${draft.id} ${draft.title} — (draft · interview ${draft.status})`); console.log(` ◌ ${draft.id} ${draft.title} — (draft · interview ${formatMissionInterviewDraftStatus(draft.status)})`);
} }
console.log(); console.log();
} }

View File

@@ -2325,11 +2325,11 @@ export default function kbExtension(pi: ExtensionAPI) {
`SELECT id, title, status, updatedAt `SELECT id, title, status, updatedAt
FROM ai_sessions FROM ai_sessions
WHERE type = 'mission_interview' WHERE type = 'mission_interview'
AND status IN ('generating', 'awaiting_input', 'error') AND status IN ('generating', 'awaiting_input', 'error', 'complete')
AND COALESCE(archived, 0) = 0 AND COALESCE(archived, 0) = 0
ORDER BY updatedAt DESC`, ORDER BY updatedAt DESC`,
) )
.all() as Array<{ id: string; title: string; status: "generating" | "awaiting_input" | "error"; updatedAt: string }>) .all() as Array<{ id: string; title: string; status: "generating" | "awaiting_input" | "error" | "complete"; updatedAt: string }>)
: []; : [];
if (missions.length === 0 && drafts.length === 0) { if (missions.length === 0 && drafts.length === 0) {
@@ -2357,7 +2357,8 @@ export default function kbExtension(pi: ExtensionAPI) {
if (drafts.length > 0) { if (drafts.length > 0) {
lines.push(`Drafts (${drafts.length})`); lines.push(`Drafts (${drafts.length})`);
for (const draft of drafts) { for (const draft of drafts) {
lines.push(` ◌ ${draft.id}: ${draft.title} (draft · interview ${draft.status})`); const draftStatus = draft.status === "complete" ? "plan ready" : draft.status;
lines.push(` ◌ ${draft.id}: ${draft.title} (draft · interview ${draftStatus})`);
} }
lines.push(""); lines.push("");
} }

View File

@@ -191,6 +191,7 @@ const missionInterviewListStatuses: ReadonlySet<AiSessionSummary["status"]> = ne
"generating", "generating",
"awaiting_input", "awaiting_input",
"error", "error",
"complete",
]); ]);
function getInterviewStatusLabel(status: AiSessionSummary["status"]): string { function getInterviewStatusLabel(status: AiSessionSummary["status"]): string {
@@ -201,6 +202,8 @@ function getInterviewStatusLabel(status: AiSessionSummary["status"]): string {
return "Awaiting input"; return "Awaiting input";
case "error": case "error":
return "Needs retry"; return "Needs retry";
case "complete":
return "Plan ready";
default: default:
return status; return status;
} }
@@ -212,6 +215,8 @@ function getInterviewActionLabel(status: AiSessionSummary["status"]): string {
return "Retry interview"; return "Retry interview";
case "generating": case "generating":
return "Generating plan"; return "Generating plan";
case "complete":
return "Review plan";
default: default:
return "Resume interview"; return "Resume interview";
} }
@@ -4021,7 +4026,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const renderInterviewSessionItems = () => missionInterviewDrafts.map((session) => { const renderInterviewSessionItems = () => missionInterviewDrafts.map((session) => {
const isErrored = session.status === "error"; const isErrored = session.status === "error";
const isGenerating = session.status === "generating"; const isGenerating = session.status === "generating";
const isComplete = session.status === "complete";
const resumeActionLabel = getInterviewActionLabel(session.status); const resumeActionLabel = getInterviewActionLabel(session.status);
const description = isGenerating
? "Generating mission hierarchy from interview context."
: isErrored
? "Interview hit an error. Retry from this list item."
: isComplete
? "Plan ready — review and approve to create the mission."
: "Interview is waiting for your next response.";
const actionText = isGenerating ? "Generating…" : isErrored ? "Retry" : isComplete ? "Review" : "Resume";
return ( return (
<div <div
@@ -4053,13 +4067,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{getInterviewStatusLabel(session.status)} {getInterviewStatusLabel(session.status)}
</span> </span>
</div> </div>
<p className="mission-list__item-description"> <p className="mission-list__item-description">{description}</p>
{isGenerating
? "Generating mission hierarchy from interview context."
: isErrored
? "Interview hit an error. Retry from this list item."
: "Interview is waiting for your next response."}
</p>
</div> </div>
<div className="mission-list__item-actions mission-list__resume-actions" onClick={(event) => event.stopPropagation()}> <div className="mission-list__item-actions mission-list__resume-actions" onClick={(event) => event.stopPropagation()}>
<button <button
@@ -4070,7 +4078,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
disabled={isGenerating} disabled={isGenerating}
> >
{isGenerating ? <Loader2 size={14} className="spinner" /> : isErrored ? <RefreshCw size={14} /> : <Sparkles size={14} />} {isGenerating ? <Loader2 size={14} className="spinner" /> : isErrored ? <RefreshCw size={14} /> : <Sparkles size={14} />}
<span>{isGenerating ? "Generating…" : isErrored ? "Retry" : "Resume"}</span> <span>{actionText}</span>
</button> </button>
<button <button
className="mission-btn mission-btn--danger mission-btn--sm" className="mission-btn mission-btn--danger mission-btn--sm"

View File

@@ -65,6 +65,7 @@ vi.mock("lucide-react", () => ({
Package: () => <span data-testid="package-icon">Package</span>, Package: () => <span data-testid="package-icon">Package</span>,
Box: () => <span data-testid="box-icon">Box</span>, Box: () => <span data-testid="box-icon">Box</span>,
Check: () => <span data-testid="check-icon">Check</span>, Check: () => <span data-testid="check-icon">Check</span>,
CheckCircle: () => <span data-testid="check-circle-icon">CheckCircle</span>,
Loader2: ({ className }: any) => <span data-testid="loader-icon" className={className}>Loader</span>, Loader2: ({ className }: any) => <span data-testid="loader-icon" className={className}>Loader</span>,
Link: () => <span data-testid="link-icon">Link</span>, Link: () => <span data-testid="link-icon">Link</span>,
Unlink: () => <span data-testid="unlink-icon">Unlink</span>, Unlink: () => <span data-testid="unlink-icon">Unlink</span>,
@@ -2334,6 +2335,15 @@ describe("MissionManager", () => {
updatedAt: "2026-05-12T00:15:00.000Z", updatedAt: "2026-05-12T00:15:00.000Z",
hasConversation: true, hasConversation: true,
}, },
{
id: "draft-complete",
title: "Draft ready to review",
status: "complete",
projectId: null,
createdAt: "2026-05-12T00:16:00.000Z",
updatedAt: "2026-05-12T00:20:00.000Z",
hasConversation: true,
},
]); ]);
globalThis.fetch = createFetchMock(); globalThis.fetch = createFetchMock();
@@ -2343,11 +2353,15 @@ describe("MissionManager", () => {
expect(screen.getByText("Draft awaiting input")).toBeInTheDocument(); expect(screen.getByText("Draft awaiting input")).toBeInTheDocument();
expect(screen.getByText("Draft generating")).toBeInTheDocument(); expect(screen.getByText("Draft generating")).toBeInTheDocument();
expect(screen.getByText("Draft with error")).toBeInTheDocument(); expect(screen.getByText("Draft with error")).toBeInTheDocument();
expect(screen.getByText("Draft ready to review")).toBeInTheDocument();
expect(screen.getByText("Plan ready")).toBeInTheDocument();
expect(screen.getByText("Plan ready — review and approve to create the mission.")).toBeInTheDocument();
const statusCases = [ const statusCases = [
["Draft awaiting input", "Resume interview", "Resume", false], ["Draft awaiting input", "Resume interview", "Resume", false],
["Draft generating", "Generating plan", "Generating…", true], ["Draft generating", "Generating plan", "Generating…", true],
["Draft with error", "Retry interview", "Retry", false], ["Draft with error", "Retry interview", "Retry", false],
["Draft ready to review", "Review plan", "Review", false],
] as const; ] as const;
for (const [title, actionLabel, buttonText, disabled] of statusCases) { for (const [title, actionLabel, buttonText, disabled] of statusCases) {
@@ -2375,6 +2389,7 @@ describe("MissionManager", () => {
["awaiting_input", "Resume interview", "Resume", false], ["awaiting_input", "Resume interview", "Resume", false],
["generating", "Generating plan", "Generating…", true], ["generating", "Generating plan", "Generating…", true],
["error", "Retry interview", "Retry", false], ["error", "Retry interview", "Retry", false],
["complete", "Review plan", "Review", false],
] as const)( ] as const)(
"renders draft action copy for %s status", "renders draft action copy for %s status",
async (status, actionLabel, visibleLabel, disabled) => { async (status, actionLabel, visibleLabel, disabled) => {
@@ -2472,6 +2487,61 @@ describe("MissionManager", () => {
}); });
}); });
it("reopens a complete mission interview draft at the summary review step", async () => {
mockFetchAiSession.mockResolvedValue({
id: "draft-complete",
type: "mission_interview",
status: "complete",
title: "Draft ready to review",
inputPayload: JSON.stringify({ missionTitle: "Draft ready to review" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify({
missionTitle: "Draft ready to review",
missionDescription: "Recovered summary",
milestones: [
{
title: "Milestone 1",
description: "Ship it",
verification: "Review the plan",
slices: [],
},
],
}),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-05-12T00:16:00.000Z",
updatedAt: "2026-05-12T00:20:00.000Z",
});
mockFetchMissionInterviewDrafts.mockResolvedValueOnce([
{
id: "draft-complete",
title: "Draft ready to review",
status: "complete",
projectId: null,
createdAt: "2026-05-12T00:16:00.000Z",
updatedAt: "2026-05-12T00:20:00.000Z",
hasConversation: true,
},
]);
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
const draftRow = await screen.findByText("Draft ready to review");
const item = draftRow.closest(".mission-list__item");
expect(item).not.toBeNull();
fireEvent.click(within(item!).getByRole("button", { name: "Review plan" }));
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("draft-complete");
expect(screen.getByText("Plan Mission with AI")).toBeInTheDocument();
expect(screen.getByText("Approve Plan")).toBeInTheDocument();
});
});
it("hides drafts section when no mission interview drafts exist", async () => { it("hides drafts section when no mission interview drafts exist", async () => {
mockFetchMissionInterviewDrafts.mockResolvedValueOnce([]); mockFetchMissionInterviewDrafts.mockResolvedValueOnce([]);
globalThis.fetch = createFetchMock(); globalThis.fetch = createFetchMock();

View File

@@ -39,7 +39,7 @@ export interface AutopilotStatus {
export interface MissionInterviewDraftSummary { export interface MissionInterviewDraftSummary {
id: string; id: string;
title: string; title: string;
status: "generating" | "awaiting_input" | "error"; status: "generating" | "awaiting_input" | "error" | "complete";
projectId: string | null; projectId: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@@ -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-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-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"); db.prepare("UPDATE ai_sessions SET archived = 1 WHERE id = ?").run("draft-archived");
const res = await request(app, "GET", "/api/missions/interview/drafts"); const res = await request(app, "GET", "/api/missions/interview/drafts");
expect(res.status).toBe(200); 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 () => { it("POST /interview/drafts/:sessionId/discard removes a hot in-memory session", async () => {

View File

@@ -20,6 +20,7 @@ import {
getMissionInterviewSession, getMissionInterviewSession,
getMissionInterviewSummary, getMissionInterviewSummary,
getRateLimitResetTime, getRateLimitResetTime,
listMissionInterviewDrafts,
InvalidSessionStateError, InvalidSessionStateError,
missionInterviewStreamManager, missionInterviewStreamManager,
parseMissionAgentResponse, 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 { on(event: "ai_session:deleted", listener: (sessionId: string) => void): this {
return super.on(event, listener); 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", () => { describe("rehydration and session lookup", () => {
it("rehydrates mission interview sessions from recoverable rows", () => { it("rehydrates mission interview sessions from recoverable rows", () => {
const store = new MockAiSessionStore(); const store = new MockAiSessionStore();

View File

@@ -19,7 +19,7 @@ import type { PlanningQuestion, PromptOverrideMap, TaskStore } from "@fusion/cor
import { resolvePrompt } from "@fusion/core"; import { resolvePrompt } from "@fusion/core";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events"; 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 { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
import { import {
createSessionDiagnostics, createSessionDiagnostics,
@@ -98,7 +98,7 @@ export const GENERATION_TIMEOUT_MS = 180_000;
const generationGuard = new GenerationGuard(); 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( function isMissionInterviewDraftStatus(
status: AiSessionStatus, status: AiSessionStatus,
@@ -1314,26 +1314,30 @@ export function listMissionInterviewDrafts(projectId?: string): MissionInterview
} }
return _aiSessionStore return _aiSessionStore
.listActive(projectId) .listAll(projectId)
.filter((session) => { .filter(
if (session.type !== "mission_interview") { (
return false; session,
} ): session is AiSessionSummary & { type: "mission_interview"; status: MissionInterviewDraftSummary["status"] } => {
if (!isMissionInterviewDraftStatus(session.status)) { if (session.type !== "mission_interview") {
return false; return false;
} }
if (projectId) { if (!isMissionInterviewDraftStatus(session.status)) {
return session.projectId === projectId; return false;
} }
return session.projectId == null; if (projectId) {
}) return session.projectId === projectId;
}
return session.projectId == null;
},
)
.map((session) => { .map((session) => {
const row = _aiSessionStore?.get(session.id); const row = _aiSessionStore?.get(session.id);
const conversation = row ? safeParseJson<unknown[]>(row.conversationHistory, []) : []; const conversation = row ? safeParseJson<unknown[]>(row.conversationHistory, []) : [];
return { return {
id: session.id, id: session.id,
title: session.title, title: session.title,
status: session.status as MissionInterviewDraftSummary["status"], status: session.status,
projectId: session.projectId, projectId: session.projectId,
createdAt: row?.createdAt ?? session.updatedAt, createdAt: row?.createdAt ?? session.updatedAt,
updatedAt: session.updatedAt, updatedAt: session.updatedAt,