feat(adapters): external adapter plugin system with dynamic UI parser

- Plugin loader: install/reload/remove/reinstall external adapters
  from npm packages or local directories
- Plugin store persisted at ~/.paperclip/adapter-plugins.json
- Self-healing UI parser resolution with version caching
- UI: Adapter Manager page, dynamic loader, display registry
  with humanized names for unknown adapter types
- Dev watch: exclude adapter-plugins dir from tsx watcher
  to prevent mid-request server restarts during reinstall
- All consumer fallbacks use getAdapterLabel() for consistent display
- AdapterTypeDropdown uses controlled open state for proper close behavior
- Remove hermes-local from built-in UI (externalized to plugin)
- Add docs for external adapters and UI parser contract
This commit is contained in:
HenkDz 2026-03-31 20:21:13 +01:00
parent f8452a4520
commit 14d59da316
72 changed files with 4102 additions and 585 deletions

View file

@ -0,0 +1,58 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import type { ServerAdapterModule } from "../adapters/index.js";
import {
findServerAdapter,
listAdapterModels,
registerServerAdapter,
requireServerAdapter,
unregisterServerAdapter,
} from "../adapters/index.js";
const externalAdapter: ServerAdapterModule = {
type: "external_test",
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
}),
testEnvironment: async () => ({
adapterType: "external_test",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
models: [{ id: "external-model", label: "External Model" }],
supportsLocalAgentJwt: false,
};
describe("server adapter registry", () => {
beforeEach(() => {
unregisterServerAdapter("external_test");
});
afterEach(() => {
unregisterServerAdapter("external_test");
});
it("registers external adapters and exposes them through lookup helpers", async () => {
expect(findServerAdapter("external_test")).toBeNull();
registerServerAdapter(externalAdapter);
expect(requireServerAdapter("external_test")).toBe(externalAdapter);
expect(await listAdapterModels("external_test")).toEqual([
{ id: "external-model", label: "External Model" },
]);
});
it("removes external adapters when unregistered", () => {
registerServerAdapter(externalAdapter);
unregisterServerAdapter("external_test");
expect(findServerAdapter("external_test")).toBeNull();
expect(() => requireServerAdapter("external_test")).toThrow(
"Unknown adapter type: external_test",
);
});
});

View file

@ -0,0 +1,180 @@
import express from "express";
import request from "supertest";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { agentRoutes } from "../routes/agents.js";
import { errorHandler } from "../middleware/index.js";
import type { ServerAdapterModule } from "../adapters/index.js";
import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
const mockAgentService = vi.hoisted(() => ({
create: vi.fn(),
getById: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
ensureMembership: vi.fn(),
setPrincipalPermission: vi.fn(),
}));
const mockCompanySkillService = vi.hoisted(() => ({
listRuntimeSkillEntries: vi.fn(),
resolveRequestedSkillKeys: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config })),
}));
const mockAgentInstructionsService = vi.hoisted(() => ({
materializeManagedBundle: vi.fn(),
getBundle: vi.fn(),
readFile: vi.fn(),
updateBundle: vi.fn(),
writeFile: vi.fn(),
deleteFile: vi.fn(),
exportFiles: vi.fn(),
ensureManagedBundle: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({
upsertPolicy: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
cancelActiveForAgent: vi.fn(),
}));
const mockIssueApprovalService = vi.hoisted(() => ({
linkManyForApproval: vi.fn(),
}));
const mockApprovalService = vi.hoisted(() => ({
create: vi.fn(),
getById: vi.fn(),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
companySkillService: () => mockCompanySkillService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => ({}),
}));
vi.mock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
const externalAdapter: ServerAdapterModule = {
type: "external_test",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
testEnvironment: async () => ({
adapterType: "external_test",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
};
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", agentRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("agent routes adapter validation", () => {
beforeEach(() => {
vi.clearAllMocks();
unregisterServerAdapter("external_test");
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(true);
mockAccessService.ensureMembership.mockResolvedValue(undefined);
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
mockLogActivity.mockResolvedValue(undefined);
mockAgentService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
name: String(input.name ?? "Agent"),
urlKey: "agent",
role: String(input.role ?? "general"),
title: null,
icon: null,
status: "idle",
reportsTo: null,
capabilities: null,
adapterType: String(input.adapterType ?? "process"),
adapterConfig: (input.adapterConfig as Record<string, unknown> | undefined) ?? {},
runtimeConfig: (input.runtimeConfig as Record<string, unknown> | undefined) ?? {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
}));
});
afterEach(() => {
unregisterServerAdapter("external_test");
});
it("creates agents for dynamically registered external adapter types", async () => {
registerServerAdapter(externalAdapter);
const res = await request(createApp())
.post("/api/companies/company-1/agents")
.send({
name: "External Agent",
adapterType: "external_test",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(res.body.adapterType).toBe("external_test");
});
it("rejects unknown adapter types even when schema accepts arbitrary strings", async () => {
const res = await request(createApp())
.post("/api/companies/company-1/agents")
.send({
name: "Missing Adapter",
adapterType: "missing_adapter",
});
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(String(res.body.error ?? res.body.message ?? "")).toContain("Unknown adapter type: missing_adapter");
});
});

View file

@ -50,7 +50,7 @@ vi.mock("../services/index.js", () => ({
}));
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(),
findServerAdapter: vi.fn((_type: string) => ({ type: _type })),
listAdapterModels: vi.fn(),
}));

View file

@ -86,6 +86,7 @@ vi.mock("../services/index.js", () => ({
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(() => mockAdapter),
listAdapterModels: vi.fn(),
detectAdapterModel: vi.fn(),
}));
function createDb(requireBoardApprovalForNewAgents = false) {

View file

@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { summarizeHeartbeatRunResultJson } from "../services/heartbeat-run-summary.js";
import {
summarizeHeartbeatRunResultJson,
buildHeartbeatRunIssueComment,
} from "../services/heartbeat-run-summary.js";
describe("summarizeHeartbeatRunResultJson", () => {
it("truncates text fields and preserves cost aliases", () => {
@ -31,3 +34,24 @@ describe("summarizeHeartbeatRunResultJson", () => {
expect(summarizeHeartbeatRunResultJson({ nested: { only: "ignored" } })).toBeNull();
});
});
describe("buildHeartbeatRunIssueComment", () => {
it("uses the final summary text for issue comments on successful runs", () => {
const comment = buildHeartbeatRunIssueComment({
summary: "## Summary\n\n- fixed deploy config\n- posted issue update",
});
expect(comment).toContain("## Summary");
expect(comment).toContain("- fixed deploy config");
expect(comment).not.toContain("Run summary");
});
it("falls back to result or message when summary is missing", () => {
expect(buildHeartbeatRunIssueComment({ result: "done" })).toBe("done");
expect(buildHeartbeatRunIssueComment({ message: "completed" })).toBe("completed");
});
it("returns null when there is no usable final text", () => {
expect(buildHeartbeatRunIssueComment({ costUsd: 1.2 })).toBeNull();
});
});