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

@@ -191,6 +191,7 @@ const missionInterviewListStatuses: ReadonlySet<AiSessionSummary["status"]> = ne
"generating",
"awaiting_input",
"error",
"complete",
]);
function getInterviewStatusLabel(status: AiSessionSummary["status"]): string {
@@ -201,6 +202,8 @@ function getInterviewStatusLabel(status: AiSessionSummary["status"]): string {
return "Awaiting input";
case "error":
return "Needs retry";
case "complete":
return "Plan ready";
default:
return status;
}
@@ -212,6 +215,8 @@ function getInterviewActionLabel(status: AiSessionSummary["status"]): string {
return "Retry interview";
case "generating":
return "Generating plan";
case "complete":
return "Review plan";
default:
return "Resume interview";
}
@@ -4021,7 +4026,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const renderInterviewSessionItems = () => missionInterviewDrafts.map((session) => {
const isErrored = session.status === "error";
const isGenerating = session.status === "generating";
const isComplete = session.status === "complete";
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 (
<div
@@ -4053,13 +4067,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{getInterviewStatusLabel(session.status)}
</span>
</div>
<p className="mission-list__item-description">
{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>
<p className="mission-list__item-description">{description}</p>
</div>
<div className="mission-list__item-actions mission-list__resume-actions" onClick={(event) => event.stopPropagation()}>
<button
@@ -4070,7 +4078,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
disabled={isGenerating}
>
{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
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>,
Box: () => <span data-testid="box-icon">Box</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>,
Link: () => <span data-testid="link-icon">Link</span>,
Unlink: () => <span data-testid="unlink-icon">Unlink</span>,
@@ -2334,6 +2335,15 @@ describe("MissionManager", () => {
updatedAt: "2026-05-12T00:15:00.000Z",
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();
@@ -2343,11 +2353,15 @@ describe("MissionManager", () => {
expect(screen.getByText("Draft awaiting input")).toBeInTheDocument();
expect(screen.getByText("Draft generating")).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 = [
["Draft awaiting input", "Resume interview", "Resume", false],
["Draft generating", "Generating plan", "Generating…", true],
["Draft with error", "Retry interview", "Retry", false],
["Draft ready to review", "Review plan", "Review", false],
] as const;
for (const [title, actionLabel, buttonText, disabled] of statusCases) {
@@ -2375,6 +2389,7 @@ describe("MissionManager", () => {
["awaiting_input", "Resume interview", "Resume", false],
["generating", "Generating plan", "Generating…", true],
["error", "Retry interview", "Retry", false],
["complete", "Review plan", "Review", false],
] as const)(
"renders draft action copy for %s status",
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 () => {
mockFetchMissionInterviewDrafts.mockResolvedValueOnce([]);
globalThis.fetch = createFetchMock();

View File

@@ -39,7 +39,7 @@ export interface AutopilotStatus {
export interface MissionInterviewDraftSummary {
id: string;
title: string;
status: "generating" | "awaiting_input" | "error";
status: "generating" | "awaiting_input" | "error" | "complete";
projectId: string | null;
createdAt: 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-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 () => {

View File

@@ -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();

View File

@@ -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,