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:
Fusion
2026-04-27 10:28:51 -07:00
committed by gsxdsm
parent 6d1089bc25
commit f85d369bfc
18 changed files with 1307 additions and 904 deletions

View File

@@ -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;