diff --git a/packages/dashboard/src/__tests__/mcp-discovered-route.test.ts b/packages/dashboard/src/__tests__/mcp-discovered-route.test.ts index 60bfb44d51..948d79df4e 100644 --- a/packages/dashboard/src/__tests__/mcp-discovered-route.test.ts +++ b/packages/dashboard/src/__tests__/mcp-discovered-route.test.ts @@ -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 () => [], }; } diff --git a/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts b/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts index 81557ae546..c89ef5ffd5 100644 --- a/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts +++ b/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts @@ -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); diff --git a/packages/dashboard/src/__tests__/mcp-validate-route.test.ts b/packages/dashboard/src/__tests__/mcp-validate-route.test.ts index 4c0e860a6d..8143e4ad63 100644 --- a/packages/dashboard/src/__tests__/mcp-validate-route.test.ts +++ b/packages/dashboard/src/__tests__/mcp-validate-route.test.ts @@ -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 () => [], }; } diff --git a/packages/dashboard/src/__tests__/planning-answered-question-reemit.test.ts b/packages/dashboard/src/__tests__/planning-answered-question-reemit.test.ts index 3340bcd687..1e8d4c9a47 100644 --- a/packages/dashboard/src/__tests__/planning-answered-question-reemit.test.ts +++ b/packages/dashboard/src/__tests__/planning-answered-question-reemit.test.ts @@ -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(attempt: () => Promise): Promise { + 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; diff --git a/packages/dashboard/src/__tests__/plugin-routes.routes.test.ts b/packages/dashboard/src/__tests__/plugin-routes.routes.test.ts index e04b20f603..643d45a85e 100644 --- a/packages/dashboard/src/__tests__/plugin-routes.routes.test.ts +++ b/packages/dashboard/src/__tests__/plugin-routes.routes.test.ts @@ -139,6 +139,14 @@ function createMockTaskStore(overrides: Partial = {}): 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; } diff --git a/packages/dashboard/src/__tests__/routes-remote-access.test.ts b/packages/dashboard/src/__tests__/routes-remote-access.test.ts index 19a83e6f15..be788599dd 100644 --- a/packages/dashboard/src/__tests__/routes-remote-access.test.ts +++ b/packages/dashboard/src/__tests__/routes-remote-access.test.ts @@ -60,6 +60,13 @@ function createMockStore(overrides: Partial = {}): 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, diff --git a/packages/dashboard/src/__tests__/routes-system.test.ts b/packages/dashboard/src/__tests__/routes-system.test.ts index c5fda1eaa8..b2fa72b848 100644 --- a/packages/dashboard/src/__tests__/routes-system.test.ts +++ b/packages/dashboard/src/__tests__/routes-system.test.ts @@ -255,6 +255,21 @@ function createMockStore(overrides: Partial = {}): 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; } diff --git a/packages/dashboard/src/__tests__/task-revert-route.test.ts b/packages/dashboard/src/__tests__/task-revert-route.test.ts index e37bfd56e7..2947b98784 100644 --- a/packages/dashboard/src/__tests__/task-revert-route.test.ts +++ b/packages/dashboard/src/__tests__/task-revert-route.test.ts @@ -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; diff --git a/packages/dashboard/src/routes/__tests__/artifacts-routes.test.ts b/packages/dashboard/src/routes/__tests__/artifacts-routes.test.ts index b348fd4d9a..55b45b7aca 100644 --- a/packages/dashboard/src/routes/__tests__/artifacts-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/artifacts-routes.test.ts @@ -35,7 +35,17 @@ function makeArtifact(overrides: Partial = {}): Artifact { function makeApp(store: Partial) { 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; } diff --git a/packages/dashboard/src/routes/__tests__/native-structure-preview-routes.test.ts b/packages/dashboard/src/routes/__tests__/native-structure-preview-routes.test.ts index 011b291c8d..1568136f4d 100644 --- a/packages/dashboard/src/routes/__tests__/native-structure-preview-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/native-structure-preview-routes.test.ts @@ -26,6 +26,13 @@ function store(overrides: Record = {}): 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; } diff --git a/packages/dashboard/src/routes/__tests__/register-secrets-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-secrets-routes.test.ts index 93af0801e7..3f467bc98d 100644 --- a/packages/dashboard/src/routes/__tests__/register-secrets-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-secrets-routes.test.ts @@ -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; } diff --git a/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.merge-advance-events.test.ts b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.merge-advance-events.test.ts index 038f6c4c7a..05e41561b3 100644 --- a/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.merge-advance-events.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.merge-advance-events.test.ts @@ -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; diff --git a/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.move-bypassguards.test.ts b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.move-bypassguards.test.ts index a0e91911a6..b77b702f94 100644 --- a/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.move-bypassguards.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.move-bypassguards.test.ts @@ -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, diff --git a/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.unpause.test.ts b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.unpause.test.ts index 09b81d52f0..7c9defa09e 100644 --- a/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.unpause.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.unpause.test.ts @@ -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 = { diff --git a/packages/dashboard/src/routes/__tests__/task-review-routes.test.ts b/packages/dashboard/src/routes/__tests__/task-review-routes.test.ts index eecdf06d8f..122950f65f 100644 --- a/packages/dashboard/src/routes/__tests__/task-review-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/task-review-routes.test.ts @@ -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",