feat(FN-3954): restore scoped mission interview persistence and resume

Scoped mission interview persistence is restored, enabling resume functionality within mission interview flows, with corresponding test coverage and documentation. The implementation touches MissionManager, mission-interview routing, and plugin view registration, with the bulk of changes in tests an

Fusion-Task-Id: FN-3954
This commit is contained in:
Fusion
2026-05-10 15:47:11 -07:00
committed by gsxdsm
parent 8e958e6879
commit 9044fbc5a5
7 changed files with 86 additions and 8 deletions

View File

@@ -596,9 +596,15 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
let cancelled = false;
fetchAiSessions(projectId).then((sessions) => {
if (cancelled) return;
const pending = sessions.filter(
(s) => s.type === "mission_interview" && missionInterviewListStatuses.has(s.status),
);
const pending = sessions.filter((s) => {
if (s.type !== "mission_interview" || !missionInterviewListStatuses.has(s.status)) {
return false;
}
if (projectId) {
return s.projectId === projectId;
}
return s.projectId == null;
});
setPendingInterviewSessions(pending);
}).catch((err) => {
console.warn("[MissionManager] Failed to fetch pending interview sessions:", err);

View File

@@ -1732,6 +1732,55 @@ describe("MissionManager", () => {
});
});
it("only shows in-progress interview sessions scoped to the active project", async () => {
mockFetchAiSessions.mockResolvedValueOnce([
{
id: "session-project-a",
type: "mission_interview",
status: "awaiting_input",
title: "Project A Interview",
projectId: "project-a",
lockedByTab: null,
updatedAt: "2026-01-03T00:00:00.000Z",
},
{
id: "session-project-b",
type: "mission_interview",
status: "awaiting_input",
title: "Project B Interview",
projectId: "project-b",
lockedByTab: null,
updatedAt: "2026-01-04T00:00:00.000Z",
},
{
id: "session-unscoped",
type: "mission_interview",
status: "awaiting_input",
title: "Unscoped Interview",
projectId: null,
lockedByTab: null,
updatedAt: "2026-01-05T00:00:00.000Z",
},
]);
globalThis.fetch = createFetchMock();
render(
<MissionManager
isOpen={true}
onClose={vi.fn()}
addToast={vi.fn()}
projectId="project-a"
/>,
);
await waitFor(() => {
expect(screen.getByText("Project A Interview")).toBeInTheDocument();
});
expect(screen.queryByText("Project B Interview")).not.toBeInTheDocument();
expect(screen.queryByText("Unscoped Interview")).not.toBeInTheDocument();
expect(mockFetchAiSessions).toHaveBeenCalledWith("project-a");
});
it("exposes retry action for errored interview sessions from the mission list", async () => {
mockFetchAiSessions.mockResolvedValueOnce([
{

View File

@@ -2752,6 +2752,7 @@ describe("Mission API", () => {
{ "mission-interview-system": "Scoped mission interview prompt" },
undefined,
undefined,
projectId,
);
});
@@ -2839,6 +2840,7 @@ describe("Mission API", () => {
{},
undefined,
undefined,
null,
);
});
@@ -2914,6 +2916,7 @@ describe("Mission API", () => {
{},
"zai",
"glm-5.1",
projectId,
);
});
@@ -2954,6 +2957,7 @@ describe("Mission API", () => {
{},
"anthropic",
"claude-sonnet-4-5",
projectId,
);
});
@@ -2997,6 +3001,7 @@ describe("Mission API", () => {
{},
"openai",
"gpt-4o",
projectId,
);
});
@@ -3033,6 +3038,7 @@ describe("Mission API", () => {
{},
undefined,
undefined,
projectId,
);
});
});

View File

@@ -288,13 +288,22 @@ describe("session persistence round-trip", () => {
]),
);
const sessionId = await createMissionInterviewSession("127.0.0.44", "Mission persistence", "/tmp/project");
const sessionId = await createMissionInterviewSession(
"127.0.0.44",
"Mission persistence",
"/tmp/project",
undefined,
undefined,
undefined,
"project-mission",
);
await waitFor(() => Boolean(getMissionInterviewSession(sessionId)?.currentQuestion));
await submitMissionInterviewResponse(sessionId, { "q-m-1": "A mission" }, "/tmp/project");
const persisted = aiSessionStore.get(sessionId);
expect(persisted?.status).toBe("complete");
expect(persisted?.projectId).toBe("project-mission");
const history = JSON.parse(persisted?.conversationHistory ?? "[]") as Array<{ question: { id: string } }>;
expect(history).toHaveLength(1);

