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,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;
|
||||
|
||||
Reference in New Issue
Block a user