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