View File

@@ -182,7 +182,7 @@ describe("session resume + history restore", () => {
result: null,
thinkingOutput: "mission-latest-thinking",
error: null,
projectId: null,
projectId: "project-resume",
createdAt: now,
updatedAt: now,
lockedByTab: null,
@@ -203,6 +203,7 @@ describe("session resume + history restore", () => {
const restored = getMissionInterviewSession(row.id);
expect(restored).toBeDefined();
expect(restored?.history).toHaveLength(1);
expect(restored?.projectId).toBe("project-resume");
expect(restored?.history[0]?.thinkingOutput).toBe("mission-turn-1-thinking");
expect(restored?.thinkingOutput).toBe("mission-latest-thinking");
expect(restored?.lastGeneratedThinking).toBe("mission-latest-thinking");

View File

@@ -222,6 +222,7 @@ interface MissionInterviewHistoryEntry {
interface MissionInterviewSession {
id: string;
ip: string;
projectId: string | null;
missionId: string;
missionTitle: string;
history: MissionInterviewHistoryEntry[];
@@ -315,6 +316,7 @@ function persistMissionSession(session: MissionInterviewSession, status: "genera
title: session.missionTitle.slice(0, 120),
inputPayload: JSON.stringify({
ip: session.ip,
projectId: session.projectId,
missionTitle: session.missionTitle,
missionId: session.missionId,
modelProvider: session.modelProvider,
@@ -325,7 +327,7 @@ function persistMissionSession(session: MissionInterviewSession, status: "genera
result: session.summary ? JSON.stringify(session.summary) : null,
thinkingOutput: session.thinkingOutput,
error: error ?? null,
projectId: null,
projectId: session.projectId,
createdAt: session.createdAt.toISOString(),
updatedAt: new Date().toISOString(),
lockedByTab: null,
@@ -345,7 +347,7 @@ function unpersistMissionSession(sessionId: string): void {
}
function buildMissionInterviewSessionFromRow(row: AiSessionRow): MissionInterviewSession {
const payload = safeParseJson<{ ip?: string; missionId?: string; missionTitle?: string; modelProvider?: string; modelId?: string }>(
const payload = safeParseJson<{ ip?: string; projectId?: string | null; missionId?: string; missionTitle?: string; modelProvider?: string; modelId?: string }>(
row.inputPayload,
{},
{ throwOnError: true, fieldName: "inputPayload" },
@@ -361,6 +363,7 @@ function buildMissionInterviewSessionFromRow(row: AiSessionRow): MissionIntervie
return {
id: row.id,
ip: payload.ip ?? "",
projectId: row.projectId ?? payload.projectId ?? null,
missionId: payload.missionId ?? "",
missionTitle: payload.missionTitle ?? row.title,
history: safeParseJson<MissionInterviewHistoryEntry[]>(
@@ -1095,6 +1098,7 @@ export async function createMissionInterviewSession(
promptOverrides?: PromptOverrideMap,
modelProvider?: string,
modelId?: string,
projectId?: string | null,
): Promise<string> {
if (!checkRateLimit(ip)) {
const resetTime = getRateLimitResetTime(ip);
@@ -1109,6 +1113,7 @@ export async function createMissionInterviewSession(
const session: MissionInterviewSession = {
id: sessionId,
ip,
projectId: projectId ?? null,
missionId: "",
missionTitle,
history: [],
@@ -1292,6 +1297,7 @@ export function __registerMissionInterviewSessionForTest(sessionId: string, miss
sessions.set(sessionId, {
id: sessionId,
ip: "127.0.0.1",
projectId: null,
missionId: "",
missionTitle,
history: [],

View File

@@ -406,7 +406,7 @@ export function createMissionRouter(
try {
const ip = req.ip || req.socket.remoteAddress || "unknown";
const { store: scopedStore } = await getProjectContext(req);
const { store: scopedStore, projectId } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const settings = await scopedStore.getSettings();
@@ -425,6 +425,7 @@ export function createMissionRouter(
settings.promptOverrides,
resolvedProvider,
resolvedModelId,
projectId ?? null,
);
res.status(201).json({ sessionId });
} catch (err: unknown) {