mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
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:
parent
f8452a4520
commit
14d59da316
72 changed files with 4102 additions and 585 deletions
58
server/src/__tests__/adapter-registry.test.ts
Normal file
58
server/src/__tests__/adapter-registry.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
180
server/src/__tests__/agent-adapter-validation-routes.test.ts
Normal file
180
server/src/__tests__/agent-adapter-validation-routes.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue