feat(FN-2706): merge fusion/fn-2706 (auto-resolved)
- feat(FN-2706): complete Step 6 — document Paperclip REST runtime behavior - test(FN-2706): cover getAgentIdentity success and request payload assertions - fix(FN-2706): align promptWithFallback signature with runtime contract - fix(FN-2706): add session dispose compatibility for engine callers - fix(FN-2706): refine Paperclip API client error and config handling - test(FN-2706): complete Step 4 — cover paperclip api client and adapter flow - feat(FN-2706): complete Step 3 — wire plugin settings and remove engine guard - feat(FN-2706): complete Step 2 — rewrite paperclip runtime adapter - fix(FN-2706): restore compatibility exports during runtime migration - feat(FN-2706): complete Step 1 — add Paperclip REST client
This commit is contained in:
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Resolution stub for @fusion/engine in plugin test mode.
|
||||
*
|
||||
* Vitest resolves mocked module IDs before applying vi.mock factories. The
|
||||
* real @fusion/engine workspace package points to dist outputs that are not
|
||||
* built for plugin-local test runs, so we alias to this stub in vitest config.
|
||||
*
|
||||
* Any direct import of @fusion/engine in plugin tests should still fail fast.
|
||||
*/
|
||||
const guardError = () =>
|
||||
new Error(
|
||||
"Guard: @fusion/engine was imported without an explicit mock. Runtime plugin tests must mock '../pi-module.js' to prevent loading the real engine.",
|
||||
);
|
||||
|
||||
export const createFnAgent = () => {
|
||||
throw guardError();
|
||||
};
|
||||
|
||||
export const promptWithFallback = async () => {
|
||||
throw guardError();
|
||||
};
|
||||
|
||||
export const describeModel = () => {
|
||||
throw guardError();
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Verifies the engine guard mock is active.
|
||||
*
|
||||
* This test ensures that the setup-engine-guard.ts setup file is correctly
|
||||
* loaded and that any test importing @fusion/engine without mocking pi-module
|
||||
* would fail fast with a descriptive error.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("engine import guard", () => {
|
||||
it("should have @fusion/engine mock installed (prevents real engine load)", () => {
|
||||
// The guard is verified indirectly: if this test file runs at all,
|
||||
// the setup-engine-guard.ts loaded successfully. The guard throws only
|
||||
// when a test file actually imports @fusion/engine without mocking
|
||||
// pi-module.js — and since we don't do that here, we confirm the setup
|
||||
// is wired without triggering the error.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("should mock pi-module seam (not load real engine)", async () => {
|
||||
// Dynamically import pi-module to verify it is mocked, not the real one.
|
||||
// Since this test file has no vi.mock("../pi-module.js"), it relies on
|
||||
// no code path reaching pi-module at all. The guard in setup-engine-guard.ts
|
||||
// would throw if the real @fusion/engine were loaded.
|
||||
//
|
||||
// We do NOT import pi-module here because that would trigger the guard.
|
||||
// Instead we verify the setup file exists and is wired via vitest config.
|
||||
const { existsSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const guardPath = join(import.meta.dirname, "setup-engine-guard.ts");
|
||||
expect(existsSync(guardPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,117 +1,106 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const { mockProbePaperclipInstance, mockResolvePaperclipConfig, mockAdapterCtor, MockAdapter } = vi.hoisted(() => {
|
||||
const mockProbe = vi.fn();
|
||||
const mockResolve = vi.fn((settings?: Record<string, unknown>) => ({
|
||||
apiUrl: "http://localhost:3100",
|
||||
apiKey: undefined,
|
||||
agentId: undefined,
|
||||
companyId: undefined,
|
||||
...(settings ?? {}),
|
||||
}));
|
||||
const adapterCtor = vi.fn();
|
||||
class Adapter {
|
||||
readonly id = "paperclip";
|
||||
readonly name = "Paperclip Runtime";
|
||||
constructor(...args: unknown[]) {
|
||||
adapterCtor(...args);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mockProbePaperclipInstance: mockProbe,
|
||||
mockResolvePaperclipConfig: mockResolve,
|
||||
mockAdapterCtor: adapterCtor,
|
||||
MockAdapter: Adapter,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../pi-module.js", () => ({
|
||||
createFnAgent: vi.fn().mockResolvedValue({ session: {} }),
|
||||
promptWithFallback: vi.fn(),
|
||||
describeModel: vi.fn().mockReturnValue("mock/model"),
|
||||
probePaperclipInstance: mockProbePaperclipInstance,
|
||||
resolvePaperclipConfig: mockResolvePaperclipConfig,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-adapter.js", () => ({
|
||||
PaperclipRuntimeAdapter: MockAdapter,
|
||||
}));
|
||||
|
||||
import plugin from "../index.js";
|
||||
import { PaperclipRuntimeAdapter } from "../runtime-adapter.js";
|
||||
|
||||
// ── Test Suite ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("paperclip-runtime plugin", () => {
|
||||
describe("plugin manifest identity", () => {
|
||||
it("should export a valid FusionPlugin with correct manifest fields", () => {
|
||||
expect(plugin.manifest.id).toBe("fusion-plugin-paperclip-runtime");
|
||||
expect(plugin.manifest.name).toBe("Paperclip Runtime Plugin");
|
||||
expect(plugin.manifest.version).toBe("1.0.0");
|
||||
expect(plugin.manifest.description).toBe(
|
||||
"Provides Paperclip runtime for Fusion AI agents",
|
||||
);
|
||||
expect(plugin.manifest.author).toBe("Fusion Team");
|
||||
expect(plugin.state).toBe("installed");
|
||||
});
|
||||
|
||||
it("should have runtime manifest metadata matching manifest.json", () => {
|
||||
expect(plugin.manifest.runtime).toBeDefined();
|
||||
expect(plugin.manifest.runtime!.runtimeId).toBe("paperclip");
|
||||
expect(plugin.manifest.runtime!.name).toBe("Paperclip Runtime");
|
||||
expect(plugin.manifest.runtime!.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("should have fusionVersion requirement", () => {
|
||||
expect(plugin.manifest.fusionVersion).toBe(">=0.1.0");
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProbePaperclipInstance.mockResolvedValue({ ok: true, deploymentMode: "local_trusted" });
|
||||
});
|
||||
|
||||
describe("runtime registration", () => {
|
||||
it("should have runtime registration", () => {
|
||||
expect(plugin.runtime).toBeDefined();
|
||||
});
|
||||
|
||||
it("should have correct runtime metadata", () => {
|
||||
const runtime = plugin.runtime!;
|
||||
expect(runtime.metadata.runtimeId).toBe("paperclip");
|
||||
expect(runtime.metadata.name).toBe("Paperclip Runtime");
|
||||
expect(runtime.metadata.description).toBe(
|
||||
"Paperclip-backed AI session using the user's configured pi provider and model",
|
||||
);
|
||||
expect(runtime.metadata.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("should have a factory function", () => {
|
||||
expect(plugin.runtime!.factory).toBeDefined();
|
||||
expect(typeof plugin.runtime!.factory).toBe("function");
|
||||
});
|
||||
it("keeps manifest identity unchanged", () => {
|
||||
expect(plugin.manifest.id).toBe("fusion-plugin-paperclip-runtime");
|
||||
expect(plugin.manifest.runtime?.runtimeId).toBe("paperclip");
|
||||
expect(plugin.manifest.name).toBe("Paperclip Runtime Plugin");
|
||||
expect(plugin.runtime?.metadata.runtimeId).toBe("paperclip");
|
||||
});
|
||||
|
||||
describe("runtime factory invocation", () => {
|
||||
it("should return a PaperclipRuntimeAdapter instance when factory is invoked", async () => {
|
||||
const runtime = await plugin.runtime!.factory({} as any);
|
||||
expect(runtime).toBeInstanceOf(PaperclipRuntimeAdapter);
|
||||
});
|
||||
it("factory resolves settings and passes config/logger to adapter", async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const ctx = {
|
||||
settings: {
|
||||
apiUrl: "http://paperclip.example",
|
||||
apiKey: "secret",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
},
|
||||
logger,
|
||||
};
|
||||
|
||||
it("should return an adapter with correct id and name", async () => {
|
||||
const runtime = (await plugin.runtime!.factory({} as any)) as PaperclipRuntimeAdapter;
|
||||
expect(runtime.id).toBe("paperclip");
|
||||
expect(runtime.name).toBe("Paperclip Runtime");
|
||||
});
|
||||
await plugin.runtime!.factory(ctx as any);
|
||||
|
||||
expect(mockResolvePaperclipConfig).toHaveBeenCalledWith(ctx.settings);
|
||||
expect(mockAdapterCtor).toHaveBeenCalledWith(
|
||||
{
|
||||
apiUrl: "http://paperclip.example",
|
||||
apiKey: "secret",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
},
|
||||
logger,
|
||||
);
|
||||
});
|
||||
|
||||
describe("hooks", () => {
|
||||
it("should have onLoad hook", () => {
|
||||
expect(plugin.hooks.onLoad).toBeDefined();
|
||||
expect(typeof plugin.hooks.onLoad).toBe("function");
|
||||
});
|
||||
it("onLoad probes Paperclip and logs success without leaking apiKey", async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
const ctx = {
|
||||
settings: {
|
||||
apiUrl: "http://paperclip.example",
|
||||
apiKey: "super-secret",
|
||||
},
|
||||
logger,
|
||||
};
|
||||
|
||||
it("onLoad should not throw when called with valid context", () => {
|
||||
const mockLogger = {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
};
|
||||
const mockCtx = {
|
||||
pluginId: "fusion-plugin-paperclip-runtime",
|
||||
settings: {},
|
||||
logger: mockLogger,
|
||||
emitEvent: () => {},
|
||||
taskStore: {},
|
||||
};
|
||||
await plugin.hooks.onLoad!(ctx as any);
|
||||
|
||||
expect(() => plugin.hooks.onLoad!(mockCtx as any)).not.toThrow();
|
||||
});
|
||||
expect(mockProbePaperclipInstance).toHaveBeenCalledWith("http://paperclip.example", "super-secret");
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
"Paperclip Runtime Plugin loaded (apiUrl=http://paperclip.example)",
|
||||
);
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain("super-secret");
|
||||
});
|
||||
|
||||
it("onLoad should call logger.info", () => {
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
const mockCtx = {
|
||||
pluginId: "fusion-plugin-paperclip-runtime",
|
||||
settings: {},
|
||||
logger: mockLogger,
|
||||
emitEvent: () => {},
|
||||
taskStore: {},
|
||||
};
|
||||
it("onLoad logs warning when probe fails", async () => {
|
||||
mockProbePaperclipInstance.mockResolvedValue({ ok: false, error: "ECONNREFUSED" });
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
|
||||
plugin.hooks.onLoad!(mockCtx as any);
|
||||
await plugin.hooks.onLoad!({ settings: {}, logger } as any);
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("Paperclip Runtime Plugin loaded");
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("probe failed"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ConflictError,
|
||||
addComment,
|
||||
checkoutIssue,
|
||||
createIssue,
|
||||
getAgentIdentity,
|
||||
getIssue,
|
||||
getIssueComments,
|
||||
invokeHeartbeat,
|
||||
listIssues,
|
||||
probePaperclipInstance,
|
||||
resolvePaperclipConfig,
|
||||
updateIssue,
|
||||
} from "../pi-module.js";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("paperclip client", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("resolvePaperclipConfig", () => {
|
||||
it("prefers plugin settings over env vars", () => {
|
||||
process.env.PAPERCLIP_API_URL = "http://env-host:3100";
|
||||
process.env.PAPERCLIP_API_KEY = "env-key";
|
||||
process.env.PAPERCLIP_AGENT_ID = "env-agent";
|
||||
process.env.PAPERCLIP_COMPANY_ID = "env-company";
|
||||
|
||||
const config = resolvePaperclipConfig({
|
||||
apiUrl: "http://settings-host:4000/",
|
||||
apiKey: "settings-key",
|
||||
agentId: "settings-agent",
|
||||
companyId: "settings-company",
|
||||
});
|
||||
|
||||
expect(config).toEqual({
|
||||
apiUrl: "http://settings-host:4000",
|
||||
apiKey: "settings-key",
|
||||
agentId: "settings-agent",
|
||||
companyId: "settings-company",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses env vars when settings are absent", () => {
|
||||
process.env.PAPERCLIP_API_URL = "http://env-host:3100/";
|
||||
process.env.PAPERCLIP_API_KEY = "env-key";
|
||||
process.env.PAPERCLIP_AGENT_ID = "env-agent";
|
||||
process.env.PAPERCLIP_COMPANY_ID = "env-company";
|
||||
|
||||
expect(resolvePaperclipConfig()).toEqual({
|
||||
apiUrl: "http://env-host:3100",
|
||||
apiKey: "env-key",
|
||||
agentId: "env-agent",
|
||||
companyId: "env-company",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to hardcoded defaults", () => {
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_AGENT_ID;
|
||||
delete process.env.PAPERCLIP_COMPANY_ID;
|
||||
|
||||
expect(resolvePaperclipConfig()).toEqual({
|
||||
apiUrl: "http://localhost:3100",
|
||||
apiKey: undefined,
|
||||
agentId: undefined,
|
||||
companyId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("probePaperclipInstance returns success on health check", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ status: "ok", deploymentMode: "local_trusted" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(probePaperclipInstance("http://localhost:3100", "secret")).resolves.toEqual({
|
||||
ok: true,
|
||||
deploymentMode: "local_trusted",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("http://localhost:3100/api/health", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: "Bearer secret",
|
||||
},
|
||||
body: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("probePaperclipInstance returns error on connection failure", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
|
||||
|
||||
const result = await probePaperclipInstance("http://localhost:3100");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("network error");
|
||||
}
|
||||
});
|
||||
|
||||
it("getAgentIdentity returns agent on 200 and structured auth failures", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ id: "AG-1", name: "Agent", companyId: "CO-1", role: "executor", status: "active" }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401))
|
||||
.mockResolvedValueOnce(jsonResponse({ error: "forbidden" }, 403));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(getAgentIdentity("http://localhost:3100", "key")).resolves.toEqual({
|
||||
ok: true,
|
||||
agent: { id: "AG-1", name: "Agent", companyId: "CO-1", role: "executor", status: "active" },
|
||||
});
|
||||
|
||||
await expect(getAgentIdentity("http://localhost:3100")).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "unauthenticated",
|
||||
});
|
||||
|
||||
await expect(getAgentIdentity("http://localhost:3100", "key")).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "not_agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("createIssue posts issue payload and returns created issue", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: "ISS-1", status: "backlog" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await createIssue("http://localhost:3100", "key", "COMP-1", {
|
||||
title: "Title",
|
||||
description: "Desc",
|
||||
status: "backlog",
|
||||
assigneeAgentId: "A-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "ISS-1", status: "backlog" });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost:3100/api/companies/COMP-1/issues",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: "Title",
|
||||
description: "Desc",
|
||||
status: "backlog",
|
||||
assigneeAgentId: "A-1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("getIssue returns issue object", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: "ISS-7", status: "in_progress" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(getIssue("http://localhost:3100", "key", "ISS-7")).resolves.toEqual({
|
||||
id: "ISS-7",
|
||||
status: "in_progress",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost:3100/api/issues/ISS-7",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("checkoutIssue posts agent payload and throws ConflictError on 409", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "ISS-1", status: "in_progress" }))
|
||||
.mockResolvedValueOnce(jsonResponse({ error: "already checked out" }, 409));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(checkoutIssue("http://localhost:3100", "key", "ISS-1", "AG-1")).resolves.toEqual({
|
||||
id: "ISS-1",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await expect(checkoutIssue("http://localhost:3100", "key", "ISS-1", "AG-1")).rejects.toThrow(
|
||||
ConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
it("updateIssue sends run id header when provided", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: "ISS-1", status: "done" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await updateIssue("http://localhost:3100", "key", "ISS-1", { status: "done" }, "RUN-1");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost:3100/api/issues/ISS-1",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
headers: expect.objectContaining({ "X-Paperclip-Run-Id": "RUN-1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("getIssueComments and addComment hit comment endpoints", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([{ id: "C1", body: "result" }]))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "C2" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(getIssueComments("http://localhost:3100", "key", "ISS-1")).resolves.toEqual([
|
||||
{ id: "C1", body: "result" },
|
||||
]);
|
||||
|
||||
await expect(addComment("http://localhost:3100", "key", "ISS-1", "hello", "RUN-2")).resolves.toEqual({
|
||||
id: "C2",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
"http://localhost:3100/api/issues/ISS-1/comments",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: "hello" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("invokeHeartbeat handles queued and skipped responses", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "RUN-1", status: "queued", agentId: "AG-1" }))
|
||||
.mockResolvedValueOnce(jsonResponse({ status: "skipped" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(invokeHeartbeat("http://localhost:3100", "key", "AG-1")).resolves.toEqual({
|
||||
ok: true,
|
||||
run: { id: "RUN-1", status: "queued", agentId: "AG-1" },
|
||||
});
|
||||
|
||||
await expect(invokeHeartbeat("http://localhost:3100", "key", "AG-1")).resolves.toEqual({
|
||||
ok: true,
|
||||
skipped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("listIssues applies query params", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([{ id: "ISS-1" }]));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
listIssues("http://localhost:3100", "key", "COMP-1", {
|
||||
status: ["todo", "in_progress"],
|
||||
assigneeAgentId: "AG-1",
|
||||
}),
|
||||
).resolves.toEqual([{ id: "ISS-1" }]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost:3100/api/companies/COMP-1/issues?status=todo%2Cin_progress&assigneeAgentId=AG-1",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on non-200 and invalid JSON", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce(jsonResponse({ error: "boom" }, 500)));
|
||||
await expect(getIssue("http://localhost:3100", "key", "ISS-1")).rejects.toThrow("Paperclip API 500");
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValueOnce(
|
||||
new Response("not-json", { status: 200, headers: { "Content-Type": "application/json" } }),
|
||||
),
|
||||
);
|
||||
await expect(getIssue("http://localhost:3100", "key", "ISS-1")).rejects.toThrow("invalid JSON");
|
||||
});
|
||||
});
|
||||
@@ -1,290 +1,307 @@
|
||||
/**
|
||||
* Runtime Adapter Tests
|
||||
*
|
||||
* Tests for the PaperclipRuntimeAdapter class.
|
||||
*
|
||||
* ## Mocking Strategy
|
||||
*
|
||||
* The adapter imports pi functions from a seam module (./pi-module.js) which
|
||||
* re-exports them from the engine. This allows Vitest to mock the seam directly,
|
||||
* enabling behavioral tests of the adapter's delegation to pi functions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PaperclipRuntimeAdapter } from "../runtime-adapter.js";
|
||||
|
||||
// ── Mock Modules ────────────────────────────────────────────────────────────────
|
||||
const {
|
||||
mockCreateIssue,
|
||||
mockCheckoutIssue,
|
||||
mockInvokeHeartbeat,
|
||||
mockGetIssue,
|
||||
mockGetIssueComments,
|
||||
MockConflictError,
|
||||
} = vi.hoisted(() => {
|
||||
class LocalConflictError extends Error {
|
||||
readonly status = 409;
|
||||
}
|
||||
|
||||
// Use vi.hoisted() so Vitest properly handles the hoisted mock reference
|
||||
const { mockCreateFnAgent, mockPromptWithFallback, mockDescribeModel } = vi.hoisted(() => ({
|
||||
mockCreateFnAgent: vi.fn(),
|
||||
mockPromptWithFallback: vi.fn(),
|
||||
mockDescribeModel: vi.fn(),
|
||||
}));
|
||||
return {
|
||||
mockCreateIssue: vi.fn(),
|
||||
mockCheckoutIssue: vi.fn(),
|
||||
mockInvokeHeartbeat: vi.fn(),
|
||||
mockGetIssue: vi.fn(),
|
||||
mockGetIssueComments: vi.fn(),
|
||||
MockConflictError: LocalConflictError,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the pi-module seam so the adapter uses our mock functions
|
||||
vi.mock("../pi-module.js", () => ({
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
promptWithFallback: mockPromptWithFallback,
|
||||
describeModel: mockDescribeModel,
|
||||
resolvePaperclipConfig: vi.fn((settings?: Record<string, unknown>) => ({
|
||||
apiUrl: "http://localhost:3100",
|
||||
apiKey: undefined,
|
||||
agentId: undefined,
|
||||
companyId: undefined,
|
||||
...(settings ?? {}),
|
||||
})),
|
||||
createIssue: mockCreateIssue,
|
||||
checkoutIssue: mockCheckoutIssue,
|
||||
invokeHeartbeat: mockInvokeHeartbeat,
|
||||
getIssue: mockGetIssue,
|
||||
getIssueComments: mockGetIssueComments,
|
||||
ConflictError: MockConflictError,
|
||||
}));
|
||||
|
||||
// ── Test Suite ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PaperclipRuntimeAdapter", () => {
|
||||
let adapter: PaperclipRuntimeAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock return values
|
||||
mockDescribeModel.mockReturnValue("mock/anthropic-claude");
|
||||
adapter = new PaperclipRuntimeAdapter();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("runtime identity", () => {
|
||||
it("should have id 'paperclip'", () => {
|
||||
expect(adapter.id).toBe("paperclip");
|
||||
it("createSession returns configured Paperclip session with undefined sessionFile", async () => {
|
||||
const onText = vi.fn();
|
||||
const onThinking = vi.fn();
|
||||
const onToolStart = vi.fn();
|
||||
const onToolEnd = vi.fn();
|
||||
|
||||
const adapter = new PaperclipRuntimeAdapter({
|
||||
apiUrl: "http://paperclip.local",
|
||||
apiKey: "token",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
});
|
||||
|
||||
it("should have name 'Paperclip Runtime'", () => {
|
||||
expect(adapter.name).toBe("Paperclip Runtime");
|
||||
const { session, sessionFile } = await adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "system",
|
||||
onText,
|
||||
onThinking,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
});
|
||||
|
||||
expect(sessionFile).toBeUndefined();
|
||||
expect(session).toMatchObject({
|
||||
apiUrl: "http://paperclip.local",
|
||||
apiKey: "token",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
cwd: "/repo",
|
||||
systemPrompt: "system",
|
||||
onText,
|
||||
onThinking,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
});
|
||||
expect(session.sessionId).toBeTypeOf("string");
|
||||
});
|
||||
|
||||
it("createSession throws when required agentId/companyId config is missing", async () => {
|
||||
const adapter = new PaperclipRuntimeAdapter({ apiUrl: "http://paperclip.local" });
|
||||
|
||||
await expect(
|
||||
adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "system",
|
||||
}),
|
||||
).rejects.toThrow("missing required config");
|
||||
});
|
||||
|
||||
it("promptWithFallback creates issue, checks out, invokes heartbeat, polls, and emits output", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const onText = vi.fn();
|
||||
const onThinking = vi.fn();
|
||||
const onToolStart = vi.fn();
|
||||
const onToolEnd = vi.fn();
|
||||
|
||||
const adapter = new PaperclipRuntimeAdapter({
|
||||
apiUrl: "http://paperclip.local",
|
||||
apiKey: "token",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
});
|
||||
|
||||
const { session } = await adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "system prompt",
|
||||
onText,
|
||||
onThinking,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
});
|
||||
|
||||
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "backlog" });
|
||||
mockCheckoutIssue.mockResolvedValue({ id: "ISS-1", status: "in_progress" });
|
||||
mockInvokeHeartbeat.mockResolvedValue({ ok: true, run: { id: "RUN-1", status: "queued" } });
|
||||
mockGetIssue
|
||||
.mockResolvedValueOnce({ id: "ISS-1", status: "in_progress" })
|
||||
.mockResolvedValueOnce({ id: "ISS-1", status: "done" });
|
||||
mockGetIssueComments.mockResolvedValue([
|
||||
{ id: "C1", body: "Thinking: I should do this" },
|
||||
{ id: "C2", body: "Completed work." },
|
||||
]);
|
||||
|
||||
const promptPromise = adapter.promptWithFallback(session, "Title line\nBody");
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
await promptPromise;
|
||||
|
||||
expect(mockCreateIssue).toHaveBeenCalledWith(
|
||||
"http://paperclip.local",
|
||||
"token",
|
||||
"CO-1",
|
||||
expect.objectContaining({
|
||||
title: "Title line",
|
||||
status: "backlog",
|
||||
assigneeAgentId: "AG-1",
|
||||
}),
|
||||
);
|
||||
expect(mockCheckoutIssue).toHaveBeenCalledWith(
|
||||
"http://paperclip.local",
|
||||
"token",
|
||||
"ISS-1",
|
||||
"AG-1",
|
||||
expect.any(String),
|
||||
);
|
||||
expect(mockInvokeHeartbeat).toHaveBeenCalledWith("http://paperclip.local", "token", "AG-1");
|
||||
expect(onText).toHaveBeenCalledWith("Thinking: I should do this\n\nCompleted work.");
|
||||
expect(onThinking).toHaveBeenCalledWith("I should do this");
|
||||
expect(onToolStart).toHaveBeenCalledWith(
|
||||
"paperclip.issue",
|
||||
expect.objectContaining({ sessionId: expect.any(String) }),
|
||||
);
|
||||
expect(onToolEnd).toHaveBeenCalledWith("paperclip.issue", false, {
|
||||
issueId: "ISS-1",
|
||||
status: "done",
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSession", () => {
|
||||
it("should call createFnAgent with all options mapped correctly", async () => {
|
||||
const mockSession = { dispose: vi.fn() };
|
||||
const mockResult = { session: mockSession, sessionFile: "/path/to/session.json" };
|
||||
mockCreateFnAgent.mockResolvedValue(mockResult);
|
||||
it("handles checkout conflicts gracefully and continues", async () => {
|
||||
vi.useFakeTimers();
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
|
||||
const options = {
|
||||
cwd: "/project",
|
||||
systemPrompt: "You are helpful",
|
||||
skills: ["bash", "read"],
|
||||
};
|
||||
const adapter = new PaperclipRuntimeAdapter(
|
||||
{
|
||||
apiUrl: "http://paperclip.local",
|
||||
apiKey: "token",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
const result = await adapter.createSession(options);
|
||||
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system" });
|
||||
|
||||
expect(mockCreateFnAgent).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateFnAgent).toHaveBeenCalledWith({
|
||||
cwd: "/project",
|
||||
systemPrompt: "You are helpful",
|
||||
tools: undefined,
|
||||
customTools: undefined,
|
||||
onText: undefined,
|
||||
onThinking: undefined,
|
||||
onToolStart: undefined,
|
||||
onToolEnd: undefined,
|
||||
defaultProvider: undefined,
|
||||
defaultModelId: undefined,
|
||||
fallbackProvider: undefined,
|
||||
fallbackModelId: undefined,
|
||||
defaultThinkingLevel: undefined,
|
||||
sessionManager: undefined,
|
||||
skillSelection: undefined,
|
||||
skills: ["bash", "read"],
|
||||
});
|
||||
expect(result.session).toBe(mockSession);
|
||||
expect(result.sessionFile).toBe("/path/to/session.json");
|
||||
});
|
||||
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "backlog" });
|
||||
mockCheckoutIssue.mockRejectedValue(new MockConflictError("conflict"));
|
||||
mockInvokeHeartbeat.mockResolvedValue({ ok: true, skipped: true });
|
||||
mockGetIssue.mockResolvedValue({ id: "ISS-1", status: "done" });
|
||||
mockGetIssueComments.mockResolvedValue([{ body: "done" }]);
|
||||
|
||||
it("should pass through model provider options", async () => {
|
||||
mockCreateFnAgent.mockResolvedValue({ session: {} });
|
||||
const promise = adapter.promptWithFallback(session, "Prompt");
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await promise;
|
||||
|
||||
await adapter.createSession({
|
||||
cwd: "/project",
|
||||
systemPrompt: "Test",
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
fallbackProvider: "openai",
|
||||
fallbackModelId: "gpt-4o",
|
||||
});
|
||||
|
||||
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
fallbackProvider: "openai",
|
||||
fallbackModelId: "gpt-4o",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass through custom tools", async () => {
|
||||
mockCreateFnAgent.mockResolvedValue({ session: {} });
|
||||
const customTools = [{ name: "custom_tool", execute: vi.fn() }];
|
||||
|
||||
await adapter.createSession({
|
||||
cwd: "/project",
|
||||
systemPrompt: "Test",
|
||||
customTools,
|
||||
});
|
||||
|
||||
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
customTools,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass through skill selection context", async () => {
|
||||
mockCreateFnAgent.mockResolvedValue({ session: {} });
|
||||
const skillSelection = {
|
||||
projectRootDir: "/project",
|
||||
requestedSkillNames: ["bash"],
|
||||
sessionPurpose: "executor" as const,
|
||||
};
|
||||
|
||||
await adapter.createSession({
|
||||
cwd: "/project",
|
||||
systemPrompt: "Test",
|
||||
skillSelection,
|
||||
});
|
||||
|
||||
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skillSelection,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass through event handlers", async () => {
|
||||
mockCreateFnAgent.mockResolvedValue({ session: {} });
|
||||
const onText = vi.fn();
|
||||
const onThinking = vi.fn();
|
||||
const onToolStart = vi.fn();
|
||||
const onToolEnd = vi.fn();
|
||||
|
||||
await adapter.createSession({
|
||||
cwd: "/project",
|
||||
systemPrompt: "Test",
|
||||
onText,
|
||||
onThinking,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
});
|
||||
|
||||
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onText,
|
||||
onThinking,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass through thinking level and session manager", async () => {
|
||||
mockCreateFnAgent.mockResolvedValue({ session: {} });
|
||||
const sessionManager = { maxHistory: 100 };
|
||||
|
||||
await adapter.createSession({
|
||||
cwd: "/project",
|
||||
systemPrompt: "Test",
|
||||
defaultThinkingLevel: "medium",
|
||||
sessionManager,
|
||||
});
|
||||
|
||||
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
defaultThinkingLevel: "medium",
|
||||
sessionManager,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("checkout conflict"));
|
||||
expect(mockInvokeHeartbeat).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("promptWithFallback", () => {
|
||||
it("should delegate to promptWithFallback from pi module with options", async () => {
|
||||
const mockSession = { id: "test-session" };
|
||||
mockPromptWithFallback.mockResolvedValue(undefined);
|
||||
it("handles heartbeat skipped responses and continues polling", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
await adapter.promptWithFallback(mockSession as any, "Hello", { images: [] });
|
||||
|
||||
expect(mockPromptWithFallback).toHaveBeenCalledTimes(1);
|
||||
expect(mockPromptWithFallback).toHaveBeenCalledWith(mockSession, "Hello", { images: [] });
|
||||
const adapter = new PaperclipRuntimeAdapter({
|
||||
apiUrl: "http://paperclip.local",
|
||||
apiKey: "token",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
});
|
||||
|
||||
it("should delegate to promptWithFallback without options", async () => {
|
||||
mockPromptWithFallback.mockResolvedValue(undefined);
|
||||
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system" });
|
||||
|
||||
await adapter.promptWithFallback({} as any, "Hello");
|
||||
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "backlog" });
|
||||
mockCheckoutIssue.mockResolvedValue({ id: "ISS-1", status: "in_progress" });
|
||||
mockInvokeHeartbeat.mockResolvedValue({ ok: true, skipped: true });
|
||||
mockGetIssue.mockResolvedValue({ id: "ISS-1", status: "done" });
|
||||
mockGetIssueComments.mockResolvedValue([{ body: "done" }]);
|
||||
|
||||
expect(mockPromptWithFallback).toHaveBeenCalledTimes(1);
|
||||
expect(mockPromptWithFallback).toHaveBeenCalledWith({}, "Hello", undefined);
|
||||
});
|
||||
const promise = adapter.promptWithFallback(session, "Prompt");
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await promise;
|
||||
|
||||
it("should forward session object directly to pi", async () => {
|
||||
const mockSession = {
|
||||
id: "session-123",
|
||||
model: { provider: "anthropic", id: "claude-sonnet-4-5" },
|
||||
};
|
||||
mockPromptWithFallback.mockResolvedValue(undefined);
|
||||
|
||||
await adapter.promptWithFallback(mockSession as any, "Tell me a joke");
|
||||
|
||||
expect(mockPromptWithFallback).toHaveBeenCalledWith(mockSession, "Tell me a joke", undefined);
|
||||
expect(mockSession.id).toBe("session-123");
|
||||
});
|
||||
expect(mockInvokeHeartbeat).toHaveBeenCalled();
|
||||
expect(mockGetIssue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("describeModel", () => {
|
||||
it("should return model description from pi describeModel", () => {
|
||||
const mockSession = { model: { provider: "anthropic", id: "claude-sonnet-4-5" } };
|
||||
mockDescribeModel.mockReturnValue("anthropic/claude-sonnet-4-5");
|
||||
it("returns output on timeout with whatever comments are available", async () => {
|
||||
vi.useFakeTimers();
|
||||
const onText = vi.fn();
|
||||
|
||||
const result = adapter.describeModel(mockSession as any);
|
||||
|
||||
expect(mockDescribeModel).toHaveBeenCalledTimes(1);
|
||||
expect(mockDescribeModel).toHaveBeenCalledWith(mockSession);
|
||||
expect(result).toBe("anthropic/claude-sonnet-4-5");
|
||||
const adapter = new PaperclipRuntimeAdapter({
|
||||
apiUrl: "http://paperclip.local",
|
||||
apiKey: "token",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
});
|
||||
|
||||
it("should return unknown model when session has no model", () => {
|
||||
mockDescribeModel.mockReturnValue("unknown model");
|
||||
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system", onText });
|
||||
|
||||
const result = adapter.describeModel({} as any);
|
||||
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "backlog" });
|
||||
mockCheckoutIssue.mockResolvedValue({ id: "ISS-1", status: "in_progress" });
|
||||
mockInvokeHeartbeat.mockResolvedValue({ ok: true, run: { id: "RUN-1", status: "queued" } });
|
||||
mockGetIssue.mockResolvedValue({ id: "ISS-1", status: "in_progress" });
|
||||
mockGetIssueComments.mockResolvedValue([{ body: "partial result" }]);
|
||||
|
||||
expect(mockDescribeModel).toHaveBeenCalledWith({});
|
||||
expect(result).toBe("unknown model");
|
||||
});
|
||||
const promise = adapter.promptWithFallback(session, "Prompt");
|
||||
await vi.advanceTimersByTimeAsync(130_000);
|
||||
await promise;
|
||||
|
||||
it("should forward the session object directly to pi describeModel", () => {
|
||||
const mockSession = { id: "test-session", model: { provider: "openai", id: "gpt-4o" } };
|
||||
mockDescribeModel.mockReturnValue("openai/gpt-4o");
|
||||
|
||||
adapter.describeModel(mockSession as any);
|
||||
|
||||
expect(mockDescribeModel).toHaveBeenCalledWith(mockSession);
|
||||
});
|
||||
expect(onText).toHaveBeenCalledWith("partial result");
|
||||
});
|
||||
|
||||
describe("dispose", () => {
|
||||
it("should call session.dispose() when available", async () => {
|
||||
const disposeMock = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSession = { dispose: disposeMock } as any;
|
||||
it("uses exponential backoff intervals while polling", async () => {
|
||||
vi.useFakeTimers();
|
||||
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await adapter.dispose(mockSession);
|
||||
|
||||
expect(disposeMock).toHaveBeenCalledTimes(1);
|
||||
const adapter = new PaperclipRuntimeAdapter({
|
||||
apiUrl: "http://paperclip.local",
|
||||
apiKey: "token",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
});
|
||||
|
||||
it("should be a no-op when session has no dispose method", async () => {
|
||||
const mockSession = { id: "test" } as any;
|
||||
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system" });
|
||||
|
||||
// Should not throw
|
||||
await expect(adapter.dispose(mockSession)).resolves.toBeUndefined();
|
||||
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "backlog" });
|
||||
mockCheckoutIssue.mockResolvedValue({ id: "ISS-1", status: "in_progress" });
|
||||
mockInvokeHeartbeat.mockResolvedValue({ ok: true, run: { id: "RUN-1", status: "queued" } });
|
||||
mockGetIssue
|
||||
.mockResolvedValueOnce({ id: "ISS-1", status: "in_progress" })
|
||||
.mockResolvedValueOnce({ id: "ISS-1", status: "in_progress" })
|
||||
.mockResolvedValueOnce({ id: "ISS-1", status: "in_progress" })
|
||||
.mockResolvedValueOnce({ id: "ISS-1", status: "in_progress" })
|
||||
.mockResolvedValueOnce({ id: "ISS-1", status: "done" });
|
||||
mockGetIssueComments.mockResolvedValue([{ body: "done" }]);
|
||||
|
||||
const promise = adapter.promptWithFallback(session, "Prompt");
|
||||
await vi.advanceTimersByTimeAsync(2_000 + 4_000 + 8_000 + 10_000 + 10_000);
|
||||
await promise;
|
||||
|
||||
const timeoutDurations = timeoutSpy.mock.calls.map((call) => call[1]).filter((value) => typeof value === "number");
|
||||
expect(timeoutDurations).toEqual(expect.arrayContaining([2_000, 4_000, 8_000, 10_000]));
|
||||
});
|
||||
|
||||
it("describeModel returns paperclip/<agentId>", async () => {
|
||||
const adapter = new PaperclipRuntimeAdapter({
|
||||
apiUrl: "http://paperclip.local",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
});
|
||||
|
||||
it("should handle dispose that throws", async () => {
|
||||
const disposeMock = vi.fn().mockRejectedValue(new Error("Dispose failed"));
|
||||
const mockSession = { dispose: disposeMock } as any;
|
||||
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system" });
|
||||
expect(adapter.describeModel(session)).toBe("paperclip/AG-1");
|
||||
});
|
||||
|
||||
await expect(adapter.dispose(mockSession)).rejects.toThrow("Dispose failed");
|
||||
it("dispose is a no-op", async () => {
|
||||
const adapter = new PaperclipRuntimeAdapter({
|
||||
apiUrl: "http://paperclip.local",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
});
|
||||
|
||||
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system" });
|
||||
expect(typeof session.dispose).toBe("function");
|
||||
expect(() => session.dispose?.()).not.toThrow();
|
||||
await expect(adapter.dispose(session)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Engine import guard for plugin tests.
|
||||
*
|
||||
* Runtime plugin tests mock the seam module (../pi-module.js) to avoid loading
|
||||
* the real @fusion/engine, which pulls in @fusion/core and triggers homedir()-
|
||||
* based path resolution. This setup file installs a global vi.mock on
|
||||
* @fusion/engine that throws if the real module is ever loaded without an
|
||||
* explicit override.
|
||||
*
|
||||
* If you see the guard error in a test:
|
||||
* 1. Add `vi.mock("../pi-module.js", ...)` at the top of the failing test
|
||||
* 2. If you genuinely need to import @fusion/engine, you must also add HOME
|
||||
* isolation setup (see setup-test-isolation.ts in packages/core) to this
|
||||
* plugin's vitest config setupFiles before the guard.
|
||||
*/
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("@fusion/engine", () => {
|
||||
throw new Error(
|
||||
"Guard: @fusion/engine was imported without an explicit mock. " +
|
||||
"Runtime plugin tests must mock '../pi-module.js' to prevent loading " +
|
||||
"the real engine. If you need the real engine, add HOME isolation " +
|
||||
"setup (setup-test-isolation.ts) to vitest config setupFiles BEFORE " +
|
||||
"this guard, and remove or override this mock.",
|
||||
);
|
||||
});
|
||||
@@ -1,55 +1,31 @@
|
||||
/**
|
||||
* Paperclip Runtime Plugin
|
||||
*
|
||||
* Provides the Paperclip runtime for Fusion AI agents, backed by the user's
|
||||
* configured pi provider and model.
|
||||
*
|
||||
* ## Runtime Capabilities
|
||||
*
|
||||
* This plugin implements the AgentRuntime interface, providing:
|
||||
* - Session creation via createFnAgent
|
||||
* - Prompt with automatic retry and compaction
|
||||
* - Model description extraction
|
||||
* - Session disposal support
|
||||
*/
|
||||
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import { probePaperclipInstance, resolvePaperclipConfig } from "./pi-module.js";
|
||||
import { PaperclipRuntimeAdapter } from "./runtime-adapter.js";
|
||||
import type {
|
||||
FusionPlugin,
|
||||
PluginRuntimeRegistration,
|
||||
} from "@fusion/plugin-sdk";
|
||||
RuntimeLogger,
|
||||
} from "./types.js";
|
||||
|
||||
// ── Runtime Registration ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Paperclip runtime factory.
|
||||
*
|
||||
* Creates a new PaperclipRuntimeAdapter instance when the runtime is resolved.
|
||||
*
|
||||
* @returns Promise resolving to a PaperclipRuntimeAdapter instance
|
||||
*/
|
||||
async function paperclipRuntimeFactory(): Promise<PaperclipRuntimeAdapter> {
|
||||
return new PaperclipRuntimeAdapter();
|
||||
function getSettingsConfig(settings: unknown) {
|
||||
return resolvePaperclipConfig((settings ?? {}) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
async function paperclipRuntimeFactory(ctx: { settings?: unknown; logger?: RuntimeLogger }): Promise<unknown> {
|
||||
const config = getSettingsConfig(ctx.settings);
|
||||
return new PaperclipRuntimeAdapter(config, ctx.logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paperclip runtime registration for Fusion's plugin runtime system.
|
||||
* Uses the PluginRuntimeRegistration contract from FN-2256.
|
||||
*/
|
||||
const paperclipRuntime: PluginRuntimeRegistration = {
|
||||
metadata: {
|
||||
runtimeId: "paperclip",
|
||||
name: "Paperclip Runtime",
|
||||
description:
|
||||
"Paperclip-backed AI session using the user's configured pi provider and model",
|
||||
description: "Paperclip-backed AI session via Paperclip REST API",
|
||||
version: "1.0.0",
|
||||
},
|
||||
factory: paperclipRuntimeFactory,
|
||||
};
|
||||
|
||||
// ── Plugin Definition ─────────────────────────────────────────────────────────
|
||||
|
||||
const plugin: FusionPlugin = definePlugin({
|
||||
manifest: {
|
||||
id: "fusion-plugin-paperclip-runtime",
|
||||
@@ -62,16 +38,26 @@ const plugin: FusionPlugin = definePlugin({
|
||||
runtime: {
|
||||
runtimeId: "paperclip",
|
||||
name: "Paperclip Runtime",
|
||||
description:
|
||||
"Paperclip-backed AI session using the user's configured pi provider and model",
|
||||
description: "Paperclip-backed AI session via Paperclip REST API",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
state: "installed",
|
||||
runtime: paperclipRuntime,
|
||||
hooks: {
|
||||
onLoad: (ctx) => {
|
||||
ctx.logger.info("Paperclip Runtime Plugin loaded");
|
||||
onLoad: async (ctx) => {
|
||||
const config = getSettingsConfig(ctx.settings);
|
||||
ctx.logger.info(`Paperclip Runtime Plugin loaded (apiUrl=${config.apiUrl})`);
|
||||
|
||||
const probe = await probePaperclipInstance(config.apiUrl, config.apiKey);
|
||||
if (probe.ok) {
|
||||
ctx.logger.info(
|
||||
`Paperclip probe succeeded (deploymentMode=${probe.deploymentMode ?? "unknown"})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.logger.warn(`Paperclip probe failed: ${probe.error}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,63 +1,374 @@
|
||||
/**
|
||||
* Pi Module Seam
|
||||
* Paperclip REST API client.
|
||||
*
|
||||
* Provides a mockable import path for pi functions used by the PaperclipRuntimeAdapter.
|
||||
* Tests intercept this module via `vi.mock("../pi-module.js", ...)`. The runtime
|
||||
* implementations come from @fusion/engine; the local types provide a loose
|
||||
* surface so the adapter doesn't have to depend on @fusion/engine's full types.
|
||||
* This module intentionally does not depend on @fusion/engine.
|
||||
*/
|
||||
import {
|
||||
createFnAgent as _createFnAgent,
|
||||
promptWithFallback as _promptWithFallback,
|
||||
describeModel as _describeModel,
|
||||
} from "@fusion/engine";
|
||||
|
||||
// ── Type Declarations ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Minimal AgentSession type for the adapter */
|
||||
export interface PiAgentSession {
|
||||
dispose?: () => Promise<void> | void;
|
||||
export interface PaperclipConfig {
|
||||
apiUrl: string;
|
||||
apiKey: string | undefined;
|
||||
agentId: string | undefined;
|
||||
companyId: string | undefined;
|
||||
}
|
||||
|
||||
/** Result from createFnAgent */
|
||||
export interface PiAgentResult {
|
||||
session: PiAgentSession;
|
||||
sessionFile?: string;
|
||||
export interface ProbeResult {
|
||||
ok: true;
|
||||
deploymentMode: string | undefined;
|
||||
}
|
||||
|
||||
/** Options for createFnAgent */
|
||||
export interface PiAgentOptions {
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
tools?: unknown;
|
||||
customTools?: unknown;
|
||||
onText?: (text: string) => void;
|
||||
onThinking?: (text: string) => void;
|
||||
onToolStart?: (toolName: string, args?: unknown) => void;
|
||||
onToolEnd?: (toolName: string, result?: unknown) => void;
|
||||
defaultProvider?: string;
|
||||
defaultModelId?: string;
|
||||
fallbackProvider?: string;
|
||||
fallbackModelId?: string;
|
||||
defaultThinkingLevel?: string;
|
||||
sessionManager?: unknown;
|
||||
skillSelection?: unknown;
|
||||
skills?: string[];
|
||||
export interface ProbeFailure {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
// ── Module Exports ────────────────────────────────────────────────────────────
|
||||
export type ProbePaperclipResult = ProbeResult | ProbeFailure;
|
||||
|
||||
/** Create a new agent session using the pi backend */
|
||||
export const createFnAgent = _createFnAgent as unknown as (
|
||||
options: PiAgentOptions,
|
||||
) => Promise<PiAgentResult>;
|
||||
export interface AgentIdentity {
|
||||
id: string;
|
||||
name?: string;
|
||||
companyId: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** Prompt the session with automatic retry and fallback */
|
||||
export const promptWithFallback = _promptWithFallback as unknown as (
|
||||
session: PiAgentSession,
|
||||
prompt: string,
|
||||
options?: unknown,
|
||||
) => Promise<void>;
|
||||
export type AgentIdentityResult =
|
||||
| { ok: true; agent: AgentIdentity }
|
||||
| { ok: false; reason: "unauthenticated" | "not_agent" };
|
||||
|
||||
export interface ListIssuesFilters {
|
||||
status?: string | string[];
|
||||
assigneeAgentId?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export class ConflictError extends Error {
|
||||
readonly status = 409;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
interface ParsedBody {
|
||||
value: unknown;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
function normalizeApiUrl(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function getSettingString(settings: Record<string, unknown> | undefined, key: string): string | undefined {
|
||||
const value = settings?.[key];
|
||||
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function buildApiUrl(apiUrl: string, path: string): string {
|
||||
const base = normalizeApiUrl(apiUrl);
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${base}/api${normalizedPath}`;
|
||||
}
|
||||
|
||||
function toErrorMessage(status: number, statusText: string, body: unknown, rawBody: string): string {
|
||||
if (body && typeof body === "object") {
|
||||
if (typeof (body as { error?: unknown }).error === "string") {
|
||||
return (body as { error: string }).error;
|
||||
}
|
||||
if (typeof (body as { message?: unknown }).message === "string") {
|
||||
return (body as { message: string }).message;
|
||||
}
|
||||
}
|
||||
|
||||
if (rawBody.trim() !== "") {
|
||||
return rawBody.trim();
|
||||
}
|
||||
|
||||
return `${status} ${statusText}`.trim();
|
||||
}
|
||||
|
||||
async function parseBody(response: Response): Promise<ParsedBody> {
|
||||
const raw = await response.text();
|
||||
|
||||
if (raw.trim() === "") {
|
||||
return { value: undefined, raw };
|
||||
}
|
||||
|
||||
try {
|
||||
return { value: JSON.parse(raw), raw };
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Paperclip API ${response.status} ${response.statusText}: invalid JSON response body`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
apiUrl: string,
|
||||
path: string,
|
||||
options?: {
|
||||
method?: string;
|
||||
apiKey?: string;
|
||||
body?: unknown;
|
||||
runId?: string;
|
||||
query?: URLSearchParams;
|
||||
},
|
||||
): Promise<T> {
|
||||
const method = options?.method ?? "GET";
|
||||
const url = `${buildApiUrl(apiUrl, path)}${options?.query ? `?${options.query.toString()}` : ""}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
if (options?.apiKey) {
|
||||
headers.Authorization = `Bearer ${options.apiKey}`;
|
||||
}
|
||||
|
||||
if (options?.runId) {
|
||||
headers["X-Paperclip-Run-Id"] = options.runId;
|
||||
}
|
||||
|
||||
let body: string | undefined;
|
||||
if (options && "body" in options && options.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { method, headers, body });
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Paperclip API network error (${method} ${url}): ${reason}`);
|
||||
}
|
||||
|
||||
const parsed = await parseBody(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = toErrorMessage(response.status, response.statusText, parsed.value, parsed.raw);
|
||||
const errorMessage = `Paperclip API ${response.status} (${method} ${path}): ${message}`;
|
||||
if (response.status === 409) {
|
||||
throw new ConflictError(errorMessage);
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return parsed.value as T;
|
||||
}
|
||||
|
||||
export function resolvePaperclipConfig(settings?: Record<string, unknown>): PaperclipConfig {
|
||||
const apiUrl =
|
||||
getSettingString(settings, "apiUrl") ??
|
||||
process.env.PAPERCLIP_API_URL?.trim() ??
|
||||
"http://localhost:3100";
|
||||
|
||||
const envApiKey = process.env.PAPERCLIP_API_KEY?.trim() || undefined;
|
||||
const envAgentId = process.env.PAPERCLIP_AGENT_ID?.trim() || undefined;
|
||||
const envCompanyId = process.env.PAPERCLIP_COMPANY_ID?.trim() || undefined;
|
||||
|
||||
return {
|
||||
apiUrl: normalizeApiUrl(apiUrl),
|
||||
apiKey: getSettingString(settings, "apiKey") ?? envApiKey,
|
||||
agentId: getSettingString(settings, "agentId") ?? envAgentId,
|
||||
companyId: getSettingString(settings, "companyId") ?? envCompanyId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function probePaperclipInstance(
|
||||
apiUrl: string,
|
||||
apiKey?: string,
|
||||
): Promise<ProbePaperclipResult> {
|
||||
try {
|
||||
const result = await request<{ status?: string; deploymentMode?: string }>(apiUrl, "/health", {
|
||||
apiKey,
|
||||
});
|
||||
|
||||
if (result.status !== "ok") {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Paperclip health check did not return ok status${
|
||||
result.status ? ` (status=${result.status})` : ""
|
||||
}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, deploymentMode: result.deploymentMode };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAgentIdentity(apiUrl: string, apiKey?: string): Promise<AgentIdentityResult> {
|
||||
const url = buildApiUrl(apiUrl, "/agents/me");
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { method: "GET", headers });
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Paperclip API network error (GET ${url}): ${reason}`);
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
return { ok: false, reason: "unauthenticated" };
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
return { ok: false, reason: "not_agent" };
|
||||
}
|
||||
|
||||
const parsed = await parseBody(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = toErrorMessage(response.status, response.statusText, parsed.value, parsed.raw);
|
||||
throw new Error(`Paperclip API ${response.status} (GET /agents/me): ${message}`);
|
||||
}
|
||||
|
||||
const agent = parsed.value as Partial<AgentIdentity>;
|
||||
if (!agent.id || !agent.companyId) {
|
||||
throw new Error("Paperclip API returned invalid agent identity response");
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
agent: {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
companyId: agent.companyId,
|
||||
role: agent.role,
|
||||
status: agent.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function listIssues(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
companyId: string,
|
||||
filters?: ListIssuesFilters,
|
||||
): Promise<unknown[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (filters?.status) {
|
||||
query.set("status", Array.isArray(filters.status) ? filters.status.join(",") : filters.status);
|
||||
}
|
||||
if (filters?.assigneeAgentId) {
|
||||
query.set("assigneeAgentId", filters.assigneeAgentId);
|
||||
}
|
||||
if (filters?.projectId) {
|
||||
query.set("projectId", filters.projectId);
|
||||
}
|
||||
|
||||
return request<unknown[]>(apiUrl, `/companies/${companyId}/issues`, {
|
||||
apiKey,
|
||||
query: query.size > 0 ? query : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getIssue(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
issueId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return request<Record<string, unknown>>(apiUrl, `/issues/${issueId}`, { apiKey });
|
||||
}
|
||||
|
||||
export async function createIssue(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
companyId: string,
|
||||
issue: {
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
assigneeAgentId: string;
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
return request<Record<string, unknown>>(apiUrl, `/companies/${companyId}/issues`, {
|
||||
method: "POST",
|
||||
apiKey,
|
||||
body: issue,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateIssue(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
issueId: string,
|
||||
patch: Record<string, unknown>,
|
||||
runId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return request<Record<string, unknown>>(apiUrl, `/issues/${issueId}`, {
|
||||
method: "PATCH",
|
||||
apiKey,
|
||||
body: patch,
|
||||
runId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkoutIssue(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
issueId: string,
|
||||
agentId: string,
|
||||
runId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return request<Record<string, unknown>>(apiUrl, `/issues/${issueId}/checkout`, {
|
||||
method: "POST",
|
||||
apiKey,
|
||||
body: {
|
||||
agentId,
|
||||
expectedStatuses: ["todo", "backlog"],
|
||||
},
|
||||
runId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getIssueComments(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
issueId: string,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
return request<Array<Record<string, unknown>>>(apiUrl, `/issues/${issueId}/comments`, { apiKey });
|
||||
}
|
||||
|
||||
export async function addComment(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
issueId: string,
|
||||
body: string,
|
||||
runId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return request<Record<string, unknown>>(apiUrl, `/issues/${issueId}/comments`, {
|
||||
method: "POST",
|
||||
apiKey,
|
||||
body: { body },
|
||||
runId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function invokeHeartbeat(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
agentId: string,
|
||||
): Promise<{ ok: true; skipped: true } | { ok: true; run: Record<string, unknown> }> {
|
||||
const result = await request<Record<string, unknown>>(apiUrl, `/agents/${agentId}/heartbeat/invoke`, {
|
||||
method: "POST",
|
||||
apiKey,
|
||||
});
|
||||
|
||||
if (result.status === "skipped") {
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
|
||||
return { ok: true, run: result };
|
||||
}
|
||||
|
||||
/** Get a human-readable model description from a session */
|
||||
export const describeModel = _describeModel as unknown as (session: PiAgentSession) => string;
|
||||
|
||||
@@ -1,142 +1,195 @@
|
||||
/**
|
||||
* Paperclip Runtime Adapter
|
||||
*
|
||||
* Implements the AgentRuntime interface for Fusion's plugin system, providing
|
||||
* AI agent sessions backed by the user's configured pi provider and model.
|
||||
*
|
||||
* ## Responsibilities
|
||||
*
|
||||
* - Wraps `createFnAgent` from the engine's pi module
|
||||
* - Delegates `promptWithFallback` to the pi implementation
|
||||
* - Provides model description via pi's `describeModel`
|
||||
* - Handles session disposal when explicitly requested
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* ```typescript
|
||||
* import { PaperclipRuntimeAdapter } from "./runtime-adapter.js";
|
||||
*
|
||||
* const adapter = new PaperclipRuntimeAdapter();
|
||||
* const { session } = await adapter.createSession({
|
||||
* cwd: process.cwd(),
|
||||
* systemPrompt: "You are a helpful assistant",
|
||||
* skills: ["bash", "read"],
|
||||
* });
|
||||
*
|
||||
* await adapter.promptWithFallback(session, "Hello, world!");
|
||||
* console.log(adapter.describeModel(session)); // e.g., "anthropic/claude-sonnet-4-5"
|
||||
*
|
||||
* await adapter.dispose(session);
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
ConflictError,
|
||||
createIssue,
|
||||
checkoutIssue,
|
||||
getIssue,
|
||||
getIssueComments,
|
||||
invokeHeartbeat,
|
||||
resolvePaperclipConfig,
|
||||
} from "./pi-module.js";
|
||||
import type {
|
||||
AgentRuntime,
|
||||
AgentRuntimeOptions,
|
||||
AgentSession,
|
||||
AgentSessionResult,
|
||||
PaperclipRuntimeConfig,
|
||||
PaperclipSession,
|
||||
RuntimeLogger,
|
||||
} from "./types.js";
|
||||
|
||||
// ── Pi Module Seam ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The pi functions are imported from a local seam module (pi-module.ts) which
|
||||
// re-exports them from the engine. This approach provides a mockable import path
|
||||
// for Vitest tests without relying on CommonJS require() which bypasses mocks.
|
||||
//
|
||||
// The seam module is at: ./pi-module.js
|
||||
//
|
||||
import { createFnAgent, promptWithFallback, describeModel } from "./pi-module.js";
|
||||
const POLL_INITIAL_INTERVAL_MS = 2_000;
|
||||
const POLL_MAX_INTERVAL_MS = 10_000;
|
||||
const POLL_TIMEOUT_MS = 120_000;
|
||||
const TERMINAL_STATUSES = new Set(["done", "cancelled", "in_review"]);
|
||||
|
||||
/** Cached describeModel reference for synchronous describeModel() calls */
|
||||
const getModelDescription = describeModel;
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function deriveIssueTitle(prompt: string): string {
|
||||
const firstLine = prompt.split("\n").find((line) => line.trim() !== "") ?? "Fusion runtime prompt";
|
||||
return firstLine.slice(0, 200);
|
||||
}
|
||||
|
||||
function buildIssueDescription(session: PaperclipSession, prompt: string): string {
|
||||
return [
|
||||
`System Prompt:\n${session.systemPrompt}`,
|
||||
`Working Directory: ${session.cwd}`,
|
||||
`Prompt:\n${prompt}`,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
function collectCommentText(comments: Array<Record<string, unknown>>): { text: string; thinking: string } {
|
||||
const textParts: string[] = [];
|
||||
const thinkingParts: string[] = [];
|
||||
|
||||
for (const comment of comments) {
|
||||
const body = asString(comment.body)?.trim();
|
||||
if (!body) {
|
||||
continue;
|
||||
}
|
||||
|
||||
textParts.push(body);
|
||||
|
||||
const kind = asString(comment.kind) ?? asString(comment.type);
|
||||
if (kind === "thinking" || kind === "reasoning") {
|
||||
thinkingParts.push(body);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (body.toLowerCase().startsWith("thinking:")) {
|
||||
thinkingParts.push(body.replace(/^thinking:\s*/i, ""));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: textParts.join("\n\n"),
|
||||
thinking: thinkingParts.join("\n\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function pickIssueId(issue: Record<string, unknown>): string {
|
||||
const issueId = asString(issue.id);
|
||||
if (!issueId) {
|
||||
throw new Error("Paperclip createIssue response missing issue id");
|
||||
}
|
||||
return issueId;
|
||||
}
|
||||
|
||||
function pickIssueStatus(issue: Record<string, unknown>): string {
|
||||
return asString(issue.status) ?? "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Paperclip runtime adapter implementing the Fusion AgentRuntime interface.
|
||||
*
|
||||
* This adapter wraps the existing pi agent creation and session management,
|
||||
* making it available through Fusion's plugin runtime system.
|
||||
*
|
||||
* ## Disposal Semantics
|
||||
*
|
||||
* The `dispose()` method is provided as an extension to the AgentRuntime interface.
|
||||
* Engine session consumers may call `dispose()` to clean up sessions when done.
|
||||
* If the session doesn't support disposal, this is a no-op.
|
||||
*/
|
||||
export class PaperclipRuntimeAdapter implements AgentRuntime {
|
||||
/** Unique runtime identifier */
|
||||
readonly id = "paperclip";
|
||||
|
||||
/** Human-readable runtime name */
|
||||
readonly name = "Paperclip Runtime";
|
||||
|
||||
/**
|
||||
* Create a new agent session using the pi backend.
|
||||
*
|
||||
* @param options - Session creation options including cwd, systemPrompt, model selection, and skills
|
||||
* @returns Promise resolving to the session result with session and optional sessionFile
|
||||
*/
|
||||
private readonly config: PaperclipRuntimeConfig;
|
||||
private readonly logger: RuntimeLogger;
|
||||
|
||||
constructor(config?: Partial<PaperclipRuntimeConfig>, logger?: RuntimeLogger) {
|
||||
this.config = {
|
||||
...resolvePaperclipConfig(config as Record<string, unknown> | undefined),
|
||||
...config,
|
||||
};
|
||||
this.logger = logger ?? console;
|
||||
}
|
||||
|
||||
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
||||
return createFnAgent({
|
||||
cwd: options.cwd,
|
||||
if (!this.config.agentId || !this.config.companyId) {
|
||||
const missing = [!this.config.agentId ? "agentId" : null, !this.config.companyId ? "companyId" : null]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
throw new Error(
|
||||
`Paperclip runtime is missing required config: ${missing}. Configure plugin settings (apiUrl, apiKey, agentId, companyId) or PAPERCLIP_* environment variables.`,
|
||||
);
|
||||
}
|
||||
|
||||
const session: PaperclipSession = {
|
||||
apiUrl: this.config.apiUrl,
|
||||
apiKey: this.config.apiKey,
|
||||
agentId: this.config.agentId,
|
||||
companyId: this.config.companyId,
|
||||
sessionId: randomUUID(),
|
||||
systemPrompt: options.systemPrompt,
|
||||
tools: options.tools,
|
||||
customTools: options.customTools,
|
||||
cwd: options.cwd,
|
||||
onText: options.onText,
|
||||
onThinking: options.onThinking,
|
||||
onToolStart: options.onToolStart,
|
||||
onToolEnd: options.onToolEnd,
|
||||
defaultProvider: options.defaultProvider,
|
||||
defaultModelId: options.defaultModelId,
|
||||
fallbackProvider: options.fallbackProvider,
|
||||
fallbackModelId: options.fallbackModelId,
|
||||
defaultThinkingLevel: options.defaultThinkingLevel,
|
||||
sessionManager: options.sessionManager,
|
||||
skillSelection: options.skillSelection,
|
||||
skills: options.skills,
|
||||
dispose: () => undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
session,
|
||||
sessionFile: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async promptWithFallback(
|
||||
session: PaperclipSession,
|
||||
prompt: string,
|
||||
_options?: unknown,
|
||||
): Promise<void> {
|
||||
session.onToolStart?.("paperclip.issue", { sessionId: session.sessionId });
|
||||
|
||||
const createdIssue = await createIssue(session.apiUrl, session.apiKey, session.companyId, {
|
||||
title: deriveIssueTitle(prompt),
|
||||
description: buildIssueDescription(session, prompt),
|
||||
status: "backlog",
|
||||
assigneeAgentId: session.agentId,
|
||||
});
|
||||
|
||||
const issueId = pickIssueId(createdIssue);
|
||||
|
||||
try {
|
||||
await checkoutIssue(session.apiUrl, session.apiKey, issueId, session.agentId, session.sessionId);
|
||||
} catch (error) {
|
||||
if (error instanceof ConflictError) {
|
||||
this.logger.warn(`Paperclip checkout conflict for issue ${issueId}; continuing: ${error.message}`);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await invokeHeartbeat(session.apiUrl, session.apiKey, session.agentId);
|
||||
|
||||
let issue = createdIssue;
|
||||
let status = pickIssueStatus(issue);
|
||||
let intervalMs = POLL_INITIAL_INTERVAL_MS;
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (!TERMINAL_STATUSES.has(status) && Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
await sleep(intervalMs);
|
||||
issue = await getIssue(session.apiUrl, session.apiKey, issueId);
|
||||
status = pickIssueStatus(issue);
|
||||
intervalMs = Math.min(intervalMs * 2, POLL_MAX_INTERVAL_MS);
|
||||
}
|
||||
|
||||
const comments = await getIssueComments(session.apiUrl, session.apiKey, issueId);
|
||||
const { text, thinking } = collectCommentText(comments);
|
||||
if (text) {
|
||||
session.onText?.(text);
|
||||
}
|
||||
if (thinking) {
|
||||
session.onThinking?.(thinking);
|
||||
}
|
||||
|
||||
session.onToolEnd?.("paperclip.issue", false, {
|
||||
issueId,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the session with user input, with automatic retry and compaction.
|
||||
*
|
||||
* Delegates to the pi backend's promptWithFallback implementation which handles:
|
||||
* - Automatic retry on transient errors
|
||||
* - Context compaction on context limit errors
|
||||
* - Model fallback on retryable model selection errors
|
||||
*
|
||||
* @param session - The agent session to prompt
|
||||
* @param prompt - The prompt text
|
||||
* @param options - Optional prompt options (e.g., images for vision)
|
||||
*/
|
||||
async promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
|
||||
return promptWithFallback(session, prompt, options);
|
||||
describeModel(session: PaperclipSession): string {
|
||||
return `paperclip/${session.agentId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable model description from a session.
|
||||
*
|
||||
* Returns the model in the format `"<provider>/<modelId>"`
|
||||
* or `"unknown model"` when the session has no model set.
|
||||
*
|
||||
* @param session - The agent session to describe
|
||||
* @returns Model description string
|
||||
*/
|
||||
describeModel(session: AgentSession): string {
|
||||
return getModelDescription(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of an agent session.
|
||||
*
|
||||
* Calls `session.dispose()` if the session supports disposal,
|
||||
* otherwise this is a no-op. This extension method provides
|
||||
* explicit cleanup semantics expected by engine session consumers.
|
||||
*
|
||||
* @param session - The agent session to dispose
|
||||
*/
|
||||
async dispose(session: AgentSession): Promise<void> {
|
||||
if (typeof (session as { dispose?: () => Promise<void> }).dispose === "function") {
|
||||
await (session as { dispose: () => Promise<void> }).dispose();
|
||||
}
|
||||
async dispose(_session: PaperclipSession): Promise<void> {
|
||||
// no-op: Paperclip manages run/session lifecycle server-side
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
/**
|
||||
* Paperclip Runtime Plugin - Type Definitions
|
||||
*
|
||||
* The plugin runtime contract is defined locally in this example plugin to avoid
|
||||
* a hard compile-time dependency on internal engine package exports.
|
||||
* Paperclip Runtime Plugin - Local runtime interface types.
|
||||
*/
|
||||
|
||||
// ── Local Agent Runtime Contract ──────────────────────────────────────────────
|
||||
|
||||
/** Minimal session shape used by the runtime adapter. */
|
||||
export interface AgentSession {
|
||||
dispose?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
/** Options for creating an agent session. Mirrors createFnAgent inputs used by the adapter. */
|
||||
export interface AgentRuntimeOptions {
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
@@ -21,7 +10,7 @@ export interface AgentRuntimeOptions {
|
||||
onText?: (text: string) => void;
|
||||
onThinking?: (text: string) => void;
|
||||
onToolStart?: (toolName: string, args?: unknown) => void;
|
||||
onToolEnd?: (toolName: string, result?: unknown) => void;
|
||||
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
|
||||
defaultProvider?: string;
|
||||
defaultModelId?: string;
|
||||
fallbackProvider?: string;
|
||||
@@ -32,23 +21,47 @@ export interface AgentRuntimeOptions {
|
||||
skills?: string[];
|
||||
}
|
||||
|
||||
/** Result of creating a session. */
|
||||
export interface PaperclipSession {
|
||||
apiUrl: string;
|
||||
apiKey: string | undefined;
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
sessionId: string;
|
||||
systemPrompt: string;
|
||||
cwd: string;
|
||||
onText: ((text: string) => void) | undefined;
|
||||
onThinking: ((text: string) => void) | undefined;
|
||||
onToolStart: ((toolName: string, args?: unknown) => void) | undefined;
|
||||
onToolEnd: ((toolName: string, isError: boolean, result?: unknown) => void) | undefined;
|
||||
dispose?: () => void;
|
||||
}
|
||||
|
||||
export interface AgentSessionResult {
|
||||
session: AgentSession;
|
||||
session: PaperclipSession;
|
||||
sessionFile?: string;
|
||||
}
|
||||
|
||||
/** Agent runtime adapter interface. */
|
||||
export interface AgentRuntime {
|
||||
id: string;
|
||||
name: string;
|
||||
createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult>;
|
||||
promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void>;
|
||||
describeModel(session: AgentSession): string;
|
||||
dispose?(session: AgentSession): Promise<void>;
|
||||
promptWithFallback(session: PaperclipSession, prompt: string, options?: unknown): Promise<void>;
|
||||
describeModel(session: PaperclipSession): string;
|
||||
dispose?(session: PaperclipSession): Promise<void>;
|
||||
}
|
||||
|
||||
// ── Plugin Registration Types (from @fusion/plugin-sdk) ─────────────────────
|
||||
export interface PaperclipRuntimeConfig {
|
||||
apiUrl: string;
|
||||
apiKey?: string;
|
||||
agentId?: string;
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeLogger {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
export type {
|
||||
PluginRuntimeManifestMetadata,
|
||||
|
||||
Reference in New Issue
Block a user