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