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:
@@ -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 })),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user