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:
5
.changeset/FN-4124-mission-interview-drafts.md
Normal file
5
.changeset/FN-4124-mission-interview-drafts.md
Normal 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user