FN-7078: forward MCP config to planning helpers
Dashboard readonly planning helpers now receive scoped MCP server configuration when creating AI sessions. - Thread request-scoped TaskStore access into subtask, text refine, goal drafting, agent onboarding, PR metadata, insight, and triage helper paths. - Resolve and forward in-memory MCP server declarations while preserving empty no-store fallbacks and secret hygiene. - Document the expanded MCP forwarding surface and add regression coverage for helper forwarding and retry paths. - Add a changeset for the published Fusion package. Files changed: .changeset/fn-7078-mcp-planning-helpers.md | 7 + docs/mcp.md | 6 +- .../src/__tests__/agent-generation.test.ts | 3 + .../src/__tests__/agent-onboarding.test.ts | 72 +++++- packages/dashboard/src/__tests__/ai-refine.test.ts | 96 +++++++- .../src/__tests__/mcp-helper-forwarding.test.ts | 258 +++++++++++++++++++++ .../src/__tests__/pr-metadata-generator.test.ts | 1 + .../src/__tests__/subtask-breakdown.test.ts | 137 ++++++++++- packages/dashboard/src/agent-generation.ts | 17 +- packages/dashboard/src/agent-onboarding.ts | 49 +++- packages/dashboard/src/ai-refine.ts | 18 +- packages/dashboard/src/insights-routes.ts | 40 ++-- packages/dashboard/src/pr-metadata-generator.ts | 13 +- packages/dashboard/src/routes.ts | 3 +- ...gister-agent-import-export-generation-routes.ts | 6 +- .../dashboard/src/routes/register-git-github.ts | 1 + .../src/routes/register-planning-subtask-routes.ts | 2 +- packages/dashboard/src/subtask-breakdown.ts | 27 ++- packages/dashboard/src/triage-trait.ts | 2 +- 19 files changed, 695 insertions(+), 63 deletions(-) Fusion-Task-Id: FN-7078 Fusion-Task-Lineage: 8ed30003-7a27-42e9-8b37-5ceb1a832334 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7078-mcp-planning-helpers.md
Normal file
7
.changeset/fn-7078-mcp-planning-helpers.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Configured MCP servers now reach dashboard planning helpers like subtask breakdown, text refine, and insights.
|
||||||
|
category: feature
|
||||||
|
dev: Thread TaskStore/secrets into dashboard readonly createFnAgent helpers and forward resolveMcpServersForStore; see FN-7078.
|
||||||
@@ -58,8 +58,8 @@ Fusion materializes MCP secret references only at the use seam:
|
|||||||
- when running a bounded validation/reachability probe;
|
- when running a bounded validation/reachability probe;
|
||||||
- when importing plaintext Claude Desktop env/header values and immediately creating Fusion secrets.
|
- when importing plaintext Claude Desktop env/header values and immediately creating Fusion secrets.
|
||||||
|
|
||||||
<!-- FNXC:McpConfig 2026-06-26-00:00: FN-7077 completed the agent-work surface audit. Configured MCP servers must be injected into MCP-capable Chat, Tools, and Tasks lanes, including heartbeat, manual AI-prompt workflow steps, PR-response/PR-conflict merger helpers, cron automation, and mission/milestone interview sessions. Terminal sessions and DB-row chat session creation remain non-agent-runtime surfaces and intentionally receive no MCP payload. -->
|
<!-- FNXC:McpConfig 2026-06-26-17:06: FN-7078 extended the FN-7077 forwarding invariant to dashboard readonly planning helpers. Configured MCP servers must reach subtask breakdown (stream/retry/triage), text refinement, goal drafting, agent onboarding generation, PR metadata generation, and insight extraction whenever those helpers have a scoped TaskStore; terminal sessions and DB-row chat session creation remain non-agent-runtime surfaces and intentionally receive no MCP payload. -->
|
||||||
MCP-capable AI sessions include Chat, planning, executor/Tasks, heartbeat runs, reviewer/validator/merger lanes, PR-response and PR-conflict merger helpers, manual AI-prompt workflow steps, workflow model nodes, evaluator, cron/automation, mission execution, mission and milestone/slice interviews, and agent reflection. Non-agent runtime surfaces such as terminal sessions and `chatStore.createSession` database row creation do not receive MCP servers.
|
MCP-capable AI sessions include Chat, planning, executor/Tasks, heartbeat runs, reviewer/validator/merger lanes, PR-response and PR-conflict merger helpers, manual AI-prompt workflow steps, workflow model nodes, evaluator, cron/automation, mission execution, mission and milestone/slice interviews, agent reflection, and dashboard readonly planning helpers such as subtask breakdown, text refinement/goal drafting, agent onboarding generation, PR metadata generation, and insight extraction. Non-agent runtime surfaces such as terminal sessions and `chatStore.createSession` database row creation do not receive MCP servers.
|
||||||
|
|
||||||
Expected outcome: API responses, CLI output, settings JSON, exports, and structured logs show secret references or counts/status metadata only; they do not include decrypted env/header values.
|
Expected outcome: API responses, CLI output, settings JSON, exports, and structured logs show secret references or counts/status metadata only; they do not include decrypted env/header values.
|
||||||
|
|
||||||
@@ -262,7 +262,7 @@ Expected outcome: API clients can display candidates, source labels, configured
|
|||||||
|
|
||||||
## How MCP servers reach AI lanes
|
## How MCP servers reach AI lanes
|
||||||
|
|
||||||
When an AI lane starts a session, Fusion resolves the effective `mcpServers` settings, materializes secret references through the scoped secrets store, and passes the resulting in-memory server declarations to runtimes that support MCP. The forwarding path covers chat/planning, executor, reviewer, validator, merger, workflow model nodes, summarization, evaluator, research, cron/automation, mission, and reflection paths.
|
When an AI lane or readonly dashboard helper starts a session, Fusion resolves the effective `mcpServers` settings, materializes secret references through the scoped secrets store, and passes the resulting in-memory server declarations to runtimes that support MCP. The forwarding path covers chat/planning, executor, reviewer, validator, merger, workflow model nodes, summarization, evaluator, research, cron/automation, mission, reflection, subtask breakdown, text refinement/goal drafting, agent onboarding generation, PR metadata generation, and insight extraction paths.
|
||||||
|
|
||||||
Runtime support is guarded. Claude/pi/ACP-compatible runtimes receive MCP servers; mock or unsupported runtimes skip forwarding and emit only structured count/provider/runtime metadata. Skipped forwarding is not a settings error: it means the selected runtime does not accept MCP server declarations.
|
Runtime support is guarded. Claude/pi/ACP-compatible runtimes receive MCP servers; mock or unsupported runtimes skip forwarding and emit only structured count/provider/runtime metadata. Skipped forwarding is not a settings error: it means the selected runtime does not accept MCP server declarations.
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ describe("agent-generation module", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.doMock("@fusion/engine", () => ({
|
vi.doMock("@fusion/engine", () => ({
|
||||||
|
resolveMcpServersForStore: vi.fn().mockResolvedValue({ servers: [], errors: [] }),
|
||||||
createFnAgent: vi.fn(async () => {
|
createFnAgent: vi.fn(async () => {
|
||||||
const messages: Array<{ role: string; content: string }> = [];
|
const messages: Array<{ role: string; content: string }> = [];
|
||||||
return {
|
return {
|
||||||
@@ -187,6 +188,7 @@ describe("agent-generation module", () => {
|
|||||||
|
|
||||||
const generationFailure = new Error("engine failed to generate spec");
|
const generationFailure = new Error("engine failed to generate spec");
|
||||||
vi.doMock("@fusion/engine", () => ({
|
vi.doMock("@fusion/engine", () => ({
|
||||||
|
resolveMcpServersForStore: vi.fn().mockResolvedValue({ servers: [], errors: [] }),
|
||||||
createFnAgent: vi.fn(async () => {
|
createFnAgent: vi.fn(async () => {
|
||||||
throw generationFailure;
|
throw generationFailure;
|
||||||
}),
|
}),
|
||||||
@@ -495,6 +497,7 @@ describe("agent-generation module", () => {
|
|||||||
capturedSystemPrompt = undefined;
|
capturedSystemPrompt = undefined;
|
||||||
// Mock createFnAgent before tests run
|
// Mock createFnAgent before tests run
|
||||||
vi.doMock("@fusion/engine", () => ({
|
vi.doMock("@fusion/engine", () => ({
|
||||||
|
resolveMcpServersForStore: vi.fn().mockResolvedValue({ servers: [], errors: [] }),
|
||||||
createFnAgent: vi.fn(async (options: { cwd: string; systemPrompt: string; tools: string }) => {
|
createFnAgent: vi.fn(async (options: { cwd: string; systemPrompt: string; tools: string }) => {
|
||||||
capturedSystemPrompt = options.systemPrompt;
|
capturedSystemPrompt = options.systemPrompt;
|
||||||
const messages: Array<{ role: string; content: string }> = [];
|
const messages: Array<{ role: string; content: string }> = [];
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { mockCreateFnAgent } = vi.hoisted(() => ({
|
const { mockCreateFnAgent, mockResolveMcpServersForStore } = vi.hoisted(() => ({
|
||||||
mockCreateFnAgent: vi.fn(),
|
mockCreateFnAgent: vi.fn(),
|
||||||
|
mockResolveMcpServersForStore: vi.fn(async () => ({ servers: [], errors: [] })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@fusion/engine", () => ({
|
vi.mock("@fusion/engine", () => ({
|
||||||
@@ -22,6 +23,7 @@ vi.mock("@fusion/engine", () => ({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
createFnAgent: mockCreateFnAgent,
|
createFnAgent: mockCreateFnAgent,
|
||||||
|
resolveMcpServersForStore: mockResolveMcpServersForStore,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +35,7 @@ import {
|
|||||||
InvalidSessionStateError,
|
InvalidSessionStateError,
|
||||||
parseAgentOnboardingResponse,
|
parseAgentOnboardingResponse,
|
||||||
respondToAgentOnboarding,
|
respondToAgentOnboarding,
|
||||||
|
retryAgentOnboardingSession,
|
||||||
SessionNotFoundError,
|
SessionNotFoundError,
|
||||||
startAgentOnboardingSession,
|
startAgentOnboardingSession,
|
||||||
} from "../agent-onboarding.js";
|
} from "../agent-onboarding.js";
|
||||||
@@ -71,6 +74,7 @@ function createSkillPluginRunner(skills: Array<{ name: string; enabled?: boolean
|
|||||||
describe("agent-onboarding", () => {
|
describe("agent-onboarding", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockResolveMcpServersForStore.mockResolvedValue({ servers: [], errors: [] });
|
||||||
__resetAgentOnboardingState();
|
__resetAgentOnboardingState();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -294,6 +298,72 @@ describe("agent-onboarding", () => {
|
|||||||
expect(options.skillSelection?.requestedSkillNames).toEqual(["fusion", "ce-debug"]);
|
expect(options.skillSelection?.requestedSkillNames).toEqual(["fusion", "ce-debug"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards store-resolved MCP servers to onboarding helper agents", async () => {
|
||||||
|
const mcpServers = [{ name: "planning-helper", command: "mcp-helper", env: { TOKEN: "materialized-secret" } }];
|
||||||
|
const store = { getSettingsByScope: vi.fn() };
|
||||||
|
mockResolveMcpServersForStore.mockResolvedValueOnce({ servers: mcpServers, errors: [] });
|
||||||
|
mockCreateFnAgent.mockResolvedValueOnce(
|
||||||
|
createMockAgent([
|
||||||
|
JSON.stringify({
|
||||||
|
type: "question",
|
||||||
|
data: { id: "goal", type: "text", question: "What is the primary goal?" },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await startAgentOnboardingSession(
|
||||||
|
"127.0.0.1",
|
||||||
|
{ intent: "mcp", existingAgents: [], templates: [] },
|
||||||
|
process.cwd(),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
store as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store);
|
||||||
|
const options = mockCreateFnAgent.mock.calls.at(-1)?.[0] as { mcpServers?: unknown[] };
|
||||||
|
expect(options.mcpServers).toBe(mcpServers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the retry scoped store when recreating an uninitialized onboarding helper agent", async () => {
|
||||||
|
const startStore = { getSettingsByScope: vi.fn() };
|
||||||
|
const retryStore = { getSettingsByScope: vi.fn() };
|
||||||
|
const retryMcpServers = [{ name: "planning-helper", command: "mcp-helper", env: { TOKEN: "retry-secret" } }];
|
||||||
|
mockCreateFnAgent.mockResolvedValueOnce(
|
||||||
|
createMockAgent([
|
||||||
|
JSON.stringify({
|
||||||
|
type: "question",
|
||||||
|
data: { id: "goal", type: "text", question: "What is the primary goal?" },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionId = await startAgentOnboardingSession(
|
||||||
|
"127.0.0.1",
|
||||||
|
{ intent: "mcp retry", existingAgents: [], templates: [] },
|
||||||
|
process.cwd(),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
startStore as never,
|
||||||
|
);
|
||||||
|
const session = getAgentOnboardingSession(sessionId) as { agent?: unknown; error?: string };
|
||||||
|
session.agent = undefined;
|
||||||
|
session.error = "AI generation timed out. You can retry.";
|
||||||
|
mockResolveMcpServersForStore.mockClear();
|
||||||
|
mockResolveMcpServersForStore.mockResolvedValueOnce({ servers: retryMcpServers, errors: [] });
|
||||||
|
mockCreateFnAgent.mockResolvedValueOnce(createMockAgent([JSON.stringify({ type: "question", data: { id: "retry", type: "text", question: "Retry?" } })]));
|
||||||
|
|
||||||
|
await retryAgentOnboardingSession(sessionId, retryStore as never);
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(retryStore);
|
||||||
|
const options = mockCreateFnAgent.mock.calls.at(-1)?.[0] as { mcpServers?: unknown[] };
|
||||||
|
expect(options.mcpServers).toBe(retryMcpServers);
|
||||||
|
});
|
||||||
|
|
||||||
it("requests role-fallback skills when onboarding plugin runner is unavailable", async () => {
|
it("requests role-fallback skills when onboarding plugin runner is unavailable", async () => {
|
||||||
mockCreateFnAgent.mockResolvedValueOnce(
|
mockCreateFnAgent.mockResolvedValueOnce(
|
||||||
createMockAgent([
|
createMockAgent([
|
||||||
|
|||||||
@@ -19,22 +19,58 @@ import {
|
|||||||
} from "../ai-refine.js";
|
} from "../ai-refine.js";
|
||||||
|
|
||||||
// Hoisted mock factory
|
// Hoisted mock factory
|
||||||
const { mockCreateFnAgent } = vi.hoisted(() => ({
|
const { mockCreateFnAgent, mockResolveMcpServersForStore } = vi.hoisted(() => ({
|
||||||
mockCreateFnAgent: vi.fn(),
|
mockCreateFnAgent: vi.fn(),
|
||||||
|
mockResolveMcpServersForStore: vi.fn().mockResolvedValue({ servers: [], errors: [] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the engine module to avoid dynamic import issues in tests
|
// Mock the engine module to avoid dynamic import issues in tests
|
||||||
vi.mock("@fusion/engine", () => ({
|
vi.mock("@fusion/engine", () => ({
|
||||||
listCliAdapterDescriptors: () => [],
|
listCliAdapterDescriptors: () => [],
|
||||||
createFnAgent: mockCreateFnAgent,
|
createFnAgent: mockCreateFnAgent,
|
||||||
|
resolveMcpServersForStore: mockResolveMcpServersForStore,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const resolvedMcpServers = [
|
||||||
|
{ name: "docs", transport: "stdio", command: "node", args: ["server.js"], env: { MCP_TOKEN: "materialized-secret-value" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
function createMcpEnabledStore(): object {
|
||||||
|
return {
|
||||||
|
mcpEnabledForTest: true,
|
||||||
|
getSettingsByScope: vi.fn().mockResolvedValue({ global: { mcpServers: { enabled: true, servers: [{ name: "docs" }] } }, project: {} }),
|
||||||
|
getSecretsStore: vi.fn().mockResolvedValue({ revealSecret: vi.fn().mockResolvedValue("materialized-secret-value") }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMcpDisabledStore(): object {
|
||||||
|
return {
|
||||||
|
getSettingsByScope: vi.fn().mockResolvedValue({ global: { mcpServers: { enabled: false, servers: [] } }, project: {} }),
|
||||||
|
getSecretsStore: vi.fn().mockResolvedValue({ revealSecret: vi.fn() }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockAgentWithAssistantText(text: string): void {
|
||||||
|
mockCreateFnAgent.mockResolvedValue({
|
||||||
|
session: {
|
||||||
|
state: { messages: [{ role: "assistant", content: text }] },
|
||||||
|
prompt: vi.fn(),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("ai-refine module", () => {
|
describe("ai-refine module", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
__resetRefineState();
|
__resetRefineState();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockCreateFnAgent.mockResolvedValue(null);
|
mockCreateFnAgent.mockResolvedValue(null);
|
||||||
|
mockResolveMcpServersForStore.mockImplementation(async (store: { mcpEnabledForTest?: boolean }) => (
|
||||||
|
store?.mcpEnabledForTest
|
||||||
|
? { servers: resolvedMcpServers, errors: [] }
|
||||||
|
: { servers: [], errors: [] }
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -306,6 +342,35 @@ describe("ai-refine module", () => {
|
|||||||
"Failed to initialize AI agent"
|
"Failed to initialize AI agent"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards resolved MCP servers when a store with enabled MCP is provided", async () => {
|
||||||
|
const store = createMcpEnabledStore();
|
||||||
|
mockAgentWithAssistantText("Refined text");
|
||||||
|
|
||||||
|
await expect(refineText("some text", "clarify", "/tmp/project", undefined, store as never)).resolves.toBe("Refined text");
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store);
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: resolvedMcpServers }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses an empty MCP set when MCP is disabled for text refinement", async () => {
|
||||||
|
const store = createMcpDisabledStore();
|
||||||
|
mockAgentWithAssistantText("Refined text");
|
||||||
|
|
||||||
|
await expect(refineText("some text", "clarify", "/tmp/project", undefined, store as never)).resolves.toBe("Refined text");
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store);
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses an empty MCP set when text refinement has no store", async () => {
|
||||||
|
mockAgentWithAssistantText("Refined text");
|
||||||
|
|
||||||
|
await expect(refineText("some text", "clarify", "/tmp/project")).resolves.toBe("Refined text");
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith({});
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("draftGoalDescription", () => {
|
describe("draftGoalDescription", () => {
|
||||||
@@ -350,6 +415,35 @@ describe("ai-refine module", () => {
|
|||||||
);
|
);
|
||||||
expect(mockAgent.session.dispose).toHaveBeenCalledTimes(1);
|
expect(mockAgent.session.dispose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards resolved MCP servers when a store with enabled MCP is provided", async () => {
|
||||||
|
const store = createMcpEnabledStore();
|
||||||
|
mockCreateFnAgent.mockResolvedValueOnce(createDraftMockAgent("Drafted goal description"));
|
||||||
|
|
||||||
|
await expect(draftGoalDescription("Grow plugin ecosystem", "/tmp/project", undefined, store as never)).resolves.toBe("Drafted goal description");
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store);
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: resolvedMcpServers }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses an empty MCP set when MCP is disabled for goal drafting", async () => {
|
||||||
|
const store = createMcpDisabledStore();
|
||||||
|
mockCreateFnAgent.mockResolvedValueOnce(createDraftMockAgent("Drafted goal description"));
|
||||||
|
|
||||||
|
await expect(draftGoalDescription("Grow plugin ecosystem", "/tmp/project", undefined, store as never)).resolves.toBe("Drafted goal description");
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store);
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses an empty MCP set when goal drafting has no store", async () => {
|
||||||
|
mockCreateFnAgent.mockResolvedValueOnce(createDraftMockAgent("Drafted goal description"));
|
||||||
|
|
||||||
|
await expect(draftGoalDescription("Grow plugin ecosystem", "/tmp/project")).resolves.toBe("Drafted goal description");
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith({});
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("__resetRefineState", () => {
|
describe("__resetRefineState", () => {
|
||||||
|
|||||||
258
packages/dashboard/src/__tests__/mcp-helper-forwarding.test.ts
Normal file
258
packages/dashboard/src/__tests__/mcp-helper-forwarding.test.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { InsightStore, TaskStore as TaskStoreClass, type Task, type TaskStore } from "@fusion/core";
|
||||||
|
import * as coreModule from "@fusion/core";
|
||||||
|
import { request } from "../test-request.js";
|
||||||
|
|
||||||
|
const { mockCreateFnAgent, mockPromptWithFallback, mockResolveMcpServersForStore, execMock } = vi.hoisted(() => ({
|
||||||
|
mockCreateFnAgent: vi.fn(),
|
||||||
|
mockPromptWithFallback: vi.fn(),
|
||||||
|
mockResolveMcpServersForStore: vi.fn(),
|
||||||
|
execMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:child_process", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||||
|
return { ...actual, exec: execMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@fusion/engine", () => ({
|
||||||
|
listCliAdapterDescriptors: () => [],
|
||||||
|
buildSessionSkillContextSync: () => ({
|
||||||
|
skillSelectionContext: { projectRootDir: "/tmp/project", requestedSkillNames: ["fusion"], sessionPurpose: "executor" },
|
||||||
|
resolvedSkillNames: ["fusion"],
|
||||||
|
skillSource: "role-fallback" as const,
|
||||||
|
}),
|
||||||
|
createFnAgent: mockCreateFnAgent,
|
||||||
|
promptWithFallback: mockPromptWithFallback,
|
||||||
|
resolveMcpServersForStore: mockResolveMcpServersForStore,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resolvedMcpServers = [
|
||||||
|
{ name: "docs", transport: "stdio", command: "node", args: ["server.js"], env: { MCP_TOKEN: "materialized-secret-value" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
function createMcpEnabledStore(): object {
|
||||||
|
return {
|
||||||
|
mcpEnabledForTest: true,
|
||||||
|
getSettingsByScope: vi.fn().mockResolvedValue({ global: { mcpServers: { enabled: true, servers: [{ name: "docs" }] } }, project: {} }),
|
||||||
|
getSecretsStore: vi.fn().mockResolvedValue({ revealSecret: vi.fn().mockResolvedValue("materialized-secret-value") }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMcpDisabledStore(): object {
|
||||||
|
return {
|
||||||
|
getSettingsByScope: vi.fn().mockResolvedValue({ global: { mcpServers: { enabled: false, servers: [] } }, project: {} }),
|
||||||
|
getSecretsStore: vi.fn().mockResolvedValue({ revealSecret: vi.fn() }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTask(): Task {
|
||||||
|
return {
|
||||||
|
id: "FN-7078",
|
||||||
|
title: "Forward helper MCP",
|
||||||
|
description: "Wire MCP into helper sessions",
|
||||||
|
status: "todo",
|
||||||
|
column: "todo",
|
||||||
|
priority: "normal",
|
||||||
|
dependencies: [],
|
||||||
|
size: "M",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
} as Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createInsightTaskStore(mode: boolean | "no-settings-scope"): Promise<{ root: string; store: TaskStore }> {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "kb-mcp-helper-forwarding-"));
|
||||||
|
const store = new TaskStoreClass(root, join(root, ".fusion-global-settings"), { inMemoryDb: true }) as TaskStore & { mcpEnabledForTest?: boolean; getSettingsByScope?: unknown };
|
||||||
|
await store.init();
|
||||||
|
if (mode === true) store.mcpEnabledForTest = true;
|
||||||
|
if (mode === "no-settings-scope") {
|
||||||
|
Object.defineProperty(store, "getSettingsByScope", { value: undefined, configurable: true });
|
||||||
|
}
|
||||||
|
return { root, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockResolveMcpServersForStore.mockImplementation(async (store: { mcpEnabledForTest?: boolean }) => (
|
||||||
|
store?.mcpEnabledForTest ? { servers: resolvedMcpServers, errors: [] } : { servers: [], errors: [] }
|
||||||
|
));
|
||||||
|
mockPromptWithFallback.mockResolvedValue(undefined);
|
||||||
|
mockCreateFnAgent.mockImplementation(async (options?: { onText?: (delta: string) => void; systemPrompt?: string }) => {
|
||||||
|
const messages: Array<{ role: string; content: string }> = [];
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
state: { messages },
|
||||||
|
prompt: vi.fn(async () => {
|
||||||
|
if (options?.onText) {
|
||||||
|
options.onText(JSON.stringify({
|
||||||
|
title: "feat: forward MCP",
|
||||||
|
summary: "Summary",
|
||||||
|
changes: "Changes",
|
||||||
|
testing: "Tests",
|
||||||
|
linkedTask: "FN-7078",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
messages.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: JSON.stringify({
|
||||||
|
title: "Generated Agent",
|
||||||
|
icon: "🤖",
|
||||||
|
role: "custom",
|
||||||
|
description: "Generated description",
|
||||||
|
systemPrompt: "Generated prompt",
|
||||||
|
thinkingLevel: "low",
|
||||||
|
maxTurns: 12,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
execMock.mockImplementation((command: string, options: unknown, callback?: (error: Error | null, stdout: string, stderr: string) => void) => {
|
||||||
|
const cb = typeof options === "function" ? options : callback;
|
||||||
|
cb?.(null, command.includes("rev-parse") ? "main\n" : "mock output\n", "");
|
||||||
|
return { kill: vi.fn() };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MCP forwarding for readonly dashboard helper seams", () => {
|
||||||
|
it.each([
|
||||||
|
["enabled", createMcpEnabledStore, resolvedMcpServers],
|
||||||
|
["disabled", createMcpDisabledStore, []],
|
||||||
|
["absent", () => undefined, []],
|
||||||
|
])("agent onboarding generation forwards MCP set for %s store", async (_label, storeFactory, expectedServers) => {
|
||||||
|
const { startAgentGeneration, generateAgentSpec } = await import("../agent-generation.js");
|
||||||
|
const session = await startAgentGeneration(`127.0.0.${Math.floor(Math.random() * 200) + 1}`, "Create a docs helper");
|
||||||
|
const store = storeFactory();
|
||||||
|
|
||||||
|
await generateAgentSpec(session.id, "/tmp/project", undefined, store as never);
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store ?? {});
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ tools: "none", mcpServers: expectedServers }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["enabled", createMcpEnabledStore, resolvedMcpServers],
|
||||||
|
["disabled", createMcpDisabledStore, []],
|
||||||
|
["absent", () => undefined, []],
|
||||||
|
])("agent onboarding interview start forwards MCP set for %s store", async (_label, storeFactory, expectedServers) => {
|
||||||
|
const { __resetAgentOnboardingState, startAgentOnboardingSession } = await import("../agent-onboarding.js");
|
||||||
|
const store = storeFactory();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startAgentOnboardingSession(
|
||||||
|
`127.0.0.${Math.floor(Math.random() * 200) + 1}`,
|
||||||
|
{ intent: "Create a docs helper", existingAgents: [], templates: [] },
|
||||||
|
"/tmp/project",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
store as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store ?? {});
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ tools: "readonly", mcpServers: expectedServers }));
|
||||||
|
} finally {
|
||||||
|
__resetAgentOnboardingState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agent onboarding interview retry forwards scoped MCP set when recreating an uninitialized helper agent", async () => {
|
||||||
|
const { __resetAgentOnboardingState, getAgentOnboardingSession, retryAgentOnboardingSession, startAgentOnboardingSession } = await import("../agent-onboarding.js");
|
||||||
|
const startStore = createMcpDisabledStore();
|
||||||
|
const retryStore = createMcpEnabledStore();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionId = await startAgentOnboardingSession(
|
||||||
|
"127.0.0.77",
|
||||||
|
{ intent: "Create a docs helper", existingAgents: [], templates: [] },
|
||||||
|
"/tmp/project",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
startStore as never,
|
||||||
|
);
|
||||||
|
const session = getAgentOnboardingSession(sessionId) as { agent?: unknown; error?: string };
|
||||||
|
session.agent = undefined;
|
||||||
|
session.error = "AI generation timed out. You can retry.";
|
||||||
|
mockResolveMcpServersForStore.mockClear();
|
||||||
|
mockCreateFnAgent.mockClear();
|
||||||
|
|
||||||
|
await retryAgentOnboardingSession(sessionId, retryStore as never);
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(retryStore);
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ tools: "readonly", mcpServers: resolvedMcpServers }));
|
||||||
|
} finally {
|
||||||
|
__resetAgentOnboardingState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["enabled", createMcpEnabledStore, resolvedMcpServers],
|
||||||
|
["disabled", createMcpDisabledStore, []],
|
||||||
|
["absent", () => undefined, []],
|
||||||
|
])("PR metadata generation forwards MCP set for %s store", async (_label, storeFactory, expectedServers) => {
|
||||||
|
const { generatePrMetadata } = await import("../pr-metadata-generator.js");
|
||||||
|
const store = storeFactory();
|
||||||
|
|
||||||
|
await generatePrMetadata({
|
||||||
|
task: createTask(),
|
||||||
|
repoRoot: "/tmp/project",
|
||||||
|
settings: {},
|
||||||
|
store: store as never,
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store ?? {});
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ tools: "readonly", mcpServers: expectedServers }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["enabled", true, resolvedMcpServers],
|
||||||
|
["disabled", false, []],
|
||||||
|
["no settings-scope", "no-settings-scope", []],
|
||||||
|
] as const)("insight extraction forwards MCP set for %s scoped store", async (_label, mode, expectedServers) => {
|
||||||
|
const { createInsightsRouter } = await import("../insights-routes.js");
|
||||||
|
const readWorkingMemorySpy = vi.spyOn(coreModule, "readWorkingMemory").mockResolvedValue("working memory notes");
|
||||||
|
vi.spyOn(coreModule, "readInsightsMemory").mockResolvedValue(null);
|
||||||
|
vi.spyOn(coreModule, "writeInsightsMemory").mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(coreModule, "buildInsightExtractionPrompt").mockReturnValue("prompt");
|
||||||
|
vi.spyOn(coreModule, "parseInsightExtractionResponse").mockReturnValue({
|
||||||
|
summary: "Extraction summary",
|
||||||
|
insights: [],
|
||||||
|
extractedAt: "2026-06-26T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
vi.spyOn(coreModule, "mergeInsights").mockReturnValue("# merged insights");
|
||||||
|
const { root, store } = await createInsightTaskStore(mode);
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const router = createInsightsRouter(store) as ReturnType<typeof createInsightsRouter> & { __disposeSweeper?: () => void };
|
||||||
|
app.use("/api/insights", router);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await request(app, "POST", "/api/insights/run", JSON.stringify({ trigger: "manual" }), { "content-type": "application/json" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(readWorkingMemorySpy).toHaveBeenCalledWith(root);
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store);
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ tools: "readonly", mcpServers: expectedServers }));
|
||||||
|
} finally {
|
||||||
|
router.__disposeSweeper?.();
|
||||||
|
await store.close();
|
||||||
|
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ vi.mock("node:child_process", async (importOriginal) => {
|
|||||||
|
|
||||||
vi.mock("@fusion/engine", () => ({
|
vi.mock("@fusion/engine", () => ({
|
||||||
listCliAdapterDescriptors: () => [],
|
listCliAdapterDescriptors: () => [],
|
||||||
|
resolveMcpServersForStore: vi.fn().mockResolvedValue({ servers: [], errors: [] }),
|
||||||
createFnAgent: vi.fn(async () => ({
|
createFnAgent: vi.fn(async () => ({
|
||||||
session: {
|
session: {
|
||||||
prompt: promptMock,
|
prompt: promptMock,
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import { EventEmitter } from "node:events";
|
|||||||
import ts from "typescript";
|
import ts from "typescript";
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { mockCreateFnAgent } = vi.hoisted(() => ({
|
const { mockCreateFnAgent, mockResolveMcpServersForStore } = vi.hoisted(() => ({
|
||||||
mockCreateFnAgent: vi.fn(),
|
mockCreateFnAgent: vi.fn(),
|
||||||
|
mockResolveMcpServersForStore: vi.fn().mockResolvedValue({ servers: [], errors: [] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@fusion/engine", () => ({
|
vi.mock("@fusion/engine", () => ({
|
||||||
listCliAdapterDescriptors: () => [],
|
listCliAdapterDescriptors: () => [],
|
||||||
createFnAgent: mockCreateFnAgent,
|
createFnAgent: mockCreateFnAgent,
|
||||||
|
resolveMcpServersForStore: mockResolveMcpServersForStore,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import type { AiSessionRow } from "../ai-session-store.js";
|
import type { AiSessionRow } from "../ai-session-store.js";
|
||||||
@@ -26,6 +28,7 @@ import {
|
|||||||
cancelSubtaskSession,
|
cancelSubtaskSession,
|
||||||
cleanupSubtaskSession,
|
cleanupSubtaskSession,
|
||||||
createSubtaskSession,
|
createSubtaskSession,
|
||||||
|
decomposeForTriage,
|
||||||
retrySubtaskSession,
|
retrySubtaskSession,
|
||||||
getSubtaskSession,
|
getSubtaskSession,
|
||||||
rehydrateFromStore,
|
rehydrateFromStore,
|
||||||
@@ -240,9 +243,40 @@ beforeAll(async () => {
|
|||||||
internalFns = await loadInternalSubtaskFunctions();
|
internalFns = await loadInternalSubtaskFunctions();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const resolvedMcpServers = [
|
||||||
|
{ name: "docs", transport: "stdio", command: "node", args: ["server.js"], env: { MCP_TOKEN: "materialized-secret-value" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
function createMcpEnabledStore(): object {
|
||||||
|
return {
|
||||||
|
mcpEnabledForTest: true,
|
||||||
|
getSettingsByScope: vi.fn().mockResolvedValue({
|
||||||
|
global: { mcpServers: { enabled: true, servers: [{ name: "docs" }] } },
|
||||||
|
project: {},
|
||||||
|
}),
|
||||||
|
getSecretsStore: vi.fn().mockResolvedValue({ revealSecret: vi.fn().mockResolvedValue("materialized-secret-value") }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMcpDisabledStore(): object {
|
||||||
|
return {
|
||||||
|
getSettingsByScope: vi.fn().mockResolvedValue({
|
||||||
|
global: { mcpServers: { enabled: false, servers: [] } },
|
||||||
|
project: {},
|
||||||
|
}),
|
||||||
|
getSecretsStore: vi.fn().mockResolvedValue({ revealSecret: vi.fn() }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockCreateFnAgent.mockReset();
|
mockCreateFnAgent.mockReset();
|
||||||
mockCreateFnAgent.mockImplementation(async () => createMockSubtaskAgent());
|
mockCreateFnAgent.mockImplementation(async () => createMockSubtaskAgent());
|
||||||
|
mockResolveMcpServersForStore.mockReset();
|
||||||
|
mockResolveMcpServersForStore.mockImplementation(async (store: { mcpEnabledForTest?: boolean }) => (
|
||||||
|
store?.mcpEnabledForTest
|
||||||
|
? { servers: resolvedMcpServers, errors: [] }
|
||||||
|
: { servers: [], errors: [] }
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -625,6 +659,69 @@ describe("subtask session lifecycle", () => {
|
|||||||
expect(callArg?.systemPrompt).toContain("task decomposition assistant");
|
expect(callArg?.systemPrompt).toContain("task decomposition assistant");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards resolved MCP servers for streaming subtask generation", async () => {
|
||||||
|
const store = createMcpEnabledStore();
|
||||||
|
const created = await createSubtaskSession("Break down MCP-aware work", store as never, "/tmp/project");
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const session = getSubtaskSession(created.sessionId);
|
||||||
|
return session?.status === "complete";
|
||||||
|
}, { timeout: 5000 });
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store);
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: resolvedMcpServers }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps streaming subtask generation MCP-empty when MCP is disabled", async () => {
|
||||||
|
const store = createMcpDisabledStore();
|
||||||
|
const created = await createSubtaskSession("Break down disabled MCP work", store as never, "/tmp/project");
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const session = getSubtaskSession(created.sessionId);
|
||||||
|
return session?.status === "complete";
|
||||||
|
}, { timeout: 5000 });
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store);
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps streaming subtask generation MCP-empty for no-store callers", async () => {
|
||||||
|
const created = await createSubtaskSession("Break down no-store work", undefined, "/tmp/project");
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const session = getSubtaskSession(created.sessionId);
|
||||||
|
return session?.status === "complete";
|
||||||
|
}, { timeout: 5000 });
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith({});
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards resolved MCP servers for triage one-shot subtask decomposition", async () => {
|
||||||
|
const store = createMcpEnabledStore();
|
||||||
|
|
||||||
|
await decomposeForTriage("Break down triage work", "/tmp/project", undefined, store as never);
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store);
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: resolvedMcpServers }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps triage one-shot subtask decomposition MCP-empty when MCP is disabled", async () => {
|
||||||
|
const store = createMcpDisabledStore();
|
||||||
|
|
||||||
|
await decomposeForTriage("Break down triage work", "/tmp/project", undefined, store as never);
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store);
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps triage one-shot subtask decomposition MCP-empty without a store", async () => {
|
||||||
|
await decomposeForTriage("Break down triage work", "/tmp/project");
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith({});
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] }));
|
||||||
|
});
|
||||||
|
|
||||||
it("retrySubtaskSession retries errored sessions restored from SQLite", async () => {
|
it("retrySubtaskSession retries errored sessions restored from SQLite", async () => {
|
||||||
const store = new MockAiSessionStore();
|
const store = new MockAiSessionStore();
|
||||||
const row = buildSubtaskRow({
|
const row = buildSubtaskRow({
|
||||||
@@ -646,6 +743,44 @@ describe("subtask session lifecycle", () => {
|
|||||||
expect(store.get(row.id)?.error).toBeNull();
|
expect(store.get(row.id)?.error).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards resolved MCP servers for retry subtask generation", async () => {
|
||||||
|
const sessionStore = new MockAiSessionStore();
|
||||||
|
const row = buildSubtaskRow({ id: "subtask-retry-mcp", status: "error", error: "Transient failure", result: null });
|
||||||
|
sessionStore.rows.set(row.id, row);
|
||||||
|
setAiSessionStore(sessionStore as any);
|
||||||
|
const store = createMcpEnabledStore();
|
||||||
|
|
||||||
|
await retrySubtaskSession(row.id, "/tmp/project", undefined, store as never);
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store);
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: resolvedMcpServers }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps retry subtask generation MCP-empty when MCP is disabled", async () => {
|
||||||
|
const sessionStore = new MockAiSessionStore();
|
||||||
|
const row = buildSubtaskRow({ id: "subtask-retry-disabled-mcp", status: "error", error: "Transient failure", result: null });
|
||||||
|
sessionStore.rows.set(row.id, row);
|
||||||
|
setAiSessionStore(sessionStore as any);
|
||||||
|
const store = createMcpDisabledStore();
|
||||||
|
|
||||||
|
await retrySubtaskSession(row.id, "/tmp/project", undefined, store as never);
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store);
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps retry subtask generation MCP-empty without a store", async () => {
|
||||||
|
const sessionStore = new MockAiSessionStore();
|
||||||
|
const row = buildSubtaskRow({ id: "subtask-retry-no-store-mcp", status: "error", error: "Transient failure", result: null });
|
||||||
|
sessionStore.rows.set(row.id, row);
|
||||||
|
setAiSessionStore(sessionStore as any);
|
||||||
|
|
||||||
|
await retrySubtaskSession(row.id, "/tmp/project");
|
||||||
|
|
||||||
|
expect(mockResolveMcpServersForStore).toHaveBeenCalledWith({});
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] }));
|
||||||
|
});
|
||||||
|
|
||||||
it("retrySubtaskSession rejects non-error sessions", async () => {
|
it("retrySubtaskSession rejects non-error sessions", async () => {
|
||||||
const store = new MockAiSessionStore();
|
const store = new MockAiSessionStore();
|
||||||
const row = buildSubtaskRow({ id: "subtask-retry-2", status: "generating" });
|
const row = buildSubtaskRow({ id: "subtask-retry-2", status: "generating" });
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { TaskStore } from "@fusion/core";
|
||||||
import { createSessionDiagnostics, nonfatal } from "./ai-session-diagnostics.js";
|
import { createSessionDiagnostics, nonfatal } from "./ai-session-diagnostics.js";
|
||||||
import { registerBeforeExitCleanup } from "./process-lifecycle.js";
|
import { registerBeforeExitCleanup } from "./process-lifecycle.js";
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ async function initPromptCatalog() {
|
|||||||
// Initialize prompt catalog (will be awaited in actual usage)
|
// Initialize prompt catalog (will be awaited in actual usage)
|
||||||
const promptCatalogReadyPromise = initPromptCatalog();
|
const promptCatalogReadyPromise = initPromptCatalog();
|
||||||
|
|
||||||
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
|
import { createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const createFnAgent: any = engineCreateFnAgent;
|
const createFnAgent: any = engineCreateFnAgent;
|
||||||
@@ -459,7 +460,8 @@ export async function startAgentGeneration(
|
|||||||
export async function generateAgentSpec(
|
export async function generateAgentSpec(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
promptOverrides?: PromptOverrideMap
|
promptOverrides?: PromptOverrideMap,
|
||||||
|
store?: TaskStore,
|
||||||
): Promise<AgentGenerationSpec> {
|
): Promise<AgentGenerationSpec> {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -469,7 +471,7 @@ export async function generateAgentSpec(
|
|||||||
try {
|
try {
|
||||||
await ensureEngineReady();
|
await ensureEngineReady();
|
||||||
await promptCatalogReadyPromise;
|
await promptCatalogReadyPromise;
|
||||||
const spec = await generateSpecWithAI(session, rootDir, promptOverrides);
|
const spec = await generateSpecWithAI(session, rootDir, promptOverrides, store);
|
||||||
session.spec = spec;
|
session.spec = spec;
|
||||||
session.updatedAt = new Date();
|
session.updatedAt = new Date();
|
||||||
return spec;
|
return spec;
|
||||||
@@ -488,7 +490,8 @@ export async function generateAgentSpec(
|
|||||||
async function generateSpecWithAI(
|
async function generateSpecWithAI(
|
||||||
session: Session,
|
session: Session,
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
promptOverrides?: PromptOverrideMap
|
promptOverrides?: PromptOverrideMap,
|
||||||
|
store?: TaskStore,
|
||||||
): Promise<AgentGenerationSpec> {
|
): Promise<AgentGenerationSpec> {
|
||||||
if (!createFnAgent) {
|
if (!createFnAgent) {
|
||||||
throw new Error("AI agent not available. Ensure the engine is properly configured.");
|
throw new Error("AI agent not available. Ensure the engine is properly configured.");
|
||||||
@@ -497,14 +500,16 @@ async function generateSpecWithAI(
|
|||||||
// Resolve the system prompt using prompt overrides (with fallback to default)
|
// Resolve the system prompt using prompt overrides (with fallback to default)
|
||||||
const effectiveSystemPrompt = resolvePrompt("agent-generation-system", promptOverrides) || AGENT_GENERATION_SYSTEM_PROMPT;
|
const effectiveSystemPrompt = resolvePrompt("agent-generation-system", promptOverrides) || AGENT_GENERATION_SYSTEM_PROMPT;
|
||||||
|
|
||||||
|
const mcpServers = (await resolveMcpServersForStore(store ?? {})).servers;
|
||||||
/*
|
/*
|
||||||
* FNXC:McpConfig 2026-06-26-00:00:
|
* FNXC:McpConfig 2026-06-26-16:58:
|
||||||
* Agent generation is a tools:none dashboard helper built from an in-memory session and rootDir only. No TaskStore/secrets reader is available here, so configured MCP servers are intentionally not injected.
|
* Agent onboarding generation is a tools:none readonly helper, but routes can provide a dashboard-scoped TaskStore. Forward the resolved in-memory MCP server set consistently without changing tool semantics; no-store callers remain empty and secrets are never logged.
|
||||||
*/
|
*/
|
||||||
const agent = await createFnAgent({
|
const agent = await createFnAgent({
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
systemPrompt: effectiveSystemPrompt,
|
systemPrompt: effectiveSystemPrompt,
|
||||||
tools: "none",
|
tools: "none",
|
||||||
|
mcpServers,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import type { AgentCapability, PlanningQuestion } from "@fusion/core";
|
import type { AgentCapability, PlanningQuestion, TaskStore } from "@fusion/core";
|
||||||
import { resolvePrompt, type PromptOverrideMap } from "@fusion/core";
|
import { resolvePrompt, type PromptOverrideMap } from "@fusion/core";
|
||||||
import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent } from "@fusion/engine";
|
import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine";
|
||||||
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
|
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
|
||||||
|
|
||||||
export interface AgentOnboardingSummary {
|
export interface AgentOnboardingSummary {
|
||||||
@@ -98,6 +98,11 @@ interface Session {
|
|||||||
history: Array<{ question: PlanningQuestion; response: Record<string, unknown> }>;
|
history: Array<{ question: PlanningQuestion; response: Record<string, unknown> }>;
|
||||||
thinkingOutput: string;
|
thinkingOutput: string;
|
||||||
agent?: OnboardingAgent;
|
agent?: OnboardingAgent;
|
||||||
|
rootDir: string;
|
||||||
|
modelProvider?: string;
|
||||||
|
modelId?: string;
|
||||||
|
promptOverrides?: PromptOverrideMap;
|
||||||
|
pluginRunner?: SkillSelectionPluginRunner;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -283,6 +288,7 @@ export async function startAgentOnboardingSession(
|
|||||||
modelId?: string,
|
modelId?: string,
|
||||||
promptOverrides?: PromptOverrideMap,
|
promptOverrides?: PromptOverrideMap,
|
||||||
pluginRunner?: SkillSelectionPluginRunner,
|
pluginRunner?: SkillSelectionPluginRunner,
|
||||||
|
store?: TaskStore,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const mode: OnboardingMode = initialContext.mode ?? "create";
|
const mode: OnboardingMode = initialContext.mode ?? "create";
|
||||||
@@ -299,18 +305,39 @@ export async function startAgentOnboardingSession(
|
|||||||
}),
|
}),
|
||||||
history: [],
|
history: [],
|
||||||
thinkingOutput: "",
|
thinkingOutput: "",
|
||||||
|
rootDir,
|
||||||
|
modelProvider,
|
||||||
|
modelId,
|
||||||
|
promptOverrides,
|
||||||
|
pluginRunner,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
sessions.set(id, session);
|
sessions.set(id, session);
|
||||||
|
|
||||||
const systemPrompt = resolvePrompt("agent-onboarding-system", promptOverrides) || AGENT_ONBOARDING_SYSTEM_PROMPT;
|
session.agent = await createAgentOnboardingAgent(session, store);
|
||||||
const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, pluginRunner);
|
|
||||||
session.agent = await createFnAgent({
|
void continueConversation(session, session.contextPrompt);
|
||||||
cwd: rootDir,
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAgentOnboardingAgent(session: Session, store?: TaskStore): Promise<OnboardingAgent> {
|
||||||
|
const systemPrompt = resolvePrompt("agent-onboarding-system", session.promptOverrides) || AGENT_ONBOARDING_SYSTEM_PROMPT;
|
||||||
|
const skillContext = buildSessionSkillContextSync(null, "executor", session.rootDir, session.pluginRunner);
|
||||||
|
const mcpServers = (await resolveMcpServersForStore(store ?? {})).servers;
|
||||||
|
/*
|
||||||
|
* FNXC:McpConfig 2026-06-26-17:26:
|
||||||
|
* Agent onboarding interviews are dashboard readonly planning helpers. Resolve MCP from the request-scoped TaskStore when routes can provide it; no-store callers stay empty and this seam must not log secret material.
|
||||||
|
*
|
||||||
|
* FNXC:McpConfig 2026-06-26-18:10:
|
||||||
|
* Retry can recover a session whose agent was not initialized, so the retry route must pass its scoped TaskStore into this same createFnAgent seam instead of continuing with secret-less defaults.
|
||||||
|
*/
|
||||||
|
return createFnAgent({
|
||||||
|
cwd: session.rootDir,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
tools: "readonly",
|
tools: "readonly",
|
||||||
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
mcpServers,
|
||||||
|
...(session.modelProvider && session.modelId ? { defaultProvider: session.modelProvider, defaultModelId: session.modelId } : {}),
|
||||||
/*
|
/*
|
||||||
FNXC:InterviewSkills 2026-06-17-21:53:
|
FNXC:InterviewSkills 2026-06-17-21:53:
|
||||||
Agent onboarding is a model-only dashboard interview lane, so it must request executor role-fallback skills plus enabled plugin skills such as ce-debug like other agent-acting sessions.
|
Agent onboarding is a model-only dashboard interview lane, so it must request executor role-fallback skills plus enabled plugin skills such as ce-debug like other agent-acting sessions.
|
||||||
@@ -325,9 +352,6 @@ export async function startAgentOnboardingSession(
|
|||||||
agentOnboardingStreamManager.broadcast(session.id, { type: "thinking", data: delta });
|
agentOnboardingStreamManager.broadcast(session.id, { type: "thinking", data: delta });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
void continueConversation(session, session.contextPrompt);
|
|
||||||
return id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runGenerationWithTimeout<T>(session: Session, operation: () => Promise<T>): Promise<T> {
|
async function runGenerationWithTimeout<T>(session: Session, operation: () => Promise<T>): Promise<T> {
|
||||||
@@ -392,11 +416,14 @@ export async function respondToAgentOnboarding(sessionId: string, responses: Rec
|
|||||||
await continueConversation(session, formatted);
|
await continueConversation(session, formatted);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function retryAgentOnboardingSession(sessionId: string): Promise<void> {
|
export async function retryAgentOnboardingSession(sessionId: string, store?: TaskStore): Promise<void> {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (!session) throw new SessionNotFoundError(`Agent onboarding session ${sessionId} not found or expired`);
|
if (!session) throw new SessionNotFoundError(`Agent onboarding session ${sessionId} not found or expired`);
|
||||||
if (!session.error) throw new InvalidSessionStateError("Session is not in an error state");
|
if (!session.error) throw new InvalidSessionStateError("Session is not in an error state");
|
||||||
session.error = undefined;
|
session.error = undefined;
|
||||||
|
if (!session.agent) {
|
||||||
|
session.agent = await createAgentOnboardingAgent(session, store);
|
||||||
|
}
|
||||||
const retryPrompt = session.currentQuestion
|
const retryPrompt = session.currentQuestion
|
||||||
? `Please continue from the last question: ${session.currentQuestion.question}`
|
? `Please continue from the last question: ${session.currentQuestion.question}`
|
||||||
: "Please continue and ask the next best onboarding question.";
|
: "Please continue and ask the next best onboarding question.";
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
* - Prompt override support for project-level customization
|
* - Prompt override support for project-level customization
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PromptOverrideMap } from "@fusion/core";
|
import type { PromptOverrideMap, TaskStore } from "@fusion/core";
|
||||||
import { resolvePrompt } from "@fusion/core";
|
import { resolvePrompt } from "@fusion/core";
|
||||||
|
|
||||||
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
|
import { createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine";
|
||||||
import { registerBeforeExitCleanup } from "./process-lifecycle.js";
|
import { registerBeforeExitCleanup } from "./process-lifecycle.js";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -322,6 +322,7 @@ export async function refineText(
|
|||||||
type: RefinementType,
|
type: RefinementType,
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
promptOverrides?: PromptOverrideMap,
|
promptOverrides?: PromptOverrideMap,
|
||||||
|
store?: TaskStore,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// Ensure engine is loaded before using createFnAgent
|
// Ensure engine is loaded before using createFnAgent
|
||||||
await ensureEngineReady();
|
await ensureEngineReady();
|
||||||
@@ -332,14 +333,16 @@ export async function refineText(
|
|||||||
|
|
||||||
const effectivePrompt = resolvePrompt("ai-refine-system", promptOverrides);
|
const effectivePrompt = resolvePrompt("ai-refine-system", promptOverrides);
|
||||||
|
|
||||||
|
const mcpServers = (await resolveMcpServersForStore(store ?? {})).servers;
|
||||||
/*
|
/*
|
||||||
* FNXC:McpConfig 2026-06-26-00:00:
|
* FNXC:McpConfig 2026-06-26-16:55:
|
||||||
* Text refinement receives only rootDir and prompt overrides, not a TaskStore/secrets reader. This readonly helper intentionally skips configured MCP resolution until a store is threaded through the API.
|
* Text refinement is a readonly dashboard helper that receives the request-scoped TaskStore from routes. Resolve configured MCP servers at session creation and forward only the in-memory server set; keep no-store callers on an empty set and never log materialized secrets.
|
||||||
*/
|
*/
|
||||||
const agentResult = await createFnAgent({
|
const agentResult = await createFnAgent({
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
systemPrompt: effectivePrompt,
|
systemPrompt: effectivePrompt,
|
||||||
tools: "readonly",
|
tools: "readonly",
|
||||||
|
mcpServers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!agentResult?.session) {
|
if (!agentResult?.session) {
|
||||||
@@ -395,6 +398,7 @@ export async function draftGoalDescription(
|
|||||||
title: string,
|
title: string,
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
_promptOverrides?: PromptOverrideMap,
|
_promptOverrides?: PromptOverrideMap,
|
||||||
|
store?: TaskStore,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
await ensureEngineReady();
|
await ensureEngineReady();
|
||||||
|
|
||||||
@@ -402,14 +406,16 @@ export async function draftGoalDescription(
|
|||||||
throw new AiServiceError("AI engine not available");
|
throw new AiServiceError("AI engine not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mcpServers = (await resolveMcpServersForStore(store ?? {})).servers;
|
||||||
/*
|
/*
|
||||||
* FNXC:McpConfig 2026-06-26-00:00:
|
* FNXC:McpConfig 2026-06-26-16:55:
|
||||||
* Goal description drafting shares the text-refine no-store seam; without TaskStore/secrets access it intentionally creates a readonly non-MCP session.
|
* Goal description drafting shares the text-refine readonly helper seam and now resolves MCP from the dashboard-scoped TaskStore when routes can provide it. No-store callers intentionally receive an empty server set; do not log env/header secret values.
|
||||||
*/
|
*/
|
||||||
const agentResult = await createFnAgent({
|
const agentResult = await createFnAgent({
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
systemPrompt: GOAL_DRAFT_SYSTEM_PROMPT,
|
systemPrompt: GOAL_DRAFT_SYSTEM_PROMPT,
|
||||||
tools: "readonly",
|
tools: "readonly",
|
||||||
|
mcpServers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!agentResult?.session) {
|
if (!agentResult?.session) {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
startInsightRunSweeper,
|
startInsightRunSweeper,
|
||||||
sweepStaleInsightRuns,
|
sweepStaleInsightRuns,
|
||||||
} from "./insight-run-sweeper.js";
|
} from "./insight-run-sweeper.js";
|
||||||
import { createFnAgent, promptWithFallback } from "@fusion/engine";
|
import { createFnAgent, promptWithFallback, resolveMcpServersForStore } from "@fusion/engine";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-throws an error as an ApiError, converting unknown errors to internal errors.
|
* Re-throws an error as an ApiError, converting unknown errors to internal errors.
|
||||||
@@ -129,6 +129,7 @@ async function executeInsightAttempt(params: {
|
|||||||
runId: string;
|
runId: string;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
insightStore: InsightStore;
|
insightStore: InsightStore;
|
||||||
|
taskStore?: TaskStore;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
modelProvider?: string;
|
modelProvider?: string;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
@@ -158,9 +159,15 @@ async function executeInsightAttempt(params: {
|
|||||||
const fallbackModelId = hasCustomModel ? settingsModelId : undefined;
|
const fallbackModelId = hasCustomModel ? settingsModelId : undefined;
|
||||||
|
|
||||||
const existingInsights = await readInsightsMemory(params.rootDir);
|
const existingInsights = await readInsightsMemory(params.rootDir);
|
||||||
|
const mcpServers = (await resolveMcpServersForStore(params.taskStore ?? {})).servers;
|
||||||
let responseText = "";
|
let responseText = "";
|
||||||
const { session } = await createFnAgent({
|
const { session } = await createFnAgent({
|
||||||
cwd: params.rootDir,
|
cwd: params.rootDir,
|
||||||
|
/*
|
||||||
|
* FNXC:McpConfig 2026-06-26-16:58:
|
||||||
|
* Insight extraction runs as a readonly dashboard helper under AsyncLocalStorage request scope. Resolve configured MCP servers from that scoped TaskStore at session creation; lightweight/no-store attempts get an empty set and diagnostics must never include materialized secret values.
|
||||||
|
*/
|
||||||
|
mcpServers,
|
||||||
defaultProvider: finalProvider,
|
defaultProvider: finalProvider,
|
||||||
defaultModelId: finalModelId,
|
defaultModelId: finalModelId,
|
||||||
fallbackProvider,
|
fallbackProvider,
|
||||||
@@ -279,23 +286,22 @@ export function createInsightsRouter(store: TaskStore): Router {
|
|||||||
* Uses projectId from query/body to get the scoped store if provided,
|
* Uses projectId from query/body to get the scoped store if provided,
|
||||||
* otherwise falls back to the default store.
|
* otherwise falls back to the default store.
|
||||||
*/
|
*/
|
||||||
router.use((req: Request, res: Response, next: NextFunction) => {
|
router.use(async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const projectId = getProjectId(req);
|
try {
|
||||||
if (projectId) {
|
const projectId = getProjectId(req);
|
||||||
// Import here to avoid circular dependency issues
|
const scopedStore = projectId
|
||||||
import("./project-store-resolver.js").then(({ getOrCreateProjectStore }) => {
|
// Import here to avoid circular dependency issues
|
||||||
getOrCreateProjectStore(projectId).then((scopedStore) => {
|
? await import("./project-store-resolver.js").then(({ getOrCreateProjectStore }) => getOrCreateProjectStore(projectId))
|
||||||
requestContext.run(scopedStore, () => {
|
: store;
|
||||||
next();
|
requestContext.run(scopedStore, () => {
|
||||||
});
|
|
||||||
}).catch((err) => {
|
|
||||||
rethrowAsApiError(err, "Failed to get project store");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
requestContext.run(store, () => {
|
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
rethrowAsApiError(err, "Failed to get project store");
|
||||||
|
} catch (apiError) {
|
||||||
|
next(apiError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -424,6 +430,7 @@ export function createInsightsRouter(store: TaskStore): Router {
|
|||||||
runId: run.id,
|
runId: run.id,
|
||||||
signal,
|
signal,
|
||||||
insightStore,
|
insightStore,
|
||||||
|
taskStore,
|
||||||
settings,
|
settings,
|
||||||
modelProvider,
|
modelProvider,
|
||||||
modelId,
|
modelId,
|
||||||
@@ -629,6 +636,7 @@ export function createInsightsRouter(store: TaskStore): Router {
|
|||||||
runId: run.id,
|
runId: run.id,
|
||||||
signal,
|
signal,
|
||||||
insightStore: store,
|
insightStore: store,
|
||||||
|
taskStore,
|
||||||
settings,
|
settings,
|
||||||
modelProvider: retryModelProvider,
|
modelProvider: retryModelProvider,
|
||||||
modelId: retryModelId,
|
modelId: retryModelId,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { promisify } from "node:util";
|
|||||||
import { exec as execCb } from "node:child_process";
|
import { exec as execCb } from "node:child_process";
|
||||||
import { access, readFile } from "node:fs/promises";
|
import { access, readFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { GlobalSettings, ProjectSettings, Settings, Task } from "@fusion/core";
|
import type { GlobalSettings, ProjectSettings, Settings, Task, TaskStore } from "@fusion/core";
|
||||||
import { resolveTitleSummarizerSettingsModel } from "@fusion/core";
|
import { resolveTitleSummarizerSettingsModel } from "@fusion/core";
|
||||||
import { createFnAgent } from "@fusion/engine";
|
import { createFnAgent, resolveMcpServersForStore } from "@fusion/engine";
|
||||||
|
|
||||||
const execAsync = promisify(execCb);
|
const execAsync = promisify(execCb);
|
||||||
export const PR_METADATA_TIMEOUT_MS = 60_000;
|
export const PR_METADATA_TIMEOUT_MS = 60_000;
|
||||||
@@ -224,10 +224,11 @@ export async function generatePrMetadata(input: {
|
|||||||
task: Task;
|
task: Task;
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
settings: ProjectSettings & GlobalSettings;
|
settings: ProjectSettings & GlobalSettings;
|
||||||
|
store?: TaskStore;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): Promise<GeneratedPrMetadata> {
|
}): Promise<GeneratedPrMetadata> {
|
||||||
const { task, repoRoot, settings, signal, timeoutMs = PR_METADATA_TIMEOUT_MS } = input;
|
const { task, repoRoot, settings, store, signal, timeoutMs = PR_METADATA_TIMEOUT_MS } = input;
|
||||||
const fallback = buildFallbackPrMetadata(task);
|
const fallback = buildFallbackPrMetadata(task);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const abortFromCaller = () => controller.abort(signal?.reason instanceof Error ? signal.reason : createAbortError());
|
const abortFromCaller = () => controller.abort(signal?.reason instanceof Error ? signal.reason : createAbortError());
|
||||||
@@ -269,10 +270,16 @@ export async function generatePrMetadata(input: {
|
|||||||
const template = templateExists ? await raceWithAbort(readFile(templatePath, "utf8"), combinedSignal) : "";
|
const template = templateExists ? await raceWithAbort(readFile(templatePath, "utf8"), combinedSignal) : "";
|
||||||
|
|
||||||
const model = resolveTitleSummarizerSettingsModel(settings as Partial<Settings>);
|
const model = resolveTitleSummarizerSettingsModel(settings as Partial<Settings>);
|
||||||
|
const mcpServers = (await raceWithAbort(resolveMcpServersForStore(store ?? {}), combinedSignal)).servers;
|
||||||
let aiText = "";
|
let aiText = "";
|
||||||
const { session } = await raceWithAbort(createFnAgent({
|
const { session } = await raceWithAbort(createFnAgent({
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
tools: "readonly",
|
tools: "readonly",
|
||||||
|
/*
|
||||||
|
* FNXC:McpConfig 2026-06-26-16:58:
|
||||||
|
* PR metadata generation is a readonly dashboard helper with a strict modal timeout. Resolve MCP through the request-scoped TaskStore inside the abort budget and forward only the materialized in-memory server set; no-store fallbacks stay empty and secret values must not be logged.
|
||||||
|
*/
|
||||||
|
mcpServers,
|
||||||
defaultProvider: model.provider,
|
defaultProvider: model.provider,
|
||||||
defaultModelId: model.modelId,
|
defaultModelId: model.modelId,
|
||||||
systemPrompt: [
|
systemPrompt: [
|
||||||
|
|||||||
@@ -1946,6 +1946,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
validated.type,
|
validated.type,
|
||||||
rootDir,
|
rootDir,
|
||||||
settings.promptOverrides,
|
settings.promptOverrides,
|
||||||
|
scopedStore,
|
||||||
);
|
);
|
||||||
res.json({ refined });
|
res.json({ refined });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -2005,7 +2006,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const description = await draftGoalDescription(validatedTitle, rootDir, settings.promptOverrides);
|
const description = await draftGoalDescription(validatedTitle, rootDir, settings.promptOverrides, scopedStore);
|
||||||
res.json({ description });
|
res.json({ description });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
|
|||||||
@@ -923,6 +923,7 @@ export function registerAgentGenerationRoutes(ctx: ApiRoutesContext): void {
|
|||||||
planningModelId,
|
planningModelId,
|
||||||
settings.promptOverrides,
|
settings.promptOverrides,
|
||||||
options?.pluginRunner as Parameters<typeof import("@fusion/engine").buildSessionSkillContextSync>[3],
|
options?.pluginRunner as Parameters<typeof import("@fusion/engine").buildSessionSkillContextSync>[3],
|
||||||
|
scopedStore,
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(201).json({ sessionId });
|
res.status(201).json({ sessionId });
|
||||||
@@ -1014,8 +1015,9 @@ export function registerAgentGenerationRoutes(ctx: ApiRoutesContext): void {
|
|||||||
router.post("/agents/onboarding/:sessionId/retry", async (req, res) => {
|
router.post("/agents/onboarding/:sessionId/retry", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { sessionId } = req.params;
|
const { sessionId } = req.params;
|
||||||
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
const { retryAgentOnboardingSession } = await import("../agent-onboarding.js");
|
const { retryAgentOnboardingSession } = await import("../agent-onboarding.js");
|
||||||
await retryAgentOnboardingSession(sessionId);
|
await retryAgentOnboardingSession(sessionId, scopedStore);
|
||||||
res.json({ success: true, sessionId });
|
res.json({ success: true, sessionId });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) throw err;
|
if (err instanceof ApiError) throw err;
|
||||||
@@ -1111,7 +1113,7 @@ export function registerAgentGenerationRoutes(ctx: ApiRoutesContext): void {
|
|||||||
const rootDir = scopedStore.getRootDir();
|
const rootDir = scopedStore.getRootDir();
|
||||||
const settings = await scopedStore.getSettings();
|
const settings = await scopedStore.getSettings();
|
||||||
|
|
||||||
const spec = await generateAgentSpec(sessionId, rootDir, settings.promptOverrides);
|
const spec = await generateAgentSpec(sessionId, rootDir, settings.promptOverrides, scopedStore);
|
||||||
res.json({ spec });
|
res.json({ spec });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
|
|||||||
@@ -5132,6 +5132,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
task,
|
task,
|
||||||
repoRoot: scopedStore.getRootDir(),
|
repoRoot: scopedStore.getRootDir(),
|
||||||
settings,
|
settings,
|
||||||
|
store: scopedStore,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
timeoutMs: PR_METADATA_ROUTE_TIMEOUT_MS,
|
timeoutMs: PR_METADATA_ROUTE_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -459,7 +459,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
const { store: scopedStore } = await getProjectContext(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
const settings = await scopedStore.getSettings();
|
const settings = await scopedStore.getSettings();
|
||||||
const { retrySubtaskSession } = await import("../subtask-breakdown.js");
|
const { retrySubtaskSession } = await import("../subtask-breakdown.js");
|
||||||
await retrySubtaskSession(sessionId, scopedStore.getRootDir(), settings.promptOverrides);
|
await retrySubtaskSession(sessionId, scopedStore.getRootDir(), settings.promptOverrides, scopedStore);
|
||||||
res.json({ success: true, sessionId });
|
res.json({ success: true, sessionId });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "./ai-session-diagnostics.js";
|
} from "./ai-session-diagnostics.js";
|
||||||
import { GenerationGuard, createAbortError, isAbortError } from "./ai-session-timeout.js";
|
import { GenerationGuard, createAbortError, isAbortError } from "./ai-session-timeout.js";
|
||||||
|
|
||||||
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
|
import { createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const createFnAgent: any = engineCreateFnAgent;
|
const createFnAgent: any = engineCreateFnAgent;
|
||||||
@@ -398,6 +398,7 @@ export async function decomposeForTriage(
|
|||||||
description: string,
|
description: string,
|
||||||
rootDir?: string,
|
rootDir?: string,
|
||||||
promptOverrides?: PromptOverrideMap,
|
promptOverrides?: PromptOverrideMap,
|
||||||
|
store?: TaskStore,
|
||||||
): Promise<SubtaskItem[]> {
|
): Promise<SubtaskItem[]> {
|
||||||
await ensureEngineReady();
|
await ensureEngineReady();
|
||||||
const cwd = rootDir ?? process.cwd();
|
const cwd = rootDir ?? process.cwd();
|
||||||
@@ -407,11 +408,12 @@ export async function decomposeForTriage(
|
|||||||
return generateFallbackSubtasks(description);
|
return generateFallbackSubtasks(description);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mcpServers = (await resolveMcpServersForStore(store ?? {})).servers;
|
||||||
/*
|
/*
|
||||||
* FNXC:McpConfig 2026-06-26-00:00:
|
* FNXC:McpConfig 2026-06-26-16:45:
|
||||||
* Triage subtask decomposition receives only description/rootDir/prompt overrides. With no TaskStore or secrets reader at this one-shot seam, configured MCP servers are intentionally skipped.
|
* Triage subtask decomposition is a readonly planning helper; when the dashboard triage hook provides a scoped store, resolve MCP at session creation and forward only the in-memory server set. Keep no-store callers on an empty MCP set and never log materialized secrets.
|
||||||
*/
|
*/
|
||||||
const agent: SubtaskAgent = await createFnAgent({ cwd, systemPrompt, tools: "readonly" });
|
const agent: SubtaskAgent = await createFnAgent({ cwd, systemPrompt, tools: "readonly", mcpServers });
|
||||||
try {
|
try {
|
||||||
await agent.session.prompt(description);
|
await agent.session.prompt(description);
|
||||||
const messages = agent.session.state.messages as Array<{
|
const messages = agent.session.state.messages as Array<{
|
||||||
@@ -440,7 +442,7 @@ export async function decomposeForTriage(
|
|||||||
|
|
||||||
export async function createSubtaskSession(
|
export async function createSubtaskSession(
|
||||||
initialDescription: string,
|
initialDescription: string,
|
||||||
_store?: TaskStore,
|
store?: TaskStore,
|
||||||
rootDir?: string,
|
rootDir?: string,
|
||||||
promptOverrides?: PromptOverrideMap,
|
promptOverrides?: PromptOverrideMap,
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
@@ -460,7 +462,7 @@ export async function createSubtaskSession(
|
|||||||
persistSubtaskSession(session, "generating");
|
persistSubtaskSession(session, "generating");
|
||||||
|
|
||||||
const cwd = rootDir ?? process.cwd();
|
const cwd = rootDir ?? process.cwd();
|
||||||
void startSubtaskGeneration(sessionId, cwd, promptOverrides);
|
void startSubtaskGeneration(sessionId, cwd, promptOverrides, store);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -475,9 +477,10 @@ async function startSubtaskGeneration(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
promptOverrides?: PromptOverrideMap,
|
promptOverrides?: PromptOverrideMap,
|
||||||
|
store?: TaskStore,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await generateSubtasks(sessionId, cwd, promptOverrides);
|
await generateSubtasks(sessionId, cwd, promptOverrides, store);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Timeout / user-stop already published an error state via the guard
|
// Timeout / user-stop already published an error state via the guard
|
||||||
// handlers. Don't overwrite it with a generic AbortError message.
|
// handlers. Don't overwrite it with a generic AbortError message.
|
||||||
@@ -498,6 +501,7 @@ async function generateSubtasks(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
promptOverrides?: PromptOverrideMap,
|
promptOverrides?: PromptOverrideMap,
|
||||||
|
store?: TaskStore,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (!session) throw new SessionNotFoundError(`Subtask session ${sessionId} not found`);
|
if (!session) throw new SessionNotFoundError(`Subtask session ${sessionId} not found`);
|
||||||
@@ -532,14 +536,16 @@ async function generateSubtasks(
|
|||||||
FNXC:SubtaskBreakdown 2026-06-16-20:15:
|
FNXC:SubtaskBreakdown 2026-06-16-20:15:
|
||||||
FN-6511 requires the full subtask generation lifecycle to be timeout-bounded, including createFnAgent construction before prompt() starts. Keep construction and prompt inside one GenerationGuard entry so a model-registry or extension-discovery stall cannot pin the SSE session in generating forever.
|
FN-6511 requires the full subtask generation lifecycle to be timeout-bounded, including createFnAgent construction before prompt() starts. Keep construction and prompt inside one GenerationGuard entry so a model-registry or extension-discovery stall cannot pin the SSE session in generating forever.
|
||||||
*/
|
*/
|
||||||
|
const mcpServers = (await resolveMcpServersForStore(store ?? {})).servers;
|
||||||
/*
|
/*
|
||||||
FNXC:McpConfig 2026-06-26-00:00:
|
FNXC:McpConfig 2026-06-26-16:45:
|
||||||
Streaming subtask generation currently does not thread the optional TaskStore into the async generation worker, so this readonly planning surface intentionally skips MCP until that larger session-state refactor is performed.
|
Streaming subtask generation is a readonly planning helper that now carries the dashboard-scoped TaskStore into the timeout-bounded worker. Resolve MCP inside the GenerationGuard window and forward only counts/errors if diagnostics are added; never expose plaintext env/header secrets.
|
||||||
*/
|
*/
|
||||||
const agentPromise = createFnAgent({
|
const agentPromise = createFnAgent({
|
||||||
cwd,
|
cwd,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
tools: "readonly",
|
tools: "readonly",
|
||||||
|
mcpServers,
|
||||||
onThinking: (delta: string) => {
|
onThinking: (delta: string) => {
|
||||||
const current = sessions.get(sessionId);
|
const current = sessions.get(sessionId);
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
@@ -700,6 +706,7 @@ export async function retrySubtaskSession(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
promptOverrides?: PromptOverrideMap,
|
promptOverrides?: PromptOverrideMap,
|
||||||
|
store?: TaskStore,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const visibleSession = getSubtaskSession(sessionId);
|
const visibleSession = getSubtaskSession(sessionId);
|
||||||
if (!visibleSession) {
|
if (!visibleSession) {
|
||||||
@@ -730,7 +737,7 @@ export async function retrySubtaskSession(
|
|||||||
session.updatedAt = new Date();
|
session.updatedAt = new Date();
|
||||||
persistSubtaskSession(session, "generating");
|
persistSubtaskSession(session, "generating");
|
||||||
|
|
||||||
await startSubtaskGeneration(sessionId, rootDir, promptOverrides);
|
await startSubtaskGeneration(sessionId, rootDir, promptOverrides, store);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSubtaskSession(sessionId: string): SubtaskSession | undefined {
|
export function getSubtaskSession(sessionId: string): SubtaskSession | undefined {
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ export async function runTriageOnEnter(task: Task, deps: TriageDeps): Promise<Tr
|
|||||||
|
|
||||||
let subtasks: SubtaskItem[];
|
let subtasks: SubtaskItem[];
|
||||||
try {
|
try {
|
||||||
const decompose = deps.decompose ?? ((d: string) => decomposeForTriage(d, deps.rootDir, deps.promptOverrides));
|
const decompose = deps.decompose ?? ((d: string) => decomposeForTriage(d, deps.rootDir, deps.promptOverrides, deps.store));
|
||||||
subtasks = await decompose(task.description);
|
subtasks = await decompose(task.description);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return parkInTriage(store, task, err, "decompose");
|
return parkInTriage(store, task, err, "decompose");
|
||||||
|
|||||||
Reference in New Issue
Block a user