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:
@@ -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 () => [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user