feat(FN-4124): surface mission interview drafts across mission tooling

- Add dashboard API routes and mission interview store support to list and inspect draft missions
- Update Mission Manager UI to surface draft interview sessions with styling and regression coverage
- Extend CLI mission commands and extension tools to expose draft listings consistently
- Document the draft surfacing behavior and include a changeset for the published CLI package

Fusion-Task-Id: FN-4124
This commit is contained in:
Fusion
2026-05-12 19:38:36 -07:00
committed by gsxdsm
parent e6b1108067
commit af39d474d5
19 changed files with 912 additions and 26 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Surface in-flight mission interview drafts in the dashboard Missions view, `fn mission list`, and the `fn_mission_list` pi tool. Adds Resume and Discard actions for drafts, plus `GET /api/missions/interview/drafts` and `POST /api/missions/interview/drafts/:sessionId/discard` endpoints.

View File

@@ -53,6 +53,22 @@ The dashboard supports mission planning workflows where you can:
- 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
- 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 Drafts
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.
- **CLI:** `fn mission list` shows drafts by default before normal mission status sections. Pass `--no-drafts` to hide them.
- **pi extension:** `fn_mission_list` includes drafts by default and accepts `includeDrafts: false` to suppress them.
- **Discarding drafts:** discarding removes the `ai_sessions` row even for cold drafts after a server restart.
Mission interview draft endpoints:
| Endpoint | Purpose |
|---|---|
| `GET /api/missions/interview/drafts` | List in-flight mission interview drafts |
| `POST /api/missions/interview/drafts/:sessionId/discard` | Discard a draft session |
### Auto-Generated Assertions
When missions are created through the interview planning workflow, Fusion automatically generates contract assertions for each feature:

View File

@@ -180,7 +180,9 @@ Create a new mission — a high-level objective that can span multiple milestone
List all missions with their current status.
No parameters.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `includeDrafts` | boolean | — | Include in-flight mission interview drafts (default: true) |
### fn_mission_show

View File

