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:
5
.changeset/fn-5880-mission-interview-complete-draft.md
Normal file
5
.changeset/fn-5880-mission-interview-complete-draft.md
Normal 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.
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user