feat(FN-2962): merge fusion/fn-2962

- Add changeset for `@runfusion/fusion` minor release introducing custom provider registration support

Commits merged:
- feat(FN-2962): complete Step 8 — add changeset and documentation

Files changed:
.changeset/register-custom-providers.md | 5 +++++
 1 file changed, 5 insertions(+)

Fusion-Task-Id: FN-2962
This commit is contained in:
Fusion
2026-04-29 21:42:44 -07:00
committed by gsxdsm
parent 476d17e2ca
commit 38f5159719
33 changed files with 1048 additions and 60 deletions

View File

@@ -13,6 +13,7 @@ import {
type HeartbeatExecutionOptions,
HEARTBEAT_SYSTEM_PROMPT,
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
HEARTBEAT_PROCEDURE,
} from "../agent-heartbeat.js";
import { AgentLogger } from "../agent-logger.js";
import * as agentTools from "../agent-tools.js";
@@ -1655,6 +1656,10 @@ describe("HeartbeatMonitor", () => {
// Should NOT include task-specific content
expect(executionPrompt).not.toContain("Assigned task:");
expect(executionPrompt).not.toContain("Task description:");
// Should include Wake Delta + Heartbeat Procedure (paperclip-style per-tick anchoring)
expect(executionPrompt).toContain("## Wake Delta");
expect(executionPrompt).toContain("wake reason:");
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
});
it("task-scoped run receives HEARTBEAT_SYSTEM_PROMPT as system prompt", async () => {
@@ -2123,6 +2128,60 @@ describe("HeartbeatMonitor", () => {
expect(executionPrompt).toContain("Pending Messages:");
expect(executionPrompt).toContain("[id: msg-1] [from: agent:agent-2] Hello from agent-2");
expect(executionPrompt).toContain("[id: msg-2] [from: user:user-1] Hello from user");
// Task-scoped prompts must include Wake Delta + Heartbeat Procedure so
// the agent re-runs its procedure each tick instead of grinding on the
// assigned task (paperclip-parity).
expect(executionPrompt).toContain("## Wake Delta");
expect(executionPrompt).toContain("wake reason: message_received");
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
});
it("substitutes per-agent heartbeatProcedurePath content for the default procedure", async () => {
const tmpRoot = mkdtempSync(join(tmpdir(), "fn-hb-procedure-"));
try {
const customProcedure = "## Custom CEO Procedure\n\n1. Review reports\n2. Update strategy\n3. Exit";
writeFileSync(join(tmpRoot, "MY-PROCEDURE.md"), customProcedure, "utf-8");
const store = createStoreWithAgentForExec({
heartbeatProcedurePath: "MY-PROCEDURE.md",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: tmpRoot });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
const promptCalls = mockSession.prompt.mock.calls;
expect(promptCalls.length).toBeGreaterThan(0);
const executionPrompt = promptCalls[promptCalls.length - 1][0];
// Custom procedure should appear; default constant should not.
expect(executionPrompt).toContain("## Custom CEO Procedure");
expect(executionPrompt).toContain("1. Review reports");
expect(executionPrompt).not.toContain(HEARTBEAT_PROCEDURE);
// Wake Delta still rendered.
expect(executionPrompt).toContain("## Wake Delta");
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("falls back to default procedure when heartbeatProcedurePath is invalid (traversal)", async () => {
const store = createStoreWithAgentForExec({
heartbeatProcedurePath: "../escape.md",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
const promptCalls = mockSession.prompt.mock.calls;
const executionPrompt = promptCalls[promptCalls.length - 1][0];
// Invalid path → fall back to the default constant.
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
});
it("does not include message section when no unread messages", async () => {

View File

@@ -21,6 +21,7 @@ const reloadMock = vi.fn(async () => {});
const execSyncMock = vi.fn((_cmd?: any, _opts?: any) => "");
const existsSyncMock = vi.fn((_path: PathLike) => false);
const readFileSyncMock = vi.fn((_path?: any) => "{}");
const readCustomProvidersMock = vi.fn(() => []);
// Route async `exec` through the `execSync` mock so the promisify bridge works.
// Use Symbol.for("nodejs.util.promisify.custom") directly to avoid async imports
@@ -68,6 +69,10 @@ vi.mock("node:fs", async () => {
};
});
vi.mock("../custom-providers.js", () => ({
readCustomProviders: readCustomProvidersMock,
}));
vi.mock("@mariozechner/pi-coding-agent", () => ({
AuthStorage: {
create: () => ({
@@ -382,6 +387,7 @@ describe("createFnAgent", () => {
execSyncMock.mockReturnValue("");
existsSyncMock.mockReturnValue(false);
readFileSyncMock.mockReturnValue("{}");
readCustomProvidersMock.mockReturnValue([]);
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
createAgentSessionMock.mockResolvedValue({
session: {
@@ -484,6 +490,36 @@ describe("createFnAgent", () => {
expect(refreshMock).toHaveBeenCalled();
});
it("registers custom providers from global settings", async () => {
readCustomProvidersMock.mockReturnValue([
{
id: "custom-openai",
name: "Custom OpenAI",
apiType: "openai-compatible",
baseUrl: "https://custom.example/v1",
apiKey: "CUSTOM_API_KEY",
models: [{ id: "custom-model", name: "Custom Model" }],
},
] as any);
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "readonly",
defaultProvider: "openai-codex",
defaultModelId: "gpt-5.4",
});
expect(registerProviderMock).toHaveBeenCalledWith("custom-openai", expect.objectContaining({
baseUrl: "https://custom.example/v1",
api: "openai-completions",
apiKey: "CUSTOM_API_KEY",
models: [expect.objectContaining({ id: "custom-model", name: "Custom Model" })],
}));
});
it("avoids lock-based SettingsManager.create when loading extension providers", async () => {
const { createFnAgent } = await import("../pi.js");