@@ -526,7 +526,12 @@ describe("bin command routing and fallbacks", () => {
it("routes mission list alias", async () => {
await runBin(["mission", "ls"]);
expect(commandMocks.runMissionList).toHaveBeenCalledWith(undefined);
expect(commandMocks.runMissionList).toHaveBeenCalledWith(undefined, { includeDrafts: true });
});
it("routes mission list with --no-drafts", async () => {
await runBin(["mission", "list", "--no-drafts"]);
expect(commandMocks.runMissionList).toHaveBeenCalledWith(undefined, { includeDrafts: false });
});
it("routes mission show alias", async () => {

View File

@@ -977,7 +977,6 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
describe("fn_mission_list", () => {
it("returns formatted list of missions", async () => {
// First create a mission
const createTool = api.tools.get("fn_mission_create")!;
await createTool.execute(
"c1",
@@ -1000,6 +999,44 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
expect(result.content[0].text).toContain("Missions");
expect(result.content[0].text).toContain("Summary:");
});
it("includes mission interview drafts by default and exposes them in details", async () => {
const store = new TaskStore(tmpDir);
await store.init();
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', '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");
const listTool = api.tools.get("fn_mission_list")!;
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("draft-1: Draft Mission (draft · interview awaiting_input)");
expect(result.details.drafts).toEqual([
{
id: "draft-1",
title: "Draft Mission",
status: "awaiting_input",
updatedAt: "2026-05-12T00:00:00.000Z",
},
]);
});
it("suppresses mission interview drafts when includeDrafts is false", async () => {
const store = new TaskStore(tmpDir);
await store.init();
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', 'error', ?, '{}', '[]', NULL, NULL, '', NULL, NULL, ?, ?, NULL, NULL)`,
).run("draft-2", "Hidden Draft", "2026-05-12T00:00:00.000Z", "2026-05-12T00:00:00.000Z");
const listTool = api.tools.get("fn_mission_list")!;
const result = await listTool.execute("call-1", { includeDrafts: false }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).not.toContain("Drafts");
expect(result.details.drafts).toEqual([]);
});
});
describe("fn_mission_show", () => {

View File

@@ -1224,9 +1224,11 @@ async function main() {
break;
}
case "list":
case "ls":
await runMissionList(projectName);
case "ls": {
const includeDrafts = !args.includes("--no-drafts");
await runMissionList(projectName, { includeDrafts });
break;
}
case "show":
case "info": {
const id = args[2];

View File

@@ -155,13 +155,22 @@ function createMockMissionStore(overrides = {}) {
};
}
function createMockDatabase(drafts: Array<{ id: string; title: string; status: string; updatedAt: string }> = []) {
return {
prepare: vi.fn().mockReturnValue({
all: vi.fn().mockReturnValue(drafts),
}),
};
}
function mockResolvedProjectStore(
missionStore: ReturnType<typeof createMockMissionStore>,
overrides: Partial<{ getTask: ReturnType<typeof vi.fn> }> = {},
overrides: Partial<{ getTask: ReturnType<typeof vi.fn>; getDatabase: ReturnType<typeof createMockDatabase> }> = {},
) {
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => missionStore,
getTask: vi.fn().mockResolvedValue({ id: "FN-001" }),
getDatabase: () => createMockDatabase(),
...overrides,
} as any);
}
@@ -281,9 +290,7 @@ describe("mission commands", () => {
describe("runMissionList", () => {
it("displays missions in formatted output", async () => {
const mockMissionStore = createMockMissionStore();
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
mockResolvedProjectStore(mockMissionStore);
const consoleCapture = captureConsole();
@@ -313,9 +320,7 @@ describe("mission commands", () => {
const mockMissionStore = createMockMissionStore({
listMissions: vi.fn().mockReturnValue([]),
});
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
mockResolvedProjectStore(mockMissionStore);
const consoleCapture = captureConsole();
@@ -337,6 +342,102 @@ describe("mission commands", () => {
consoleCapture.restore();
}
});
it("shows drafts before mission status sections when present", async () => {
const mockMissionStore = createMockMissionStore();
mockResolvedProjectStore(mockMissionStore, {
getDatabase: () => createMockDatabase([
{
id: "draft-1",
title: "Draft mission",
status: "awaiting_input",
updatedAt: "2026-05-12T00:00:00.000Z",
},
]),
});
const consoleCapture = captureConsole();
try {
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
try {
await runMissionList();
} catch {
// expected
}
const joined = consoleCapture.logs.join("\n");
expect(joined).toContain("◌ Drafts (1)");
expect(joined).toContain("draft-1 Draft mission — (draft · interview awaiting_input)");
expect(joined.indexOf("◌ Drafts (1)")).toBeLessThan(joined.indexOf("● Active (1)"));
mockExit.mockRestore();
} finally {
consoleCapture.restore();
}
});
it("suppresses drafts when includeDrafts is false", async () => {
const mockMissionStore = createMockMissionStore();
mockResolvedProjectStore(mockMissionStore, {
getDatabase: () => createMockDatabase([
{
id: "draft-1",
title: "Draft mission",
status: "awaiting_input",
updatedAt: "2026-05-12T00:00:00.000Z",
},
]),
});
const consoleCapture = captureConsole();
try {
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
try {
await runMissionList(undefined, { includeDrafts: false });
} catch {
// expected
}
expect(consoleCapture.logs.join("\n")).not.toContain("Drafts");
mockExit.mockRestore();
} finally {
consoleCapture.restore();
}
});
it("omits drafts heading when no drafts exist", async () => {
const mockMissionStore = createMockMissionStore();
mockResolvedProjectStore(mockMissionStore, {
getDatabase: () => createMockDatabase([]),
});
const consoleCapture = captureConsole();
try {
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
try {
await runMissionList();
} catch {
// expected
}
expect(consoleCapture.logs.join("\n")).not.toContain("Drafts");
mockExit.mockRestore();
} finally {
consoleCapture.restore();
}
});
});
describe("runMissionShow", () => {

View File

@@ -93,22 +93,47 @@ export async function runMissionCreate(titleArg?: string, descriptionArg?: strin
console.log();
}
interface RunMissionListOptions {
includeDrafts?: boolean;
}
/**
* List all missions with status summary.
*/
export async function runMissionList(projectName?: string) {
export async function runMissionList(projectName?: string, options: RunMissionListOptions = {}) {
const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore();
const includeDrafts = options.includeDrafts ?? true;
const missions = missionStore.listMissions();
const drafts = includeDrafts
? (store.getDatabase()
.prepare(
`SELECT id, title, status, updatedAt
FROM ai_sessions
WHERE type = 'mission_interview'
AND status IN ('generating', 'awaiting_input', 'error')
AND COALESCE(archived, 0) = 0
ORDER BY updatedAt DESC`,
)
.all() as Array<{ id: string; title: string; status: "generating" | "awaiting_input" | "error"; updatedAt: string }>)
: [];
if (missions.length === 0) {
if (missions.length === 0 && drafts.length === 0) {
console.log("\n No missions yet. Create one with: fn mission create\n");
process.exit(0);
}
console.log();
if (drafts.length > 0) {
console.log(` ◌ Drafts (${drafts.length})`);
for (const draft of drafts) {
console.log(`${draft.id} ${draft.title} — (draft · interview ${draft.status})`);
}
console.log();
}
// Group by status
const byStatus: Record<string, typeof missions> = {};
for (const mission of missions) {

View File

@@ -2020,20 +2020,36 @@ export default function kbExtension(pi: ExtensionAPI) {
promptGuidelines: [
"Use to see all missions and their current status",
"Missions are grouped by status (active, planning, complete, etc.)",
"Drafts represent unfinished mission interview sessions; fn_mission_show does not work on draft IDs because no mission row exists yet",
"Use before fn_mission_show to find a specific mission ID",
],
parameters: Type.Object({}),
parameters: Type.Object({
includeDrafts: Type.Optional(Type.Boolean({ description: "Include in-flight mission interview drafts (default: true)" })),
}),
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const includeDrafts = params.includeDrafts ?? true;
const missions = missionStore.listMissions();
const drafts = includeDrafts
? (store.getDatabase()
.prepare(
`SELECT id, title, status, updatedAt
FROM ai_sessions
WHERE type = 'mission_interview'
AND status IN ('generating', 'awaiting_input', 'error')
AND COALESCE(archived, 0) = 0
ORDER BY updatedAt DESC`,
)
.all() as Array<{ id: string; title: string; status: "generating" | "awaiting_input" | "error"; updatedAt: string }>)
: [];
if (missions.length === 0) {
if (missions.length === 0 && drafts.length === 0) {
return {
content: [{ type: "text", text: "No missions yet." }],
details: { count: 0 },
details: { count: 0, drafts: [] },
};
}
@@ -2048,8 +2064,17 @@ export default function kbExtension(pi: ExtensionAPI) {
const lines: string[] = [];
lines.push(`Missions (${missions.length})`);
lines.push(
`Summary: active ${summary.active}, planning ${summary.planning}, blocked ${summary.blocked}, complete ${summary.complete}, archived ${summary.archived}\n`,
`Summary: active ${summary.active}, planning ${summary.planning}, blocked ${summary.blocked}, complete ${summary.complete}, archived ${summary.archived}`,
);
lines.push("");
if (drafts.length > 0) {
lines.push(`Drafts (${drafts.length})`);
for (const draft of drafts) {
lines.push(`${draft.id}: ${draft.title} (draft · interview ${draft.status})`);
}
lines.push("");
}
for (const mission of missions) {
const statusIcon = mission.status === "complete" ? "✓" : mission.status === "active" ? "●" : mission.status === "blocked" ? "⚠" : "○";
@@ -2059,7 +2084,11 @@ export default function kbExtension(pi: ExtensionAPI) {
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { count: missions.length, missions: missions.map((m) => ({ id: m.id, title: m.title, status: m.status })) },
details: {
count: missions.length,
missions: missions.map((m) => ({ id: m.id, title: m.title, status: m.status })),
drafts: drafts.map((draft) => ({ id: draft.id, title: draft.title, status: draft.status, updatedAt: draft.updatedAt })),
},
};
},
});

View File

@@ -5,6 +5,8 @@ import {
fetchAgentLogsWithMeta,
fetchAiSessions,
fetchAiSession,
fetchMissionInterviewDrafts,
discardMissionInterviewDraft,
deleteAiSession,
updateTask,
createTask,
@@ -966,6 +968,54 @@ describe("streamChatResponse", () => {
expect(callbacks.error).toEqual([]);
});
describe("mission interview draft api helpers", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("fetches mission interview drafts with project scope", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(true, {
drafts: [{
id: "session-1",
title: "Draft mission",
status: "awaiting_input",
projectId: "project-a",
createdAt: "2026-05-12T00:00:00.000Z",
updatedAt: "2026-05-12T01:00:00.000Z",
hasConversation: true,
}],
}),
);
const drafts = await fetchMissionInterviewDrafts("project-a");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/missions/interview/drafts?projectId=project-a",
expect.objectContaining({ headers: expect.anything() }),
);
expect(drafts).toHaveLength(1);
expect(drafts[0]?.id).toBe("session-1");
});
it("discards a mission interview draft", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { removed: true }));
const result = await discardMissionInterviewDraft("session-2", "project-a", "tab-1");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/missions/interview/drafts/session-2/discard?projectId=project-a",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ tabId: "tab-1" }),
}),
);
expect(result).toEqual({ removed: true });
});
});
it("fires onError when fetch aborts unexpectedly", async () => {
const callbacks = {
error: [] as string[],

View File

@@ -79,7 +79,7 @@ import type {
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry } from "@fusion/dashboard";
import type { MilestoneValidationTelemetry } from "../components/mission-types";
import type { MilestoneValidationTelemetry, MissionInterviewDraftSummary } from "../components/mission-types";
import type {
ResearchAvailability,
ResearchRunDetail,
@@ -7005,6 +7005,26 @@ export function cancelMissionInterview(sessionId: string, projectId?: string, ta
});
}
export async function fetchMissionInterviewDrafts(projectId?: string): Promise<MissionInterviewDraftSummary[]> {
const query = projectId ? `?${new URLSearchParams({ projectId }).toString()}` : "";
const result = await api<{ drafts?: MissionInterviewDraftSummary[] }>(`/missions/interview/drafts${query}`);
return result.drafts ?? [];
}
export function discardMissionInterviewDraft(
sessionId: string,
projectId?: string,
tabId?: string,
): Promise<{ removed: boolean }> {
return api<{ removed: boolean }>(
withProjectId(`/missions/interview/drafts/${encodeURIComponent(sessionId)}/discard`, projectId),
{
method: "POST",
body: JSON.stringify({ tabId }),
},
);
}
/** Create mission from completed interview */
export function createMissionFromInterview(
sessionId: string,

View File

@@ -513,6 +513,21 @@
gap: var(--space-sm);
}
.mission-list__drafts-group {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.mission-list__drafts-header {
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--text-muted);
font-size: inherit;
font-weight: 600;
}
.mission-list__footer {
display: flex;
flex-direction: column;
@@ -2410,6 +2425,10 @@
padding: var(--space-md);
}
.mission-list__drafts-header {
flex-wrap: wrap;
}
/* Touch-friendly controls */
.mission-list__item-actions .mission-icon-btn,
.mission-milestone__actions .mission-icon-btn,

View File

@@ -58,6 +58,7 @@ import {
fetchMission,
updateMission,
deleteMission,
ApiRequestError,
createMilestone,
updateMilestone,
deleteMilestone,
@@ -93,9 +94,11 @@ import {
fetchValidationRun,
fetchAiSessions,
fetchAiSession,
fetchMissionInterviewDrafts,
discardMissionInterviewDraft,
type AiSessionSummary,
} from "../api";
import type { AutopilotState } from "./mission-types";
import type { AutopilotState, MissionInterviewDraftSummary } from "./mission-types";
const MISSION_SIDEBAR_DEFAULT_WIDTH = 300;
const MISSION_SIDEBAR_MIN_WIDTH = 220;
@@ -566,7 +569,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const [showInterviewModal, setShowInterviewModal] = useState(false);
// Pending mission interview sessions (for resume prompt after page reload)
const [pendingInterviewSessions, setPendingInterviewSessions] = useState<AiSessionSummary[]>([]);
const [_pendingInterviewSessions, setPendingInterviewSessions] = useState<AiSessionSummary[]>([]);
const [missionInterviewDrafts, setMissionInterviewDrafts] = useState<MissionInterviewDraftSummary[]>([]);
const [localResumeSessionId, setLocalResumeSessionId] = useState<string | undefined>(undefined);
const dismissedResumeSessionIdRef = useRef<string | null>(null);
const effectiveResumeSessionId =
@@ -624,6 +628,25 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
return () => { cancelled = true; };
}, [isActive, projectId, effectiveResumeSessionId]);
useEffect(() => {
if (!isActive) return;
let cancelled = false;
fetchMissionInterviewDrafts(projectId)
.then((drafts) => {
if (!cancelled) {
setMissionInterviewDrafts(drafts);
}
})
.catch((err) => {
console.warn("[MissionManager] Failed to fetch mission interview drafts:", err);
});
return () => {
cancelled = true;
};
}, [isActive, projectId, effectiveResumeSessionId]);
// Auto-open milestone/slice interview modal when resuming from background session
useEffect(() => {
if (!isActive || !milestoneSliceResumeSessionId) return;
@@ -3539,7 +3562,27 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
setShowInterviewModal(false);
};
const renderInterviewSessionItems = () => pendingInterviewSessions.map((session) => {
const handleDiscardInterviewSession = async (sessionId: string) => {
try {
await discardMissionInterviewDraft(sessionId, projectId);
setMissionInterviewDrafts((current) => current.filter((session) => session.id !== sessionId));
} catch (err) {
if (err instanceof ApiRequestError && err.status === 409) {
addToast("Draft is open in another tab", "error");
return;
}
if (err instanceof ApiRequestError && err.status === 404) {
setMissionInterviewDrafts((current) => current.filter((session) => session.id !== sessionId));
return;
}
addToast(getErrorMessage(err) || "Failed to discard draft", "error");
return;
} finally {
setDeleteConfirmId(null);
}
};
const renderInterviewSessionItems = () => missionInterviewDrafts.map((session) => {
const isErrored = session.status === "error";
const isGenerating = session.status === "generating";
@@ -3576,6 +3619,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
>
{isGenerating ? <Loader2 size={14} className="spinner" /> : isErrored ? <RefreshCw size={14} /> : <Sparkles size={14} />}
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "interview_draft", id: session.id })}
title="Discard draft"
aria-label="Discard draft"
>
<Trash2 size={14} />
</button>
</div>
</div>
);
@@ -3779,7 +3830,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{/* Mission and interview items */}
{renderMissionListItems(persistedInterviewMissions, { interviewStyle: true })}
{renderMissionListItems(standardMissions)}
{renderInterviewSessionItems()}
{missionInterviewDrafts.length > 0 && (
<div className="mission-list__drafts-group">
<div className="mission-list__drafts-header">
<Sparkles size={16} className="mission-list__item-icon" />
<span>Drafts</span>
<span>({missionInterviewDrafts.length})</span>
</div>
{renderInterviewSessionItems()}
</div>
)}
{/* Edit mission form */}
{editingMissionId && (
@@ -3884,6 +3944,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
await handleDeleteSlice(deleteConfirmId.id);
} else if (deleteConfirmId.type === "feature") {
await handleDeleteFeature(deleteConfirmId.id);
} else if (deleteConfirmId.type === "interview_draft") {
await handleDiscardInterviewSession(deleteConfirmId.id);
}
}}
>

View File

@@ -15,6 +15,8 @@ import { MissionManager } from "../MissionManager";
const mockFetchAiSession = vi.fn();
const mockFetchAiSessions = vi.fn();
const mockFetchMissionInterviewDrafts = vi.fn();
const mockDiscardMissionInterviewDraft = vi.fn();
const mockCancelMissionInterview = vi.fn();
const mockConnectMissionInterviewStream = vi.fn();
const mockPreviewEnrichedDescription = vi.fn();
@@ -36,6 +38,8 @@ vi.mock("../../api", async () => {
...actual,
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
fetchAiSessions: (...args: any[]) => mockFetchAiSessions(...args),
fetchMissionInterviewDrafts: (...args: any[]) => mockFetchMissionInterviewDrafts(...args),
discardMissionInterviewDraft: (...args: any[]) => mockDiscardMissionInterviewDraft(...args),
cancelMissionInterview: (...args: any[]) => mockCancelMissionInterview(...args),
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
previewEnrichedDescription: (...args: any[]) => mockPreviewEnrichedDescription(...args),
@@ -695,10 +699,14 @@ describe("MissionManager", () => {
originalEventSource = globalThis.EventSource;
mockFetchAiSession.mockReset();
mockFetchAiSessions.mockReset();
mockFetchMissionInterviewDrafts.mockReset();
mockDiscardMissionInterviewDraft.mockReset();
mockCancelMissionInterview.mockReset();
mockConnectMissionInterviewStream.mockReset();
mockFetchAiSession.mockResolvedValue(null);
mockFetchAiSessions.mockResolvedValue([]);
mockFetchMissionInterviewDrafts.mockResolvedValue([]);
mockDiscardMissionInterviewDraft.mockResolvedValue({ removed: true });
mockCancelMissionInterview.mockResolvedValue(undefined);
mockConnectMissionInterviewStream.mockReturnValue({
close: vi.fn(),
@@ -1687,6 +1695,17 @@ describe("MissionManager", () => {
updatedAt: "2026-01-03T00:00:00.000Z",
},
]);
mockFetchMissionInterviewDrafts.mockResolvedValue([
{
id: "session-bg-1",
title: "Project A transient interview",
status: "awaiting_input",
projectId: "project-a",
createdAt: "2026-01-03T00:00:00.000Z",
updatedAt: "2026-01-03T00:00:00.000Z",
hasConversation: true,
},
]);
const missionsWithPersistedInterview = [
{
@@ -1789,6 +1808,17 @@ describe("MissionManager", () => {
updatedAt: "2026-01-04T00:00:00.000Z",
},
]);
mockFetchMissionInterviewDrafts.mockResolvedValue([
{
id: "session-bg-1",
title: "Project A transient interview",
status: "awaiting_input",
projectId: "project-a",
createdAt: "2026-01-03T00:00:00.000Z",
updatedAt: "2026-01-03T00:00:00.000Z",
hasConversation: true,
},
]);
globalThis.fetch = createFetchMockWithHealth(missionsWithPersistedInterview as Array<Record<string, unknown>>, {
...mockMissionHealthById,
@@ -1928,6 +1958,35 @@ describe("MissionManager", () => {
updatedAt: "2026-01-06T00:00:00.000Z",
},
]);
mockFetchMissionInterviewDrafts.mockResolvedValueOnce([
{
id: "session-awaiting",
title: "Payment workflow planning",
status: "awaiting_input",
projectId: null,
createdAt: "2026-01-03T00:00:00.000Z",
updatedAt: "2026-01-03T00:00:00.000Z",
hasConversation: true,
},
{
id: "session-generating",
title: "Analytics mission drafting",
status: "generating",
projectId: null,
createdAt: "2026-01-04T00:00:00.000Z",
updatedAt: "2026-01-04T00:00:00.000Z",
hasConversation: false,
},
{
id: "session-error",
title: "SRE guardrails",
status: "error",
projectId: null,
createdAt: "2026-01-05T00:00:00.000Z",
updatedAt: "2026-01-05T00:00:00.000Z",
hasConversation: true,
},
]);
mockFetchAiSession.mockResolvedValue({
id: "session-awaiting",
type: "mission_interview",
@@ -1988,6 +2047,17 @@ describe("MissionManager", () => {
updatedAt: "2026-01-03T00:00:00.000Z",
},
]);
mockFetchMissionInterviewDrafts.mockResolvedValueOnce([
{
id: "session-awaiting",
title: "Transient interview session",
status: "awaiting_input",
projectId: null,
createdAt: "2026-01-03T00:00:00.000Z",
updatedAt: "2026-01-03T00:00:00.000Z",
hasConversation: false,
},
]);
globalThis.fetch = createFetchMockWithHealth(missionsWithInterview as Array<Record<string, unknown>>, {
...mockMissionHealthById,
@@ -2052,6 +2122,17 @@ describe("MissionManager", () => {
updatedAt: "2026-01-05T00:00:00.000Z",
},
]);
mockFetchMissionInterviewDrafts.mockResolvedValueOnce([
{
id: "session-project-a",
title: "Project A Interview",
status: "awaiting_input",
projectId: "project-a",
createdAt: "2026-01-03T00:00:00.000Z",
updatedAt: "2026-01-03T00:00:00.000Z",
hasConversation: true,
},
]);
globalThis.fetch = createFetchMock();
render(
@@ -2083,6 +2164,17 @@ describe("MissionManager", () => {
updatedAt: "2026-01-05T00:00:00.000Z",
},
]);
mockFetchMissionInterviewDrafts.mockResolvedValueOnce([
{
id: "session-error",
title: "Mission in error",
status: "error",
projectId: null,
createdAt: "2026-01-05T00:00:00.000Z",
updatedAt: "2026-01-05T00:00:00.000Z",
hasConversation: true,
},
]);
mockFetchAiSession.mockResolvedValue({
id: "session-error",
type: "mission_interview",
@@ -2114,6 +2206,102 @@ describe("MissionManager", () => {
});
});
it("renders mission interview drafts with discard actions", async () => {
mockFetchMissionInterviewDrafts.mockResolvedValueOnce([
{
id: "draft-awaiting",
title: "Draft awaiting input",
status: "awaiting_input",
projectId: null,
createdAt: "2026-05-12T00:00:00.000Z",
updatedAt: "2026-05-12T00:05:00.000Z",
hasConversation: true,
},
{
id: "draft-error",
title: "Draft with error",
status: "error",
projectId: null,
createdAt: "2026-05-12T00:10:00.000Z",
updatedAt: "2026-05-12T00:15:00.000Z",
hasConversation: true,
},
]);
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
expect(await screen.findByText("Drafts")).toBeInTheDocument();
expect(screen.getByText("Draft awaiting input")).toBeInTheDocument();
expect(screen.getByText("Draft with error")).toBeInTheDocument();
expect(screen.getByText("Awaiting input")).toBeInTheDocument();
expect(screen.getByText("Needs retry")).toBeInTheDocument();
fireEvent.click(screen.getAllByLabelText("Discard draft")[0]!);
fireEvent.click(screen.getByText("Delete"));
await waitFor(() => {
expect(mockDiscardMissionInterviewDraft).toHaveBeenCalledWith("draft-awaiting", undefined);
expect(screen.queryByText("Draft awaiting input")).not.toBeInTheDocument();
});
});
it("resumes a mission interview draft", async () => {
mockFetchAiSession.mockResolvedValue({
id: "draft-awaiting",
type: "mission_interview",
status: "awaiting_input",
title: "Draft awaiting input",
inputPayload: JSON.stringify({ missionTitle: "Draft awaiting input" }),
conversationHistory: "[]",
currentQuestion: JSON.stringify({
id: "q-1",
type: "text",
question: "What should happen next?",
description: "Resume the interview",
}),
result: null,
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-05-12T00:00:00.000Z",
updatedAt: "2026-05-12T00:05:00.000Z",
});
mockFetchMissionInterviewDrafts.mockResolvedValueOnce([
{
id: "draft-awaiting",
title: "Draft awaiting input",
status: "awaiting_input",
projectId: null,
createdAt: "2026-05-12T00:00:00.000Z",
updatedAt: "2026-05-12T00:05:00.000Z",
hasConversation: true,
},
]);
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
expect(await screen.findByText("Draft awaiting input")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Resume interview"));
await waitFor(() => {
expect(screen.getByText("Plan Mission with AI")).toBeInTheDocument();
});
});
it("hides drafts section when no mission interview drafts exist", async () => {
mockFetchMissionInterviewDrafts.mockResolvedValueOnce([]);
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
});
expect(screen.queryByText("Drafts")).not.toBeInTheDocument();
});
it("logs a warning when pending interview session fetch fails", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const pendingFetchError = new Error("Pending sessions failed");

View File

@@ -36,6 +36,16 @@ export interface AutopilotStatus {
nextScheduledCheck?: string;
}
export interface MissionInterviewDraftSummary {
id: string;
title: string;
status: "generating" | "awaiting_input" | "error";
projectId: string | null;
createdAt: string;
updatedAt: string;
hasConversation: boolean;
}
export interface Mission {
id: string;
title: string;

View File

@@ -0,0 +1,187 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Database, type TaskStore } from "@fusion/core";
import { createMissionRouter } from "../mission-routes.js";
vi.mock("../project-store-resolver.js", () => ({
getOrCreateProjectStore: vi.fn().mockResolvedValue({
getMissionStore: vi.fn().mockReturnValue({}),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getSettings: vi.fn().mockResolvedValue({ promptOverrides: {} }),
pauseTask: vi.fn(),
}),
}));
import { AiSessionStore, type AiSessionRow } from "../ai-session-store.js";
import { request } from "../test-request.js";
import {
__registerMissionInterviewSessionForTest,
__resetMissionInterviewState,
setAiSessionStore,
} from "../mission-interview.js";
function createMockStore(): TaskStore {
return {
getMissionStore: vi.fn().mockReturnValue({}),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getSettings: vi.fn().mockResolvedValue({ promptOverrides: {} }),
pauseTask: vi.fn(),
} as unknown as TaskStore;
}
function buildApp(aiSessionStore: AiSessionStore) {
const app = express();
app.use(express.json());
app.use("/api/missions", createMissionRouter(createMockStore(), undefined, aiSessionStore));
return app;
}
function makeRow(overrides: Partial<AiSessionRow> & Pick<AiSessionRow, "id">): AiSessionRow {
const now = overrides.updatedAt ?? "2026-05-12T00:00:00.000Z";
return {
id: overrides.id,
type: overrides.type ?? "mission_interview",
status: overrides.status ?? "awaiting_input",
title: overrides.title ?? overrides.id,
inputPayload: overrides.inputPayload ?? JSON.stringify({ missionTitle: overrides.title ?? overrides.id }),
conversationHistory: overrides.conversationHistory ?? "[]",
currentQuestion: overrides.currentQuestion ?? null,
result: overrides.result ?? null,
thinkingOutput: overrides.thinkingOutput ?? "",
error: overrides.error ?? null,
projectId: overrides.projectId ?? null,
createdAt: overrides.createdAt ?? now,
updatedAt: now,
lockedByTab: overrides.lockedByTab ?? null,
lockedAt: overrides.lockedAt ?? null,
archived: overrides.archived,
};
}
describe("mission interview draft routes", () => {
let tmpRoot: string;
let db: Database;
let aiSessionStore: AiSessionStore;
let app: ReturnType<typeof buildApp>;
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), "kb-mission-drafts-"));
db = new Database(join(tmpRoot, ".fusion"));
db.init();
aiSessionStore = new AiSessionStore(db);
__resetMissionInterviewState();
setAiSessionStore(aiSessionStore);
app = buildApp(aiSessionStore);
});
afterEach(async () => {
__resetMissionInterviewState();
aiSessionStore.stopScheduledCleanup();
try {
db.close();
} catch {
// ignore
}
await rm(tmpRoot, { recursive: true, force: true });
});
it("GET /interview/drafts returns only non-terminal mission interview drafts for the requested project", async () => {
aiSessionStore.upsert(makeRow({ id: "draft-a", title: "Draft A", projectId: "project-a", status: "awaiting_input", conversationHistory: "[{\"q\":1}]" }));
aiSessionStore.upsert(makeRow({ id: "draft-b", title: "Draft B", projectId: "project-b", status: "generating" }));
aiSessionStore.upsert(makeRow({ id: "draft-unscoped", title: "Draft Unscoped", projectId: null, status: "error" }));
aiSessionStore.upsert(makeRow({ id: "planning-row", type: "planning", title: "Planning", projectId: "project-a", status: "awaiting_input" }));
const res = await request(app, "GET", "/api/missions/interview/drafts?projectId=project-a");
expect(res.status).toBe(200);
expect(res.body).toEqual({
drafts: [
expect.objectContaining({
id: "draft-a",
title: "Draft A",
status: "awaiting_input",
projectId: "project-a",
hasConversation: true,
}),
],
});
});
it("GET /interview/drafts excludes complete and archived mission interview 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-archived", title: "Archived draft", status: "error" }));
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"]);
});
it("POST /interview/drafts/:sessionId/discard removes a hot in-memory session", async () => {
__registerMissionInterviewSessionForTest("draft-hot", "Hot draft");
aiSessionStore.upsert(makeRow({ id: "draft-hot", title: "Hot draft", status: "awaiting_input" }));
const res = await request(
app,
"POST",
"/api/missions/interview/drafts/draft-hot/discard",
JSON.stringify({ tabId: "tab-1" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, removed: true });
expect(aiSessionStore.get("draft-hot")).toBeNull();
});
it("POST /interview/drafts/:sessionId/discard removes a cold persisted session", async () => {
aiSessionStore.upsert(makeRow({ id: "draft-cold", title: "Cold draft", status: "error" }));
const res = await request(app, "POST", "/api/missions/interview/drafts/draft-cold/discard", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, removed: true });
expect(aiSessionStore.get("draft-cold")).toBeNull();
});
it("POST /interview/drafts/:sessionId/discard returns 404 when the session does not exist", async () => {
const res = await request(app, "POST", "/api/missions/interview/drafts/missing/discard", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(404);
expect((res.body as { error: string }).error).toContain("missing");
});
it("POST /interview/drafts/:sessionId/discard returns 409 when locked by another tab", async () => {
aiSessionStore.upsert(makeRow({ id: "draft-locked", title: "Locked draft", status: "awaiting_input" }));
db.prepare("UPDATE ai_sessions SET lockedByTab = ?, lockedAt = ? WHERE id = ?").run(
"tab-owner",
"2026-05-12T00:00:00.000Z",
"draft-locked",
);
const res = await request(
app,
"POST",
"/api/missions/interview/drafts/draft-locked/discard",
JSON.stringify({ tabId: "tab-other" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(409);
expect(res.body).toEqual({
error: "Session locked by another tab",
lockedByTab: "tab-owner",
});
});
});

View File

@@ -617,6 +617,27 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
this.emit("ai_session:deleted", id);
}
deleteByIdAndType(id: string, type: AiSessionType): boolean {
const existing = this.db
.prepare("SELECT id FROM ai_sessions WHERE id = ? AND type = ?")
.get(id, type) as { id: string } | undefined;
if (!existing) {
return false;
}
this.clearThinkingTimer(id);
const result = this.db
.prepare("DELETE FROM ai_sessions WHERE id = ? AND type = ?")
.run(id, type) as { changes?: number };
const removed = Number(result.changes ?? 0) > 0;
if (removed) {
this.emit("ai_session:deleted", id);
}
return removed;
}
/**
* Recover sessions after server restart.
* - `generating` sessions with a currentQuestion -> `awaiting_input`

View File

@@ -19,7 +19,7 @@ import type { PlanningQuestion, PromptOverrideMap } from "@fusion/core";
import { resolvePrompt } from "@fusion/core";
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
import type { AiSessionStore, AiSessionRow, AiSessionStatus } from "./ai-session-store.js";
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
import {
createSessionDiagnostics,
@@ -96,6 +96,14 @@ export const GENERATION_TIMEOUT_MS = 180_000;
const generationGuard = new GenerationGuard();
const MISSION_INTERVIEW_DRAFT_STATUSES = ["generating", "awaiting_input", "error"] as const;
function isMissionInterviewDraftStatus(
status: AiSessionStatus,
): status is Extract<AiSessionStatus, (typeof MISSION_INTERVIEW_DRAFT_STATUSES)[number]> {
return (MISSION_INTERVIEW_DRAFT_STATUSES as readonly string[]).includes(status);
}
/** Mission interview system prompt */
export const MISSION_INTERVIEW_SYSTEM_PROMPT = `You are a mission planning assistant for a project management system.
@@ -200,6 +208,16 @@ export type MissionInterviewResponse =
| { type: "question"; data: PlanningQuestion }
| { type: "complete"; data: MissionPlanSummary };
export interface MissionInterviewDraftSummary {
id: string;
title: string;
status: Extract<AiSessionStatus, (typeof MISSION_INTERVIEW_DRAFT_STATUSES)[number]>;
projectId: string | null;
createdAt: string;
updatedAt: string;
hasConversation: boolean;
}
/** SSE event types for mission interview streaming */
export type MissionInterviewStreamEvent =
| { type: "thinking"; data: string }
@@ -1251,6 +1269,54 @@ export async function cancelMissionInterviewSession(sessionId: string): Promise<
unpersistMissionSession(sessionId);
}
export function listMissionInterviewDrafts(projectId?: string): MissionInterviewDraftSummary[] {
if (!_aiSessionStore) {
return [];
}
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;
})
.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"],
projectId: session.projectId,
createdAt: row?.createdAt ?? session.updatedAt,
updatedAt: session.updatedAt,
hasConversation: conversation.length > 0,
};
});
}
export async function discardMissionInterviewSession(sessionId: string): Promise<{ removed: boolean }> {
try {
await cancelMissionInterviewSession(sessionId);
return { removed: true };
} catch (error) {
if (!(error instanceof SessionNotFoundError)) {
throw error;
}
}
const removed = _aiSessionStore?.deleteByIdAndType(sessionId, "mission_interview") ?? false;
return { removed };
}
export function getMissionInterviewSession(sessionId: string): MissionInterviewSession | undefined {
const inMemory = sessions.get(sessionId);
if (inMemory) {

View File

@@ -590,6 +590,47 @@ export function createMissionRouter(
})
);
router.get(
"/interview/drafts",
catchTypedHandler(async (req, res) => {
const projectId = typeof req.query.projectId === "string" && req.query.projectId.trim().length > 0
? req.query.projectId.trim()
: undefined;
const { listMissionInterviewDrafts } = await import("./mission-interview.js");
res.json({ drafts: listMissionInterviewDrafts(projectId) });
})
);
router.post(
"/interview/drafts/:sessionId/discard",
catchTypedHandler(async (req, res) => {
const { sessionId } = req.params;
const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0
? req.body.tabId.trim()
: undefined;
if (!sessionId || typeof sessionId !== "string") {
throw badRequest("sessionId is required");
}
const lockCheck = checkSessionLock(sessionId, tabId, aiSessionStore);
if (!lockCheck.allowed) {
res.status(409).json({
error: "Session locked by another tab",
lockedByTab: lockCheck.currentHolder,
});
return;
}
const { discardMissionInterviewSession } = await import("./mission-interview.js");
const result = await discardMissionInterviewSession(sessionId);
if (!result.removed) {
throw notFound(`Mission interview session ${sessionId} not found or expired`);
}
res.json({ success: true, removed: true });
})
);
/**
* GET /api/missions/interview/:sessionId/stream
* SSE endpoint for real-time interview session updates.