469 lines
17 KiB
TypeScript
469 lines
17 KiB
TypeScript
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
|
import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils";
|
|
import type { ServerAdapterModule } from "../adapters/index.js";
|
|
|
|
import {
|
|
detectAdapterModel,
|
|
findActiveServerAdapter,
|
|
findServerAdapter,
|
|
listAdapterModels,
|
|
registerServerAdapter,
|
|
requireServerAdapter,
|
|
unregisterServerAdapter,
|
|
} from "../adapters/index.js";
|
|
import {
|
|
resolveExternalAdapterRegistration,
|
|
setOverridePaused,
|
|
} from "../adapters/registry.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");
|
|
unregisterServerAdapter("hermes_local");
|
|
unregisterServerAdapter("hermes_gateway");
|
|
unregisterServerAdapter("claude_local");
|
|
setOverridePaused("claude_local", false);
|
|
});
|
|
|
|
afterEach(() => {
|
|
unregisterServerAdapter("external_test");
|
|
unregisterServerAdapter("hermes_local");
|
|
unregisterServerAdapter("hermes_gateway");
|
|
unregisterServerAdapter("claude_local");
|
|
setOverridePaused("claude_local", false);
|
|
});
|
|
|
|
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",
|
|
);
|
|
});
|
|
|
|
it("allows external plugin to override a built-in adapter type", () => {
|
|
// claude_local is always built-in
|
|
const builtIn = findServerAdapter("claude_local");
|
|
expect(builtIn).not.toBeNull();
|
|
|
|
const plugin: ServerAdapterModule = {
|
|
type: "claude_local",
|
|
execute: async () => ({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
}),
|
|
testEnvironment: async () => ({
|
|
adapterType: "claude_local",
|
|
status: "pass",
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
models: [{ id: "plugin-model", label: "Plugin Override" }],
|
|
supportsLocalAgentJwt: false,
|
|
};
|
|
|
|
registerServerAdapter(plugin);
|
|
|
|
// Plugin wins
|
|
const resolved = requireServerAdapter("claude_local");
|
|
expect(resolved).toBe(plugin);
|
|
expect(resolved.models).toEqual([
|
|
{ id: "plugin-model", label: "Plugin Override" },
|
|
]);
|
|
});
|
|
|
|
it("ships Hermes adapters as built-ins and still accepts external overrides", () => {
|
|
const builtInLocal = findServerAdapter("hermes_local");
|
|
const builtInGateway = findServerAdapter("hermes_gateway");
|
|
|
|
expect(builtInLocal).not.toBeNull();
|
|
expect(builtInLocal?.supportsLocalAgentJwt).toBe(true);
|
|
expect(builtInLocal?.supportsInstructionsBundle).toBe(true);
|
|
expect(builtInLocal?.requiresMaterializedRuntimeSkills).toBe(false);
|
|
expect(builtInLocal?.detectModel).toBeTypeOf("function");
|
|
expect(builtInLocal?.getConfigSchema).toBeTypeOf("function");
|
|
|
|
expect(builtInGateway).not.toBeNull();
|
|
expect(builtInGateway?.supportsLocalAgentJwt).toBe(false);
|
|
expect(builtInGateway?.supportsInstructionsBundle).toBe(false);
|
|
expect(builtInGateway?.requiresMaterializedRuntimeSkills).toBe(false);
|
|
expect(builtInGateway?.getConfigSchema).toBeTypeOf("function");
|
|
|
|
const hermesLocalExternalAdapter: ServerAdapterModule = {
|
|
type: "hermes_local",
|
|
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
|
testEnvironment: async () => ({
|
|
adapterType: "hermes_local",
|
|
status: "pass",
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
supportsLocalAgentJwt: true,
|
|
supportsInstructionsBundle: true,
|
|
instructionsPathKey: "instructionsFilePath",
|
|
requiresMaterializedRuntimeSkills: false,
|
|
listSkills: async () => ({
|
|
adapterType: "hermes_local",
|
|
supported: true,
|
|
mode: "ephemeral",
|
|
desiredSkills: [],
|
|
entries: [],
|
|
warnings: [],
|
|
}),
|
|
getConfigSchema: () => ({ fields: [{ key: "provider", label: "Provider", type: "text" }] }),
|
|
detectModel: async () => ({
|
|
model: "hermes-model",
|
|
provider: "openrouter",
|
|
source: "test",
|
|
}),
|
|
};
|
|
|
|
const hermesGatewayExternalAdapter: ServerAdapterModule = {
|
|
type: "hermes_gateway",
|
|
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
|
testEnvironment: async () => ({
|
|
adapterType: "hermes_gateway",
|
|
status: "pass",
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
supportsLocalAgentJwt: false,
|
|
supportsInstructionsBundle: false,
|
|
requiresMaterializedRuntimeSkills: false,
|
|
getConfigSchema: () => ({
|
|
fields: [{ key: "apiBaseUrl", label: "API URL", type: "text" }],
|
|
}),
|
|
};
|
|
|
|
registerServerAdapter(hermesLocalExternalAdapter);
|
|
|
|
expect(requireServerAdapter("hermes_local")).toBe(hermesLocalExternalAdapter);
|
|
expect(findActiveServerAdapter("hermes_local")?.supportsLocalAgentJwt).toBe(true);
|
|
|
|
unregisterServerAdapter("hermes_local");
|
|
|
|
expect(requireServerAdapter("hermes_local")).toBe(builtInLocal);
|
|
|
|
registerServerAdapter(hermesGatewayExternalAdapter);
|
|
|
|
expect(requireServerAdapter("hermes_gateway")).toBe(hermesGatewayExternalAdapter);
|
|
expect(findActiveServerAdapter("hermes_gateway")?.supportsLocalAgentJwt).toBe(false);
|
|
|
|
unregisterServerAdapter("hermes_gateway");
|
|
|
|
expect(requireServerAdapter("hermes_gateway")).toBe(builtInGateway);
|
|
});
|
|
|
|
it("exposes capability flags from registered adapters", () => {
|
|
const adapterWithCaps: ServerAdapterModule = {
|
|
type: "external_test",
|
|
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
|
testEnvironment: async () => ({
|
|
adapterType: "external_test",
|
|
status: "pass" as const,
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
supportsLocalAgentJwt: true,
|
|
supportsInstructionsBundle: true,
|
|
instructionsPathKey: "customPathKey",
|
|
requiresMaterializedRuntimeSkills: true,
|
|
};
|
|
|
|
registerServerAdapter(adapterWithCaps);
|
|
|
|
const resolved = findActiveServerAdapter("external_test");
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved!.supportsInstructionsBundle).toBe(true);
|
|
expect(resolved!.instructionsPathKey).toBe("customPathKey");
|
|
expect(resolved!.requiresMaterializedRuntimeSkills).toBe(true);
|
|
expect(resolved!.supportsLocalAgentJwt).toBe(true);
|
|
});
|
|
|
|
it("returns undefined for capability flags on adapters that do not set them", () => {
|
|
registerServerAdapter(externalAdapter);
|
|
|
|
const resolved = findActiveServerAdapter("external_test");
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved!.supportsInstructionsBundle).toBeUndefined();
|
|
expect(resolved!.instructionsPathKey).toBeUndefined();
|
|
expect(resolved!.requiresMaterializedRuntimeSkills).toBeUndefined();
|
|
});
|
|
|
|
it("built-in claude_local adapter declares capability flags", () => {
|
|
const adapter = findActiveServerAdapter("claude_local");
|
|
expect(adapter).not.toBeNull();
|
|
expect(adapter!.supportsInstructionsBundle).toBe(true);
|
|
expect(adapter!.instructionsPathKey).toBe("instructionsFilePath");
|
|
expect(adapter!.requiresMaterializedRuntimeSkills).toBe(false);
|
|
expect(adapter!.supportsLocalAgentJwt).toBe(true);
|
|
});
|
|
|
|
it("rejects an incomplete managed runner provider before probing Codex", async () => {
|
|
const adapter = requireServerAdapter("paperclip_runner");
|
|
expect(adapter.supportsInstructionsBundle).toBe(true);
|
|
expect(adapter.instructionsPathKey).toBe("instructionsFilePath");
|
|
const result = await adapter.testEnvironment({
|
|
companyId: "company-1",
|
|
adapterType: "paperclip_runner",
|
|
config: { provider: "claude_managed" },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
adapterType: "paperclip_runner",
|
|
status: "fail",
|
|
checks: [{
|
|
code: "paperclip_runner_claude_managed_profile_required",
|
|
level: "error",
|
|
}],
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["claude_managed", {
|
|
managedProfileId: "managed-primary",
|
|
managedAgentsRetentionAcknowledged: true,
|
|
}, "claude_managed_profile_selected"],
|
|
["aws_agentcore", {
|
|
agentCoreProfileId: "agentcore-primary",
|
|
agentCoreRetentionAcknowledged: true,
|
|
}, "aws_agentcore_profile_selected"],
|
|
] as const)("accepts a complete %s profile selection", async (provider, config, code) => {
|
|
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
|
companyId: "company-1",
|
|
adapterType: "paperclip_runner",
|
|
config: { provider, ...config },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
adapterType: "paperclip_runner",
|
|
status: "warn",
|
|
checks: expect.arrayContaining([expect.objectContaining({ code, level: "info" })]),
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["claude", "claude-sonnet-5"],
|
|
["codex", "gpt-5.6-sol"],
|
|
] as const)("accepts the qualified ACPX %s environment profile", async (acpxAgent, model) => {
|
|
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
|
companyId: "company-1",
|
|
adapterType: "paperclip_runner",
|
|
config: { provider: "acpx", acpxAgent, model },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
adapterType: "paperclip_runner",
|
|
status: "pass",
|
|
checks: [{ code: "acpx_profile_qualified", level: "info" }],
|
|
});
|
|
});
|
|
|
|
it("keeps the ACPX Pi profile unavailable", async () => {
|
|
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
|
companyId: "company-1",
|
|
adapterType: "paperclip_runner",
|
|
config: {
|
|
provider: "acpx",
|
|
acpxAgent: "pi",
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
},
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
status: "fail",
|
|
checks: [{ code: "paperclip_runner_acpx_agent_unavailable" }],
|
|
});
|
|
});
|
|
it("wraps built-in npm runtime installs with the sandbox-aware install helper", () => {
|
|
const expectedClaudeInstall = `if ! command -v 'claude' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@anthropic-ai/claude-code")}; fi`;
|
|
const expectedCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex")}; fi`;
|
|
const expectedGeminiInstall = `if ! command -v 'gemini' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@google/gemini-cli")}; fi`;
|
|
const expectedOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai")}; fi`;
|
|
const expectedRunnerCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex@0.148.0")}; fi`;
|
|
const expectedRunnerOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai@1.18.17")}; fi`;
|
|
|
|
expect(findActiveServerAdapter("claude_local")?.getRuntimeCommandSpec?.({})).toEqual({
|
|
command: "claude",
|
|
detectCommand: "claude",
|
|
installCommand: expectedClaudeInstall,
|
|
});
|
|
expect(findActiveServerAdapter("codex_local")?.getRuntimeCommandSpec?.({})).toEqual({
|
|
command: "codex",
|
|
detectCommand: "codex",
|
|
installCommand: expectedCodexInstall,
|
|
});
|
|
expect(findActiveServerAdapter("gemini_local")?.getRuntimeCommandSpec?.({})).toEqual({
|
|
command: "gemini",
|
|
detectCommand: "gemini",
|
|
installCommand: expectedGeminiInstall,
|
|
});
|
|
expect(findActiveServerAdapter("opencode_local")?.getRuntimeCommandSpec?.({})).toEqual({
|
|
command: "opencode",
|
|
detectCommand: "opencode",
|
|
installCommand: expectedOpenCodeInstall,
|
|
});
|
|
expect(findActiveServerAdapter("paperclip_runner")?.getRuntimeCommandSpec?.({ provider: "codex" })).toEqual({
|
|
command: "codex",
|
|
detectCommand: "codex",
|
|
installCommand: expectedRunnerCodexInstall,
|
|
});
|
|
expect(findActiveServerAdapter("paperclip_runner")?.getRuntimeCommandSpec?.({ provider: "opencode" })).toEqual({
|
|
command: "opencode",
|
|
detectCommand: "opencode",
|
|
installCommand: expectedRunnerOpenCodeInstall,
|
|
});
|
|
expect(findActiveServerAdapter("paperclip_runner")?.getRuntimeCommandSpec?.({ provider: "acpx" })).toEqual({
|
|
command: "paperclip-runnerd",
|
|
detectCommand: null,
|
|
installCommand: null,
|
|
});
|
|
});
|
|
|
|
it("switches active adapter behavior back to the builtin when an override is paused", async () => {
|
|
const builtIn = findServerAdapter("claude_local");
|
|
expect(builtIn).not.toBeNull();
|
|
|
|
const detectModel = vi.fn(async () => ({
|
|
model: "plugin-model",
|
|
provider: "plugin-provider",
|
|
source: "plugin-source",
|
|
}));
|
|
const plugin: ServerAdapterModule = {
|
|
type: "claude_local",
|
|
execute: async () => ({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
}),
|
|
testEnvironment: async () => ({
|
|
adapterType: "claude_local",
|
|
status: "pass",
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
models: [{ id: "plugin-model", label: "Plugin Override" }],
|
|
detectModel,
|
|
supportsLocalAgentJwt: false,
|
|
};
|
|
|
|
registerServerAdapter(plugin);
|
|
|
|
expect(findActiveServerAdapter("claude_local")).toBe(plugin);
|
|
expect(await listAdapterModels("claude_local")).toEqual([
|
|
{ id: "plugin-model", label: "Plugin Override" },
|
|
]);
|
|
expect(await detectAdapterModel("claude_local")).toMatchObject({
|
|
model: "plugin-model",
|
|
provider: "plugin-provider",
|
|
});
|
|
|
|
expect(setOverridePaused("claude_local", true)).toBe(true);
|
|
|
|
expect(findActiveServerAdapter("claude_local")).not.toBe(plugin);
|
|
expect(await listAdapterModels("claude_local")).toEqual(builtIn?.models ?? []);
|
|
expect(await detectAdapterModel("claude_local")).toBeNull();
|
|
expect(detectModel).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("resolveExternalAdapterRegistration", () => {
|
|
it("preserves module-provided sessionManagement", () => {
|
|
const sessionManagement = {
|
|
supportsSessionResume: true,
|
|
nativeContextManagement: "unknown" as const,
|
|
defaultSessionCompaction: {
|
|
enabled: true,
|
|
maxSessionRuns: 200,
|
|
maxRawInputTokens: 2_000_000,
|
|
maxSessionAgeHours: 72,
|
|
},
|
|
};
|
|
const adapter: ServerAdapterModule = {
|
|
type: "external_session_test",
|
|
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
|
testEnvironment: async () => ({
|
|
adapterType: "external_session_test",
|
|
status: "pass",
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
sessionManagement,
|
|
};
|
|
|
|
const resolved = resolveExternalAdapterRegistration(adapter);
|
|
|
|
expect(resolved.sessionManagement).toBe(sessionManagement);
|
|
});
|
|
|
|
it("falls back to the hardcoded registry when the module omits sessionManagement", () => {
|
|
// An external that overrides a built-in type should inherit the built-in's
|
|
// sessionManagement when it does not provide its own.
|
|
const adapter: ServerAdapterModule = {
|
|
type: "claude_local",
|
|
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
|
testEnvironment: async () => ({
|
|
adapterType: "claude_local",
|
|
status: "pass",
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
};
|
|
|
|
const resolved = resolveExternalAdapterRegistration(adapter);
|
|
|
|
expect(resolved.sessionManagement).toBeDefined();
|
|
expect(resolved.sessionManagement?.supportsSessionResume).toBe(true);
|
|
expect(resolved.sessionManagement?.nativeContextManagement).toBe("confirmed");
|
|
});
|
|
|
|
it("leaves sessionManagement undefined when neither module nor registry provides one", () => {
|
|
const adapter: ServerAdapterModule = {
|
|
type: "external_unknown_test",
|
|
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
|
testEnvironment: async () => ({
|
|
adapterType: "external_unknown_test",
|
|
status: "pass",
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
};
|
|
|
|
const resolved = resolveExternalAdapterRegistration(adapter);
|
|
|
|
expect(resolved.sessionManagement).toBeUndefined();
|
|
});
|
|
});
|