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:
@@ -596,9 +596,15 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchAiSessions(projectId).then((sessions) => {
|
fetchAiSessions(projectId).then((sessions) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const pending = sessions.filter(
|
const pending = sessions.filter((s) => {
|
||||||
(s) => s.type === "mission_interview" && missionInterviewListStatuses.has(s.status),
|
if (s.type !== "mission_interview" || !missionInterviewListStatuses.has(s.status)) {
|
||||||
);
|
return false;
|
||||||
|
}
|
||||||
|
if (projectId) {
|
||||||
|
return s.projectId === projectId;
|
||||||
|
}
|
||||||
|
return s.projectId == null;
|
||||||
|
});
|
||||||
setPendingInterviewSessions(pending);
|
setPendingInterviewSessions(pending);
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.warn("[MissionManager] Failed to fetch pending interview sessions:", err);
|
console.warn("[MissionManager] Failed to fetch pending interview sessions:", err);
|
||||||
|
|||||||
@@ -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 () => {
|
it("exposes retry action for errored interview sessions from the mission list", async () => {
|
||||||
mockFetchAiSessions.mockResolvedValueOnce([
|
mockFetchAiSessions.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2752,6 +2752,7 @@ describe("Mission API", () => {
|
|||||||
{ "mission-interview-system": "Scoped mission interview prompt" },
|
{ "mission-interview-system": "Scoped mission interview prompt" },
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
|
projectId,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2839,6 +2840,7 @@ describe("Mission API", () => {
|
|||||||
{},
|
{},
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
|
null,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2914,6 +2916,7 @@ describe("Mission API", () => {
|
|||||||
{},
|
{},
|
||||||
"zai",
|
"zai",
|
||||||
"glm-5.1",
|
"glm-5.1",
|
||||||
|
projectId,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2954,6 +2957,7 @@ describe("Mission API", () => {
|
|||||||
{},
|
{},
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"claude-sonnet-4-5",
|
"claude-sonnet-4-5",
|
||||||
|
projectId,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2997,6 +3001,7 @@ describe("Mission API", () => {
|
|||||||
{},
|
{},
|
||||||
"openai",
|
"openai",
|
||||||
"gpt-4o",
|
"gpt-4o",
|
||||||
|
projectId,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3033,6 +3038,7 @@ describe("Mission API", () => {
|
|||||||
{},
|
{},
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
|
projectId,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 waitFor(() => Boolean(getMissionInterviewSession(sessionId)?.currentQuestion));
|
||||||
|
|
||||||
await submitMissionInterviewResponse(sessionId, { "q-m-1": "A mission" }, "/tmp/project");
|
await submitMissionInterviewResponse(sessionId, { "q-m-1": "A mission" }, "/tmp/project");
|
||||||
|
|
||||||
const persisted = aiSessionStore.get(sessionId);
|
const persisted = aiSessionStore.get(sessionId);
|
||||||
expect(persisted?.status).toBe("complete");
|
expect(persisted?.status).toBe("complete");
|
||||||
|
expect(persisted?.projectId).toBe("project-mission");
|
||||||
|
|
||||||
const history = JSON.parse(persisted?.conversationHistory ?? "[]") as Array<{ question: { id: string } }>;
|
const history = JSON.parse(persisted?.conversationHistory ?? "[]") as Array<{ question: { id: string } }>;
|
||||||
expect(history).toHaveLength(1);
|
expect(history).toHaveLength(1);
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ describe("session resume + history restore", () => {
|
|||||||
result: null,
|
result: null,
|
||||||
thinkingOutput: "mission-latest-thinking",
|
thinkingOutput: "mission-latest-thinking",
|
||||||
error: null,
|
error: null,
|
||||||
projectId: null,
|
projectId: "project-resume",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
lockedByTab: null,
|
lockedByTab: null,
|
||||||
@@ -203,6 +203,7 @@ describe("session resume + history restore", () => {
|
|||||||
const restored = getMissionInterviewSession(row.id);
|
const restored = getMissionInterviewSession(row.id);
|
||||||
expect(restored).toBeDefined();
|
expect(restored).toBeDefined();
|
||||||
expect(restored?.history).toHaveLength(1);
|
expect(restored?.history).toHaveLength(1);
|
||||||
|
expect(restored?.projectId).toBe("project-resume");
|
||||||
expect(restored?.history[0]?.thinkingOutput).toBe("mission-turn-1-thinking");
|
expect(restored?.history[0]?.thinkingOutput).toBe("mission-turn-1-thinking");
|
||||||
expect(restored?.thinkingOutput).toBe("mission-latest-thinking");
|
expect(restored?.thinkingOutput).toBe("mission-latest-thinking");
|
||||||
expect(restored?.lastGeneratedThinking).toBe("mission-latest-thinking");
|
expect(restored?.lastGeneratedThinking).toBe("mission-latest-thinking");
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ interface MissionInterviewHistoryEntry {
|
|||||||
interface MissionInterviewSession {
|
interface MissionInterviewSession {
|
||||||
id: string;
|
id: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
|
projectId: string | null;
|
||||||
missionId: string;
|
missionId: string;
|
||||||
missionTitle: string;
|
missionTitle: string;
|
||||||
history: MissionInterviewHistoryEntry[];
|
history: MissionInterviewHistoryEntry[];
|
||||||
@@ -315,6 +316,7 @@ function persistMissionSession(session: MissionInterviewSession, status: "genera
|
|||||||
title: session.missionTitle.slice(0, 120),
|
title: session.missionTitle.slice(0, 120),
|
||||||
inputPayload: JSON.stringify({
|
inputPayload: JSON.stringify({
|
||||||
ip: session.ip,
|
ip: session.ip,
|
||||||
|
projectId: session.projectId,
|
||||||
missionTitle: session.missionTitle,
|
missionTitle: session.missionTitle,
|
||||||
missionId: session.missionId,
|
missionId: session.missionId,
|
||||||
modelProvider: session.modelProvider,
|
modelProvider: session.modelProvider,
|
||||||
@@ -325,7 +327,7 @@ function persistMissionSession(session: MissionInterviewSession, status: "genera
|
|||||||
result: session.summary ? JSON.stringify(session.summary) : null,
|
result: session.summary ? JSON.stringify(session.summary) : null,
|
||||||
thinkingOutput: session.thinkingOutput,
|
thinkingOutput: session.thinkingOutput,
|
||||||
error: error ?? null,
|
error: error ?? null,
|
||||||
projectId: null,
|
projectId: session.projectId,
|
||||||
createdAt: session.createdAt.toISOString(),
|
createdAt: session.createdAt.toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
lockedByTab: null,
|
lockedByTab: null,
|
||||||
@@ -345,7 +347,7 @@ function unpersistMissionSession(sessionId: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildMissionInterviewSessionFromRow(row: AiSessionRow): MissionInterviewSession {
|
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,
|
row.inputPayload,
|
||||||
{},
|
{},
|
||||||
{ throwOnError: true, fieldName: "inputPayload" },
|
{ throwOnError: true, fieldName: "inputPayload" },
|
||||||
@@ -361,6 +363,7 @@ function buildMissionInterviewSessionFromRow(row: AiSessionRow): MissionIntervie
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
ip: payload.ip ?? "",
|
ip: payload.ip ?? "",
|
||||||
|
projectId: row.projectId ?? payload.projectId ?? null,
|
||||||
missionId: payload.missionId ?? "",
|
missionId: payload.missionId ?? "",
|
||||||
missionTitle: payload.missionTitle ?? row.title,
|
missionTitle: payload.missionTitle ?? row.title,
|
||||||
history: safeParseJson<MissionInterviewHistoryEntry[]>(
|
history: safeParseJson<MissionInterviewHistoryEntry[]>(
|
||||||
@@ -1095,6 +1098,7 @@ export async function createMissionInterviewSession(
|
|||||||
promptOverrides?: PromptOverrideMap,
|
promptOverrides?: PromptOverrideMap,
|
||||||
modelProvider?: string,
|
modelProvider?: string,
|
||||||
modelId?: string,
|
modelId?: string,
|
||||||
|
projectId?: string | null,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!checkRateLimit(ip)) {
|
if (!checkRateLimit(ip)) {
|
||||||
const resetTime = getRateLimitResetTime(ip);
|
const resetTime = getRateLimitResetTime(ip);
|
||||||
@@ -1109,6 +1113,7 @@ export async function createMissionInterviewSession(
|
|||||||
const session: MissionInterviewSession = {
|
const session: MissionInterviewSession = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
ip,
|
ip,
|
||||||
|
projectId: projectId ?? null,
|
||||||
missionId: "",
|
missionId: "",
|
||||||
missionTitle,
|
missionTitle,
|
||||||
history: [],
|
history: [],
|
||||||
@@ -1292,6 +1297,7 @@ export function __registerMissionInterviewSessionForTest(sessionId: string, miss
|
|||||||
sessions.set(sessionId, {
|
sessions.set(sessionId, {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
ip: "127.0.0.1",
|
ip: "127.0.0.1",
|
||||||
|
projectId: null,
|
||||||
missionId: "",
|
missionId: "",
|
||||||
missionTitle,
|
missionTitle,
|
||||||
history: [],
|
history: [],
|
||||||
|
|||||||
@@ -406,7 +406,7 @@ export function createMissionRouter(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
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 rootDir = scopedStore.getRootDir();
|
||||||
const settings = await scopedStore.getSettings();
|
const settings = await scopedStore.getSettings();
|
||||||
|
|
||||||
@@ -425,6 +425,7 @@ export function createMissionRouter(
|
|||||||
settings.promptOverrides,
|
settings.promptOverrides,
|
||||||
resolvedProvider,
|
resolvedProvider,
|
||||||
resolvedModelId,
|
resolvedModelId,
|
||||||
|
projectId ?? null,
|
||||||
);
|
);
|
||||||
res.status(201).json({ sessionId });
|
res.status(201).json({ sessionId });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
Reference in New Issue
Block a user