fix(dashboard-tests): clear the FN-8491 plugin-MCP mock backlog unmasked by the watchdog fix

The core watchdog kill had been hiding everything scheduled after it in
shard 4; with the budget fixed, 15 api-lane files surfaced. Thirteen needed
the established FN-8491 recipe (mock stores expose
getProjectScopedPluginMcpServers so the binder short-circuits). Also:
mcp-lane-forwarding tracks resolveManualAiPromptMcpServers' move to
automation-step-execution and FN-8538's getSettings on planning stores;
planning-answered-question-reemit handles PR #2417's synchronous single-turn
admission with a bounded onceAdmitted retry (admission rejections are
side-effect free); routes-system tracks getProjectPluginLoader resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-24 01:33:29 -07:00
parent cdc5b7f901
commit 16d30ee7b0
15 changed files with 172 additions and 9 deletions

View File

@@ -38,6 +38,13 @@ function createMockStore(settingsByScope = {
return {
getRootDir: () => "/repo",
getSettingsByScopeFast: async () => settingsByScope,
/*
FNXC:PluginMcpServers 2026-07-24-01:25:
FN-8491 (3cd023fa4) binds a project-scoped plugin-MCP provider on every getProjectContext.
Exposing getProjectScopedPluginMcpServers marks this mock as runtime-owned so the binder
short-circuits instead of calling getPluginStore().
*/
getProjectScopedPluginMcpServers: async () => [],
};
}

View File

@@ -77,7 +77,21 @@ vi.mock("../planning-board-tools.js", () => ({
}));
import { __resetPlanningState, createSession, createSessionWithAgent, planningStreamManager } from "../planning.js";
import { resolveManualAiPromptMcpServers } from "../routes.js";
/*
FNXC:DashboardTests 2026-07-24-01:25:
resolveManualAiPromptMcpServers moved out of the routes.ts monolith into the
automation-step-execution registrar module and is no longer re-exported from routes.js.
*/
import { resolveManualAiPromptMcpServers } from "../routes/automation-step-execution.js";
/*
FNXC:PlanningMode 2026-07-24-01:25:
FN-8538 (3f976e3dc) gave Planning Mode a dedicated collaborative prompt:
resolvePlanningModeSystemPrompt now reads store.getSettings() on every planning
agent creation, so planning-session stores must expose it (bare {} throws and
aborts agent init before the MCP forwarding under test happens).
*/
const makePlanningStore = () => ({ getSettings: vi.fn(async () => ({})) }) as never;
import { createMissionInterviewAgent } from "../mission-interview.js";
import { createTargetInterviewAgent } from "../milestone-slice-interview.js";
@@ -89,7 +103,7 @@ describe("dashboard MCP lane forwarding", () => {
});
it("forwards the materialized MCP set to chat/planning createFnAgent sessions", async () => {
const store = {} as never;
const store = makePlanningStore();
await createSession("127.0.0.1", "Build a feature", store, "/tmp/fusion-dashboard-test");
@@ -105,7 +119,7 @@ describe("dashboard MCP lane forwarding", () => {
it("defaults an undefined MCP resolver result to empty servers for non-streaming planning", async () => {
resolveMcpServersForStoreMock.mockResolvedValueOnce(undefined as never);
await createSession("127.0.0.1", "Build without MCP", {} as never, "/tmp/fusion-dashboard-test");
await createSession("127.0.0.1", "Build without MCP", makePlanningStore(), "/tmp/fusion-dashboard-test");
expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({
tools: "readonly",
@@ -121,7 +135,7 @@ describe("dashboard MCP lane forwarding", () => {
"127.0.0.1",
"Stream without MCP",
"/tmp/fusion-dashboard-test",
{} as never,
makePlanningStore(),
);
const startInitialTurn = planningStreamManager.consumeInitialTurn(sessionId);

View File

@@ -31,6 +31,13 @@ function createMockStore() {
getRootDir: () => "/workspace",
getSecretsStore: () => ({ revealSecret: vi.fn() }),
getSettingsByScope: async () => ({ global: { mcpServers: { enabled: true, servers: [] } }, project: {} }),
/*
FNXC:PluginMcpServers 2026-07-24-01:25:
FN-8491 (3cd023fa4) binds a project-scoped plugin-MCP provider on every getProjectContext.
Exposing getProjectScopedPluginMcpServers marks this mock as runtime-owned so the binder
short-circuits instead of calling getPluginStore().
*/
getProjectScopedPluginMcpServers: async () => [],
};
}

View File

@@ -35,6 +35,7 @@ import {
__resetPlanningState,
__setCreateFnAgent,
createSessionWithAgent,
GenerationInProgressError,
getSession,
planningStreamManager,
retrySession,
@@ -42,7 +43,39 @@ import {
submitResponse,
} from "../planning.js";
/*
FNXC:PlanningTurnAdmission 2026-07-24-01:25:
PR #2417 gave every planning generation entry point a synchronous single-turn reservation
that is held for the FULL span of the turn — including persistence work after
session.currentQuestion is emitted. The mock agent's microtask-based firstQuestionEmitted
signal therefore fires while the initial turn still holds the reservation, so an immediate
submitResponse/retrySession is (correctly) rejected with GenerationInProgressError.
Admission rejections are side-effect free by contract (the admission guards run before any
session mutation), so polling until admission succeeds is safe and deterministic.
*/
async function onceAdmitted<T>(attempt: () => Promise<T>): Promise<T> {
const deadline = Date.now() + 10_000;
for (;;) {
try {
return await attempt();
} catch (err) {
if (err instanceof GenerationInProgressError && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
continue;
}
throw err;
}
}
}
const MOCK_TASK_STORE = {
/*
FNXC:PlanningMode 2026-07-24-01:25:
FN-8538 (3f976e3dc) gave Planning Mode a dedicated collaborative prompt:
resolvePlanningModeSystemPrompt now reads store.getSettings() on every planning
agent creation, so the mock store must expose it.
*/
getSettings: vi.fn(async () => ({})),
listTasks: vi.fn(async () => []),
getTask: vi.fn(async () => {
throw new Error("not found");
@@ -195,7 +228,7 @@ describe("answered planning questions are never re-emittable", () => {
const sessionId = await startSessionAtFirstQuestion(agent);
const hungEntered = agent.hungTurnEntered;
const submitPromise = submitResponse(sessionId, { q1: "ship auth first" }, "/tmp/project", undefined, MOCK_TASK_STORE);
const submitPromise = onceAdmitted(() => submitResponse(sessionId, { q1: "ship auth first" }, "/tmp/project", undefined, MOCK_TASK_STORE));
submitPromise.catch(() => {});
await hungEntered;
@@ -218,7 +251,7 @@ describe("answered planning questions are never re-emittable", () => {
]);
const sessionId = await startSessionAtFirstQuestion(agent);
const result = await submitResponse(sessionId, { q1: "ship auth first" }, "/tmp/project", undefined, MOCK_TASK_STORE);
const result = await onceAdmitted(() => submitResponse(sessionId, { q1: "ship auth first" }, "/tmp/project", undefined, MOCK_TASK_STORE));
// The modal ignores this body and lets the SSE error event drive recovery;
// it must stay a resolved response, not a thrown InvalidSessionStateError.
@@ -236,14 +269,14 @@ describe("answered planning questions are never re-emittable", () => {
{ kind: "hang" },
]);
const sessionId = await startSessionAtFirstQuestion(agent);
await submitResponse(sessionId, { q1: "ship auth first" }, "/tmp/project", undefined, MOCK_TASK_STORE);
await onceAdmitted(() => submitResponse(sessionId, { q1: "ship auth first" }, "/tmp/project", undefined, MOCK_TASK_STORE));
// Simulate a row persisted by a pre-fix build where the answered question lingered.
const session = await getSession(sessionId);
session!.currentQuestion = Q1;
const hungEntered = agent.hungTurnEntered;
const retryPromise = retrySession(sessionId, "/tmp/project", undefined, MOCK_TASK_STORE);
const retryPromise = onceAdmitted(() => retrySession(sessionId, "/tmp/project", undefined, MOCK_TASK_STORE));
retryPromise.catch(() => {});
await hungEntered;

View File

@@ -139,6 +139,14 @@ function createMockTaskStore(overrides: Partial<TaskStore> = {}): TaskStore {
deleteMissionTask: vi.fn(),
}),
getPluginStore: vi.fn(),
/*
FNXC:PluginMcpServers 2026-07-24-01:25:
FN-8491 (3cd023fa4) binds a project-scoped plugin-MCP provider on every getProjectContext.
Exposing getProjectScopedPluginMcpServers marks these mocks (host AND projectId-scoped
stores) as runtime-owned so the binder short-circuits instead of building a fallback
loader over the mocked PluginStore, whose missing init() 500'd every scoped route.
*/
getProjectScopedPluginMcpServers: vi.fn().mockResolvedValue([]),
...overrides,
} as unknown as TaskStore;
}

View File

@@ -60,6 +60,13 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
moveTask: vi.fn(),
logEntry: vi.fn(),
getAgentLogs: vi.fn().mockResolvedValue([]),
/*
FNXC:PluginMcpServers 2026-07-24-01:25:
FN-8491 (3cd023fa4) binds a project-scoped plugin-MCP provider on every getProjectContext.
Exposing getProjectScopedPluginMcpServers marks this mock as runtime-owned so the binder
short-circuits instead of calling getPluginStore().
*/
getProjectScopedPluginMcpServers: vi.fn().mockResolvedValue([]),
on: vi.fn(),
off: vi.fn(),
...overrides,

View File

@@ -255,6 +255,21 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
getMissionTask: vi.fn(),
deleteMissionTask: vi.fn(),
}),
/*
FNXC:PluginMcpServers 2026-07-24-01:25:
FN-8491 (3cd023fa4) binds a project-scoped plugin-MCP provider on every getProjectContext.
Exposing getProjectScopedPluginMcpServers marks this mock as runtime-owned so the binder
short-circuits instead of calling getPluginStore().
*/
getProjectScopedPluginMcpServers: vi.fn().mockResolvedValue([]),
/*
FNXC:PluginEnablementScope 2026-07-24-01:25:
getProjectPluginLoader (moved into routes/context.ts 2026-07-22) calls
scopedStore.getPluginStore() to decide between the host loader and a scoped fallback.
Returning undefined matches the undefined options.pluginStore, so routes resolve the
pluginLoader passed to createApiRoutes — the contract these tests assert.
*/
getPluginStore: vi.fn(),
...overrides,
} as unknown as TaskStore;
}

View File

@@ -204,6 +204,13 @@ function createMockStore(
updatePrInfo: vi.fn().mockResolvedValue(task),
addPrInfo: vi.fn().mockResolvedValue(task),
logEntry: vi.fn().mockResolvedValue(undefined),
/*
FNXC:PluginMcpServers 2026-07-24-01:25:
FN-8491 (3cd023fa4) binds a project-scoped plugin-MCP provider on every getProjectContext.
Exposing getProjectScopedPluginMcpServers marks this mock as runtime-owned so the binder
short-circuits instead of calling getPluginStore().
*/
getProjectScopedPluginMcpServers: vi.fn().mockResolvedValue([]),
on: vi.fn(),
off: vi.fn(),
} as unknown as TaskStore;

View File

@@ -35,7 +35,17 @@ function makeArtifact(overrides: Partial<Artifact> = {}): Artifact {
function makeApp(store: Partial<TaskStore>) {
const app = express();
app.use("/api", createApiRoutes(store as TaskStore));
/*
FNXC:PluginMcpServers 2026-07-24-01:25:
FN-8491 (3cd023fa4) binds a project-scoped plugin-MCP provider on every getProjectContext.
Exposing getProjectScopedPluginMcpServers marks these partial mocks as runtime-owned so the
binder short-circuits instead of calling getPluginStore().
*/
const boundStore = {
getProjectScopedPluginMcpServers: async () => [],
...store,
};
app.use("/api", createApiRoutes(boundStore as TaskStore));
return app;
}

View File

@@ -26,6 +26,13 @@ function store(overrides: Record<string, unknown> = {}): TaskStore {
getMilestone: vi.fn(async (id) => id === roadmapMilestone.id ? roadmapMilestone : undefined),
})),
getRootDir: vi.fn(() => process.cwd()),
/*
FNXC:PluginMcpServers 2026-07-24-01:25:
FN-8491 (3cd023fa4) binds a project-scoped plugin-MCP provider on every getProjectContext.
Exposing getProjectScopedPluginMcpServers marks this mock as runtime-owned so the binder
short-circuits instead of calling getPluginStore().
*/
getProjectScopedPluginMcpServers: vi.fn(async () => []),
...overrides,
} as unknown as TaskStore;
}

View File

@@ -38,6 +38,13 @@ function createStore() {
on: vi.fn(),
off: vi.fn(),
getSecretsStore: vi.fn().mockResolvedValue(secretsStore),
/*
FNXC:PluginMcpServers 2026-07-24-01:25:
FN-8491 (3cd023fa4) binds a project-scoped plugin-MCP provider on every getProjectContext.
Exposing getProjectScopedPluginMcpServers marks this mock as runtime-owned so the binder
short-circuits instead of calling getPluginStore().
*/
getProjectScopedPluginMcpServers: vi.fn().mockResolvedValue([]),
} as any;
}

View File

@@ -25,6 +25,10 @@ describe("merge advance events route", () => {
it("returns empty events when audit store is empty", async () => {
const store: TaskStore = {
getRootDir: vi.fn(() => process.cwd()),
// FNXC:PluginMcpServers 2026-07-24-01:25: FN-8491 (3cd023fa4) binds a project-scoped
// plugin-MCP provider on every getProjectContext; exposing this marks the mock as
// runtime-owned so the binder short-circuits instead of calling getPluginStore().
getProjectScopedPluginMcpServers: vi.fn(async () => []),
// FNXC:PostgresCutover 2026-07-16-06:30: API routes use the async
// run-audit accessor when backed by PostgreSQL, including test doubles.
getRunAuditEventsAsync: vi.fn(async () => []),
@@ -77,6 +81,10 @@ describe("merge advance events route", () => {
const store: TaskStore = {
getRootDir: vi.fn(() => process.cwd()),
// FNXC:PluginMcpServers 2026-07-24-01:25: FN-8491 (3cd023fa4) binds a project-scoped
// plugin-MCP provider on every getProjectContext; exposing this marks the mock as
// runtime-owned so the binder short-circuits instead of calling getPluginStore().
getProjectScopedPluginMcpServers: vi.fn(async () => []),
getRunAuditEventsAsync: vi.fn(async (filters?: { mutationType?: string }) => getRunAuditEvents(filters)),
} as unknown as TaskStore;
@@ -156,6 +164,10 @@ describe("merge advance events route", () => {
const store: TaskStore = {
getRootDir: vi.fn(() => process.cwd()),
// FNXC:PluginMcpServers 2026-07-24-01:25: FN-8491 (3cd023fa4) binds a project-scoped
// plugin-MCP provider on every getProjectContext; exposing this marks the mock as
// runtime-owned so the binder short-circuits instead of calling getPluginStore().
getProjectScopedPluginMcpServers: vi.fn(async () => []),
getRunAuditEventsAsync: vi.fn(async (filters?: { mutationType?: string }) => {
if (filters?.mutationType === "merge:integration-ref-advance") return [advance];
if (filters?.mutationType === "merge:auto-sync") return [clean, conflict, stale];
@@ -195,6 +207,10 @@ describe("merge advance events route", () => {
const store: TaskStore = {
getRootDir: vi.fn(() => process.cwd()),
// FNXC:PluginMcpServers 2026-07-24-01:25: FN-8491 (3cd023fa4) binds a project-scoped
// plugin-MCP provider on every getProjectContext; exposing this marks the mock as
// runtime-owned so the binder short-circuits instead of calling getPluginStore().
getProjectScopedPluginMcpServers: vi.fn(async () => []),
getRunAuditEventsAsync: vi.fn(async (filters?: { mutationType?: string }) =>
filters?.mutationType === "merge:integration-ref-advance" ? [advance] : []),
} as unknown as TaskStore;
@@ -210,6 +226,10 @@ describe("merge advance events route", () => {
it("defaults limit to 20, clamps max to 100, rejects invalid limit", async () => {
const store: TaskStore = {
getRootDir: vi.fn(() => process.cwd()),
// FNXC:PluginMcpServers 2026-07-24-01:25: FN-8491 (3cd023fa4) binds a project-scoped
// plugin-MCP provider on every getProjectContext; exposing this marks the mock as
// runtime-owned so the binder short-circuits instead of calling getPluginStore().
getProjectScopedPluginMcpServers: vi.fn(async () => []),
getRunAuditEventsAsync: vi.fn(async () => []),
} as unknown as TaskStore;

View File

@@ -24,6 +24,13 @@ describe("task move route — bypassGuards is not forwardable", () => {
const store: TaskStore = {
getRootDir: vi.fn(() => process.cwd()),
/*
FNXC:PluginMcpServers 2026-07-24-01:25:
FN-8491 (3cd023fa4) binds a project-scoped plugin-MCP provider on every getProjectContext.
Exposing getProjectScopedPluginMcpServers marks this mock as runtime-owned so the binder
short-circuits instead of calling getPluginStore().
*/
getProjectScopedPluginMcpServers: vi.fn(async () => []),
getTask: vi.fn(async () => ({ id: "FN-001", column: "todo" })),
getSettings: vi.fn(async () => ({})),
moveTask,

View File

@@ -25,6 +25,13 @@ const createPauseRouteHarness = (initialTaskState: any) => {
let taskState = initialTaskState;
const store: TaskStore = {
getRootDir: vi.fn(() => process.cwd()),
/*
FNXC:PluginMcpServers 2026-07-24-01:25:
FN-8491 (3cd023fa4) binds a project-scoped plugin-MCP provider on every getProjectContext.
Exposing getProjectScopedPluginMcpServers marks this mock as runtime-owned so the binder
short-circuits instead of calling getPluginStore().
*/
getProjectScopedPluginMcpServers: vi.fn(async () => []),
getTask: vi.fn(async () => taskState),
pauseTask: vi.fn(async (_id: string, paused: boolean) => {
taskState = {

View File

@@ -10,6 +10,13 @@ function createMockStore(): TaskStore {
const now = "2026-05-01T00:00:00.000Z";
return {
getRootDir: vi.fn(() => process.cwd()),
/*
FNXC:PluginMcpServers 2026-07-24-01:25:
FN-8491 (3cd023fa4) binds a project-scoped plugin-MCP provider on every getProjectContext.
Exposing getProjectScopedPluginMcpServers marks this mock as runtime-owned so the binder
short-circuits instead of calling getPluginStore().
*/
getProjectScopedPluginMcpServers: vi.fn().mockResolvedValue([]),
getTask: vi.fn().mockResolvedValue({
id: "FN-001",
description: "task",